afterglow/view/sprites.py¶
Part of Afterglow.
1"""EntityView: billboarded visuals for every dynamic sim entity, synced each frame.
2
3This is the *dynamic* half of the 3D view. It spawns one visual node per sim
4entity (plus the player Wisp) on first ``sync`` for a room, then on every later
5``sync`` reads room state to drive each visual: position, animation, visibility,
6and emissive/modulate pulses. It never mutates the sim.
7
8Visuals:
9 Wisp (player) : AnimatedSprite3D from build_wisp_sheet(); state->animation,
10 facing->flip_h, glow boosts modulate toward warm-white.
11 Crystal : emissive billboard, hidden when not alive.
12 GlowOrb : pulsing emissive billboard, dimmed when not available.
13 LightGate : thin emissive box, lit/opaque only while .solid (player glowing).
14 Spring : small metal pad billboard with a contact flash.
15 Shard : sparkling billboard, hidden once collected.
16 MovingPlatform : a metal box that tracks the platform's oscillation.
17 exit : a soft glowing portal billboard.
18
19GPU-free to instantiate: sprite textures are numpy arrays, meshes are pure data.
20"""
21
22from __future__ import annotations
23
24import math
25
26import numpy as np
27
28from simvx.core import (
29 AnimatedSprite3D,
30 Material,
31 MeshInstance3D,
32 Node3D,
33 Sprite3D,
34 create_box,
35)
36
37from ..assets.sprite_gen import (
38 FRAMES_HORIZONTAL,
39 FRAMES_VERTICAL,
40 build_wisp_sheet,
41)
42from ..assets.textures import world_palette
43from ..sim.entities import (
44 Crystal,
45 GlowOrb,
46 LightGate,
47 MovingPlatform,
48 Shard,
49 Spring,
50)
51
52TILE_SIZE = 8
53WORLD_SCALE = 1.0 / TILE_SIZE
54
55#: Seconds a spring stays lit after launching the Wisp.
56_SPRING_FLASH = 0.25
57
58# sim player.state -> wisp animation name (sprite_gen ANIMATIONS keys).
59_STATE_ANIM = {
60 "idle": "idle-bob",
61 "run": "run",
62 "jump": "jump",
63 "fall": "fall",
64 "wallslide": "wallslide",
65 "dash": "dash",
66}
67
68# One tile == 1.0 world unit (the diorama places a tile per world unit), so a
69# sprite's world size is just its desired tile-extent / its source pixel count.
70# Wisp: a 24px frame drawn ~2.4 tiles tall so the hero reads large and clearly
71# above its 8px (~1 tile) hitbox and dominates the frame as the brightest element.
72_WISP_PIXEL = 2.4 / 24.0
73# Pickups (crystal/orb/shard/exit/spring) ~1 tile across from a 16px source.
74_PICKUP_PIXEL = 1.0 / 16.0
75
76# The EXIT portal's ring colour: a bright warm white, deliberately NOT any
77# world's palette accent so the goal contrasts against the green/blue/amber
78# rooms instead of camouflaging into them.
79_EXIT_COLOUR = (255, 244, 214)
80
81
82# Sprites sit a touch in front of the z=0 play plane so they read clearly
83# against the carved relief and never z-fight the back wall.
84_SPRITE_Z = 2.0 # in FRONT of the extruded diorama boxes (which reach ~+1.5),
85# so pickups (shard / crystal / orb / exit) are never occluded by level geometry
86# from the tilted camera. At 0.4 they sat behind the boxes and vanished.
87# The Wisp rides further forward so the extruded diorama relief can never
88# occlude the hero, and is drawn HDR-bright (modulate > 1) so it survives the
89# scene's punchy exposure + ACES tonemap and always blooms / pops.
90_WISP_Z = 2.2
91
92
93def _centre_world(e, z: float = _SPRITE_Z) -> tuple[float, float, float]:
94 """World position of an entity centre via the authoritative pixel mapping.
95
96 ``e.cx``/``e.cy`` are logical *pixels*; ``WORLD_SCALE`` converts them into
97 the diorama's tile-unit world space (one tile == one world unit).
98 """
99 return (e.cx * WORLD_SCALE, -e.cy * WORLD_SCALE, z)
100
101
102def _radial_sprite(colour: tuple[int, int, int], size: int = 16, soft: float = 1.4) -> np.ndarray:
103 """A bright emissive radial dot for pickups: saturated tint, only a tiny hot pip.
104
105 Drawn UNLIT, so the dot carries its own light. The tint is held at full
106 saturation across the whole disc (the HDR modulate in ``_sync_entity`` lifts
107 it over the bloom threshold so it blooms in its OWN colour), and only a small
108 central pip is pushed toward white -- enough to read as a "core" without
109 washing the crystal cyan / orb gold to a colourless white blob.
110 """
111 yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
112 c = (size - 1) / 2.0
113 d = np.sqrt((xx - c) ** 2 + (yy - c) ** 2) / (size * 0.5)
114 field = np.clip(1.0 - d, 0.0, 1.0) ** soft
115 # A SMALL, gentle white pip only at the very centre: a wider plateau would
116 # bleach the dot, and the hue has to survive across the whole disc.
117 core = np.clip((field - 0.78) / 0.22, 0.0, 1.0) ** 1.3 * 0.55
118 glow = 0.62 + 0.38 * field # lift the tint floor so the halo glows, not dims
119 out = np.zeros((size, size, 4), dtype=np.uint8)
120 for ch, cc in enumerate(colour):
121 tint = cc * glow
122 out[..., ch] = np.clip(tint + (255.0 - tint) * core, 0, 255)
123 out[..., 3] = np.clip(field * 255, 0, 255)
124 return out
125
126
127def _portal_sprite(colour: tuple[int, int, int], size: int = 32) -> np.ndarray:
128 """A glowing ring (annulus) for the EXIT: a clearly different shape to pickups.
129
130 Pickups are filled dots; the exit is a bright open ring with a soft halo, so
131 the goal reads as a doorway/portal at a glance and never blends into the room
132 (a filled green dot on the glade's green stone would). The ring is drawn in a
133 saturated, high-contrast colour and runs HDR-bright so it blooms.
134 """
135 yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
136 c = (size - 1) / 2.0
137 r = np.sqrt((xx - c) ** 2 + (yy - c) ** 2) / (size * 0.5)
138 # Ring centred at r~0.62, plus a soft outer halo so it glows.
139 ring = np.exp(-(((r - 0.62) / 0.16) ** 2))
140 halo = np.clip(1.0 - r, 0.0, 1.0) ** 2.2 * 0.45
141 field = np.clip(ring + halo, 0.0, 1.0)
142 # A small white pip on the ring crest keeps it crisp without killing the hue.
143 crest = np.clip((ring - 0.85) / 0.15, 0.0, 1.0) * 0.5
144 out = np.zeros((size, size, 4), dtype=np.uint8)
145 for ch, cc in enumerate(colour):
146 tint = cc * (0.7 + 0.3 * field)
147 out[..., ch] = np.clip(tint * field + (255.0 - tint) * crest, 0, 255)
148 out[..., 3] = np.clip(field * 255, 0, 255)
149 return out
150
151
152def _star_sprite(colour: tuple[int, int, int], size: int = 32) -> np.ndarray:
153 """A bold, FILLED 4-point star with a white-hot core + glow halo.
154
155 A chunky filled star (thick tapering arms + a round body + a soft halo) so
156 the collectible reads clearly from across the room. Arm thickness scales with
157 the sprite: fixed thin arms would vanish at gameplay distance.
158 """
159 yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
160 c = (size - 1) / 2.0
161 dx, dy = np.abs(xx - c), np.abs(yy - c)
162 r = np.sqrt(dx * dx + dy * dy) / (size * 0.5)
163 half = size * 0.5
164 arm_w = size * 0.16 # thick arms, sized relative to the sprite
165 bar_h = np.clip(1.0 - dy / arm_w, 0, 1) * np.clip(1.0 - dx / half, 0, 1)
166 bar_v = np.clip(1.0 - dx / arm_w, 0, 1) * np.clip(1.0 - dy / half, 0, 1)
167 star = np.maximum(bar_h, bar_v)
168 body = np.clip(1.0 - r, 0, 1) ** 1.4 # round filled centre
169 glow = np.clip(1.12 - r, 0, 1) ** 2 * 0.55 # soft outer halo
170 field = np.clip(np.maximum(np.maximum(star, body), glow), 0.0, 1.0)
171 hot = np.clip(1.0 - r * 1.6, 0, 1) ** 1.5 # white-hot centre
172 out = np.zeros((size, size, 4), dtype=np.uint8)
173 for ch, cc in enumerate(colour):
174 out[..., ch] = np.clip(cc * field + (255.0 - cc) * hot, 0, 255)
175 out[..., 3] = np.clip(field * 255, 0, 255)
176 return out
177
178
179class EntityView(Node3D):
180 """Billboard visuals for one room's entities + the Wisp. ``sync`` per frame.
181
182 Usage::
183
184 ev = EntityView()
185 parent.add_child(ev)
186 ev.build(room) # (re)spawn visuals for a room
187 # each frame, after room.step(...):
188 ev.sync(room, dt)
189 """
190
191 def __init__(self, **kwargs):
192 super().__init__(**kwargs)
193 self._world: str | None = None
194 self._wisp: AnimatedSprite3D | None = None
195 self._wisp_anim: str | None = None
196 # Parallel list aligned with room.entities; each slot is the visual node.
197 self._entity_views: list[Node3D | None] = []
198 self._spring_flash: dict[int, float] = {}
199 self._t = 0.0
200 self._sheet: np.ndarray | None = None
201
202 # -- public ------------------------------------------------------------
203
204 def clear(self) -> None:
205 self.clear_children()
206 self._wisp = None
207 self._wisp_anim = None
208 self._entity_views = []
209 self._spring_flash.clear()
210 self._t = 0.0
211
212 def build(self, room) -> None:
213 """Spawn one visual per entity plus the player Wisp for ``room``."""
214 self.clear()
215 self._world = room.palette
216 pal = world_palette(self._world)
217
218 self._wisp = self.add_child(self._build_wisp())
219 # Build one visual per entity AND parent it under this view, so every
220 # shard / crystal / orb / gate / spring / platform / EXIT actually
221 # renders (entities with no visual stay None and are skipped in sync).
222 self._entity_views = []
223 for e in room.entities:
224 view = self._build_entity(e, pal)
225 if view is not None:
226 self.add_child(view)
227 self._entity_views.append(view)
228 self.sync(room, 0.0)
229
230 def sync(self, room, dt: float = 0.0) -> None:
231 """Read room state and update every visual. Read-only over the sim."""
232 self._t += dt
233 self._sync_wisp(room.player)
234 for i, e in enumerate(room.entities):
235 view = self._entity_views[i]
236 if view is None:
237 continue
238 self._sync_entity(i, e, view, dt)
239
240 # -- wisp --------------------------------------------------------------
241
242 def _build_wisp(self) -> AnimatedSprite3D:
243 sheet, anims = build_wisp_sheet()
244 self._sheet = sheet
245 spr = AnimatedSprite3D(
246 texture=sheet,
247 frames_h=FRAMES_HORIZONTAL,
248 frames_v=FRAMES_VERTICAL,
249 pixel_size=_WISP_PIXEL,
250 billboard=True,
251 name="wisp",
252 )
253 for name, a in anims.items():
254 spr.add_animation(name, frames=a["frames"], fps=a["fps"], loop=a["loop"])
255 spr.play("idle-bob")
256 self._wisp_anim = "idle-bob"
257 return spr
258
259 def _sync_wisp(self, player) -> None:
260 w = self._wisp
261 if w is None:
262 return
263 # Dead: the scatter burst is the Wisp now, so hide the sprite until the
264 # room respawns a live one rather than freezing it at the death spot.
265 w.visible = player.alive
266 if not player.alive:
267 return
268 w.position = _centre_world(player, _WISP_Z)
269 w.flip_h = player.facing < 0
270
271 # Glow overrides the state animation with the warm pulsing 'glow' clip.
272 anim = "glow" if player.glowing else _STATE_ANIM.get(player.state, "idle-bob")
273 if anim != self._wisp_anim:
274 w.play(anim)
275 self._wisp_anim = anim
276
277 # HDR-bright modulate (>1) so the unlit Wisp always pops + blooms against
278 # the dark, punchy worlds; glowing pushes it brighter and warm.
279 if player.glowing:
280 pulse = 0.5 + 0.5 * math.sin(self._t * 8.0)
281 b = 3.4 + 0.9 * pulse
282 w.modulate = (b, b * (0.94 + 0.06 * pulse), b * (0.74 + 0.18 * pulse), 1.0)
283 else:
284 b = 2.6
285 w.modulate = (b, b, b, 1.0)
286
287 # -- per-entity visuals ------------------------------------------------
288
289 def _build_entity(self, e, pal) -> Node3D | None:
290 if isinstance(e, Crystal):
291 # Larger (1.6 tiles) so the resonance crystal reads clearly; the HDR
292 # pulse in _sync_entity makes it bloom out of the dark caverns.
293 return self._pickup_sprite(pal["crystal"], "crystal_view", soft=1.6, tiles=1.6)
294 if isinstance(e, GlowOrb):
295 return self._pickup_sprite(pal["accent"], "orb_view", soft=2.0, tiles=1.7)
296 if isinstance(e, Shard):
297 # ~3.6 tiles, a bold GOLD star: a distinct colour + filled shape so it
298 # never blends into the white Wisp or the world's stone. HDR pulse in
299 # _sync_entity makes it bloom brightly.
300 spr = Sprite3D(
301 texture=_star_sprite((255, 205, 70), size=32),
302 pixel_size=3.6 / 32.0,
303 billboard=True,
304 name="shard_view",
305 )
306 return spr
307 if isinstance(e, Spring):
308 return self._pickup_sprite(pal["metal"], "spring_view", soft=1.2, size=16)
309 if isinstance(e, LightGate):
310 return self._gate_box(pal)
311 if isinstance(e, MovingPlatform):
312 return self._platform_box(e, pal)
313 if getattr(e, "kind", "") == "exit":
314 return self._exit_portal()
315 return None
316
317 def _exit_portal(self) -> Sprite3D:
318 # A bright warm-white ring so the goal reads as a doorway of light on
319 # every world (green glade / blue caverns / amber spire), never blending
320 # into the room the way a palette-accent dot did.
321 return Sprite3D(
322 texture=_portal_sprite(_EXIT_COLOUR, size=32),
323 pixel_size=2.4 / 32.0,
324 billboard=True,
325 name="exit_view",
326 )
327
328 def _pickup_sprite(self, colour, name, *, soft=1.5, size=16, tiles=1.0) -> Sprite3D:
329 return Sprite3D(
330 texture=_radial_sprite(colour, size=size, soft=soft),
331 pixel_size=tiles / size,
332 billboard=True,
333 name=name,
334 )
335
336 def _gate_box(self, pal) -> MeshInstance3D:
337 mesh = create_box((1.0, 1.0, 0.6))
338 colour = np.array(pal["crystal"], dtype=np.float32) / 255.0
339 mat = Material(
340 colour=(*colour, 0.5),
341 blend="alpha",
342 emissive_colour=tuple(colour),
343 emissive_strength=2.0,
344 )
345 return MeshInstance3D(mesh=mesh, material=mat, name="gate_view")
346
347 def _platform_box(self, e, pal) -> MeshInstance3D:
348 # Platform is 16x4 px: 2.0 x 0.5 world units, shallow in Z.
349 mesh = create_box((e.w * WORLD_SCALE, e.h * WORLD_SCALE, 0.6))
350 colour = np.array(pal["metal"], dtype=np.float32) / 255.0
351 mat = Material(colour=(*colour, 1.0), roughness=0.5, metallic=0.6)
352 return MeshInstance3D(mesh=mesh, material=mat, name="platform_view")
353
354 def _sync_entity(self, i: int, e, view: Node3D, dt: float) -> None:
355 # Flat billboard pickups ride forward (_SPRITE_Z) so the diorama relief
356 # never occludes them; the box-mesh entities (gate / moving platform) are
357 # real 3D geometry and stay at the level's play-plane depth.
358 z = 0.0 if isinstance(e, LightGate | MovingPlatform) else _SPRITE_Z
359 view.position = _centre_world(e, z)
360
361 if isinstance(e, Crystal):
362 view.visible = e.alive
363 # HDR-bright (>1), pulsing so the unlit billboard clears the bloom
364 # threshold and the resonance crystal visibly glows + breathes.
365 pulse = 0.5 + 0.5 * math.sin(self._t * 4.0 + i)
366 b = 2.8 + 1.0 * pulse
367 view.modulate = (b, b, b, 1.0)
368 elif isinstance(e, GlowOrb):
369 view.visible = True
370 # Same HDR-bright pulse; dims (but stays lit) once consumed so the
371 # spent orb still reads as a fading ember rather than vanishing.
372 pulse = 0.5 + 0.5 * math.sin(self._t * 4.0 + i)
373 b = (3.0 + 1.2 * pulse) if e.available else 0.6
374 view.modulate = (b, b, b, 1.0)
375 elif isinstance(e, Shard):
376 view.visible = not e.collected
377 # Bright + pulsing so it blooms, but modest enough that the GOLD colour
378 # still reads (a high HDR multiplier washes it to white).
379 b = 1.9 + 0.4 * math.sin(self._t * 6.0 + i)
380 view.modulate = (b, b, b, 1.0)
381 elif isinstance(e, Spring):
382 # Contact flash: latched full on launch, then drained in REAL time so
383 # the flash lasts the same 0.25s at any frame rate.
384 flash = self._spring_flash.get(i, 0.0)
385 if e.cooldown > 0.0:
386 self._spring_flash[i] = _SPRING_FLASH
387 flash = _SPRING_FLASH
388 elif flash > 0.0:
389 flash = max(0.0, flash - dt)
390 self._spring_flash[i] = flash
391 view.modulate = (1.0, 1.0, 1.0, min(1.0, 0.6 + 2.0 * flash))
392 elif isinstance(e, LightGate):
393 view.visible = True
394 mat: Material = view.material
395 if e.solid:
396 mat.colour = (*mat.colour[:3], 0.9)
397 mat.emissive_strength = 2.5
398 else:
399 # Untriggered: a barely-there ghost so it clearly reads as passable
400 # (you can walk/dash through it until you are glowing).
401 mat.colour = (*mat.colour[:3], 0.05)
402 mat.emissive_strength = 0.12
403 elif isinstance(e, MovingPlatform):
404 pass # position already tracked above
405 elif getattr(e, "kind", "") == "exit":
406 # HDR-bright and gently breathing so the portal ring clears the bloom
407 # threshold and beckons (was a dim alpha pulse that barely showed).
408 pulse = 0.5 + 0.5 * math.sin(self._t * 2.5)
409 b = 2.4 + 0.8 * pulse
410 view.modulate = (b, b, b, 1.0)