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)