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"]