From 8dbc5f4c7b95f7c04e71d1482ab476cff93bc50a Mon Sep 17 00:00:00 2001 From: aeroreyna Date: Sun, 21 Jun 2026 21:19:51 -0400 Subject: [PATCH] Add RLCD animations and screensavers --- .../hermes_voice_animations.py | 143 ++++++++ screensavers/README.md | 48 +++ screensavers/alien_invaders.py | 322 ++++++++++++++++++ screensavers/asteroid_drift.py | 319 +++++++++++++++++ screensavers/block_puzzle.py | 297 ++++++++++++++++ screensavers/manifest.json | 29 ++ screensavers/maze_chomper.py | 289 ++++++++++++++++ screensavers/paddle_bounce.py | 223 ++++++++++++ voice_animations/README.md | 70 ++++ voice_animations/ack_pulse.py | 68 ++++ voice_animations/battery_low.py | 91 +++++ voice_animations/breathing_idle.py | 80 +++++ voice_animations/caption_scan.py | 111 ++++++ voice_animations/error_alert.py | 95 ++++++ voice_animations/kid_companion.py | 109 ++++++ voice_animations/listening_wave.py | 88 +++++ voice_animations/manifest.json | 74 ++++ voice_animations/network_retry.py | 101 ++++++ voice_animations/speaking_mouth.py | 88 +++++ voice_animations/success_spark.py | 84 +++++ voice_animations/update_progress.py | 89 +++++ voice_animations/waiting_orbit.py | 81 +++++ voice_animations/wake_attention.py | 96 ++++++ voice_animations/working_gears.py | 102 ++++++ 24 files changed, 3097 insertions(+) create mode 100644 micropython_voice_animations/hermes_voice_animations.py create mode 100644 screensavers/README.md create mode 100644 screensavers/alien_invaders.py create mode 100644 screensavers/asteroid_drift.py create mode 100644 screensavers/block_puzzle.py create mode 100644 screensavers/manifest.json create mode 100644 screensavers/maze_chomper.py create mode 100644 screensavers/paddle_bounce.py create mode 100644 voice_animations/README.md create mode 100644 voice_animations/ack_pulse.py create mode 100644 voice_animations/battery_low.py create mode 100644 voice_animations/breathing_idle.py create mode 100644 voice_animations/caption_scan.py create mode 100644 voice_animations/error_alert.py create mode 100644 voice_animations/kid_companion.py create mode 100644 voice_animations/listening_wave.py create mode 100644 voice_animations/manifest.json create mode 100644 voice_animations/network_retry.py create mode 100644 voice_animations/speaking_mouth.py create mode 100644 voice_animations/success_spark.py create mode 100644 voice_animations/update_progress.py create mode 100644 voice_animations/waiting_orbit.py create mode 100644 voice_animations/wake_attention.py create mode 100644 voice_animations/working_gears.py diff --git a/micropython_voice_animations/hermes_voice_animations.py b/micropython_voice_animations/hermes_voice_animations.py new file mode 100644 index 0000000..ad20637 --- /dev/null +++ b/micropython_voice_animations/hermes_voice_animations.py @@ -0,0 +1,143 @@ +# Hermes ESP32 voice-state animations for MicroPython screen boards. +# Uploaded by the API server; can also be run manually with: +# exec(open('hermes_voice_animations.py').read()); run_state('ack') +import time +try: + import board_config +except Exception: + board_config = None + + +def _display(): + return getattr(board_config, 'display_instance', None) if board_config else None + + +def _sleep(ms): + try: + time.sleep_ms(ms) + except AttributeError: + time.sleep(ms / 1000) + + +def _text(d, msg, x, y, scale=1, color=1): + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale=scale, color=color) + return + except TypeError: + pass + d.text(msg, x, y, color) + + +def _center_text(d, msg, y, scale=1, color=1): + width = getattr(d, 'width', 320) + char_w = 8 * scale + x = max(0, (width - len(msg) * char_w) // 2) + _text(d, msg, x, y, scale=scale, color=color) + + +def _wrap(text, width_chars=34, max_lines=4): + words = str(text or '').replace('\n', ' ').split() + lines, line = [], '' + for w in words: + candidate = (line + ' ' + w).strip() + if len(candidate) > width_chars and line: + lines.append(line) + line = w + if len(lines) >= max_lines: + break + else: + line = candidate + if line and len(lines) < max_lines: + lines.append(line) + return lines + + +def _spinner(d, title, subtitle='', frames=24, delay=70): + width, height = getattr(d, 'width', 320), getattr(d, 'height', 240) + cx, cy = width // 2, height // 2 - 8 + spokes = [(-22,0),(-16,-16),(0,-22),(16,-16),(22,0),(16,16),(0,22),(-16,16)] + for frame in range(frames): + d.clear(0) + _center_text(d, title, 18, scale=2) + for i, (dx, dy) in enumerate(spokes): + color = 1 if (i + frame) % len(spokes) in (0, 1, 2, 3) else 0 + size = 4 if i == frame % len(spokes) else 2 + d.fill_rect(cx + dx - size // 2, cy + dy - size // 2, size, size, color) + d.rect(8, height - 34, width - 16, 18, 1) + d.fill_rect(10, height - 32, ((width - 20) * ((frame % 16) + 1)) // 16, 14, 1) + if subtitle: + _center_text(d, subtitle[:32], height - 56, scale=1) + d.show() + _sleep(delay) + + +def ack(frames=18): + d = _display() + if not d: + return + width, height = d.width, d.height + for i in range(frames): + d.clear(0) + _center_text(d, 'GOT IT', 18, scale=2) + # expanding check pulse + box = 48 + (i % 6) * 8 + x, y = width // 2 - box // 2, height // 2 - box // 2 + d.rect(x, y, box, box, 1) + d.line(width//2 - 24, height//2, width//2 - 6, height//2 + 18, 1) + d.line(width//2 - 6, height//2 + 18, width//2 + 30, height//2 - 22, 1) + _center_text(d, 'Listening captured', height - 36, scale=1) + d.show() + _sleep(55) + + +def captioning(frames=36): + d = _display() + if not d: + return + width, height = d.width, d.height + for i in range(frames): + d.clear(0) + _center_text(d, 'CAPTIONING', 16, scale=2) + for bar in range(9): + h = 8 + ((i * 5 + bar * 13) % 42) + x = width // 2 - 72 + bar * 18 + d.fill_rect(x, height // 2 - h // 2, 10, h, 1) + dots = '.' * ((i // 5) % 4) + _center_text(d, 'Transcribing' + dots, height - 42, scale=1) + d.show() + _sleep(65) + + +def waiting(text='', frames=44): + d = _display() + if not d: + return + width, height = d.width, d.height + for i in range(frames): + d.clear(0) + _center_text(d, 'THINKING', 12, scale=2) + r = 18 + (i % 10) * 2 + cx, cy = width // 2, height // 2 - 10 + d.rect(cx - r, cy - r, r * 2, r * 2, 1) + d.rect(cx - r//2, cy - r//2, r, r, 1) + if text: + d.text('Heard:', 10, height - 68, 1) + for idx, line in enumerate(_wrap(text, 36, 3)): + d.text(line[:38], 10, height - 54 + idx * 12, 1) + else: + _center_text(d, 'Waiting for reply...', height - 42, scale=1) + d.show() + _sleep(75) + + +def run_state(state='ack', text='', frames=None): + state = str(state or 'ack').lower() + if state in ('ack', 'listening', 'got_it'): + ack(frames or 18) + elif state in ('caption', 'captioning', 'transcribing'): + captioning(frames or 36) + elif state in ('wait', 'waiting', 'thinking', 'reply'): + waiting(text, frames or 44) + else: + _spinner(_display(), 'HERMES', state[:28], frames or 20) diff --git a/screensavers/README.md b/screensavers/README.md new file mode 100644 index 0000000..ab52a37 --- /dev/null +++ b/screensavers/README.md @@ -0,0 +1,48 @@ +# Retro Video Game Screensavers for Reyna little32 ESP32-S3 + +A collection of 5 standalone, retro-game-inspired MicroPython screensavers designed for high-contrast, low-resolution reflective LCD screens (specifically the Waveshare ESP32-S3-RLCD-4.2). + +## Screensavers List + +1. **Maze Chomper (`maze_chomper.py`)** + * **Inspired By:** *maze-chase arcade classic* + * **Description:** A wedge-shaped mouth chomps its way through a symmetric maze grid of dots. A ghost follows and chases the chomper. If the ghost catches the chomper, a collision is registered, and the board resets. +2. **Block Puzzle (`block_puzzle.py`)** + * **Inspired By:** *falling-block puzzle classic* + * **Description:** Tetromino pieces fall from the top of the board. A simple self-playing heuristic automatically shifts and rotates each piece to target a column. When a horizontal row is fully occupied, it is cleared. The game resets once blocks reach the ceiling. +3. **Alien Invaders (`alien_invaders.py`)** + * **Inspired By:** *alien-wave shooter classic* + * **Description:** Crawling leg-animated invaders march side-to-side and descend down the screen. An automated defender ship moves underneath to aim and destroy invaders. Both invaders and ship shoot bullets/bombs, causing pixel-spark explosions. +4. **Asteroid Drift (`asteroid_drift.py`)** + * **Inspired By:** *vector-rock space classic* + * **Description:** A wireframe, vector-style ship sits in the center of the screen, automatically aiming its nose towards the closest drifting space rock. The ship fires lasers to blow up and split larger rocks into medium and small fragments. +5. **Paddle Bounce (`paddle_bounce.py`)** + * **Inspired By:** *paddle-ball arcade classic* + * **Description:** Two vertical paddles play tennis against each other. The ball bounces off the top/bottom boundaries and the paddles, increasing speed with each paddle contact. Simulated reaction times and position offsets introduce realistic mistakes, allowing scores to accrue. + +## How It Works + +* **Standalone Execution:** Each script is designed to run completely standalone and can be executed via REPL or boot routines. +* **Hardware Fallback:** If `board_config` or `display_instance` is not available (such as running under test environments or without initialized hardware), the script gracefully prints a warning message and exits without throwing a crash exception. +* **Touch Stop Detection:** Every script contains a robust `check_touch` function that intercepts touch interrupts/coordinates across various formats (`board_config.touch_instance`, `board_config.touch`, `display.get_touch()`, or `display.touch()`). If a touch is registered, the loop terminates immediately. +* **Frame Control:** Every script exposes a `run(text="", frames=None)` function. If `frames` is set to `1` (useful for syntax checks or single-frame rendering), the screensaver renders exactly one frame and exits. If `frames` is `None` or `<=0`, it runs indefinitely. + +## Example Execution Commands + +Upload the desired screensaver to your board using `mpremote`: +```bash +mpremote connect /dev/cu.usbmodem101 cp screensavers/maze_chomper.py : +``` + +Run the screensaver directly from the MicroPython REPL: +```python +# Execute the entire file to run automatically +exec(open("maze_chomper.py").read()) +``` + +Or import the module and invoke with custom parameters: +```python +# Import and run with frame limits (e.g. exit after 300 frames) +import maze_chomper +maze_chomper.run(text="BREAKROOM SCREEN", frames=300) +``` diff --git a/screensavers/alien_invaders.py b/screensavers/alien_invaders.py new file mode 100644 index 0000000..30ac670 --- /dev/null +++ b/screensavers/alien_invaders.py @@ -0,0 +1,322 @@ +# Standalone MicroPython Screensaver: Alien Invaders +# Inspired by alien-wave shooter classic. Exposes run(text="", frames=None). + +import time +import math +import random + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def sleep_ms(ms): + try: + time.sleep_ms(ms) + except AttributeError: + time.sleep(ms / 1000.0) + +def check_touch(d, bc): + """Robust check for screen touch to stop the screensaver.""" + if bc is not None: + touch_inst = getattr(bc, 'touch_instance', None) + if touch_inst is not None: + if hasattr(touch_inst, 'is_touched'): + try: + if touch_inst.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_inst, meth): + try: + res = getattr(touch_inst, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + touch_val = getattr(bc, 'touch', None) + if touch_val is not None: + if callable(touch_val): + try: + res = touch_val() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + else: + if hasattr(touch_val, 'is_touched'): + try: + if touch_val.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_val, meth): + try: + res = getattr(touch_val, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + if d is not None: + for meth in ('get_touch', 'touch'): + if hasattr(d, meth): + try: + res = getattr(d, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + return False + +def draw_invader(d, ix, iy, frame_state): + # Alternating animation frames for crawling legs + d.fill_rect(ix - 7, iy - 4, 14, 3, 1) + d.fill_rect(ix - 5, iy - 1, 10, 3, 1) + if frame_state: + # Legs out + d.fill_rect(ix - 7, iy + 2, 2, 3, 1) + d.fill_rect(ix + 5, iy + 2, 2, 3, 1) + else: + # Legs in + d.fill_rect(ix - 4, iy + 2, 2, 3, 1) + d.fill_rect(ix + 2, iy + 2, 2, 3, 1) + # Eyes + d.fill_rect(ix - 3, iy - 3, 2, 2, 0) + d.fill_rect(ix + 1, iy - 3, 2, 2, 0) + +def draw_ship(d, sx, sy): + d.fill_rect(sx - 9, sy - 4, 18, 5, 1) + d.fill_rect(sx - 3, sy - 8, 6, 4, 1) + d.fill_rect(sx - 1, sy - 12, 2, 4, 1) + +def run(text="", frames=None): + if not display: + print("board_config.display_instance is not available. Skipping screensaver.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + + # Game parameters + invader_w, invader_h = 14, 9 + col_spacing = 28 + row_spacing = 18 + + grid_cols = 6 + grid_rows = 3 + + # Initialize game objects + invaders = [] # entries: [col, row, alive_bool] + def reset_invaders(): + invaders.clear() + for r in range(grid_rows): + for c in range(grid_cols): + invaders.append([c, r, True]) + + reset_invaders() + + inv_grid_x = (width - (grid_cols * col_spacing)) // 2 + inv_grid_y = 40 + inv_speed = 1.5 + inv_dir = 1 + + ship_x = width // 2 + ship_y = height - 15 + ship_speed = 2.0 + + bullets = [] # player bullets: [x, y] + bombs = [] # alien bombs: [x, y] + explosions = [] # particle list: [x, y, vx, vy, life] + + score = 0 + frame = 0 + cooldown = 0 + death_timer = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + if check_touch(display, board_config): + print("Screensaver stopped by screen touch.") + break + + # If player was hit, run a death screen-shake / reset sequence + if death_timer > 0: + death_timer -= 1 + display.clear(0) + # Simple screen shake by offsetting lines slightly + shake = random.randint(-4, 4) + display.text("SYSTEM ERROR", width // 2 - 48 + shake, height // 2 - 4 + shake, 1) + display.show() + if death_timer == 0: + reset_invaders() + bullets.clear() + bombs.clear() + explosions.clear() + inv_grid_x = (width - (grid_cols * col_spacing)) // 2 + inv_grid_y = 40 + ship_x = width // 2 + frame += 1 + sleep_ms(30) + continue + + # AI: Move player ship toward closest invader horizontally + alive_invaders = [inv for inv in invaders if inv[2]] + if not alive_invaders: + score += 500 + reset_invaders() + inv_grid_y = 40 + bullets.clear() + bombs.clear() + continue + + # Find the lowest/closest invader to follow + target_inv = None + max_r = -1 + for inv in alive_invaders: + if inv[1] > max_r: + max_r = inv[1] + target_inv = inv + + if target_inv: + inv_col, inv_row = target_inv[0], target_inv[1] + t_x = inv_grid_x + inv_col * col_spacing + col_spacing // 2 + if ship_x < t_x: + ship_x = min(width - 15, ship_x + ship_speed) + elif ship_x > t_x: + ship_x = max(15, ship_x - ship_speed) + + # Auto shooting + if cooldown > 0: + cooldown -= 1 + elif random.random() < 0.15: + bullets.append([ship_x, ship_y - 12]) + cooldown = 12 + + # Invader movements + # Calculate grid bounds based on active invaders + left_col = min(inv[0] for inv in alive_invaders) + right_col = max(inv[0] for inv in alive_invaders) + + grid_left = inv_grid_x + left_col * col_spacing + grid_right = inv_grid_x + right_col * col_spacing + col_spacing + + # Check boundary collision + if inv_dir == 1 and grid_right >= width - 15: + inv_dir = -1 + inv_grid_y += row_spacing + elif inv_dir == -1 and grid_left <= 15: + inv_dir = 1 + inv_grid_y += row_spacing + else: + inv_grid_x += inv_dir * inv_speed + + # Invader shooting (bombs) + if random.random() < 0.03: + shooter = random.choice(alive_invaders) + bx = inv_grid_x + shooter[0] * col_spacing + col_spacing // 2 + by = inv_grid_y + shooter[1] * row_spacing + row_spacing + bombs.append([bx, by]) + + # Update bullets (going up) + next_bullets = [] + for b in bullets: + b[1] -= 4 + hit = False + # Collision check with invaders + for inv in invaders: + if inv[2]: + ix = inv_grid_x + inv[0] * col_spacing + col_spacing // 2 + iy = inv_grid_y + inv[1] * row_spacing + row_spacing // 2 + if abs(b[0] - ix) < inv_col * 0 + 8 and abs(b[1] - iy) < 6: + inv[2] = False + hit = True + score += 30 + # Spawn explosion particles + for _ in range(8): + explosions.append([ix, iy, random.uniform(-2, 2), random.uniform(-2, 2), 10]) + break + if not hit and b[1] > 28: + next_bullets.append(b) + bullets = next_bullets + + # Update bombs (going down) + next_bombs = [] + for b in bombs: + b[1] += 3 + # Collision check with player ship + if abs(b[0] - ship_x) < 10 and abs(b[1] - ship_y) < 6: + death_timer = 50 # Start system error state + # Large explosion + for _ in range(20): + explosions.append([ship_x, ship_y, random.uniform(-4, 4), random.uniform(-4, 4), 20]) + break + elif b[1] < height: + next_bombs.append(b) + bombs = next_bombs + + # Update explosions + next_explosions = [] + for p in explosions: + p[0] += p[2] + p[1] += p[3] + p[4] -= 1 + if p[4] > 0: + next_explosions.append(p) + explosions = next_explosions + + # Check game over by invaders reaching bottom + max_y = max(inv_grid_y + inv[1] * row_spacing for inv in alive_invaders) + if max_y >= ship_y - 8: + death_timer = 50 + for _ in range(20): + explosions.append([ship_x, ship_y, random.uniform(-4, 4), random.uniform(-4, 4), 20]) + continue + + # Render game objects + display.clear(0) + + # Draw header + display.text(text if text else "INVADER DRIFT", 10, 10, 1) + display.text(f"SCORE: {score:05d}", width - 120, 10, 1) + display.line(0, 26, width, 26, 1) + + # Draw invaders + inv_anim_state = (frame // 10) % 2 == 0 + for inv in invaders: + if inv[2]: + ix = inv_grid_x + inv[0] * col_spacing + col_spacing // 2 + iy = inv_grid_y + inv[1] * row_spacing + row_spacing // 2 + draw_invader(display, ix, iy, inv_anim_state) + + # Draw player ship + draw_ship(display, ship_x, ship_y) + + # Draw player bullets + for b in bullets: + display.fill_rect(b[0] - 1, b[1] - 3, 2, 6, 1) + + # Draw alien bombs + for b in bombs: + display.line(b[0], b[1] - 3, b[0], b[1] + 3, 1) + display.line(b[0] - 1, b[1], b[0] + 1, b[1], 1) + + # Draw explosion particles + for p in explosions: + display.fill_rect(int(p[0]), int(p[1]), 2, 2, 1) + + display.show() + frame += 1 + sleep_ms(30) + + except KeyboardInterrupt: + print("Screensaver stopped by user interrupt.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/screensavers/asteroid_drift.py b/screensavers/asteroid_drift.py new file mode 100644 index 0000000..4a7d338 --- /dev/null +++ b/screensavers/asteroid_drift.py @@ -0,0 +1,319 @@ +# Standalone MicroPython Screensaver: Asteroid Drift +# Inspired by vector-rock space classic. Exposes run(text="", frames=None). + +import time +import math +import random + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def sleep_ms(ms): + try: + time.sleep_ms(ms) + except AttributeError: + time.sleep(ms / 1000.0) + +def check_touch(d, bc): + """Robust check for screen touch to stop the screensaver.""" + if bc is not None: + touch_inst = getattr(bc, 'touch_instance', None) + if touch_inst is not None: + if hasattr(touch_inst, 'is_touched'): + try: + if touch_inst.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_inst, meth): + try: + res = getattr(touch_inst, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + touch_val = getattr(bc, 'touch', None) + if touch_val is not None: + if callable(touch_val): + try: + res = touch_val() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + else: + if hasattr(touch_val, 'is_touched'): + try: + if touch_val.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_val, meth): + try: + res = getattr(touch_val, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + if d is not None: + for meth in ('get_touch', 'touch'): + if hasattr(d, meth): + try: + res = getattr(d, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + return False + +def make_rock(x, y, radius): + num_verts = 8 + vertices = [] + for i in range(num_verts): + angle = i * (2 * math.pi / num_verts) + vert_r = radius + random.uniform(-radius / 3.0, radius / 3.0) + dx = vert_r * math.cos(angle) + dy = vert_r * math.sin(angle) + vertices.append((dx, dy)) + + # Velocity + speed = random.uniform(0.5, 1.5) + v_angle = random.uniform(0, 2 * math.pi) + vx = speed * math.cos(v_angle) + vy = speed * math.sin(v_angle) + + return { + 'x': x, + 'y': y, + 'vx': vx, + 'vy': vy, + 'radius': radius, + 'vertices': vertices + } + +def draw_rock(d, rock): + rx, ry = rock['x'], rock['y'] + verts = rock['vertices'] + n = len(verts) + for i in range(n): + x1, y1 = rx + verts[i][0], ry + verts[i][1] + x2, y2 = rx + verts[(i+1)%n][0], ry + verts[(i+1)%n][1] + d.line(int(x1), int(y1), int(x2), int(y2), 1) + +def run(text="", frames=None): + if not display: + print("board_config.display_instance is not available. Skipping screensaver.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + + px, py = width // 2, height // 2 + theta = 0.0 + + bullets = [] # elements: {'x', 'y', 'vx', 'vy', 'life'} + rocks = [] # elements: rock dicts + explosions = [] # elements: {'x', 'y', 'vx', 'vy', 'life'} + + score = 0 + cooldown = 0 + frame = 0 + + # Spawn initial rocks + def spawn_initial_rocks(): + rocks.clear() + for _ in range(4): + # Spawn rocks away from center + rx = random.choice([random.uniform(20, px - 50), random.uniform(px + 50, width - 20)]) + ry = random.choice([random.uniform(20, py - 50), random.uniform(py + 50, height - 20)]) + rocks.append(make_rock(rx, ry, 24)) + + spawn_initial_rocks() + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + if check_touch(display, board_config): + print("Screensaver stopped by screen touch.") + break + + # If no rocks are left, spawn more + if not rocks: + score += 1000 + spawn_initial_rocks() + bullets.clear() + continue + + # AI: Target the nearest rock + nearest_rock = None + min_dist = 999999 + for r in rocks: + dx = r['x'] - px + dy = r['y'] - py + dist = dx*dx + dy*dy + if dist < min_dist: + min_dist = dist + nearest_rock = r + + if nearest_rock: + tx = nearest_rock['x'] + ty = nearest_rock['y'] + target_theta = math.atan2(ty - py, tx - px) + + # Rotate toward target + angle_diff = target_theta - theta + # Normalize angle to -pi..pi + while angle_diff > math.pi: + angle_diff -= 2 * math.pi + while angle_diff < -math.pi: + angle_diff += 2 * math.pi + + if abs(angle_diff) < 0.08: + theta = target_theta + else: + theta += 0.08 if angle_diff > 0 else -0.08 + + # Shoot if aligned and off cooldown + if abs(angle_diff) < 0.25: + if cooldown == 0: + bx = px + 12 * math.cos(theta) + by = py + 12 * math.sin(theta) + bvx = 5.0 * math.cos(theta) + bvy = 5.0 * math.sin(theta) + bullets.append({'x': bx, 'y': by, 'vx': bvx, 'vy': bvy, 'life': 45}) + cooldown = 15 + + if cooldown > 0: + cooldown -= 1 + + # Update rocks + for r in rocks: + r['x'] += r['vx'] + r['y'] += r['vy'] + + # Wrap boundaries + rad = r['radius'] + if r['x'] < -rad: + r['x'] = width + rad + elif r['x'] > width + rad: + r['x'] = -rad + + if r['y'] < -rad: + r['y'] = height + rad + elif r['y'] > height + rad: + r['y'] = -rad + + # Update bullets + next_bullets = [] + for b in bullets: + b['x'] += b['vx'] + b['y'] += b['vy'] + b['life'] -= 1 + + # Wrap + if b['x'] < 0: + b['x'] = width + elif b['x'] > width: + b['x'] = 0 + if b['y'] < 0: + b['y'] = height + elif b['y'] > height: + b['y'] = 0 + + # Collision with rocks + hit = False + for r in rocks: + rdx = b['x'] - r['x'] + rdy = b['y'] - r['y'] + if rdx*rdx + rdy*rdy < r['radius']*r['radius']: + hit = True + rocks.remove(r) + score += int(100 / r['radius'] * 10) + + # Split rock + if r['radius'] > 8: + new_r = r['radius'] // 2 + rocks.append(make_rock(r['x'], r['y'], new_r)) + rocks.append(make_rock(r['x'], r['y'], new_r)) + + # Spawn particle sparks + for _ in range(6): + explosions.append({ + 'x': r['x'], + 'y': r['y'], + 'vx': random.uniform(-2, 2), + 'vy': random.uniform(-2, 2), + 'life': 12 + }) + break + + if not hit and b['life'] > 0: + next_bullets.append(b) + bullets = next_bullets + + # Update explosions + next_explosions = [] + for p in explosions: + p['x'] += p['vx'] + p['y'] += p['vy'] + p['life'] -= 1 + if p['life'] > 0: + next_explosions.append(p) + explosions = next_explosions + + # Render frame + display.clear(0) + + # Draw header + display.text(text if text else "ASTEROID DRIFT", 10, 10, 1) + display.text(f"SCORE: {score:05d}", width - 120, 10, 1) + display.line(0, 26, width, 26, 1) + + # Draw ship (triangle) + fx = px + 12 * math.cos(theta) + fy = py + 12 * math.sin(theta) + blx = px + 8 * math.cos(theta + 2.5) + bly = py + 8 * math.sin(theta + 2.5) + brx = px + 8 * math.cos(theta - 2.5) + bry = py + 8 * math.sin(theta - 2.5) + + display.line(int(fx), int(fy), int(blx), int(bly), 1) + display.line(int(blx), int(bly), int(brx), int(bry), 1) + display.line(int(brx), int(bry), int(fx), int(fy), 1) + + # Draw small engine flame when firing + if cooldown > 10: + flame_tip_x = px - 12 * math.cos(theta) + random.uniform(-2, 2) + flame_tip_y = py - 12 * math.sin(theta) + random.uniform(-2, 2) + display.line(int(blx), int(bly), int(flame_tip_x), int(flame_tip_y), 1) + display.line(int(brx), int(bry), int(flame_tip_x), int(flame_tip_y), 1) + + # Draw rocks + for r in rocks: + draw_rock(display, r) + + # Draw bullets + for b in bullets: + display.fill_rect(int(b['x']) - 1, int(b['y']) - 1, 2, 2, 1) + + # Draw particles + for p in explosions: + display.pixel(int(p['x']), int(p['y']), 1) if hasattr(display, 'pixel') else display.fill_rect(int(p['x']), int(p['y']), 1, 1, 1) + + display.show() + frame += 1 + sleep_ms(30) + + except KeyboardInterrupt: + print("Screensaver stopped by user interrupt.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/screensavers/block_puzzle.py b/screensavers/block_puzzle.py new file mode 100644 index 0000000..a31e821 --- /dev/null +++ b/screensavers/block_puzzle.py @@ -0,0 +1,297 @@ +# Standalone MicroPython Screensaver: Block Puzzle +# Inspired by falling-block puzzle classic. Exposes run(text="", frames=None). + +import time +import math +import random + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def sleep_ms(ms): + try: + time.sleep_ms(ms) + except AttributeError: + time.sleep(ms / 1000.0) + +def check_touch(d, bc): + """Robust check for screen touch to stop the screensaver.""" + if bc is not None: + touch_inst = getattr(bc, 'touch_instance', None) + if touch_inst is not None: + if hasattr(touch_inst, 'is_touched'): + try: + if touch_inst.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_inst, meth): + try: + res = getattr(touch_inst, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + touch_val = getattr(bc, 'touch', None) + if touch_val is not None: + if callable(touch_val): + try: + res = touch_val() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + else: + if hasattr(touch_val, 'is_touched'): + try: + if touch_val.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_val, meth): + try: + res = getattr(touch_val, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + if d is not None: + for meth in ('get_touch', 'touch'): + if hasattr(d, meth): + try: + res = getattr(d, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + return False + +# Shapes relative coordinates +SHAPES = [ + [(0, 0), (1, 0), (2, 0), (3, 0)], # I + [(0, 0), (1, 0), (0, 1), (1, 1)], # O + [(1, 0), (0, 1), (1, 1), (2, 1)], # T + [(0, 0), (0, 1), (1, 1), (2, 1)], # L + [(2, 0), (0, 1), (1, 1), (2, 1)], # J + [(1, 0), (2, 0), (0, 1), (1, 1)], # S + [(0, 0), (1, 0), (1, 1), (2, 1)] # Z +] + +def rotate_coords(coords, rotation): + # Rotate coords clockwise + res = coords + for _ in range(rotation % 4): + res = [(-y, x) for x, y in res] + # Normalize coordinates so min x and min y are at least 0 (easier bounding) + min_x = min(x for x, y in res) + min_y = min(y for x, y in res) + return [(x - min_x, y - min_y) for x, y in res] + +def get_piece_dims(coords): + w = max(x for x, y in coords) - min(x for x, y in coords) + 1 + h = max(y for x, y in coords) - min(y for x, y in coords) + 1 + return w, h + +def run(text="", frames=None): + if not display: + print("board_config.display_instance is not available. Skipping screensaver.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + + # Calculate grid sizing + cell_size = (height - 40) // 20 + board_w = 10 * cell_size + board_h = 20 * cell_size + board_x = (width - board_w) // 2 + board_y = (height - board_h) // 2 + + grid = [[0 for _ in range(10)] for _ in range(20)] + score = 0 + lines_cleared = 0 + + # Game states + current_shape_idx = random.randint(0, len(SHAPES) - 1) + next_shape_idx = random.randint(0, len(SHAPES) - 1) + + # Piece rotation & coords + piece_rot = 0 + piece_coords = rotate_coords(SHAPES[current_shape_idx], piece_rot) + + # Initial piece placement + pw, ph = get_piece_dims(piece_coords) + px = (10 - pw) // 2 + py = 0 + + # Self-playing AI target + target_x = random.randint(0, 10 - pw) + target_rot = random.randint(0, 3) + + frame = 0 + game_over_delay = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + if check_touch(display, board_config): + print("Screensaver stopped by screen touch.") + break + + # If in game over state, delay and restart + if game_over_delay > 0: + game_over_delay -= 1 + if game_over_delay == 0: + grid = [[0 for _ in range(10)] for _ in range(20)] + score = 0 + lines_cleared = 0 + display.clear(0) + display.text("GAME OVER", width // 2 - 36, height // 2 - 4, 1) + display.show() + frame += 1 + sleep_ms(30) + continue + + # AI logic: adjust rotation and column position as block falls + # Try to rotate toward target rotation + if piece_rot != target_rot: + new_rot = (piece_rot + 1) % 4 + new_coords = rotate_coords(SHAPES[current_shape_idx], new_rot) + npw, nph = get_piece_dims(new_coords) + # Keep position valid when rotating + npx = max(0, min(px, 10 - npw)) + # Validate + valid = True + for dx, dy in new_coords: + tx, ty = npx + dx, py + dy + if ty >= 20 or tx < 0 or tx >= 10 or grid[ty][tx]: + valid = False + break + if valid: + piece_rot = new_rot + piece_coords = new_coords + px = npx + + # Try to move toward target column + if px != target_x: + dx_step = 1 if target_x > px else -1 + npx = px + dx_step + valid = True + for dx, dy in piece_coords: + tx, ty = npx + dx, py + dy + if ty >= 20 or tx < 0 or tx >= 10 or grid[ty][tx]: + valid = False + break + if valid: + px = npx + + # Apply gravity every 4 frames (speeds up self-play nicely) + if frame % 4 == 0: + npy = py + 1 + can_drop = True + for dx, dy in piece_coords: + tx, ty = px + dx, npy + dy + if ty >= 20 or grid[ty][tx]: + can_drop = False + break + + if can_drop: + py = npy + else: + # Lock piece + for dx, dy in piece_coords: + grid[py + dy][px + dx] = 1 + + # Check lines + lines_this_turn = 0 + for r in range(20): + if all(grid[r]): + del grid[r] + grid.insert(0, [0 for _ in range(10)]) + lines_this_turn += 1 + + if lines_this_turn > 0: + lines_cleared += lines_this_turn + score += [0, 40, 100, 300, 1200][min(lines_this_turn, 4)] + + # Spawn next piece + current_shape_idx = next_shape_idx + next_shape_idx = random.randint(0, len(SHAPES) - 1) + + piece_rot = 0 + piece_coords = rotate_coords(SHAPES[current_shape_idx], piece_rot) + pw, ph = get_piece_dims(piece_coords) + px = (10 - pw) // 2 + py = 0 + + # Set next targets + target_x = random.randint(0, 10 - pw) + target_rot = random.randint(0, 3) + + # Check game over (spawning block collides immediately) + for dx, dy in piece_coords: + if grid[py + dy][px + dx]: + game_over_delay = 60 # 2 seconds of game over display + break + + # Draw game state + display.clear(0) + + # Draw board border + display.rect(board_x - 2, board_y - 2, board_w + 4, board_h + 4, 1) + + # Draw grid contents + for r in range(20): + for c in range(10): + cx_px = board_x + c * cell_size + cy_px = board_y + r * cell_size + if grid[r][c] == 1: + display.fill_rect(cx_px + 1, cy_px + 1, cell_size - 1, cell_size - 1, 1) + + # Draw active piece + for dx, dy in piece_coords: + cx_px = board_x + (px + dx) * cell_size + cy_px = board_y + (py + dy) * cell_size + if 0 <= py + dy < 20: + display.fill_rect(cx_px + 1, cy_px + 1, cell_size - 1, cell_size - 1, 1) + # Add a small white dot in falling piece for visual difference + display.fill_rect(cx_px + cell_size//2, cy_px + cell_size//2, 2, 2, 0) + + # Side panel (Left) - Title & Info + display.text(text if text else "FALLING", 10, 20, 1) + display.text("PUZZLE", 10, 32, 1) + display.text(f"SCORE:", 10, 80, 1) + display.text(f"{score:05d}", 10, 92, 1) + display.text(f"LINES:", 10, 130, 1) + display.text(f"{lines_cleared}", 10, 142, 1) + + # Side panel (Right) - Next piece preview + preview_x = board_x + board_w + 20 + display.text("NEXT:", preview_x, 20, 1) + display.rect(preview_x, 35, 50, 50, 1) + + next_coords = rotate_coords(SHAPES[next_shape_idx], 0) + npw, nph = get_piece_dims(next_coords) + off_x = preview_x + (50 - npw * cell_size) // 2 + off_y = 35 + (50 - nph * cell_size) // 2 + + for dx, dy in next_coords: + display.fill_rect(off_x + dx * cell_size + 1, off_y + dy * cell_size + 1, cell_size - 1, cell_size - 1, 1) + + display.show() + frame += 1 + sleep_ms(30) + + except KeyboardInterrupt: + print("Screensaver stopped by user interrupt.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/screensavers/manifest.json b/screensavers/manifest.json new file mode 100644 index 0000000..5c56869 --- /dev/null +++ b/screensavers/manifest.json @@ -0,0 +1,29 @@ +{ + "screensavers": [ + { + "filename": "maze_chomper.py", + "inspired_by_style": "maze-chase arcade classic", + "purpose": "A wedge-shaped chomper navigates a grid maze eating dots while being chased by a ghost. If they collide, they reset after showing a collision warning." + }, + { + "filename": "block_puzzle.py", + "inspired_by_style": "falling-block puzzle classic", + "purpose": "Falling tetromino blocks are auto-arranged by a self-playing heuristic, aligning and locking into a grid. Full lines are cleared, and a game over triggers a reset when the grid fills to the top." + }, + { + "filename": "alien_invaders.py", + "inspired_by_style": "alien-wave shooter classic", + "purpose": "A grid of crawling invaders crawls across and down the screen. An automated player tank tracks the closest invaders and shoots them down, dodging falling enemy bombs." + }, + { + "filename": "asteroid_drift.py", + "inspired_by_style": "vector-rock space classic", + "purpose": "A central vector-style triangular ship rotates to automatically track and shoot laser bullets at drifting wireframe rocks. Shot rocks split into smaller rocks and wrap around borders." + }, + { + "filename": "paddle_bounce.py", + "inspired_by_style": "paddle-ball arcade classic", + "purpose": "Two automated paddles play table tennis with a bouncing ball that speeds up with each hit. Paddles track the ball with minor errors to allow points to be scored." + } + ] +} diff --git a/screensavers/maze_chomper.py b/screensavers/maze_chomper.py new file mode 100644 index 0000000..fce801e --- /dev/null +++ b/screensavers/maze_chomper.py @@ -0,0 +1,289 @@ +# Standalone MicroPython Screensaver: Maze Chomper +# Inspired by maze-chase arcade classic. Exposes run(text="", frames=None). + +import time +import math +import random + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def sleep_ms(ms): + try: + time.sleep_ms(ms) + except AttributeError: + time.sleep(ms / 1000.0) + +def check_touch(d, bc): + """Robust check for screen touch to stop the screensaver.""" + if bc is not None: + touch_inst = getattr(bc, 'touch_instance', None) + if touch_inst is not None: + if hasattr(touch_inst, 'is_touched'): + try: + if touch_inst.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_inst, meth): + try: + res = getattr(touch_inst, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + touch_val = getattr(bc, 'touch', None) + if touch_val is not None: + if callable(touch_val): + try: + res = touch_val() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + else: + if hasattr(touch_val, 'is_touched'): + try: + if touch_val.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_val, meth): + try: + res = getattr(touch_val, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + if d is not None: + for meth in ('get_touch', 'touch'): + if hasattr(d, meth): + try: + res = getattr(d, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + return False + +def random_choice(seq): + if not seq: + return None + return seq[random.randint(0, len(seq) - 1)] + +def draw_chomper(d, cx, cy, rad, direction, mouth_open): + # Draw body + for dy in range(-rad, rad + 1): + dx = int(math.sqrt(rad*rad - dy*dy)) + d.line(cx - dx, cy + dy, cx + dx, cy + dy, 1) + + # Draw open mouth + if mouth_open: + dx_dir, dy_dir = direction + if dx_dir == 0 and dy_dir == 0: + dx_dir = 1 + + if dx_dir > 0: + for dy in range(-rad//2, rad//2 + 1): + mx = rad - abs(dy) * 2 + d.line(cx, cy + dy, cx + mx, cy + dy, 0) + elif dx_dir < 0: + for dy in range(-rad//2, rad//2 + 1): + mx = rad - abs(dy) * 2 + d.line(cx - mx, cy + dy, cx, cy + dy, 0) + elif dy_dir > 0: + for dx in range(-rad//2, rad//2 + 1): + my = rad - abs(dx) * 2 + d.line(cx + dx, cy, cx + dx, cy + my, 0) + elif dy_dir < 0: + for dx in range(-rad//2, rad//2 + 1): + my = rad - abs(dx) * 2 + d.line(cx + dx, cy - my, cx + dx, cy, 0) + +def draw_ghost(d, gcx, gcy, rad): + for dy in range(-rad, 0): + dx = int(math.sqrt(rad*rad - dy*dy)) + d.line(gcx - dx, gcy + dy, gcx + dx, gcy + dy, 1) + d.fill_rect(gcx - rad, gcy, rad * 2 + 1, rad, 1) + eye_offset = rad // 3 + d.fill_rect(gcx - eye_offset - 1, gcy - eye_offset, 2, 2, 0) + d.fill_rect(gcx + eye_offset - 1, gcy - eye_offset, 2, 2, 0) + for i in range(gcx - rad, gcx + rad + 1, 4): + d.fill_rect(i, gcy + rad - 2, 2, 3, 0) + +def run(text="", frames=None): + if not display: + print("board_config.display_instance is not available. Skipping screensaver.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + + header_h = 30 + grid_y_offset = header_h + cell_size = 40 + + cols = width // cell_size + rows = (height - header_h) // cell_size + + grid = [] + for r in range(rows): + row_data = [] + for c in range(cols): + is_wall = False + if r == 0 or r == rows - 1 or c == 0 or c == cols - 1: + is_wall = True + elif (r == 2 or r == rows - 3) and (2 <= c <= 3 or cols - 4 <= c <= cols - 3): + is_wall = True + elif r == rows // 2 and (cols // 2 - 1 <= c <= cols // 2 + 1): + is_wall = True + row_data.append(2 if is_wall else 1) + grid.append(row_data) + + score = 0 + chomper_x, chomper_y = cell_size, grid_y_offset + cell_size + chomper_dx, chomper_dy = 1, 0 + + ghost_x, ghost_y = (cols - 2) * cell_size, grid_y_offset + (rows - 2) * cell_size + ghost_dx, ghost_dy = -1, 0 + + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + if check_touch(display, board_config): + print("Screensaver stopped by screen touch.") + break + + chomper_rel_y = chomper_y - grid_y_offset + if chomper_x % cell_size == 0 and chomper_rel_y % cell_size == 0: + cx, cy = chomper_x // cell_size, chomper_rel_y // cell_size + if grid[cy][cx] == 1: + grid[cy][cx] = 0 + score += 10 + + has_dots = False + for r_data in grid: + if 1 in r_data: + has_dots = True + break + if not has_dots: + for r in range(rows): + for c in range(cols): + if grid[r][c] == 0: + grid[r][c] = 1 + + valid_dirs = [] + dot_dirs = [] + for ndx, ndy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + nx, ny = cx + ndx, cy + ndy + if 0 <= nx < cols and 0 <= ny < rows: + if grid[ny][nx] != 2: + valid_dirs.append((ndx, ndy)) + if grid[ny][nx] == 1: + dot_dirs.append((ndx, ndy)) + + if dot_dirs: + if (chomper_dx, chomper_dy) in dot_dirs: + pass + else: + chomper_dx, chomper_dy = random_choice(dot_dirs) + elif valid_dirs: + if (chomper_dx, chomper_dy) in valid_dirs and random.random() > 0.3: + pass + else: + chomper_dx, chomper_dy = random_choice(valid_dirs) + else: + chomper_dx, chomper_dy = 0, 0 + + ghost_rel_y = ghost_y - grid_y_offset + if ghost_x % cell_size == 0 and ghost_rel_y % cell_size == 0: + gcx, gcy = ghost_x // cell_size, ghost_rel_y // cell_size + + valid_gdirs = [] + for ndx, ndy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + if ndx == -ghost_dx and ndy == -ghost_dy: + continue + nx, ny = gcx + ndx, gcy + ndy + if 0 <= nx < cols and 0 <= ny < rows: + if grid[ny][nx] != 2: + valid_gdirs.append((ndx, ndy)) + + if not valid_gdirs: + valid_gdirs = [(-ghost_dx, -ghost_dy)] + + best_dir = valid_gdirs[0] + min_dist = 999999 + ccx, ccy = chomper_x // cell_size, chomper_rel_y // cell_size + for ndx, ndy in valid_gdirs: + nx, ny = gcx + ndx, gcy + ndy + dist = (nx - ccx)**2 + (ny - ccy)**2 + if dist < min_dist: + min_dist = dist + best_dir = (ndx, ndy) + + if random.random() < 0.25: + ghost_dx, ghost_dy = random_choice(valid_gdirs) + else: + ghost_dx, ghost_dy = best_dir + + chomper_x += chomper_dx * 2 + chomper_y += chomper_dy * 2 + + ghost_x += ghost_dx * 2 + ghost_y += ghost_dy * 2 + + dist_sq = (chomper_x - ghost_x)**2 + (chomper_y - ghost_y)**2 + if dist_sq < 256: + display.clear(0) + display.text("COLLISION!", width // 2 - 40, height // 2 - 10, 1) + display.show() + sleep_ms(800) + chomper_x, chomper_y = cell_size, grid_y_offset + cell_size + chomper_dx, chomper_dy = 1, 0 + ghost_x, ghost_y = (cols - 2) * cell_size, grid_y_offset + (rows - 2) * cell_size + ghost_dx, ghost_dy = -1, 0 + frame += 1 + continue + + display.clear(0) + + # Header + display.text(text if text else "MAZE CHOMPER", 10, 10, 1) + display.text(f"SCORE: {score:05d}", width - 120, 10, 1) + display.line(0, 28, width, 28, 1) + + # Grid / Walls / Dots + for r in range(rows): + for c in range(cols): + cell_val = grid[r][c] + cx_px = c * cell_size + cy_px = grid_y_offset + r * cell_size + if cell_val == 2: + display.fill_rect(cx_px + 2, cy_px + 2, cell_size - 4, cell_size - 4, 1) + elif cell_val == 1: + display.fill_rect(cx_px + cell_size//2 - 1, cy_px + cell_size//2 - 1, 3, 3, 1) + + # Sprites + mouth_open = (frame % 4) < 2 + draw_chomper(display, chomper_x + cell_size//2, chomper_y + cell_size//2, 12, (chomper_dx, chomper_dy), mouth_open) + draw_ghost(display, ghost_x + cell_size//2, ghost_y + cell_size//2, 12) + + display.show() + frame += 1 + sleep_ms(30) + + except KeyboardInterrupt: + print("Screensaver stopped by user interrupt.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/screensavers/paddle_bounce.py b/screensavers/paddle_bounce.py new file mode 100644 index 0000000..8fcb742 --- /dev/null +++ b/screensavers/paddle_bounce.py @@ -0,0 +1,223 @@ +# Standalone MicroPython Screensaver: Paddle Bounce +# Inspired by paddle-ball arcade classic. Exposes run(text="", frames=None). + +import time +import math +import random + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def sleep_ms(ms): + try: + time.sleep_ms(ms) + except AttributeError: + time.sleep(ms / 1000.0) + +def check_touch(d, bc): + """Robust check for screen touch to stop the screensaver.""" + if bc is not None: + touch_inst = getattr(bc, 'touch_instance', None) + if touch_inst is not None: + if hasattr(touch_inst, 'is_touched'): + try: + if touch_inst.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_inst, meth): + try: + res = getattr(touch_inst, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + touch_val = getattr(bc, 'touch', None) + if touch_val is not None: + if callable(touch_val): + try: + res = touch_val() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + else: + if hasattr(touch_val, 'is_touched'): + try: + if touch_val.is_touched(): + return True + except Exception: + pass + for meth in ('get_touch', 'read_touch'): + if hasattr(touch_val, meth): + try: + res = getattr(touch_val, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + if d is not None: + for meth in ('get_touch', 'touch'): + if hasattr(d, meth): + try: + res = getattr(d, meth)() + if res is not None and res is not False and res != 0: + return True + except Exception: + pass + return False + +def run(text="", frames=None): + if not display: + print("board_config.display_instance is not available. Skipping screensaver.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + + # Paddle parameters + pad_w = 6 + pad_h = 36 + left_x = 15 + right_x = width - 15 - pad_w + + # Initial states + ly = (height - pad_h) // 2 + ry = (height - pad_h) // 2 + + bx, by = width // 2, height // 2 + vx, vy = 3.5, 1.8 + + score_l = 0 + score_r = 0 + + paddle_speed = 3.0 + + # Simulated tracking error offset + target_offset_l = 0 + target_offset_r = 0 + + def reset_ball(serve_to_left): + nonlocal bx, by, vx, vy, target_offset_l, target_offset_r + bx = width // 2 + by = height // 2 + vx = -3.5 if serve_to_left else 3.5 + vy = random.choice([-2.0, -1.0, 1.0, 2.0]) + target_offset_l = random.randint(-12, 12) + target_offset_r = random.randint(-12, 12) + + frame = 0 + serve_delay = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + if check_touch(display, board_config): + print("Screensaver stopped by screen touch.") + break + + if serve_delay > 0: + serve_delay -= 1 + else: + # Move ball + bx += vx + by += vy + + # Ceiling/floor bounce + if by <= 32: + by = 32 + vy = -vy + elif by >= height - 8: + by = height - 8 + vy = -vy + + # AI tracking + if vx < 0: # Ball heading Left + # Track ball center with error offset + target_y = by + 3 - (pad_h // 2) + target_offset_l + if ly < target_y: + ly = min(height - pad_h - 6, ly + paddle_speed) + elif ly > target_y: + ly = max(32, ly - paddle_speed) + else: # Ball heading Right + target_y = by + 3 - (pad_h // 2) + target_offset_r + if ry < target_y: + ry = min(height - pad_h - 6, ry + paddle_speed) + elif ry > target_y: + ry = max(32, ry - paddle_speed) + + # Check Left boundary + if bx <= left_x + pad_w: + if left_x <= bx: + # Ball within x-range of paddle, check y-collision + if ly - 3 <= by <= ly + pad_h + 3: + bx = left_x + pad_w + # Bounce & increase speed + vx = -vx * 1.05 + # Cap horizontal speed to prevent teleporting + if abs(vx) > 8.0: + vx = 8.0 if vx > 0 else -8.0 + vy = vy * 1.05 + random.uniform(-0.6, 0.6) + # Assign new tracking error for right paddle + target_offset_r = random.randint(-15, 15) + else: + # Miss! Point for Right + score_r += 1 + reset_ball(serve_to_left=False) + serve_delay = 40 + + # Check Right boundary + if bx + 6 >= right_x: + if bx <= right_x + pad_w: + # Ball within x-range of paddle, check y-collision + if ry - 3 <= by <= ry + pad_h + 3: + bx = right_x - 6 + # Bounce & increase speed + vx = -vx * 1.05 + if abs(vx) > 8.0: + vx = 8.0 if vx > 0 else -8.0 + vy = vy * 1.05 + random.uniform(-0.6, 0.6) + # Assign new tracking error for left paddle + target_offset_l = random.randint(-15, 15) + else: + # Miss! Point for Left + score_l += 1 + reset_ball(serve_to_left=True) + serve_delay = 40 + + # Draw frame + display.clear(0) + + # Header score line + display.text(text if text else "PADDLE BOUNCE", 10, 10, 1) + display.text(f"{score_l:02d} {score_r:02d}", width // 2 - 24, 10, 1) + display.line(0, 26, width, 26, 1) + + # Draw center dotted line + for y in range(30, height, 16): + display.fill_rect(width // 2 - 1, y, 2, 8, 1) + + # Draw paddles + display.fill_rect(left_x, int(ly), pad_w, pad_h, 1) + display.fill_rect(right_x, int(ry), pad_w, pad_h, 1) + + # Draw ball (6x6 square) + if serve_delay == 0 or (serve_delay // 4) % 2 == 0: + display.fill_rect(int(bx), int(by), 6, 6, 1) + + display.show() + frame += 1 + sleep_ms(30) + + except KeyboardInterrupt: + print("Screensaver stopped by user interrupt.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/README.md b/voice_animations/README.md new file mode 100644 index 0000000..f8256a0 --- /dev/null +++ b/voice_animations/README.md @@ -0,0 +1,70 @@ +# Reyna little32 ESP32-S3 Voice State Animations + +A collection of 14 standalone MicroPython animation scripts designed for the Reyna little32 ESP32-S3 reflective screen board. These animations support voice gateway visual states instead of a spoken initial acknowledgement (ACK). + +## Hardware & API Assumptions +- **Display Size:** Auto-scaling (reads `display.width` and `display.height`; defaults to 400x300 for RLCD, but supports others). +- **Colors:** Designed for monochrome-ish 0 (White/Background) and 1 (Black/Foreground) high-contrast visuals. +- **Methods Used:** `clear(c)`, `fill_rect(x,y,w,h,c)`, `rect(x,y,w,h,c)`, `line(x1,y1,x2,y2,c)`, `text(msg,x,y,c)`, `show()`. +- **Standalone:** Each script is entirely self-contained. It can be uploaded individually to board flash and requires no helper modules. +- **Robustness:** Gracefully falls back and prints a warning if `board_config` or `board_config.display_instance` is missing. + +--- + +## Animation List & State Mappings + +| Filename | Voice States / Purposes | Visual Behavior | +| :--- | :--- | :--- | +| **[ack_pulse.py](file:///home/adolforeyna/Projects/Screen/voice_animations/ack_pulse.py)** | `ack`, `received`, `got_it` | Bold checkmark with expanding concentric radar pulses. | +| **[listening_wave.py](file:///home/adolforeyna/Projects/Screen/voice_animations/listening_wave.py)** | `listening`, `audio_input` | A decorative mic icon at the top and dual active overlapping sine waves. | +| **[caption_scan.py](file:///home/adolforeyna/Projects/Screen/voice_animations/caption_scan.py)** | `captioning`, `transcribing` | Simulated typing log with scanner sweeps and blinking typewriter cursors. | +| **[waiting_orbit.py](file:///home/adolforeyna/Projects/Screen/voice_animations/waiting_orbit.py)** | `waiting`, `thinking`, `processing` | 4 circular ring nodes orbiting a central pulsing energy reactor core. | +| **[working_gears.py](file:///home/adolforeyna/Projects/Screen/voice_animations/working_gears.py)** | `working`, `tool_use`, `executing` | Large and small mechanical gears interlocking and rotating in opposite directions. | +| **[speaking_mouth.py](file:///home/adolforeyna/Projects/Screen/voice_animations/speaking_mouth.py)** | `speaking`, `audio_output` | Equalizer voiceprint spectrum bars bouncing symmetrically from a center axis. | +| **[success_spark.py](file:///home/adolforeyna/Projects/Screen/voice_animations/success_spark.py)** | `success`, `done`, `completed` | Bold checkmark inside a blooming radial starburst explosion. | +| **[error_alert.py](file:///home/adolforeyna/Projects/Screen/voice_animations/error_alert.py)** | `error`, `failure`, `alert` | Flashing bold exclamation triangle with diagonal warning hazard stripes. | +| **[network_retry.py](file:///home/adolforeyna/Projects/Screen/voice_animations/network_retry.py)** | `network_retry`, `reconnecting` | Wi-Fi waves lighting up sequentially inside looping dashed reload arrows. | +| **[battery_low.py](file:///home/adolforeyna/Projects/Screen/voice_animations/battery_low.py)** | `battery_low`, `low_power` | Battery outline with tips, flashing empty charge block, and warning triangle. | +| **[update_progress.py](file:///home/adolforeyna/Projects/Screen/voice_animations/update_progress.py)** | `update`, `uploading`, `downloading` | Sliding download arrow dropping into a tray above a percentage progress bar. | +| **[breathing_idle.py](file:///home/adolforeyna/Projects/Screen/voice_animations/breathing_idle.py)** | `idle`, `ready`, `breathing` | Concentric squares and accent dots scaling slowly in a calm breathing rhythm. | +| **[wake_attention.py](file:///home/adolforeyna/Projects/Screen/voice_animations/wake_attention.py)** | `wake`, `attention`, `active` | Stylized robot eyes blinking and shifting gaze left and right to look around. | +| **[kid_companion.py](file:///home/adolforeyna/Projects/Screen/voice_animations/kid_companion.py)** | `kid_friendly`, `companion` | Friendly robot head with wiggling ears, a winking eye, and a warm smile. | + +--- + +## Execution Guide + +### 1. Uploading to Flash +You can upload the desired animation files using tools like `ampy`, `rshell`, `mpremote`, or via the board's web console. E.g. +```bash +mpremote cp voice_animations/*.py : +``` + +### 2. Execution from REPL (Script Mode) +Run any script automatically with default parameters using `exec`: +```python +# Runs for 100 frames (approx. 5 seconds) and exits +exec(open("ack_pulse.py").read()) +``` + +### 3. Importing and Running (Module Mode) +You can import the module and call its `run` method directly. The `run` method supports text customization and infinite looping: +```python +import listening_wave + +# Run with custom caption text for 50 frames +listening_wave.run(text="HOTWORD CAPTURED", frames=50) + +# Run indefinitely (blocking loop, break with Ctrl+C) +listening_wave.run(text="READY TO HEAR", frames=-1) +``` + +--- + +## Technical Details + +### Customizing Loop Duration +In the `run()` function: +- Set `frames > 0` to run for a finite duration (useful for transition ACK animations). +- Set `frames=None` or `frames=-1` (or omit) to run indefinitely inside a `while True` loop. +- All scripts catch `KeyboardInterrupt` to exit gracefully, leaving the screen in a clean state if interrupted. diff --git a/voice_animations/ack_pulse.py b/voice_animations/ack_pulse.py new file mode 100644 index 0000000..183c72b --- /dev/null +++ b/voice_animations/ack_pulse.py @@ -0,0 +1,68 @@ +# Standalone MicroPython Voice Animation: ACK / Received +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "GOT IT" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw a thick checkmark in the center + display.line(cx - 24, cy + 4, cx - 6, cy + 22, 1) + display.line(cx - 23, cy + 4, cx - 5, cy + 22, 1) + display.line(cx - 6, cy + 22, cx + 30, cy - 14, 1) + display.line(cx - 5, cy + 22, cx + 31, cy - 14, 1) + + # Concentric pulsing squares radiating outwards + for p in range(3): + # Scale pulse radius based on frame count + r = 30 + ((frame * 4 + p * 30) % 90) + display.rect(cx - r, cy - r, r * 2, r * 2, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(50) + except AttributeError: + time.sleep(0.05) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/battery_low.py b/voice_animations/battery_low.py new file mode 100644 index 0000000..97b5730 --- /dev/null +++ b/voice_animations/battery_low.py @@ -0,0 +1,91 @@ +# Standalone MicroPython Voice Animation: Battery Low +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "LOW BATTERY!" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Center coordinates adjusted for warning triangle on the left side + # Battery offset to the right + bat_x = cx + 20 + bat_y = cy - 10 + + # Draw battery container outline + display.rect(bat_x - 65, bat_y - 30, 115, 60, 1) + display.rect(bat_x - 64, bat_y - 29, 113, 58, 1) # thicker + # Battery cap tip + display.fill_rect(bat_x + 50, bat_y - 12, 10, 24, 1) + + # Low charge blinking segment on the left + blink_on = (frame // 4) % 2 == 0 + if blink_on: + # Draw small low charge bar + display.fill_rect(bat_x - 58, bat_y - 23, 25, 46, 1) + + # Draw a warning triangle on the left + warn_x = cx - 110 + warn_y = cy - 10 + + # Warning triangle outline + display.line(warn_x, warn_y - 25, warn_x - 25, warn_y + 20, 1) + display.line(warn_x, warn_y - 25, warn_x + 25, warn_y + 20, 1) + display.line(warn_x - 25, warn_y + 20, warn_x + 25, warn_y + 20, 1) + + # Warning exclamation mark + display.fill_rect(warn_x - 2, warn_y - 8, 4, 16, 1) + display.fill_rect(warn_x - 2, warn_y + 11, 4, 4, 1) + + # Draw diagonal warning grid lines in battery background if empty to look stylish + if not blink_on: + for diag in range(-20, 40, 10): + display.line(bat_x - 58, bat_y - 23 + diag, bat_x - 38, bat_y + 23 + diag, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(60) + except AttributeError: + time.sleep(0.06) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/breathing_idle.py b/voice_animations/breathing_idle.py new file mode 100644 index 0000000..455697a --- /dev/null +++ b/voice_animations/breathing_idle.py @@ -0,0 +1,80 @@ +# Standalone MicroPython Voice Animation: Breathing Idle +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "READY" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Cosine-based breathing curve: slow and smooth, scaling from 0 to 1 and back + breath_scale = 0.5 + 0.5 * math.cos(frame * 0.07) + + # Draw three concentric breathing boxes + # Base radii are scaled dynamically + r1 = int(24 + 32 * breath_scale) + r2 = int(14 + 18 * breath_scale) + r3 = int(6 + 8 * breath_scale) + + # Outer box + display.rect(cx - r1, cy - r1, r1 * 2, r1 * 2, 1) + # Middle box + display.rect(cx - r2, cy - r2, r2 * 2, r2 * 2, 1) + # Inner solid core + display.fill_rect(cx - r3, cy - r3, r3 * 2, r3 * 2, 1) + + # Draw small accent dots at the corners of the outer breathing box + accent = r1 + 8 + display.fill_rect(cx - accent - 2, cy - accent - 2, 4, 4, 1) + display.fill_rect(cx + accent - 2, cy - accent - 2, 4, 4, 1) + display.fill_rect(cx - accent - 2, cy + accent - 2, 4, 4, 1) + display.fill_rect(cx + accent - 2, cy + accent - 2, 4, 4, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(65) + except AttributeError: + time.sleep(0.065) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/caption_scan.py b/voice_animations/caption_scan.py new file mode 100644 index 0000000..9632d83 --- /dev/null +++ b/voice_animations/caption_scan.py @@ -0,0 +1,111 @@ +# Standalone MicroPython Voice Animation: Caption Scan +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def wrap_text(msg, max_chars=36): + words = msg.split() + lines = [] + curr = "" + for w in words: + if len(curr) + len(w) + 1 > max_chars: + lines.append(curr) + curr = w + else: + curr = (curr + " " + w).strip() + if curr: + lines.append(curr) + return lines + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + # Simulated text sequence if no real-time caption text is supplied + default_script = [ + "RECEIVING VOICE PACKETS...", + "DECODING AUDIO STREAM...", + "MATCHING PHONEME VECTORS...", + "EXTRACTING INTENT AND CONTEXT...", + "STT METRICS: LATENCY 42MS", + "TRANSCRIPTION STABILIZED.", + "AWAITING PIPELINE RESPONSE..." + ] + + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw outer scan framing + display.rect(15, 15, width - 30, height - 50, 1) + + # Draw moving scanner bar line + scan_y = 20 + ((frame * 6) % (height - 60)) + display.line(20, scan_y, width - 20, scan_y, 1) + + # Draw static top status line + display.text("SPEECH-TO-TEXT DECODER", 25, 25, 1) + display.line(25, 37, width - 25, 37, 1) + + # Determine what text to write based on typewriter frame counter + if text: + lines = wrap_text(text, max_chars=(width - 50) // 8) + char_limit = int(frame * 2.5) + chars_drawn = 0 + for idx, line in enumerate(lines): + if chars_drawn >= char_limit: + break + line_to_draw = line[:char_limit - chars_drawn] + display.text(line_to_draw, 25, 55 + idx * 18, 1) + chars_drawn += len(line) + else: + # Typewriter animation of simulated lines + active_line_idx = (frame // 15) % len(default_script) + char_limit = int((frame % 15) * 3) + + # Show previously printed lines in full + start_line = max(0, active_line_idx - 5) + for idx in range(start_line, active_line_idx): + display.text(default_script[idx], 25, 55 + (idx - start_line) * 18, 1) + + # Typewriter effect on the current line + curr_line_text = default_script[active_line_idx][:char_limit] + display.text(curr_line_text, 25, 55 + (active_line_idx - start_line) * 18, 1) + + # Flash a cursor at the end of the typing line + if (frame // 3) % 2 == 0: + cursor_x = 25 + len(curr_line_text) * 8 + cursor_y = 55 + (active_line_idx - start_line) * 18 + display.fill_rect(cursor_x, cursor_y, 8, 8, 1) + + # Draw bottom transcription indicator + display.text("TRANSCRIBING...", 25, height - 25, 1) + + display.show() + frame += 1 + + try: + time.sleep_ms(60) + except AttributeError: + time.sleep(0.06) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/error_alert.py b/voice_animations/error_alert.py new file mode 100644 index 0000000..331669b --- /dev/null +++ b/voice_animations/error_alert.py @@ -0,0 +1,95 @@ +# Standalone MicroPython Voice Animation: Error Alert +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "ERROR OCCURRED" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Blink animation state + # Flashes between standard warning symbol and high-contrast double border + blink_state = (frame // 4) % 2 + + # Base warning triangle dimensions + ty_top = cy - 40 + ty_bottom = cy + 30 + tx_left = cx - 45 + tx_right = cx + 45 + + if blink_state == 0: + # Draw single bold warning triangle + display.line(cx, ty_top, tx_left, ty_bottom, 1) + display.line(cx, ty_top, tx_right, ty_bottom, 1) + display.line(tx_left, ty_bottom, tx_right, ty_bottom, 1) + else: + # Draw double thick warning triangle for "flashing" impact + display.line(cx, ty_top - 3, tx_left - 4, ty_bottom + 2, 1) + display.line(cx, ty_top - 3, tx_right + 4, ty_bottom + 2, 1) + display.line(tx_left - 4, ty_bottom + 2, tx_right + 4, ty_bottom + 2, 1) + + display.line(cx, ty_top, tx_left, ty_bottom, 1) + display.line(cx, ty_top, tx_right, ty_bottom, 1) + display.line(tx_left, ty_bottom, tx_right, ty_bottom, 1) + + # Draw Exclamation mark inside the triangle + # Exclamation bar + display.fill_rect(cx - 3, cy - 15, 6, 22, 1) + # Exclamation dot + display.fill_rect(cx - 3, cy + 13, 6, 6, 1) + + # Draw warning stripes on the left and right edges of the screen + stripe_y = (frame * 3) % 20 + for sy in range(-20, height + 20, 20): + # Left stripe + display.line(5, sy + stripe_y, 15, sy + stripe_y + 10, 1) + # Right stripe + display.line(width - 15, sy + stripe_y, width - 5, sy + stripe_y + 10, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(60) + except AttributeError: + time.sleep(0.06) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/kid_companion.py b/voice_animations/kid_companion.py new file mode 100644 index 0000000..e36cf16 --- /dev/null +++ b/voice_animations/kid_companion.py @@ -0,0 +1,109 @@ +# Standalone MicroPython Voice Animation: Kid Companion Robot +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "HELLO BUDDY!" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Robot head main box + head_y = cy - 15 + display.rect(cx - 50, head_y - 40, 100, 80, 1) + display.rect(cx - 49, head_y - 39, 98, 78, 1) # thicker + + # Neck + display.fill_rect(cx - 10, head_y + 40, 20, 12, 1) + + # Ears wiggling dynamically using sin waves + ear_offset = int(5 * math.sin(frame * 0.25)) + # Left ear + display.rect(cx - 62, head_y - 15 + ear_offset, 12, 30, 1) + display.line(cx - 62, head_y + ear_offset, cx - 50, head_y, 1) + # Right ear + display.rect(cx + 50, head_y - 15 - ear_offset, 12, 30, 1) + display.line(cx + 62, head_y - ear_offset, cx + 50, head_y, 1) + + # Antenna extending from head top + display.line(cx, head_y - 40, cx, head_y - 58, 1) + display.line(cx - 1, head_y - 40, cx - 1, head_y - 58, 1) + + # Antenna bulb winks (flashes) + bulb_on = (frame // 4) % 2 == 0 + if bulb_on: + display.fill_rect(cx - 6, head_y - 69, 12, 12, 1) + else: + display.rect(cx - 6, head_y - 69, 12, 12, 1) + + # Eyes + eye_y = head_y - 12 + # Left Eye (always open, cute square with highlight) + display.rect(cx - 32, eye_y - 8, 16, 16, 1) + display.fill_rect(cx - 28, eye_y - 4, 8, 8, 1) + display.fill_rect(cx - 26, eye_y - 2, 3, 3, 0) # highlight + + # Right Eye winks every 12 frames + is_winking = (frame // 10) % 2 == 1 + if is_winking: + # Wink line (happy curved line) + display.line(cx + 12, eye_y, cx + 20, eye_y + 4, 1) + display.line(cx + 20, eye_y + 4, cx + 28, eye_y, 1) + else: + # Open right eye + display.rect(cx + 16, eye_y - 8, 16, 16, 1) + display.fill_rect(cx + 20, eye_y - 4, 8, 8, 1) + display.fill_rect(cx + 22, eye_y - 2, 3, 3, 0) # highlight + + # Cute happy smile mouth + display.line(cx - 16, head_y + 16, cx + 16, head_y + 16, 1) + display.line(cx - 16, head_y + 16, cx - 20, head_y + 10, 1) + display.line(cx + 16, head_y + 16, cx + 20, head_y + 10, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(60) + except AttributeError: + time.sleep(0.06) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/listening_wave.py b/voice_animations/listening_wave.py new file mode 100644 index 0000000..a98b1ff --- /dev/null +++ b/voice_animations/listening_wave.py @@ -0,0 +1,88 @@ +# Standalone MicroPython Voice Animation: Listening Wave +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "LISTENING" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw a microphone icon at the top-center + mic_y = 35 + display.rect(cx - 8, mic_y, 16, 22, 1) # Capsule + display.line(cx - 12, mic_y + 10, cx - 12, mic_y + 18, 1) # Cup left + display.line(cx - 12, mic_y + 18, cx + 12, mic_y + 18, 1) # Cup bottom + display.line(cx + 12, mic_y + 18, cx + 12, mic_y + 10, 1) # Cup right + display.line(cx, mic_y + 22, cx, mic_y + 28, 1) # Stand stem + display.line(cx - 10, mic_y + 28, cx + 10, mic_y + 28, 1) # Stand base + + # Draw overlapping sine waves simulating microphone input + # Adjust wave amplitude with a breathing factor + breath = 0.7 + 0.3 * math.sin(frame * 0.15) + + # Draw Wave 1 (larger, main wave) + prev_y1 = cy + for x in range(30, width - 30, 4): + val = math.sin((x + frame * 10) * 0.035) * math.sin(frame * 0.08) + y1 = cy + int(breath * 35 * val) + if x > 30: + display.line(x - 4, prev_y1, x, y1, 1) + prev_y1 = y1 + + # Draw Wave 2 (smaller, high frequency wave) + prev_y2 = cy + for x in range(30, width - 30, 4): + val = math.sin((x - frame * 14) * 0.07) * math.cos(frame * 0.1) + y2 = cy + int(breath * 18 * val) + if x > 30: + display.line(x - 4, prev_y2, x, y2, 1) + prev_y2 = y2 + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(50) + except AttributeError: + time.sleep(0.05) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/manifest.json b/voice_animations/manifest.json new file mode 100644 index 0000000..956c652 --- /dev/null +++ b/voice_animations/manifest.json @@ -0,0 +1,74 @@ +{ + "voice_animations": [ + { + "filename": "ack_pulse.py", + "states": ["ack", "received", "got_it"], + "description": "Expanding concentric pulse squares around a central bold checkmark, representing an initial command receipt ACK." + }, + { + "filename": "listening_wave.py", + "states": ["listening", "audio_input"], + "description": "An active dual-sinusoid voice waveform and microphone outline indicating active microphone capture." + }, + { + "filename": "caption_scan.py", + "states": ["captioning", "transcribing", "speech_to_text"], + "description": "Real-time horizontal text line simulator with a typewriter character printer and vertical scanner sweep." + }, + { + "filename": "waiting_orbit.py", + "states": ["waiting", "thinking", "processing"], + "description": "Orbital loading nodes revolving around a central pulsing engine core, indicating wait/server processing state." + }, + { + "filename": "working_gears.py", + "states": ["working", "tool_use", "executing"], + "description": "Interlocking gears rotating in opposite directions, indicating background computation or external tool invocation." + }, + { + "filename": "speaking_mouth.py", + "states": ["speaking", "audio_output", "text_to_speech"], + "description": "Dynamic equalizer audio spectrum bars bouncing from a center axis, representing voice response playback." + }, + { + "filename": "success_spark.py", + "states": ["success", "done", "completed"], + "description": "A blooming starburst sparkle expansion and central success checkmark, representing task success." + }, + { + "filename": "error_alert.py", + "states": ["error", "failure", "alert"], + "description": "A bold warning exclamation triangle flashing alongside warning stripes, representing pipeline or connection error." + }, + { + "filename": "network_retry.py", + "states": ["network_retry", "reconnecting", "searching"], + "description": "Wi-Fi signal waves lighting up sequentially enclosed in rotating dash arrows, indicating connection retry." + }, + { + "filename": "battery_low.py", + "states": ["battery_low", "low_power"], + "description": "A horizontal battery cell with a blinking single charge bar and alert triangle, showing low power warning." + }, + { + "filename": "update_progress.py", + "states": ["update", "uploading", "downloading"], + "description": "A sliding download arrow dropping into a tray with a growing horizontal progress bar (0% - 100%)." + }, + { + "filename": "breathing_idle.py", + "states": ["idle", "ready", "breathing"], + "description": "Smoothly scaling concentric squares simulating a calm breathing rhythm, representing an idle/ready state." + }, + { + "filename": "wake_attention.py", + "states": ["wake", "attention", "active"], + "description": "Expressive robot eyes that look left, center, right, and blink periodically to display device wake attention." + }, + { + "filename": "kid_companion.py", + "states": ["kid_friendly", "companion", "child_mode"], + "description": "A cute robot face companion with wiggling ears, a happy smile, blinking antenna, and a winking eye." + } + ] +} diff --git a/voice_animations/network_retry.py b/voice_animations/network_retry.py new file mode 100644 index 0000000..bc566a7 --- /dev/null +++ b/voice_animations/network_retry.py @@ -0,0 +1,101 @@ +# Standalone MicroPython Voice Animation: Network Retry +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "CONNECTING..." + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Wi-Fi Dot base at bottom of logo + dot_y = cy + 20 + display.fill_rect(cx - 4, dot_y - 4, 8, 8, 1) + + # Determine Wi-Fi wave stage (0 to 3) for sequential scanning + stage = (frame // 3) % 4 + + # Wave 1 (inner arc) + if stage >= 1: + display.line(cx - 15, dot_y - 12, cx, dot_y - 20, 1) + display.line(cx, dot_y - 20, cx + 15, dot_y - 12, 1) + + # Wave 2 (middle arc) + if stage >= 2: + display.line(cx - 30, dot_y - 25, cx, dot_y - 36, 1) + display.line(cx, dot_y - 36, cx + 30, dot_y - 25, 1) + + # Wave 3 (outer arc) + if stage >= 3: + display.line(cx - 45, dot_y - 38, cx, dot_y - 52, 1) + display.line(cx, dot_y - 52, cx + 45, dot_y - 38, 1) + + # Draw a rotating retry circle (dashed arrow indicator) around the Wi-Fi icon + r = 68 + # Compute 2 rotating arrow segments based on frame + rot_offset = frame * 0.12 + for side in (0, math.pi): + for a_deg in range(0, 120, 15): + angle = side + rot_offset + math.radians(a_deg) + px = cx + int(r * math.cos(angle)) + py = cy - 10 + int(r * math.sin(angle)) + display.fill_rect(px - 2, py - 2, 4, 4, 1) + + # Draw arrowheads at the leading edge of each segment + lead_angle = side + rot_offset + math.radians(120) + lx = cx + int(r * math.cos(lead_angle)) + ly = cy - 10 + int(r * math.sin(lead_angle)) + + # Arrowhead wings + w1 = lead_angle - math.pi * 0.75 + w2 = lead_angle + math.pi * 0.75 + display.line(lx, ly, lx + int(8 * math.cos(w1)), ly + int(8 * math.sin(w1)), 1) + display.line(lx, ly, lx + int(8 * math.cos(w2)), ly + int(8 * math.sin(w2)), 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(55) + except AttributeError: + time.sleep(0.055) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/speaking_mouth.py b/voice_animations/speaking_mouth.py new file mode 100644 index 0000000..f9cfb9e --- /dev/null +++ b/voice_animations/speaking_mouth.py @@ -0,0 +1,88 @@ +# Standalone MicroPython Voice Animation: Speaking Mouth (Audio Spectrum) +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "SPEAKING..." + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw top and bottom boundary lines for the sound waves + display.line(cx - 100, cy - 45, cx + 100, cy - 45, 1) + display.line(cx - 100, cy + 45, cx + 100, cy + 45, 1) + + # Draw a series of vertical equalizer bars modulated to simulate speech patterns + # An envelope simulates words (peaks and silences) + word_envelope = abs(math.sin(frame * 0.05)) + + num_bars = 15 + bar_w = 8 + gap = 4 + total_w = num_bars * bar_w + (num_bars - 1) * gap + start_x = cx - total_w // 2 + + for i in range(num_bars): + bx = start_x + i * (bar_w + gap) + + # Modulate individual bar heights using frame phase offsets + phase = frame * 0.28 + i * 0.45 + noise_val = abs(math.sin(phase)) * 0.7 + abs(math.cos(phase * 1.5)) * 0.3 + + # Height peaks towards the center bars (bell shape) + center_factor = math.cos(((i - num_bars // 2) / (num_bars // 2)) * (math.pi / 2.2)) + + h = int(6 + 72 * word_envelope * noise_val * center_factor) + # Keep height within boundaries + h = min(80, h) + + # Draw vertical bar centered at cy + display.fill_rect(bx, cy - h // 2, bar_w, h, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(50) + except AttributeError: + time.sleep(0.05) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/success_spark.py b/voice_animations/success_spark.py new file mode 100644 index 0000000..651b8f8 --- /dev/null +++ b/voice_animations/success_spark.py @@ -0,0 +1,84 @@ +# Standalone MicroPython Voice Animation: Success Spark +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "SUCCESS!" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw a thick success checkmark + display.line(cx - 20, cy + 5, cx - 5, cy + 20, 1) + display.line(cx - 19, cy + 5, cx - 4, cy + 20, 1) + display.line(cx - 5, cy + 20, cx + 25, cy - 10, 1) + display.line(cx - 4, cy + 20, cx + 26, cy - 10, 1) + + # Sparkles/rays radiating outwards + # The rays expand from r=30 to r=90 and loop + dist = (frame * 6) % 80 + + # Draw rays only if within reasonable expansion bounds + if dist < 65: + for angle_deg in range(0, 360, 45): + angle = math.radians(angle_deg) + # Outer point of sparkle + sx1 = cx + int(dist * math.cos(angle)) + sy1 = cy + int(dist * math.sin(angle)) + # Inner point of sparkle + sx2 = cx + int((dist + 12) * math.cos(angle)) + sy2 = cy + int((dist + 12) * math.sin(angle)) + + display.line(sx1, sy1, sx2, sy2, 1) + + # Add side sparkles for variety on cross angles (45, 135, etc.) + if angle_deg % 90 != 0: + display.fill_rect(sx2 - 2, sy2 - 2, 4, 4, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(45) + except AttributeError: + time.sleep(0.045) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/update_progress.py b/voice_animations/update_progress.py new file mode 100644 index 0000000..47d1db0 --- /dev/null +++ b/voice_animations/update_progress.py @@ -0,0 +1,89 @@ +# Standalone MicroPython Voice Animation: Update Progress +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw a sliding downward-pointing arrow in the top half + # Offset slides down and wraps around to create motion + arrow_slide = (frame * 3) % 24 + ay = cy - 45 + arrow_slide + + # Arrow Shaft + display.fill_rect(cx - 6, ay - 24, 12, 24, 1) + # Arrow Head (triangle) + display.line(cx - 18, ay, cx, ay + 15, 1) + display.line(cx + 18, ay, cx, ay + 15, 1) + display.line(cx - 18, ay, cx + 18, ay, 1) + # Fill the head slightly + display.fill_rect(cx - 6, ay, 12, 8, 1) + + # Draw static receiver container (horizontal tray) below the arrow path + tray_y = cy + 12 + display.line(cx - 30, tray_y, cx - 30, tray_y + 10, 1) + display.line(cx - 30, tray_y + 10, cx + 30, tray_y + 10, 1) + display.line(cx + 30, tray_y + 10, cx + 30, tray_y, 1) + + # Calculate update progress percentage + # (Cycles 0 to 100 over 25 frames, or follows frame count) + percent = (frame * 4) % 101 + + # Progress bar outline + bar_y = cy + 45 + display.rect(cx - 90, bar_y, 180, 16, 1) + # Progress bar fill + fill_width = int(176 * percent / 100) + if fill_width > 0: + display.fill_rect(cx - 88, bar_y + 2, fill_width, 12, 1) + + # Draw label + percentage text + label = text if text else "UPDATING SYSTEM..." + pct_label = f"{label} {percent}%" + draw_center_text(display, pct_label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(55) + except AttributeError: + time.sleep(0.055) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/waiting_orbit.py b/voice_animations/waiting_orbit.py new file mode 100644 index 0000000..bad3377 --- /dev/null +++ b/voice_animations/waiting_orbit.py @@ -0,0 +1,81 @@ +# Standalone MicroPython Voice Animation: Waiting Orbit +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "THINKING..." + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw central pulsing core + # Uses sin wave to expand/contract + pulse = int(14 + 4 * math.sin(frame * 0.18)) + display.rect(cx - pulse, cy - pulse, pulse * 2, pulse * 2, 1) + display.rect(cx - pulse + 3, cy - pulse + 3, (pulse - 3) * 2, (pulse - 3) * 2, 1) + display.fill_rect(cx - 3, cy - 3, 6, 6, 1) + + # Draw a dotted circular path of radius 60 + for a_deg in range(0, 360, 15): + angle = math.radians(a_deg) + px = cx + int(60 * math.cos(angle)) + py = cy + int(60 * math.sin(angle)) + display.fill_rect(px, py, 2, 2, 1) + + # Draw 4 orbiting ring nodes + for node_idx in range(4): + angle = (frame * 0.08) + (node_idx * math.pi / 2) + nx = cx + int(60 * math.cos(angle)) + ny = cy + int(60 * math.sin(angle)) + + # Draw square ring nodes + display.fill_rect(nx - 6, ny - 6, 12, 12, 1) + display.fill_rect(nx - 4, ny - 4, 8, 8, 0) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 35, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(50) + except AttributeError: + time.sleep(0.05) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/wake_attention.py b/voice_animations/wake_attention.py new file mode 100644 index 0000000..9837147 --- /dev/null +++ b/voice_animations/wake_attention.py @@ -0,0 +1,96 @@ +# Standalone MicroPython Voice Animation: Wake Attention +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "HI, I'M LISTENING" + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Left and Right eye center X coordinates + left_cx = cx - 55 + right_cx = cx + 55 + eye_y = cy - 10 + + # State cycle to control pupil gaze offset (look center, look left, look right) + gaze_state = (frame // 16) % 4 + gaze_offset = 0 + if gaze_state == 1: + gaze_offset = -12 # Look left + elif gaze_state == 3: + gaze_offset = 12 # Look right + + # Blink cycle: close eyes for a few frames periodically + is_blinking = (frame % 22) in (0, 1, 2) + + if is_blinking: + # Closed eyes: draw simple horizontal lines across the eye spaces + display.line(left_cx - 28, eye_y, left_cx + 28, eye_y, 1) + display.line(left_cx - 28, eye_y + 1, left_cx + 28, eye_y + 1, 1) # thicker + display.line(right_cx - 28, eye_y, right_cx + 28, eye_y, 1) + display.line(right_cx - 28, eye_y + 1, right_cx + 28, eye_y + 1, 1) # thicker + else: + # Open eyes: draw outer rounded rectangular bounding frames + # Left eye border + display.rect(left_cx - 32, eye_y - 22, 64, 44, 1) + display.rect(left_cx - 31, eye_y - 21, 62, 42, 1) + # Right eye border + display.rect(right_cx - 32, eye_y - 22, 64, 44, 1) + display.rect(right_cx - 31, eye_y - 21, 62, 42, 1) + + # Draw pupils inside, shifted by the gaze offset + display.fill_rect(left_cx - 10 + gaze_offset, eye_y - 10, 20, 20, 1) + display.fill_rect(right_cx - 10 + gaze_offset, eye_y - 10, 20, 20, 1) + + # Draw small cute highlights inside pupils + display.fill_rect(left_cx - 6 + gaze_offset, eye_y - 6, 6, 6, 0) + display.fill_rect(right_cx - 6 + gaze_offset, eye_y - 6, 6, 6, 0) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(60) + except AttributeError: + time.sleep(0.06) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run() diff --git a/voice_animations/working_gears.py b/voice_animations/working_gears.py new file mode 100644 index 0000000..c4c9d39 --- /dev/null +++ b/voice_animations/working_gears.py @@ -0,0 +1,102 @@ +# Standalone MicroPython Voice Animation: Working Gears +# Exposes run(text="", frames=...) and runs automatically if executed directly. +import time +import math + +try: + import board_config + display = getattr(board_config, 'display_instance', None) +except Exception: + board_config = None + display = None + +def draw_center_text(d, msg, y, w, scale=1, c=1): + char_w = 8 * scale + x = max(0, (w - len(msg) * char_w) // 2) + if hasattr(d, 'text_large') and scale > 1: + try: + d.text_large(msg, x, y, scale, c) + return + except Exception: + pass + d.text(msg, x, y, c) + +def draw_gear(d, gcx, gcy, r, teeth_count, rotation, c=1): + # Draw gear boundary + d.rect(gcx - r, gcy - r, r * 2, r * 2, c) + # Inner shaft hole + d.fill_rect(gcx - 5, gcy - 5, 10, 10, c) + d.fill_rect(gcx - 2, gcy - 2, 4, 4, 0) + + # Draw gear teeth extending outwards + for i in range(teeth_count): + angle = rotation + i * (2 * math.pi / teeth_count) + # Inner teeth start + x1 = gcx + int((r - 2) * math.cos(angle)) + y1 = gcy + int((r - 2) * math.sin(angle)) + # Outer teeth end + x2 = gcx + int((r + 10) * math.cos(angle)) + y2 = gcy + int((r + 10) * math.sin(angle)) + + d.line(x1, y1, x2, y2, c) + + # Add tiny horizontal heads to teeth for thickness + perp_angle = angle + math.pi / 2 + tx1 = x2 - int(3 * math.cos(perp_angle)) + ty1 = y2 - int(3 * math.sin(perp_angle)) + tx2 = x2 + int(3 * math.cos(perp_angle)) + ty2 = y2 + int(3 * math.sin(perp_angle)) + d.line(tx1, ty1, tx2, ty2, c) + +def run(text="", frames=100): + if not display: + print("board_config.display_instance is not available. Skipping animation.") + return + + width = getattr(display, 'width', 400) + height = getattr(display, 'height', 300) + cx, cy = width // 2, height // 2 + + label = text if text else "WORKING..." + frame = 0 + + try: + while True: + if frames is not None and frames > 0 and frame >= frames: + break + + display.clear(0) + + # Draw two rotating, interlocking gears + # Large Gear (Left & slightly up) + rot1 = frame * 0.08 + draw_gear(display, cx - 45, cy - 10, r=32, teeth_count=8, rotation=rot1, c=1) + + # Small Gear (Right & slightly down) + # Rotates counter-clockwise, offset to interlock teeth + rot2 = -frame * 0.106 + (math.pi / 8) + draw_gear(display, cx + 45, cy + 20, r=24, teeth_count=6, rotation=rot2, c=1) + + # Little decorative background computer dots + dot_phase = (frame // 4) % 4 + for i in range(4): + fill_color = 1 if i == dot_phase else 0 + display.rect(cx - 80 + i * 16, cy + 70, 8, 8, 1) + if fill_color: + display.fill_rect(cx - 78 + i * 16, cy + 72, 4, 4, 1) + + # Draw centered text label at the bottom + draw_center_text(display, label, height - 30, width, scale=1, c=1) + + display.show() + frame += 1 + + try: + time.sleep_ms(50) + except AttributeError: + time.sleep(0.05) + except KeyboardInterrupt: + print("Animation interrupted by user.") + +if __name__ in ('__main__', '__name__'): + run()