Skip to content

Commit 2d24f8c

Browse files
Update CLAUDE.md
1 parent ecaaad6 commit 2d24f8c

File tree

1 file changed

+350
-0
lines changed

1 file changed

+350
-0
lines changed

CLAUDE.md

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)