afterglow/ui/hud.pyΒΆ

Part of Afterglow.

  1"""Afterglow in-game HUD overlay.
  2
  3A single :class:`HUD` ``Control`` pinned to the screen corners. It is fully
  4self-drawing (no per-element child widgets to keep in sync): each frame the game
  5calls :meth:`HUD.update` with the live ``Room`` and ``Progress`` and the HUD
  6repaints. Visuals are minimal and crisp, with an auto-dim when the player is
  7idle so the readout never fights the diorama for attention.
  8
  9Pieces:
 10  * room timer ``mm:ss.mmm`` (top-left, the hero readout);
 11  * best-time "ghost" under it (dim, only when a best exists);
 12  * dash-charge pip (filled when charged, hollow when spent);
 13  * glow meter, a horizontal countdown bar that only appears while glowing;
 14  * shard indicator (a diamond, lit when collected this run);
 15  * death counter (top-right).
 16
 17Two small flourishes use a shared tween: a bright flash + scale-pop on shard
 18pickup and on a new best time. The game pokes these via :meth:`flash_shard`
 19and :meth:`flash_best`; :meth:`update` also auto-detects a shard pickup so the
 20flash fires even if the game forgets to call it.
 21"""
 22
 23from __future__ import annotations
 24
 25import math
 26
 27from simvx.core import Anchor, Control, PointerFilter
 28
 29# Theme accents (vibrant + punchy, matches the art direction).
 30_GLOW = (0.55, 1.0, 0.78, 1.0)  # wisp green-cyan
 31_GLOW_DIM = (0.55, 1.0, 0.78, 0.35)
 32_SHARD = (1.0, 0.78, 0.30, 1.0)  # warm gold
 33_SHARD_OFF = (0.30, 0.32, 0.38, 0.7)
 34_TEXT = (0.96, 1.0, 0.96, 1.0)
 35_GHOST = (0.62, 0.70, 0.74, 0.85)
 36_DEATH = (1.0, 0.62, 0.55, 1.0)
 37_PANEL = (0.04, 0.05, 0.08, 0.45)
 38
 39
 40def format_time(seconds: float) -> str:
 41    """Format a duration as ``mm:ss.mmm`` (zero-padded, always 3 ms digits)."""
 42    if seconds < 0:
 43        seconds = 0.0
 44    minutes = int(seconds // 60)
 45    secs = seconds - minutes * 60
 46    return f"{minutes:02d}:{secs:06.3f}"
 47
 48
 49class HUD(Control):
 50    """Self-drawing heads-up display overlay for a single room run.
 51
 52    Anchored ``FULL_RECT`` over the game viewport; draws into the screen corners.
 53    Call :meth:`update` every frame with the room + progress to refresh, and
 54    :meth:`flash_shard` / :meth:`flash_best` to trigger the pickup pops.
 55
 56    ``glow_max`` is the full-bar reference for the glow meter; the game passes
 57    the sim's ``GLOW_FROM_ORB`` so the bar starts full on every orb pickup.
 58    """
 59
 60    def __init__(self, glow_max: float, **kwargs):
 61        super().__init__(anchor=Anchor.FULL_RECT, **kwargs)
 62        self.pointer_filter = PointerFilter.IGNORE  # never eat clicks meant for the game
 63
 64        #: Maximum glow duration in seconds (the meter normalises against it).
 65        self.glow_max = float(glow_max)
 66
 67        # Live readout state, refreshed by update().
 68        self._time = 0.0
 69        self._best: float | None = None
 70        self._dash_charged = True
 71        self._glow_t = 0.0
 72        self._shard = False
 73        self._deaths = 0
 74
 75        # Idle auto-dim: fade toward dim while nothing changes.
 76        self._idle = 0.0  # seconds since last meaningful change
 77        self._alpha = 1.0
 78
 79        # Flash tweens (1 -> 0), one per flourish.
 80        self._shard_flash = 0.0
 81        self._best_flash = 0.0
 82        self._prev_shard = False
 83        self._prev_best: float | None = None
 84
 85    # -- public API --------------------------------------------------------
 86
 87    def update(
 88        self,
 89        room,
 90        progress=None,
 91        *,
 92        world_id: str | None = None,
 93        room_index: int | None = None,
 94        dt: float = 1 / 60,
 95    ) -> None:
 96        """Refresh the HUD from the live room (and optional progress).
 97
 98        ``world_id`` / ``room_index`` locate the best-time ghost in ``progress``;
 99        when omitted the ghost is hidden. ``dt`` advances the idle-dim and flash
100        tweens. Auto-fires the shard / best-time flash on a rising edge so the
101        flourish never depends on the game remembering to call ``flash_*``.
102        """
103        if room is None:
104            return
105        player = room.player
106
107        prev = (self._time, self._dash_charged, self._glow_t, self._shard, self._deaths)
108
109        self._time = room.time
110        self._dash_charged = player.dash_charges > 0
111        self._glow_t = max(0.0, player.glow_timer)
112        self._shard = bool(room.shard_collected)
113        self._deaths = int(room.deaths)
114
115        # Best time ghost: only when we can locate this exact room.
116        if progress is not None and world_id is not None and room_index is not None:
117            self._best = progress.best_time(world_id, room_index)
118        else:
119            self._best = None
120
121        # Auto-flash on rising edges.
122        if self._shard and not self._prev_shard:
123            self.flash_shard()
124        self._prev_shard = self._shard
125        if self._best is not None and self._best != self._prev_best:
126            if self._prev_best is not None and self._best < self._prev_best:
127                self.flash_best()
128            self._prev_best = self._best
129
130        # Idle dim: any change in the live readout resets the idle timer.
131        changed = prev != (self._time, self._dash_charged, self._glow_t, self._shard, self._deaths)
132        moving = abs(player.vx) > 1.0 or abs(player.vy) > 1.0
133        if changed or moving or self._glow_t > 0:
134            self._idle = 0.0
135        else:
136            self._idle += dt
137        target = 0.45 if self._idle > 2.5 else 1.0
138        self._alpha += (target - self._alpha) * min(1.0, dt * 6.0)
139
140        # Decay flashes.
141        self._shard_flash = max(0.0, self._shard_flash - dt * 2.2)
142        self._best_flash = max(0.0, self._best_flash - dt * 1.4)
143
144        self.queue_redraw()
145
146    def flash_shard(self) -> None:
147        """Trigger the shard-pickup pop (bright flash + scale)."""
148        self._shard_flash = 1.0
149        self._idle = 0.0
150        self.queue_redraw()
151
152    def flash_best(self) -> None:
153        """Trigger the new-best-time pop on the ghost label."""
154        self._best_flash = 1.0
155        self._idle = 0.0
156        self.queue_redraw()
157
158    # -- drawing -----------------------------------------------------------
159
160    def on_draw(self, renderer) -> None:
161        _, _, w, h = self.get_rect()
162        a = self._alpha
163        pad = 16.0
164
165        self._draw_timer(renderer, pad, pad, a)
166        self._draw_dash_pip(renderer, pad, pad + 58, a)
167        self._draw_glow_meter(renderer, pad, pad + 80, a)
168        self._draw_shard(renderer, w - pad, pad, a)
169        self._draw_deaths(renderer, w - pad, pad + 30, a)
170
171    def _draw_timer(self, renderer, x: float, y: float, a: float) -> None:
172        scale = 28.0 / 16.0
173        text = format_time(self._time)
174        renderer.text(text, (x, y), fill=_alpha(_TEXT, a), scale=scale)
175        if self._best is not None:
176            pop = 1.0 + 0.25 * self._best_flash
177            bcol = _lerp(_GHOST, (1.0, 0.95, 0.55, 1.0), self._best_flash)
178            renderer.text(
179                f"best {format_time(self._best)}", (x, y + 30), fill=_alpha(bcol, a), scale=(14.0 / 16.0) * pop
180            )
181
182    def _draw_dash_pip(self, renderer, x: float, y: float, a: float) -> None:
183        r = 7.0
184        cx, cy = x + r, y + r
185        if self._dash_charged:
186            renderer.circle((cx, cy), r, fill=_alpha(_GLOW, a))
187            renderer.circle((cx, cy), r + 2.5, stroke=_alpha(_GLOW_DIM, a))
188        else:
189            renderer.circle((cx, cy), r, stroke=_alpha((0.35, 0.37, 0.42, 0.9), a))
190        label = "DASH" if self._dash_charged else "dash"
191        col = _GLOW if self._dash_charged else (0.5, 0.52, 0.57, 1.0)
192        renderer.text(label, (cx + r + 8, cy - 7), fill=_alpha(col, a), scale=14.0 / 16.0)
193
194    def _draw_glow_meter(self, renderer, x: float, y: float, a: float) -> None:
195        if self._glow_t <= 0 or self.glow_max <= 0:
196            return
197        ratio = max(0.0, min(1.0, self._glow_t / self.glow_max))
198        bar_w, bar_h = 132.0, 9.0
199        # Background trough plus a fill that stays bright the whole time it
200        # drains: staying bright is the "you are charged" signal.
201        renderer.rect((x, y), (bar_w, bar_h), fill=_alpha(_PANEL, a))
202        pulse = 0.85 + 0.15 * math.sin(self._glow_t * 9.0)
203        fill = (_GLOW[0], _GLOW[1], _GLOW[2], _GLOW[3] * pulse)
204        renderer.rect((x, y), (bar_w * ratio, bar_h), fill=_alpha(fill, a))
205        renderer.rect((x, y), (bar_w, bar_h), stroke=_alpha(_GLOW_DIM, a))
206        renderer.text("GLOW", (x, y - 16), fill=_alpha(_GLOW, a), scale=12.0 / 16.0)
207
208    def _draw_shard(self, renderer, right: float, y: float, a: float) -> None:
209        # Diamond drawn right-aligned; the label sits to its left.
210        size = 9.0 + 4.0 * self._shard_flash
211        cx = right - size
212        cy = y + 8
213        col = _SHARD if self._shard else _SHARD_OFF
214        if self._shard_flash > 0:
215            col = _lerp(col, (1.0, 1.0, 0.92, 1.0), self._shard_flash)
216        _diamond(renderer, cx, cy, size, _alpha(col, a), filled=self._shard)
217        if not self._shard:
218            _diamond(renderer, cx, cy, size, _alpha(_SHARD_OFF, a), filled=False)
219        label = "SHARD" if self._shard else "shard"
220        lcol = _SHARD if self._shard else (0.5, 0.5, 0.55, 1.0)
221        tw = renderer.text_width(label, 13.0 / 16.0)
222        renderer.text(label, (cx - size - 8 - tw, cy - 7), fill=_alpha(lcol, a), scale=13.0 / 16.0)
223
224    def _draw_deaths(self, renderer, right: float, y: float, a: float) -> None:
225        text = f"deaths {self._deaths}"
226        scale = 15.0 / 16.0
227        tw = renderer.text_width(text, scale)
228        renderer.text(text, (right - tw, y), fill=_alpha(_DEATH, a), scale=scale)
229
230
231# -- small drawing / colour helpers ----------------------------------------
232
233
234def _alpha(colour, a: float) -> tuple[float, float, float, float]:
235    """Scale a colour's alpha by ``a`` (the global idle-dim factor)."""
236    return (colour[0], colour[1], colour[2], colour[3] * a)
237
238
239def _lerp(c0, c1, t: float) -> tuple[float, float, float, float]:
240    """Linear blend between two RGBA colours."""
241    t = max(0.0, min(1.0, t))
242    return tuple(c0[i] + (c1[i] - c0[i]) * t for i in range(4))
243
244
245def _diamond(renderer, cx: float, cy: float, r: float, colour, *, filled: bool) -> None:
246    """Draw an axis-aligned diamond centred at ``(cx, cy)`` with radius ``r``."""
247    top = (cx, cy - r)
248    right = (cx + r, cy)
249    bottom = (cx, cy + r)
250    left = (cx - r, cy)
251    if filled:
252        renderer.polygon([(top[0], top[1]), (right[0], right[1]), (bottom[0], bottom[1])], fill=colour)
253        renderer.polygon([(top[0], top[1]), (bottom[0], bottom[1]), (left[0], left[1])], fill=colour)
254    else:
255        renderer.line(top, right, stroke=colour)
256        renderer.line(right, bottom, stroke=colour)
257        renderer.line(bottom, left, stroke=colour)
258        renderer.line(left, top, stroke=colour)
259
260
261__all__ = ["HUD", "format_time"]