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']}")