@@ -453,3 +453,353 @@ def handle_result(self, result):
453453- ` mpos.sdcard.SDCardManager ` : SD card mounting and management
454454- ` mpos.clipboard ` : System clipboard access
455455- ` mpos.battery_voltage ` : Battery level reading (ESP32 only)
456+
457+ ## Animations and Game Loops
458+
459+ MicroPythonOS supports frame-based animations and game loops using the TaskHandler event system. This pattern is used for games, particle effects, and smooth animations.
460+
461+ ### The update_frame() Pattern
462+
463+ The core pattern involves:
464+ 1 . Registering a callback that fires every frame
465+ 2 . Calculating delta time for framerate-independent physics
466+ 3 . Updating object positions and properties
467+ 4 . Rendering to LVGL objects
468+ 5 . Unregistering when animation completes
469+
470+ ** Basic structure** :
471+ ``` python
472+ from mpos.apps import Activity
473+ import mpos.ui
474+ import time
475+ import lvgl as lv
476+
477+ class MyAnimatedApp (Activity ):
478+ last_time = 0
479+
480+ def onCreate (self ):
481+ # Set up your UI
482+ self .screen = lv.obj()
483+ # ... create objects ...
484+ self .setContentView(self .screen)
485+
486+ def onResume (self , screen ):
487+ # Register the frame callback
488+ self .last_time = time.ticks_ms()
489+ mpos.ui.th.add_event_cb(self .update_frame, 1 )
490+
491+ def onPause (self , screen ):
492+ # Unregister when app goes to background
493+ mpos.ui.th.remove_event_cb(self .update_frame)
494+
495+ def update_frame (self , a , b ):
496+ # Calculate delta time for framerate independence
497+ current_time = time.ticks_ms()
498+ delta_ms = time.ticks_diff(current_time, self .last_time)
499+ delta_time = delta_ms / 1000.0 # Convert to seconds
500+ self .last_time = current_time
501+
502+ # Update your animation/game logic here
503+ # Use delta_time to make physics framerate-independent
504+ ```
505+
506+ ### Framerate-Independent Physics
507+
508+ All movement and physics should be multiplied by ` delta_time ` to ensure consistent behavior regardless of framerate:
509+
510+ ``` python
511+ # Example from QuasiBird game
512+ GRAVITY = 200 # pixels per second²
513+ PIPE_SPEED = 100 # pixels per second
514+
515+ def update_frame (self , a , b ):
516+ current_time = time.ticks_ms()
517+ delta_time = time.ticks_diff(current_time, self .last_time) / 1000.0
518+ self .last_time = current_time
519+
520+ # Update velocity with gravity
521+ self .bird_velocity += self .GRAVITY * delta_time
522+
523+ # Update position with velocity
524+ self .bird_y += self .bird_velocity * delta_time
525+
526+ # Update bird sprite position
527+ self .bird_img.set_y(int (self .bird_y))
528+
529+ # Move pipes
530+ for pipe in self .pipes:
531+ pipe.x -= self .PIPE_SPEED * delta_time
532+ ```
533+
534+ ** Key principles** :
535+ - Constants define rates in "per second" units (pixels/second, degrees/second)
536+ - Multiply all rates by ` delta_time ` when applying them
537+ - This ensures objects move at the same speed regardless of framerate
538+ - Use ` time.ticks_ms() ` and ` time.ticks_diff() ` for timing (handles rollover correctly)
539+
540+ ### Object Pooling for Performance
541+
542+ Pre-create LVGL objects and reuse them instead of creating/destroying during animation:
543+
544+ ``` python
545+ # Example from LightningPiggy confetti animation
546+ MAX_CONFETTI = 21
547+ confetti_images = []
548+ confetti_pieces = []
549+ used_img_indices = set ()
550+
551+ def onStart (self , screen ):
552+ # Pre-create all image objects (hidden initially)
553+ for i in range (self .MAX_CONFETTI ):
554+ img = lv.image(lv.layer_top())
555+ img.set_src(f " { self .ASSET_PATH } confetti { i % 5 } .png " )
556+ img.add_flag(lv.obj.FLAG .HIDDEN )
557+ self .confetti_images.append(img)
558+
559+ def _spawn_one (self ):
560+ # Find a free image slot
561+ for idx, img in enumerate (self .confetti_images):
562+ if img.has_flag(lv.obj.FLAG .HIDDEN ) and idx not in self .used_img_indices:
563+ break
564+ else :
565+ return # No free slot
566+
567+ # Create particle data (not LVGL object)
568+ piece = {
569+ ' img_idx' : idx,
570+ ' x' : random.uniform(0 , self .SCREEN_WIDTH ),
571+ ' y' : 0 ,
572+ ' vx' : random.uniform(- 80 , 80 ),
573+ ' vy' : random.uniform(- 150 , 0 ),
574+ ' rotation' : 0 ,
575+ ' scale' : 1.0 ,
576+ ' age' : 0.0
577+ }
578+ self .confetti_pieces.append(piece)
579+ self .used_img_indices.add(idx)
580+
581+ def update_frame (self , a , b ):
582+ delta_time = time.ticks_diff(time.ticks_ms(), self .last_time) / 1000.0
583+ self .last_time = time.ticks_ms()
584+
585+ new_pieces = []
586+ for piece in self .confetti_pieces:
587+ # Update physics
588+ piece[' x' ] += piece[' vx' ] * delta_time
589+ piece[' y' ] += piece[' vy' ] * delta_time
590+ piece[' vy' ] += self .GRAVITY * delta_time
591+ piece[' rotation' ] += piece[' spin' ] * delta_time
592+ piece[' age' ] += delta_time
593+
594+ # Update LVGL object
595+ img = self .confetti_images[piece[' img_idx' ]]
596+ img.remove_flag(lv.obj.FLAG .HIDDEN )
597+ img.set_pos(int (piece[' x' ]), int (piece[' y' ]))
598+ img.set_rotation(int (piece[' rotation' ] * 10 ))
599+ img.set_scale(int (256 * piece[' scale' ]))
600+
601+ # Check if particle should die
602+ if piece[' y' ] > self .SCREEN_HEIGHT or piece[' age' ] > piece[' lifetime' ]:
603+ img.add_flag(lv.obj.FLAG .HIDDEN )
604+ self .used_img_indices.discard(piece[' img_idx' ])
605+ else :
606+ new_pieces.append(piece)
607+
608+ self .confetti_pieces = new_pieces
609+ ```
610+
611+ ** Object pooling benefits** :
612+ - Avoid memory allocation/deallocation during animation
613+ - Reuse LVGL image objects (expensive to create)
614+ - Hide/show objects instead of create/delete
615+ - Track which slots are in use with a set
616+ - Separate particle data (Python dict) from rendering (LVGL object)
617+
618+ ### Particle Systems and Effects
619+
620+ ** Staggered spawning** (spawn particles over time instead of all at once):
621+ ``` python
622+ def start_animation (self ):
623+ self .spawn_timer = 0
624+ self .spawn_interval = 0.15 # seconds between spawns
625+ mpos.ui.th.add_event_cb(self .update_frame, 1 )
626+
627+ def update_frame (self , a , b ):
628+ delta_time = time.ticks_diff(time.ticks_ms(), self .last_time) / 1000.0
629+
630+ # Staggered spawning
631+ self .spawn_timer += delta_time
632+ if self .spawn_timer >= self .spawn_interval:
633+ self .spawn_timer = 0
634+ for _ in range (random.randint(1 , 2 )):
635+ if len (self .particles) < self .MAX_PARTICLES :
636+ self ._spawn_one()
637+ ```
638+
639+ ** Particle lifecycle** (age, scale, death):
640+ ``` python
641+ piece = {
642+ ' x' : x, ' y' : y,
643+ ' vx' : random.uniform(- 80 , 80 ),
644+ ' vy' : random.uniform(- 150 , 0 ),
645+ ' spin' : random.uniform(- 500 , 500 ), # degrees/sec
646+ ' age' : 0.0 ,
647+ ' lifetime' : random.uniform(5.0 , 10.0 ),
648+ ' rotation' : random.uniform(0 , 360 ),
649+ ' scale' : 1.0
650+ }
651+
652+ # In update_frame
653+ piece[' age' ] += delta_time
654+ piece[' scale' ] = max (0.3 , 1.0 - (piece[' age' ] / piece[' lifetime' ]) * 0.7 )
655+
656+ # Death check
657+ dead = (
658+ piece[' x' ] < - 60 or piece[' x' ] > SCREEN_WIDTH + 60 or
659+ piece[' y' ] > SCREEN_HEIGHT + 60 or
660+ piece[' age' ] > piece[' lifetime' ]
661+ )
662+ ```
663+
664+ ### Game Loop Patterns
665+
666+ ** Scrolling backgrounds** (parallax and tiling):
667+ ``` python
668+ # Parallax clouds (multiple layers at different speeds)
669+ CLOUD_SPEED = 30 # pixels/sec (slower than foreground)
670+ cloud_positions = [50 , 180 , 320 ]
671+
672+ for i, cloud_img in enumerate (self .cloud_images):
673+ self .cloud_positions[i] -= self .CLOUD_SPEED * delta_time
674+
675+ # Wrap around when off-screen
676+ if self .cloud_positions[i] < - 60 :
677+ self .cloud_positions[i] = SCREEN_WIDTH + 20
678+
679+ cloud_img.set_x(int (self .cloud_positions[i]))
680+
681+ # Tiled ground (infinite scrolling)
682+ self .ground_x -= self .PIPE_SPEED * delta_time
683+ self .ground_img.set_offset_x(int (self .ground_x)) # LVGL handles wrapping
684+ ```
685+
686+ ** Object pooling for game entities** :
687+ ``` python
688+ # Pre-create pipe images
689+ MAX_PIPES = 4
690+ pipe_images = []
691+
692+ for i in range (MAX_PIPES ):
693+ top_pipe = lv.image(screen)
694+ top_pipe.set_src(" M:path/to/pipe.png" )
695+ top_pipe.set_rotation(1800 ) # 180 degrees * 10
696+ top_pipe.add_flag(lv.obj.FLAG .HIDDEN )
697+
698+ bottom_pipe = lv.image(screen)
699+ bottom_pipe.set_src(" M:path/to/pipe.png" )
700+ bottom_pipe.add_flag(lv.obj.FLAG .HIDDEN )
701+
702+ pipe_images.append({" top" : top_pipe, " bottom" : bottom_pipe, " in_use" : False })
703+
704+ # Update visible pipes
705+ def update_pipe_images (self ):
706+ for pipe_img in self .pipe_images:
707+ pipe_img[" in_use" ] = False
708+
709+ for i, pipe in enumerate (self .pipes):
710+ if i < self .MAX_PIPES :
711+ pipe_imgs = self .pipe_images[i]
712+ pipe_imgs[" in_use" ] = True
713+ pipe_imgs[" top" ].remove_flag(lv.obj.FLAG .HIDDEN )
714+ pipe_imgs[" top" ].set_pos(int (pipe.x), int (pipe.gap_y - 200 ))
715+ pipe_imgs[" bottom" ].remove_flag(lv.obj.FLAG .HIDDEN )
716+ pipe_imgs[" bottom" ].set_pos(int (pipe.x), int (pipe.gap_y + pipe.gap_size))
717+
718+ # Hide unused slots
719+ for pipe_img in self .pipe_images:
720+ if not pipe_img[" in_use" ]:
721+ pipe_img[" top" ].add_flag(lv.obj.FLAG .HIDDEN )
722+ pipe_img[" bottom" ].add_flag(lv.obj.FLAG .HIDDEN )
723+ ```
724+
725+ ** Collision detection** :
726+ ``` python
727+ def check_collision (self ):
728+ # Boundaries
729+ if self .bird_y <= 0 or self .bird_y >= SCREEN_HEIGHT - 40 - self .bird_size:
730+ return True
731+
732+ # AABB (Axis-Aligned Bounding Box) collision
733+ bird_left = self .BIRD_X
734+ bird_right = self .BIRD_X + self .bird_size
735+ bird_top = self .bird_y
736+ bird_bottom = self .bird_y + self .bird_size
737+
738+ for pipe in self .pipes:
739+ pipe_left = pipe.x
740+ pipe_right = pipe.x + pipe.width
741+
742+ # Check horizontal overlap
743+ if bird_right > pipe_left and bird_left < pipe_right:
744+ # Check if bird is outside the gap
745+ if bird_top < pipe.gap_y or bird_bottom > pipe.gap_y + pipe.gap_size:
746+ return True
747+
748+ return False
749+ ```
750+
751+ ### Animation Control and Cleanup
752+
753+ ** Starting/stopping animations** :
754+ ``` python
755+ def start_animation (self ):
756+ self .animation_running = True
757+ self .last_time = time.ticks_ms()
758+ mpos.ui.th.add_event_cb(self .update_frame, 1 )
759+
760+ # Optional: auto-stop after duration
761+ lv.timer_create(self .stop_animation, 15000 , None ).set_repeat_count(1 )
762+
763+ def stop_animation (self , timer = None ):
764+ self .animation_running = False
765+ # Don't remove callback yet - let it clean up and remove itself
766+
767+ def update_frame (self , a , b ):
768+ # ... update logic ...
769+
770+ # Stop when animation completes
771+ if not self .animation_running and len (self .particles) == 0 :
772+ mpos.ui.th.remove_event_cb(self .update_frame)
773+ print (" Animation finished" )
774+ ```
775+
776+ ** Lifecycle integration** :
777+ ``` python
778+ def onResume (self , screen ):
779+ # Only start if needed (e.g., game in progress)
780+ if self .game_started and not self .game_over:
781+ self .last_time = time.ticks_ms()
782+ mpos.ui.th.add_event_cb(self .update_frame, 1 )
783+
784+ def onPause (self , screen ):
785+ # Always stop when app goes to background
786+ mpos.ui.th.remove_event_cb(self .update_frame)
787+ ```
788+
789+ ### Performance Tips
790+
791+ 1 . ** Pre-create LVGL objects** : Creating objects during animation causes lag
792+ 2 . ** Use object pools** : Reuse objects instead of create/destroy
793+ 3 . ** Limit particle counts** : Use ` MAX_PARTICLES ` constant (21 is a good default)
794+ 4 . ** Integer positions** : Convert float positions to int before setting: ` img.set_pos(int(x), int(y)) `
795+ 5 . ** Delta time** : Always use delta time for framerate independence
796+ 6 . ** Layer management** : Use ` lv.layer_top() ` for overlays (confetti, popups)
797+ 7 . ** Rotation units** : LVGL rotation is in 1/10 degrees: ` set_rotation(int(degrees * 10)) `
798+ 8 . ** Scale units** : LVGL scale is 256 = 100%: ` set_scale(int(256 * scale_factor)) `
799+ 9 . ** Hide vs destroy** : Hide objects with ` add_flag(lv.obj.FLAG.HIDDEN) ` instead of deleting
800+ 10 . ** Cleanup** : Always unregister callbacks in ` onPause() ` to prevent memory leaks
801+
802+ ### Example Apps
803+
804+ - ** QuasiBird** (` MPOS-QuasiBird/assets/quasibird.py ` ): Full game with physics, scrolling, object pooling
805+ - ** LightningPiggy** (` LightningPiggyApp/.../displaywallet.py ` ): Confetti particle system with staggered spawning
0 commit comments