329 lines
15 KiB
Python
329 lines
15 KiB
Python
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
|