File size: 2,516 Bytes
9fca766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
"""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]