"""Terminal effects: the small kit every screen's animation draws from. Honest about the medium — everything here is characters and timing. SCRYPT_REDUCED_MOTION=1 turns all of it off (accessibility, and the test suite runs with it set so timing never leaks into assertions). """ from __future__ import annotations import os import random from dataclasses import dataclass, field NOISE_CHARS = "▒░▓▚▞ " def reduced_motion() -> bool: return os.environ.get("SCRYPT_REDUCED_MOTION", "") not in ("", "0") def noise_line(width: int, rng: random.Random | None = None) -> str: rng = rng or random return "".join(rng.choice(NOISE_CHARS) for _ in range(width)) def corrupt(text: str, fraction: float = 0.12, rng: random.Random | None = None) -> str: """A glitched copy: a few characters replaced with static.""" rng = rng or random chars = list(text) for i, ch in enumerate(chars): if ch.strip() and rng.random() < fraction: chars[i] = rng.choice("▓▚▞▖▗") return "".join(chars) @dataclass class BoardFX: """One frame of combat theater, consumed by render.board(). flash (side, lane) cells whose border burns bright this frame (side: "foe" | "player" | "queue") dissolve (side, lane) -> noise intensity 0..2 for dying cards floats lane -> (text, style) shown in the float row between rows scale overrides the displayed scale (progressive tipping) """ flash: set[tuple[str, int]] = field(default_factory=set) dissolve: dict[tuple[str, int], int] = field(default_factory=dict) floats: dict[int, tuple[str, str]] = field(default_factory=dict) scale: int | None = None @property def empty(self) -> bool: return not (self.flash or self.dissolve or self.floats) and self.scale is None # Typewriter pacing: ticks to hold after revealing certain characters. PUNCT_PAUSE = {".": 6, "—": 5, "…": 6, "?": 5, "!": 5, ",": 3, ";": 3, ":": 3} # The eye. It watches. Pupil position follows what the player has selected. EYE_FRAMES = ["(● )", "( ● )", "( ● )", "( ● )", "( ●)"] EYE_BLINK = "( ──── )" EYE_WIDE = "((=●=))" def eye(selected: int, total: int, *, blink: bool = False, wide: bool = False) -> str: if wide: return EYE_WIDE if blink: return EYE_BLINK if total <= 1: return EYE_FRAMES[2] pos = round(selected / max(1, total - 1) * (len(EYE_FRAMES) - 1)) return EYE_FRAMES[pos]