from __future__ import annotations import math import random from app.core.colors import brighten, sample_palette, smoothstep from app.core.types import RGBColor, TilePatternSample, clamp from ..base import BasePattern, PatternContext, PatternDescriptor from .common import ( _random_vivid_color, _sample_for_cycle, _sample_for_tile, _temporal_noise, _tile_sample, _with_led_pixels, ) class StrobePattern(BasePattern): descriptor = PatternDescriptor( "strobe", "Strobe", "Fast on/off pulses with duty-cycle control.", ( "brightness", "fade", "tempo_multiplier", "strobe_mode", "color_mode", "primary_color", "secondary_color", "palette", "pixel_group_size", "strobe_duty_cycle", ), temporal_profile="direct", ) def render(self, context: PatternContext) -> dict[str, TilePatternSample]: if context.params.strobe_mode == "random_pixels": return self._render_random_pixels(context, grouped=True) if context.params.strobe_mode == "random_leds": return self._render_random_pixels(context, grouped=False) phase = context.pattern_tempo_phase * 4.0 bucket = math.floor(phase) on = phase % 1.0 < context.params.strobe_duty_cycle primary, _secondary = _sample_for_cycle(context, context.time_s * 0.1, bucket * 17.1 + 3.0) if on: return {tile.tile_id: _tile_sample(primary, primary, intensity=1.0) for tile in context.sorted_tiles()} black = RGBColor.black() return {tile.tile_id: _tile_sample(black, black, intensity=0.0, boost=0.0) for tile in context.sorted_tiles()} def _render_random_pixels(self, context: PatternContext, *, grouped: bool) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} bucket = math.floor(context.pattern_tempo_phase * 10.0) density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98) pixel_group_size = max(1, min(5, int(round(context.params.pixel_group_size)))) if grouped else 1 for tile in context.sorted_tiles(): amount = (tile.col - 1) / max(1, context.cols - 1) primary, _secondary = _sample_for_cycle(context, amount, bucket * 29.7 + tile.row * 11.7 + tile.col * 17.9) led_pixels: dict[str, list[RGBColor]] = {} lit_leds = 0 total_leds = 0 for segment in tile.segments: segment_pixels: list[RGBColor] = [] count = max(1, segment.led_count) for group_start in range(0, count, pixel_group_size): group_index = group_start // pixel_group_size group_end = min(count, group_start + pixel_group_size) seed = bucket * 19.7 + tile.row * 31.3 + tile.col * 17.9 + segment.start_channel * 0.11 + group_index * 1.73 active = _temporal_noise(seed) < density if context.params.color_mode == "palette": color = sample_palette(context.params.palette, (amount + group_start / max(1, count - 1) * 0.2) % 1.0) else: color = primary for _index in range(group_start, group_end): segment_pixels.append(color if active else RGBColor.black()) lit_leds += 1 if active else 0 total_leds += 1 led_pixels[segment.name] = segment_pixels activity = lit_leds / max(1, total_leds) preview_level = clamp(activity * 8.0, 0.0, 1.0) fill = primary.scaled(preview_level) if lit_leds else RGBColor.black() sample = _tile_sample(fill, primary, intensity=preview_level, boost=0.08) result[tile.tile_id] = _with_led_pixels(sample, led_pixels) return result class StopwatchPattern(BasePattern): descriptor = PatternDescriptor( "stopwatch", "Stopwatch", "LEDs fill from 1 to N and then clear from N back to 1 on every tile.", ("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "stopwatch_mode"), ) def __init__(self) -> None: self._last_base_phase_position: float | None = None def _tile_led_count(self, tile) -> int: return max(1, sum(segment.led_count for segment in tile.segments)) def _tile_cycle_color(self, context: PatternContext, tile, cycle_index: int) -> RGBColor: amount = (tile.col - 1 + tile.row - 1) / max(1, context.rows + context.cols - 2) if context.params.color_mode == "random_colors": return _random_vivid_color(cycle_index * 53.1 + tile.row * 11.7 + tile.col * 17.9) primary, _secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1) return primary def _crossed_full_count_peak(self, previous_position: float, current_position: float, cycle_length: int, led_count: int) -> bool: if current_position <= previous_position or cycle_length <= 0: return False next_peak = math.floor(previous_position / cycle_length) * cycle_length + led_count if next_peak < previous_position: next_peak += cycle_length return next_peak <= current_position def render(self, context: PatternContext) -> dict[str, TilePatternSample]: result: dict[str, TilePatternSample] = {} base_phase_position = context.pattern_tempo_phase * 20.0 previous_base_phase_position = self._last_base_phase_position use_phase_bridge = ( previous_base_phase_position is not None and previous_base_phase_position <= base_phase_position and (base_phase_position - previous_base_phase_position) <= 512.0 ) self._last_base_phase_position = base_phase_position for tile in context.sorted_tiles(): led_count = self._tile_led_count(tile) cycle_length = max(1, led_count * 2) offset = 0 if context.params.stopwatch_mode == "random": offset = int(_temporal_noise(tile.row * 13.7 + tile.col * 23.9) * cycle_length) tile_phase_position = base_phase_position + offset cycle_index = int(tile_phase_position // cycle_length) phase = tile_phase_position % cycle_length active_count = phase + 1 if phase < led_count else (2 * led_count) - phase active_count = max(1, min(led_count, int(round(active_count)))) if use_phase_bridge and previous_base_phase_position is not None: previous_tile_phase = previous_base_phase_position + offset if self._crossed_full_count_peak(previous_tile_phase, tile_phase_position, cycle_length, led_count): active_count = led_count color = self._tile_cycle_color(context, tile, cycle_index) remaining = active_count led_pixels: dict[str, list[RGBColor]] = {} for segment in tile.segments: segment_pixels: list[RGBColor] = [] for _index in range(segment.led_count): lit = remaining > 0 segment_pixels.append(color if lit else RGBColor.black()) if lit: remaining -= 1 led_pixels[segment.name] = segment_pixels activity = active_count / max(1, led_count) fill = color.scaled(activity) sample = _tile_sample(fill, color, intensity=activity, boost=0.08) result[tile.tile_id] = _with_led_pixels(sample, led_pixels) return result class SnakePattern(BasePattern): descriptor = PatternDescriptor( "snake", "Snake", "A random self-playing snake roaming across the wall.", ("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "randomness"), ) def __init__(self) -> None: self._rng = random.Random(1337) self._shape: tuple[int, int] | None = None self._snake: list[tuple[int, int]] = [] self._direction: tuple[int, int] = (0, 1) self._apple: tuple[int, int] | None = None self._blink_until_time: float | None = None self._target_length = 4 self._last_time_s: float | None = None self._step_progress = 0.0 def _reset(self, rows: int, cols: int) -> None: start_row = rows // 2 length = max(2, min(self._target_length, cols)) start_col = min(cols - 1, max(length - 1, cols // 2)) self._snake = [(start_row, start_col - index) for index in range(length)] self._direction = (0, 1) self._apple = None self._spawn_apple(rows, cols) self._blink_until_time = None self._shape = (rows, cols) self._last_time_s = None self._step_progress = 0.0 def _spawn_apple(self, rows: int, cols: int) -> None: occupied = set(self._snake) candidates = [(row, col) for row in range(rows) for col in range(cols) if (row, col) not in occupied] self._apple = self._rng.choice(candidates) if candidates else None def _neighbors(self, head: tuple[int, int], rows: int, cols: int) -> list[tuple[int, int]]: row, col = head neighbors = [] for d_row, d_col in ((0, 1), (1, 0), (0, -1), (-1, 0)): next_row = row + d_row next_col = col + d_col if 0 <= next_row < rows and 0 <= next_col < cols: neighbors.append((next_row, next_col)) return neighbors def _manhattan(self, cell_a: tuple[int, int], cell_b: tuple[int, int]) -> int: return abs(cell_a[0] - cell_b[0]) + abs(cell_a[1] - cell_b[1]) def _turn_left(self, direction: tuple[int, int]) -> tuple[int, int]: return (-direction[1], direction[0]) def _turn_right(self, direction: tuple[int, int]) -> tuple[int, int]: return (direction[1], -direction[0]) def _advance(self, rows: int, cols: int, randomness: float, current_time: float) -> None: if not self._snake: self._reset(rows, cols) head = self._snake[0] occupied = set(self._snake[:-1]) candidate_directions = [ self._direction, self._turn_left(self._direction), self._turn_right(self._direction), ] candidates: list[tuple[tuple[int, int], tuple[int, int]]] = [] for next_direction in candidate_directions: next_cell = (head[0] + next_direction[0], head[1] + next_direction[1]) if not (0 <= next_cell[0] < rows and 0 <= next_cell[1] < cols): continue if next_cell in occupied: continue candidates.append((next_cell, next_direction)) if not candidates: reverse_direction = (-self._direction[0], -self._direction[1]) reverse_cell = (head[0] + reverse_direction[0], head[1] + reverse_direction[1]) if 0 <= reverse_cell[0] < rows and 0 <= reverse_cell[1] < cols and reverse_cell not in occupied: candidates.append((reverse_cell, reverse_direction)) if not candidates: self._reset(rows, cols) return def openness(cell: tuple[int, int]) -> int: blocked = set(self._snake[:-2]) if len(self._snake) > 2 else set() return sum(1 for neighbor in self._neighbors(cell, rows, cols) if neighbor not in blocked) turniness = max(0.0, min(1.0, randomness / 1.5)) best_cell, best_direction = candidates[0] best_score = -10_000.0 for cell, next_direction in candidates: straight_bonus = 2.4 if next_direction == self._direction else 0.0 turn_penalty = -0.55 if next_direction != self._direction else 0.0 apple_bonus = 0.0 if self._apple is not None: apple_bonus = max(0.0, (rows + cols) - self._manhattan(cell, self._apple)) * 0.7 if cell == self._apple: apple_bonus += 5.0 score = openness(cell) + straight_bonus + turn_penalty + apple_bonus + self._rng.random() * (0.08 + turniness * 0.45) if score > best_score: best_score = score best_cell = cell best_direction = next_direction self._direction = best_direction self._snake.insert(0, best_cell) ate_apple = best_cell == self._apple if ate_apple: self._blink_until_time = current_time + 0.12 self._spawn_apple(rows, cols) while len(self._snake) > self._target_length: self._snake.pop() def render(self, context: PatternContext) -> dict[str, TilePatternSample]: rows = max(1, context.rows) cols = max(1, context.cols) if self._shape != (rows, cols): self._reset(rows, cols) delta_s = 0.0 if self._last_time_s is not None: raw_delta = context.time_s - self._last_time_s if 0.0 < raw_delta <= 0.5: delta_s = raw_delta self._last_time_s = context.time_s move_rate = context.pattern_tempo_hz * 2.2 self._step_progress += delta_s * move_rate steps_to_run = min(3, int(self._step_progress)) if steps_to_run: self._step_progress -= steps_to_run for _ in range(steps_to_run): self._advance(rows, cols, context.params.randomness, context.time_s) if not self._snake: self._reset(rows, cols) self._last_time_s = context.time_s body_lookup = {cell: index for index, cell in enumerate(self._snake)} blinking = self._blink_until_time is not None and context.time_s <= self._blink_until_time result: dict[str, TilePatternSample] = {} for tile in context.sorted_tiles(): row_index = tile.row - 1 col_index = tile.col - 1 primary, secondary = _sample_for_tile(context, 0.0) if (row_index, col_index) in body_lookup: is_head = body_lookup[(row_index, col_index)] == 0 fill = brighten(primary, 0.22) if blinking and is_head else primary result[tile.tile_id] = _tile_sample(fill, primary, intensity=1.0, boost=0.06 if blinking and is_head else 0.03) elif self._apple == (row_index, col_index): fill = secondary accent = brighten(secondary, 0.15) result[tile.tile_id] = _tile_sample(fill, accent, intensity=0.92, boost=0.03) else: fill = RGBColor.black() result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.0, boost=0.0) return result