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 AnchorPreset, Control
 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__(**kwargs)
 62        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 63        self.mouse_filter = False  # never eat clicks meant for the game
 64
 65        #: Maximum glow duration in seconds (the meter normalises against it).
 66        self.glow_max = float(glow_max)
 67
 68        # Live readout state, refreshed by update().
 69        self._time = 0.0
 70        self._best: float | None = None
 71        self._dash_charged = True
 72        self._glow_t = 0.0
 73        self._shard = False
 74        self._deaths = 0
 75
 76        # Idle auto-dim: fade toward dim while nothing changes.
 77        self._idle = 0.0  # seconds since last meaningful change
 78        self._alpha = 1.0
 79
 80        # Flash tweens (1 -> 0), one per flourish.
 81        self._shard_flash = 0.0
 82        self._best_flash = 0.0
 83        self._prev_shard = False
 84        self._prev_best: float | None = None
 85
 86    # -- public API --------------------------------------------------------
 87
 88    def update(
 89        self,
 90        room,
 91        progress=None,
 92        *,
 93        world_id: str | None = None,
 94        room_index: int | None = None,
 95        dt: float = 1 / 60,
 96    ) -> None:
 97        """Refresh the HUD from the live room (and optional progress).
 98
 99        ``world_id`` / ``room_index`` locate the best-time ghost in ``progress``;
100        when omitted the ghost is hidden. ``dt`` advances the idle-dim and flash
101        tweens. Auto-fires the shard / best-time flash on a rising edge so the
102        flourish never depends on the game remembering to call ``flash_*``.
103        """
104        if room is None:
105            return
106        player = room.player
107
108        prev = (self._time, self._dash_charged, self._glow_t, self._shard, self._deaths)
109
110        self._time = room.time
111        self._dash_charged = player.dash_charges > 0
112        self._glow_t = max(0.0, player.glow_timer)
113        self._shard = bool(room.shard_collected)
114        self._deaths = int(room.deaths)
115
116        # Best time ghost: only when we can locate this exact room.
117        if progress is not None and world_id is not None and room_index is not None:
118            self._best = progress.best_time(world_id, room_index)
119        else:
120            self._best = None
121
122        # Auto-flash on rising edges.
123        if self._shard and not self._prev_shard:
124            self.flash_shard()
125        self._prev_shard = self._shard
126        if self._best is not None and self._best != self._prev_best:
127            if self._prev_best is not None and self._best < self._prev_best:
128                self.flash_best()
129            self._prev_best = self._best
130
131        # Idle dim: any change in the live readout resets the idle timer.
132        changed = prev != (self._time, self._dash_charged, self._glow_t, self._shard, self._deaths)
133        moving = abs(player.vx) > 1.0 or abs(player.vy) > 1.0
134        if changed or moving or self._glow_t > 0:
135            self._idle = 0.0
136        else:
137            self._idle += dt
138        target = 0.45 if self._idle > 2.5 else 1.0
139        self._alpha += (target - self._alpha) * min(1.0, dt * 6.0)
140
141        # Decay flashes.
142        self._shard_flash = max(0.0, self._shard_flash - dt * 2.2)
143        self._best_flash = max(0.0, self._best_flash - dt * 1.4)
144
145        self.queue_redraw()
146
147    def flash_shard(self) -> None:
148        """Trigger the shard-pickup pop (bright flash + scale)."""
149        self._shard_flash = 1.0
150        self._idle = 0.0
151        self.queue_redraw()
152
153    def flash_best(self) -> None:
154        """Trigger the new-best-time pop on the ghost label."""
155        self._best_flash = 1.0
156        self._idle = 0.0
157        self.queue_redraw()
158
159    # -- drawing -----------------------------------------------------------
160
161    def on_draw(self, renderer) -> None:
162        x, y, w, h = self.get_global_rect()
163        a = self._alpha
164        pad = 16.0
165
166        self._draw_timer(renderer, x + pad, y + pad, a)
167        self._draw_dash_pip(renderer, x + pad, y + pad + 58, a)
168        self._draw_glow_meter(renderer, x + pad, y + pad + 80, a)
169        self._draw_shard(renderer, x + w - pad, y + pad, a)
170        self._draw_deaths(renderer, x + w - pad, y + pad + 30, a)
171
172    def _draw_timer(self, renderer, x: float, y: float, a: float) -> None:
173        scale = 28.0 / 16.0
174        text = format_time(self._time)
175        renderer.draw_text(text, (x, y), colour=_alpha(_TEXT, a), scale=scale)
176        if self._best is not None:
177            pop = 1.0 + 0.25 * self._best_flash
178            bcol = _lerp(_GHOST, (1.0, 0.95, 0.55, 1.0), self._best_flash)
179            renderer.draw_text(
180                f"best {format_time(self._best)}",
181                (x, y + 30),
182                colour=_alpha(bcol, a),
183                scale=(14.0 / 16.0) * pop,
184            )
185
186    def _draw_dash_pip(self, renderer, x: float, y: float, a: float) -> None:
187        r = 7.0
188        cx, cy = x + r, y + r
189        if self._dash_charged:
190            renderer.draw_circle((cx, cy), r, colour=_alpha(_GLOW, a), filled=True)
191            renderer.draw_circle((cx, cy), r + 2.5, colour=_alpha(_GLOW_DIM, a))
192        else:
193            renderer.draw_circle((cx, cy), r, colour=_alpha((0.35, 0.37, 0.42, 0.9), a))
194        label = "DASH" if self._dash_charged else "dash"
195        col = _GLOW if self._dash_charged else (0.5, 0.52, 0.57, 1.0)
196        renderer.draw_text(label, (cx + r + 8, cy - 7), colour=_alpha(col, a), scale=14.0 / 16.0)
197
198    def _draw_glow_meter(self, renderer, x: float, y: float, a: float) -> None:
199        if self._glow_t <= 0 or self.glow_max <= 0:
200            return
201        ratio = max(0.0, min(1.0, self._glow_t / self.glow_max))
202        bar_w, bar_h = 132.0, 9.0
203        # Background trough plus a fill that stays bright the whole time it
204        # drains: staying bright is the "you are charged" signal.
205        renderer.draw_rect((x, y), (bar_w, bar_h), colour=_alpha(_PANEL, a), filled=True)
206        pulse = 0.85 + 0.15 * math.sin(self._glow_t * 9.0)
207        fill = (_GLOW[0], _GLOW[1], _GLOW[2], _GLOW[3] * pulse)
208        renderer.draw_rect((x, y), (bar_w * ratio, bar_h), colour=_alpha(fill, a), filled=True)
209        renderer.draw_rect((x, y), (bar_w, bar_h), colour=_alpha(_GLOW_DIM, a))
210        renderer.draw_text("GLOW", (x, y - 16), colour=_alpha(_GLOW, a), scale=12.0 / 16.0)
211
212    def _draw_shard(self, renderer, right: float, y: float, a: float) -> None:
213        # Diamond drawn right-aligned; the label sits to its left.
214        size = 9.0 + 4.0 * self._shard_flash
215        cx = right - size
216        cy = y + 8
217        col = _SHARD if self._shard else _SHARD_OFF
218        if self._shard_flash > 0:
219            col = _lerp(col, (1.0, 1.0, 0.92, 1.0), self._shard_flash)
220        _diamond(renderer, cx, cy, size, _alpha(col, a), filled=self._shard)
221        if not self._shard:
222            _diamond(renderer, cx, cy, size, _alpha(_SHARD_OFF, a), filled=False)
223        label = "SHARD" if self._shard else "shard"
224        lcol = _SHARD if self._shard else (0.5, 0.5, 0.55, 1.0)
225        tw = renderer.text_width(label, 13.0 / 16.0)
226        renderer.draw_text(label, (cx - size - 8 - tw, cy - 7), colour=_alpha(lcol, a), scale=13.0 / 16.0)
227
228    def _draw_deaths(self, renderer, right: float, y: float, a: float) -> None:
229        text = f"deaths {self._deaths}"
230        scale = 15.0 / 16.0
231        tw = renderer.text_width(text, scale)
232        renderer.draw_text(text, (right - tw, y), colour=_alpha(_DEATH, a), scale=scale)
233
234
235# -- small drawing / colour helpers ----------------------------------------
236
237
238def _alpha(colour, a: float) -> tuple[float, float, float, float]:
239    """Scale a colour's alpha by ``a`` (the global idle-dim factor)."""
240    return (colour[0], colour[1], colour[2], colour[3] * a)
241
242
243def _lerp(c0, c1, t: float) -> tuple[float, float, float, float]:
244    """Linear blend between two RGBA colours."""
245    t = max(0.0, min(1.0, t))
246    return tuple(c0[i] + (c1[i] - c0[i]) * t for i in range(4))
247
248
249def _diamond(renderer, cx: float, cy: float, r: float, colour, *, filled: bool) -> None:
250    """Draw an axis-aligned diamond centred at ``(cx, cy)`` with radius ``r``."""
251    top = (cx, cy - r)
252    right = (cx + r, cy)
253    bottom = (cx, cy + r)
254    left = (cx - r, cy)
255    if filled:
256        renderer.fill_triangle(top[0], top[1], right[0], right[1], bottom[0], bottom[1], colour=colour)
257        renderer.fill_triangle(top[0], top[1], bottom[0], bottom[1], left[0], left[1], colour=colour)
258    else:
259        renderer.draw_line(top, right, colour=colour)
260        renderer.draw_line(right, bottom, colour=colour)
261        renderer.draw_line(bottom, left, colour=colour)
262        renderer.draw_line(left, top, colour=colour)
263
264
265__all__ = ["HUD", "format_time"]