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