afterglow/assets/sprite_gen.pyΒΆ

Part of Afterglow.

  1"""Procedural Wisp sprite-sheet generator for the Afterglow diorama view.
  2
  3PLACEHOLDER ART: generated in code; swap this for a real PNG sheet later without
  4touching gameplay. The logical sim never imports this module.
  5
  6The Wisp is a glowing orb with a soft halo and a little trailing tail. Each
  7animation state varies squash/stretch, tail length, and core brightness to read
  8the player's motion at a glance. The whole sheet is one RGBA uint8 ndarray laid
  9out in a fixed grid; named animations index into it by frame number.
 10
 11Public API:
 12    build_wisp_sheet() -> (sheet_rgba, animations)
 13        sheet_rgba : (rows*FRAME, cols*FRAME, 4) uint8 atlas, row-major frames.
 14        animations : dict[str, Animation] (see schema below).
 15
 16Sheet / animation metadata schema (consumed by the view layer):
 17    FRAME            : int   pixel size of one square frame
 18    FRAMES_HORIZONTAL: int   columns in the atlas
 19    FRAMES_VERTICAL  : int   rows in the atlas
 20    Animation TypedDict:
 21        frames : list[int]   global frame indices (row-major: idx = row*cols + col)
 22        fps    : int         playback rate
 23        loop   : bool        whether the view should loop the clip
 24    Animations: idle-bob, run, jump, fall, wallslide, dash, glow.
 25"""
 26
 27from __future__ import annotations
 28
 29from typing import TypedDict
 30
 31import numpy as np
 32
 33FRAME = 24
 34FRAMES_HORIZONTAL = 6
 35FRAMES_VERTICAL = 4
 36
 37
 38class Animation(TypedDict):
 39    frames: list[int]
 40    fps: int
 41    loop: bool
 42
 43
 44def _draw_wisp(
 45    sx: float,
 46    sy: float,
 47    *,
 48    bob: float = 0.0,
 49    tail: float = 0.0,
 50    tail_dir: tuple[float, float] = (0.0, 1.0),
 51    core: float = 1.0,
 52    colour: tuple[int, int, int] = (170, 240, 255),
 53) -> np.ndarray:
 54    """Render one FRAME x FRAME wisp frame as a GLOWING light-spirit.
 55
 56    The Wisp must be the brightest thing on screen and bloom: it is drawn UNLIT
 57    (texture * modulate), so its pixels carry their own light. We build a hot
 58    near-white core that stays at full brightness across a fat plateau, fading
 59    out through a saturated, luminous halo of ``colour``. Most opaque pixels land
 60    well above ~0.6 luminance and the core hits 1.0 so it clears the bloom gate.
 61
 62    sx, sy   : x/y radius scale (squash/stretch, 1.0 == base radius).
 63    bob      : vertical centre offset in px (idle float).
 64    tail     : tail length 0..1 (fraction of frame), drawn opposite tail_dir.
 65    tail_dir : unit-ish direction the wisp is moving (tail trails the other way).
 66    core     : core brightness multiplier (glow intensity, >=1 fully hot).
 67    """
 68    yy, xx = np.mgrid[0:FRAME, 0:FRAME].astype(np.float32)
 69    cx = FRAME / 2.0
 70    cy = FRAME / 2.0 + bob
 71    base_r = FRAME * 0.30
 72    rx = base_r * sx
 73    ry = base_r * sy
 74
 75    # Elliptical distance. ``body`` is a fat luminous disc; ``halo`` a wide soft
 76    # glow that gives the wisp its bright surrounding aura.
 77    d = np.sqrt(((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2)
 78    body = np.clip(1.0 - d, 0.0, 1.0) ** 0.6
 79    halo = np.clip(1.0 - d * 0.45, 0.0, 1.0) ** 1.5 * 0.9
 80
 81    # Tail: a directional smear opposite to motion.
 82    field = np.maximum(body, halo)
 83    if tail > 0.0:
 84        tdx, tdy = tail_dir
 85        norm = (tdx * tdx + tdy * tdy) ** 0.5 or 1.0
 86        tdx, tdy = tdx / norm, tdy / norm
 87        along = ((xx - cx) * -tdx + (yy - cy) * -tdy) / (FRAME * 0.5)
 88        across = ((xx - cx) * tdy + (yy - cy) * -tdx) / (FRAME * 0.5)
 89        tlen = tail * 1.6
 90        tail_f = np.clip(along / tlen, 0.0, 1.0)
 91        smear = np.clip(1.0 - (across / (0.35 * (1.0 - 0.6 * tail_f) + 0.05)) ** 2, 0.0, 1.0)
 92        smear *= np.clip(1.0 - tail_f, 0.0, 1.0) * (along > 0)
 93        field = np.maximum(field, smear * 0.85)
 94
 95    field = np.clip(field, 0.0, 1.0)
 96
 97    # Hot white core: a wide plateau near the centre saturates to white so the
 98    # core reads as a true light source and clears the bloom threshold. ``white``
 99    # is the white-hot fraction; the remaining colour is the saturated tint.
100    white = np.clip((field - 0.30) / 0.55, 0.0, 1.0) ** 0.6 * min(core, 1.0)
101    # Lift the tint floor so even the mid halo glows brightly, not dim colour.
102    glow = 0.45 + 0.55 * field
103    cr, cg, cb = colour
104    out = np.zeros((FRAME, FRAME, 4), dtype=np.uint8)
105    # Saturated tint through the halo, pushed to white at the core. Multiplied by
106    # ``core`` so glow frames overdrive past 255 and clip hot for the bloom.
107    for ch, cc in enumerate((cr, cg, cb)):
108        tint = cc * glow
109        lit = tint + (255.0 - tint) * white
110        out[..., ch] = np.clip(lit * core, 0, 255)
111    # Slightly tighter alpha than the glow field so the soft outer aura stays
112    # translucent and only the bright body is fully opaque.
113    out[..., 3] = np.clip(field**0.9 * 255, 0, 255)
114    return out
115
116
117def _bob_cycle(n: int, amp: float) -> list[np.ndarray]:
118    """A smooth vertical bob loop with gentle squash at the extremes."""
119    frames = []
120    for i in range(n):
121        ph = 2.0 * np.pi * i / n
122        bob = amp * np.sin(ph)
123        squash = 1.0 + 0.08 * np.cos(ph)
124        frames.append(_draw_wisp(squash, 2.0 - squash, bob=bob, core=1.25))
125    return frames
126
127
128def _build_frames() -> list[np.ndarray]:
129    """Author every frame in atlas order. Index == position in this list."""
130    frames: list[np.ndarray] = []
131
132    # 0-3: idle-bob (gentle float)
133    frames += _bob_cycle(4, amp=FRAME * 0.06)
134
135    # 4-7: run (stretched horizontally, side tail, brighter)
136    for i in range(4):
137        ph = 2.0 * np.pi * i / 4
138        frames.append(_draw_wisp(1.18 + 0.06 * np.sin(ph), 0.9, tail=0.45, tail_dir=(1.0, 0.0), core=1.35))
139
140    # 8: jump (vertical stretch, upward tail)
141    frames.append(_draw_wisp(0.8, 1.35, tail=0.5, tail_dir=(0.0, -1.0), core=1.4))
142    # 9: fall (vertical stretch, downward tail)
143    frames.append(_draw_wisp(0.85, 1.25, tail=0.4, tail_dir=(0.0, 1.0), core=1.3))
144
145    # 10-11: wallslide (squashed against wall, downward sparks)
146    for i in range(2):
147        frames.append(_draw_wisp(0.75, 1.1, bob=i * 0.6, tail=0.3, tail_dir=(0.3, 1.0), core=1.2))
148
149    # 12-15: dash (long fast tail rotating through 4 sample directions)
150    for dx, dy in [(1.0, 0.0), (0.7, -0.7), (0.0, -1.0), (0.7, 0.7)]:
151        frames.append(_draw_wisp(1.3, 0.7, tail=0.9, tail_dir=(dx, dy), core=1.7))
152
153    # 16-19: glow (pulsing white-hot core, warm-shifted aura)
154    for i in range(4):
155        pulse = 0.5 + 0.5 * np.sin(2.0 * np.pi * i / 4)
156        frames.append(
157            _draw_wisp(1.05 + 0.08 * pulse, 1.05 + 0.08 * pulse, core=1.6 + 0.6 * pulse, colour=(255, 226, 150))
158        )
159
160    # Pad the remainder of the 6x4 grid with blanks for a clean rectangular sheet.
161    while len(frames) < FRAMES_HORIZONTAL * FRAMES_VERTICAL:
162        frames.append(np.zeros((FRAME, FRAME, 4), dtype=np.uint8))
163    return frames
164
165
166ANIMATIONS: dict[str, Animation] = {
167    "idle-bob": {"frames": [0, 1, 2, 3], "fps": 6, "loop": True},
168    "run": {"frames": [4, 5, 6, 7], "fps": 12, "loop": True},
169    "jump": {"frames": [8], "fps": 1, "loop": False},
170    "fall": {"frames": [9], "fps": 1, "loop": False},
171    "wallslide": {"frames": [10, 11], "fps": 8, "loop": True},
172    "dash": {"frames": [12, 13, 14, 15], "fps": 18, "loop": False},
173    "glow": {"frames": [16, 17, 18, 19], "fps": 8, "loop": True},
174}
175
176
177def build_wisp_sheet() -> tuple[np.ndarray, dict[str, Animation]]:
178    """Build the Wisp atlas and its animation table.
179
180    Returns (sheet_rgba, animations): a single ``(FRAMES_VERTICAL*FRAME,
181    FRAMES_HORIZONTAL*FRAME, 4)`` uint8 array (row-major frames) plus the
182    ANIMATIONS metadata. Frame index ``i`` maps to grid cell
183    ``(i // FRAMES_HORIZONTAL, i % FRAMES_HORIZONTAL)``.
184    """
185    frames = _build_frames()
186    sheet = np.zeros((FRAMES_VERTICAL * FRAME, FRAMES_HORIZONTAL * FRAME, 4), dtype=np.uint8)
187    for i, f in enumerate(frames):
188        r, c = divmod(i, FRAMES_HORIZONTAL)
189        sheet[r * FRAME : (r + 1) * FRAME, c * FRAME : (c + 1) * FRAME] = f
190    return sheet, ANIMATIONS
191
192
193if __name__ == "__main__":
194    sheet, anims = build_wisp_sheet()
195    print(f"sheet shape={sheet.shape} dtype={sheet.dtype} non_zero_alpha={(sheet[..., 3] > 0).sum()}")
196    print(f"grid={FRAMES_HORIZONTAL}x{FRAMES_VERTICAL} frame={FRAME}px")
197    for name, a in anims.items():
198        print(f"  {name}: frames={a['frames']} fps={a['fps']} loop={a['loop']}")