Health bars¶
world-space bars over enemies, damage numbers, and a fixed HUD
â–¶ Run in browserTags: ui hud camera tween signals
Enemies wander the world with health bars floating above them; the bars are drawn in world space, so they scroll with a smoothed Camera2D that follows the player. Hits spawn floating damage numbers that rise and fade on a coroutine, and a killed enemy is removed cleanly (its death is a signal the HUD counts). The player’s portrait and health bar are anchored Controls, so they stay glued to the screen while the whole world pans underneath.
What it demonstrates¶
World-space draws (enemy bodies + their health bars) following entities under a moving Camera2D, next to screen-anchored Controls that do not scroll.
Floating damage numbers: a per-node coroutine rises and fades, then destroy().
A smooth HUD drain:
tween()eases the player bar toward the real value.Click-to-hit through
camera.screen_to_world, clean removal via a died signal.
Controls: WASD / arrows - Move (the camera follows) Left click - Hit the enemy under the pointer SPACE - Melee swing at the nearest enemy in range ESC - Quit
Run: uv run python examples/features/ui/health_bars.py Headless self-check: uv run python examples/features/ui/health_bars.py –test
Source¶
1"""Health bars: world-space bars over enemies, damage numbers, and a fixed HUD
2
3Enemies wander the world with health bars floating above them; the bars are
4drawn in world space, so they scroll with a smoothed Camera2D that follows the
5player. Hits spawn floating damage numbers that rise and fade on a coroutine,
6and a killed enemy is removed cleanly (its death is a signal the HUD counts).
7The player's portrait and health bar are anchored Controls, so they stay glued
8to the screen while the whole world pans underneath.
9
10# /// simvx
11# tags = ["ui", "hud", "camera", "tween", "signals"]
12# web = { root = "HealthBarsDemo", width = 960, height = 540, responsive = true }
13# ///
14
15## What it demonstrates
16- World-space draws (enemy bodies + their health bars) following entities under
17 a moving Camera2D, next to screen-anchored Controls that do not scroll.
18- Floating damage numbers: a per-node coroutine rises and fades, then destroy().
19- A smooth HUD drain: `tween()` eases the player bar toward the real value.
20- Click-to-hit through `camera.screen_to_world`, clean removal via a died signal.
21
22Controls:
23 WASD / arrows - Move (the camera follows)
24 Left click - Hit the enemy under the pointer
25 SPACE - Melee swing at the nearest enemy in range
26 ESC - Quit
27
28Run: uv run python examples/features/ui/health_bars.py
29Headless self-check: uv run python examples/features/ui/health_bars.py --test
30"""
31
32import math
33import random
34
35from simvx.core import (
36 AnchorPreset,
37 Camera2D,
38 CanvasLayer,
39 Colour,
40 Input,
41 Key,
42 Label,
43 MouseButton,
44 Node2D,
45 Panel,
46 Signal,
47 Vec2,
48 easing,
49 tween,
50 wait,
51)
52from simvx.graphics import App
53
54WIDTH, HEIGHT = 960, 540
55WORLD = 900 # half-size of the walkable world
56PAD = 14.0 # HUD gutter from the screen edges
57PORTRAIT = 56.0 # portrait panel side, pixels
58BAR_W, BAR_H = 200.0, 16.0 # player health bar, pixels
59PLAYER_R = 14.0
60PLAYER_MAX_HP = 100.0
61PLAYER_REGEN = 4.0 # hp per second
62CONTACT_DAMAGE = 10
63MELEE_RANGE = 150.0
64WAVE_SIZE = 5
65NUMBER_LIFE = 0.9 # damage number lifetime, seconds
66NUMBER_RISE = 46.0 # how far a damage number climbs, world pixels
67
68
69def _hp_colour(frac: float) -> tuple[float, float, float, float]:
70 """Green when healthy, amber when hurt, red when critical."""
71 if frac > 0.6:
72 return (0.26, 0.77, 0.39, 1.0)
73 if frac > 0.3:
74 return (0.96, 0.64, 0.38, 1.0)
75 return (0.90, 0.22, 0.27, 1.0)
76
77
78def _pin_bottom_left(ctrl, x: float, y_up: float, w: float, h: float) -> None:
79 """Anchor a top-level Control to the bottom-left corner.
80
81 With both anchors collapsed on a corner, the margin pairs define the box:
82 ``x``/``y_up`` are the gutters from the left and bottom edges, ``w``/``h``
83 the box size. The box then tracks the corner on resize.
84 """
85 ctrl.set_anchor_preset(AnchorPreset.BOTTOM_LEFT)
86 ctrl.margin_left = x
87 ctrl.margin_right = x + w
88 ctrl.margin_top = -(y_up + h)
89 ctrl.margin_bottom = -y_up
90
91
92class DamageNumber(Node2D):
93 """A floating number that rises, fades, and destroys itself."""
94
95 dynamic = True # position and alpha change every frame
96
97 def __init__(self, text: str, colour: tuple[float, float, float], scale: float = 1.4, **kwargs):
98 super().__init__(**kwargs)
99 self._text = text
100 self._colour = colour
101 self._scale = scale
102 self._alpha = 1.0
103
104 def on_ready(self):
105 self.start_coroutine(self._animate())
106
107 def _animate(self):
108 start_y = float(self.position.y)
109 t = 0.0
110 while t < NUMBER_LIFE:
111 dt = yield # the driver sends per-tick dt
112 t += dt
113 f = min(t / NUMBER_LIFE, 1.0)
114 self.position = Vec2(self.position.x, start_y - NUMBER_RISE * easing.ease_out_cubic(f))
115 self._alpha = (1.0 - f) ** 1.5
116 self.destroy()
117
118 def on_draw(self, renderer):
119 r, g, b = self._colour
120 renderer.draw_text(
121 self._text,
122 (self.position.x, self.position.y),
123 colour=(r, g, b, self._alpha),
124 scale=self._scale,
125 alignment="centre",
126 outline=0.08,
127 )
128
129
130class Enemy(Node2D):
131 """A wandering enemy with hit points and a world-space health bar."""
132
133 dynamic = True # wobbles and its bar tracks hp every frame
134
135 died = Signal(object) # emitted with the enemy just before removal
136
137 def __init__(self, home: Vec2, max_hp: int, colour: tuple[float, float, float, float], **kwargs):
138 super().__init__(position=Vec2(home.x, home.y), **kwargs)
139 self._home = Vec2(home.x, home.y)
140 self.max_hp = max_hp
141 self.hp = max_hp
142 self.colour = colour
143 self.radius = 14.0 + max_hp * 0.1
144 self._t = random.uniform(0.0, 10.0)
145 self._speed = random.uniform(0.8, 1.4)
146 self._flash = 0.0 # white hit flash, seconds left
147 self.touch_cd = 0.0 # contact-damage cooldown, seconds left
148 self._dead = False
149
150 def on_update(self, dt: float):
151 self._t += dt
152 self.position = self._home + Vec2(
153 math.sin(self._t * self._speed) * 14.0,
154 math.sin(self._t * self._speed * 0.7 + 1.7) * 9.0,
155 )
156 self._flash = max(0.0, self._flash - dt)
157 self.touch_cd = max(0.0, self.touch_cd - dt)
158
159 def take_damage(self, amount: int) -> None:
160 """Apply damage, spawn a floating number, and die at zero hp."""
161 if self._dead:
162 return
163 self.hp = max(0, self.hp - amount)
164 self._flash = 0.12
165 killed = self.hp == 0
166 p = self.world_position
167 self.parent.add_child(
168 DamageNumber(
169 str(amount),
170 (1.0, 0.36, 0.30) if killed else (1.0, 0.85, 0.40),
171 scale=1.9 if killed else 1.4,
172 position=Vec2(p.x + random.uniform(-8, 8), p.y - self.radius - 22),
173 )
174 )
175 if killed:
176 self._dead = True
177 self.died.emit(self)
178 self.destroy() # deferred: carried out at end of frame, safe here
179
180 def on_draw(self, renderer):
181 p = self.world_position
182 body = (1.0, 1.0, 1.0, 1.0) if self._flash > 0 else self.colour
183 renderer.draw_circle((p.x, p.y), self.radius, colour=body, filled=True)
184 renderer.draw_circle((p.x, p.y), self.radius, colour=(0.10, 0.10, 0.14, 1.0), filled=False)
185
186 # The floating health bar, in WORLD coordinates: it follows this enemy
187 # and scrolls with the camera like everything else drawn here.
188 frac = self.hp / self.max_hp
189 bw, bh = 46.0, 5.0
190 top = (p.x - bw / 2, p.y - self.radius - 14)
191 renderer.draw_rect(top, (bw, bh), colour=(0.08, 0.08, 0.11, 0.9), filled=True)
192 renderer.draw_rect(top, (bw * frac, bh), colour=_hp_colour(frac), filled=True)
193 renderer.draw_rect(top, (bw, bh), colour=(0.85, 0.9, 1.0, 0.5), filled=False)
194
195
196class Player(Node2D):
197 """The player avatar; movement only, hp lives on the demo root."""
198
199 dynamic = True
200 SPEED = 300.0
201
202 def on_update(self, dt: float):
203 v = Input.get_vector("move_left", "move_right", "move_up", "move_down")
204 self.position += v * self.SPEED * dt
205 self.position.x = max(-WORLD, min(WORLD, self.position.x))
206 self.position.y = max(-WORLD, min(WORLD, self.position.y))
207
208 def on_draw(self, renderer):
209 p = self.world_position
210 renderer.draw_circle((p.x, p.y), PLAYER_R, colour=(0.36, 0.72, 1.0, 1.0), filled=True)
211 renderer.draw_circle((p.x, p.y), PLAYER_R * 0.45, colour=(0.9, 0.97, 1.0, 1.0), filled=True)
212
213
214class Ground(Node2D):
215 """Static scroll reference: drawn once, panned by the camera thereafter."""
216
217 def on_draw(self, renderer):
218 for gx in range(-WORLD, WORLD + 1, 120):
219 for gy in range(-WORLD, WORLD + 1, 120):
220 renderer.draw_circle((gx, gy), 2.5, colour=(0.28, 0.29, 0.35, 1.0), filled=True)
221 renderer.draw_rect((-WORLD, -WORLD), (WORLD * 2, WORLD * 2), colour=(0.45, 0.48, 0.6, 1.0), filled=False)
222
223
224class HealthBarsDemo(Node2D):
225 """Root: world, camera, combat, and the screen-anchored player HUD."""
226
227 dynamic = True # draws the melee swing flash
228
229 input_actions = {
230 "move_left": [Key.A, Key.LEFT],
231 "move_right": [Key.D, Key.RIGHT],
232 "move_up": [Key.W, Key.UP],
233 "move_down": [Key.S, Key.DOWN],
234 "click": [MouseButton.LEFT],
235 "attack": [Key.SPACE],
236 "quit": [Key.ESCAPE],
237 }
238
239 def on_ready(self):
240 self._player_hp = PLAYER_MAX_HP
241 self._shown_hp = PLAYER_MAX_HP # what the HUD bar displays; tweens toward the truth
242 self._drain = None # handle of the running HUD tween, if any
243 self._kills = 0
244 self._swing = 0.0 # melee flash, seconds left
245 self._respawning = False
246 self._enemies: list[Enemy] = []
247
248 self.add_child(Ground(name="Ground"))
249 self._player = self.add_child(Player(name="Player", position=Vec2(0, 0)))
250 self._camera = self.add_child(Camera2D(name="Camera"))
251 self._camera.target = self._player
252 self._camera.smoothing = 5.0
253
254 self._build_hud()
255 self._spawn_wave()
256
257 # ------------------------------------------------------------- HUD
258 def _build_hud(self):
259 # Everything below is a Control anchored to the SCREEN. The CanvasLayer
260 # is what makes that literal: a Control parented straight to a Node2D
261 # still renders through the world camera and pans with it; a CanvasLayer
262 # subtree skips the camera entirely.
263 self._hud = self.add_child(CanvasLayer(name="HUD"))
264 self._portrait = Panel(name="Portrait")
265 _pin_bottom_left(self._portrait, PAD, PAD, PORTRAIT, PORTRAIT)
266 self._portrait.bg_colour = Colour.hex("#1B1B22")
267 self._hud.add_child(self._portrait)
268
269 # A stylised face inside the frame; its colour tracks the hp band.
270 self._face = Panel(name="Face")
271 self._face.margin_left = 6
272 self._face.margin_top = 6
273 self._face.size = Vec2(PORTRAIT - 12, PORTRAIT - 12)
274 self._portrait.add_child(self._face)
275 for ex in (14.0, 32.0):
276 eye = Panel()
277 eye.margin_left = ex
278 eye.margin_top = 16
279 eye.size = Vec2(6, 8)
280 eye.bg_colour = Colour.hex("#1B1B22")
281 self._face.add_child(eye)
282 mouth = Panel()
283 mouth.margin_left = 14
284 mouth.margin_top = 34
285 mouth.size = Vec2(PORTRAIT - 40, 4)
286 mouth.bg_colour = Colour.hex("#1B1B22")
287 self._face.add_child(mouth)
288
289 self._bar_bg = Panel(name="HealthBarBG")
290 _pin_bottom_left(self._bar_bg, PAD + PORTRAIT + 10, PAD + (PORTRAIT - BAR_H) / 2, BAR_W, BAR_H)
291 self._bar_bg.bg_colour = Colour.hex("#1B1B22")
292 self._hud.add_child(self._bar_bg)
293
294 self._bar_fill = Panel(name="HealthBarFill")
295 self._bar_fill.margin_left = 2
296 self._bar_fill.margin_top = 2
297 self._bar_fill.size = Vec2(BAR_W - 4, BAR_H - 4)
298 self._bar_bg.add_child(self._bar_fill)
299
300 self._hp_label = Label("")
301 self._hp_label.set_anchor_preset(AnchorPreset.CENTER)
302 self._hp_label.margin_left = -BAR_W / 2
303 self._hp_label.margin_right = BAR_W / 2
304 self._hp_label.margin_top = -BAR_H / 2
305 self._hp_label.margin_bottom = BAR_H / 2
306 self._hp_label.font_size = 11.0
307 self._hp_label.text_colour = Colour.WHITE
308 self._hp_label.alignment = "center"
309 self._bar_bg.add_child(self._hp_label)
310
311 self._kills_label = Label("Kills: 0")
312 self._kills_label.set_anchor_preset(AnchorPreset.TOP_RIGHT)
313 self._kills_label.margin_left = -180 - PAD
314 self._kills_label.margin_right = -PAD
315 self._kills_label.margin_top = PAD
316 self._kills_label.margin_bottom = PAD + 24
317 self._kills_label.font_size = 16.0
318 self._kills_label.text_colour = Colour.hex("#FFD166")
319 self._kills_label.alignment = "right"
320 self._hud.add_child(self._kills_label)
321
322 hint = Label("WASD/arrows move | click an enemy or SPACE to attack | ESC quit")
323 hint.set_anchor_preset(AnchorPreset.CENTER_BOTTOM)
324 hint.margin_left = -260
325 hint.margin_right = 260
326 hint.margin_top = -30
327 hint.margin_bottom = -10
328 hint.font_size = 13.0
329 hint.text_colour = Colour.LIGHT_GRAY
330 hint.alignment = "center"
331 self._hud.add_child(hint)
332
333 # ------------------------------------------------------------- waves
334 def _spawn_wave(self):
335 centre = self._player.world_position
336 for i in range(WAVE_SIZE):
337 angle = i * (2 * math.pi / WAVE_SIZE) + random.uniform(-0.4, 0.4)
338 dist = random.uniform(170, 330)
339 home = Vec2(
340 max(-WORLD + 50, min(WORLD - 50, centre.x + math.cos(angle) * dist)),
341 max(-WORLD + 50, min(WORLD - 50, centre.y + math.sin(angle) * dist)),
342 )
343 max_hp, colour = random.choice(
344 [
345 (40, (0.55, 0.78, 0.42, 1.0)),
346 (60, (0.72, 0.55, 0.90, 1.0)),
347 (80, (0.88, 0.47, 0.36, 1.0)),
348 ]
349 )
350 enemy = Enemy(home, max_hp, colour)
351 enemy.died.connect(self._on_enemy_died)
352 self._enemies.append(self.add_child(enemy))
353
354 def _on_enemy_died(self, enemy):
355 self._enemies.remove(enemy)
356 self._kills += 1
357 self._kills_label.text = f"Kills: {self._kills}"
358 if not self._enemies and not self._respawning:
359 self._respawning = True
360 self.start_coroutine(self._next_wave())
361
362 def _next_wave(self):
363 yield from wait(1.5)
364 self._spawn_wave()
365 self._respawning = False
366
367 # ------------------------------------------------------------- combat
368 def _hurt_player(self, amount: float):
369 self._player_hp = max(0.0, self._player_hp - amount)
370 self._camera.shake(4.0, 0.25)
371 p = self._player.world_position
372 self.add_child(
373 DamageNumber(str(int(amount)), (1.0, 0.36, 0.30), position=Vec2(p.x, p.y - PLAYER_R - 20))
374 )
375 # Ease the HUD bar down to the new value rather than snapping it.
376 if self._drain is not None:
377 self.stop_coroutine(self._drain)
378 self._drain = self.start_coroutine(
379 tween(self, "_shown_hp", self._player_hp, 0.4, easing=easing.ease_out_cubic, on_complete=self._drain_done)
380 )
381
382 def _drain_done(self):
383 self._drain = None
384
385 def on_update(self, dt: float):
386 if Input.is_action_just_pressed("quit"):
387 self.app.quit()
388 return
389
390 # Click: map the pointer through the camera into world space and hit-test.
391 if Input.is_action_just_pressed("click"):
392 world = self._camera.screen_to_world(Input.mouse_position, Vec2(*self.tree.screen_size))
393 for enemy in list(self._enemies):
394 offset = enemy.world_position - world
395 if math.hypot(offset.x, offset.y) <= enemy.radius + 6:
396 enemy.take_damage(random.randint(12, 24))
397 break
398
399 # Melee: swing at the nearest enemy within range of the player.
400 if Input.is_action_just_pressed("attack"):
401 self._swing = 0.18
402 pp = self._player.world_position
403 in_range = [
404 (math.hypot(e.world_position.x - pp.x, e.world_position.y - pp.y), e) for e in self._enemies
405 ]
406 in_range = [(d, e) for d, e in in_range if d <= MELEE_RANGE]
407 if in_range:
408 min(in_range, key=lambda pair: pair[0])[1].take_damage(random.randint(15, 25))
409 self._swing = max(0.0, self._swing - dt)
410
411 # Contact damage, throttled per enemy.
412 pp = self._player.world_position
413 for enemy in self._enemies:
414 ep = enemy.world_position
415 if enemy.touch_cd <= 0 and math.hypot(ep.x - pp.x, ep.y - pp.y) < enemy.radius + PLAYER_R + 4:
416 enemy.touch_cd = 1.0
417 self._hurt_player(CONTACT_DAMAGE)
418
419 # Slow regeneration; the HUD follows directly whenever no drain tween runs.
420 self._player_hp = min(PLAYER_MAX_HP, self._player_hp + PLAYER_REGEN * dt)
421 if self._drain is None:
422 self._shown_hp = self._player_hp
423
424 # Refresh the screen-anchored HUD from the displayed value.
425 frac = self._shown_hp / PLAYER_MAX_HP
426 self._bar_fill.size = Vec2((BAR_W - 4) * frac, BAR_H - 4)
427 self._bar_fill.bg_colour = Colour(_hp_colour(frac))
428 self._face.bg_colour = Colour(_hp_colour(self._player_hp / PLAYER_MAX_HP))
429 self._hp_label.text = f"HP {int(round(self._player_hp))} / {int(PLAYER_MAX_HP)}"
430
431 def on_draw(self, renderer):
432 if self._swing > 0:
433 p = self._player.world_position
434 renderer.draw_circle(
435 (p.x, p.y), MELEE_RANGE, colour=(0.9, 0.95, 1.0, self._swing * 2.5), filled=False
436 )
437
438
439def _selftest() -> bool:
440 """Headless: drive the combat through real input and watch the HUD follow.
441
442 Clicks go through the camera's screen mapping, so the hit-test is the one a
443 player uses; damage numbers, removal and the wave respawn are watched on the
444 live tree; and the screen-anchored HUD is checked to hold still while the
445 world scrolls past it.
446 """
447 from simvx.core.testing import InputSimulator, SceneRunner
448
449 random.seed(7)
450 runner = SceneRunner(screen_size=(WIDTH, HEIGHT))
451 scene = HealthBarsDemo(name="HealthBarsDemo")
452 runner.load(scene)
453 sim = InputSimulator(tree=runner.tree)
454 runner.advance_frames(5)
455 screen = Vec2(WIDTH, HEIGHT)
456 ok = True
457
458 def check(label: str, passed: bool, detail: str) -> None:
459 nonlocal ok
460 ok = ok and passed
461 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
462
463 def click_at(world_pos) -> None:
464 sp = scene._camera.world_to_screen(world_pos, screen)
465 sim.press_mouse(MouseButton.LEFT, (sp.x, sp.y))
466 runner.advance_frames(1)
467 sim.release_mouse()
468 runner.advance_frames(1)
469
470 def numbers() -> list[DamageNumber]:
471 return [c for c in scene.children if isinstance(c, DamageNumber)]
472
473 check("a wave of enemies spawns", len(scene._enemies) == WAVE_SIZE, f"{len(scene._enemies)} of {WAVE_SIZE}")
474
475 # One click through the camera mapping damages the enemy under the pointer.
476 enemy = scene._enemies[0]
477 before = enemy.hp
478 click_at(enemy.world_position)
479 check("clicking an enemy deals damage", enemy.hp < before, f"hp {before} -> {enemy.hp}")
480 spawned = numbers()
481 check("the hit spawned a damage number", len(spawned) == 1, f"{len(spawned)} numbers in the tree")
482
483 # The number rises and fades on its coroutine, then removes itself.
484 number = spawned[0]
485 y0, a0 = float(number.position.y), number._alpha
486 runner.advance_frames(20)
487 risen = float(number.position.y) < y0 and number._alpha < a0
488 detail = f"y {y0:.0f} -> {number.position.y:.0f}, alpha {a0:.2f} -> {number._alpha:.2f}"
489 check("the number rises and fades", risen, detail)
490 runner.advance_frames(int(NUMBER_LIFE * 60) + 20)
491 check("and removes itself when spent", not numbers(), f"{len(numbers())} numbers left")
492
493 # Clicking until the enemy dies removes it cleanly and counts the kill.
494 for _ in range(40):
495 if enemy not in scene._enemies:
496 break
497 click_at(enemy.world_position)
498 check(
499 "a killed enemy leaves the tree",
500 enemy not in scene._enemies and enemy.parent is None,
501 f"in list: {enemy in scene._enemies}, parent: {enemy.parent}",
502 )
503 check("and the kill is counted", scene._kills == 1 and "1" in scene._kills_label.text, scene._kills_label.text)
504
505 # SPACE swings at the nearest enemy in range.
506 target = scene._enemies[0]
507 scene._player.position = Vec2(target._home.x + target.radius + PLAYER_R + 40, target._home.y)
508 runner.advance_frames(1)
509 before = target.hp
510 sim.press_key(Key.SPACE)
511 runner.advance_frames(1)
512 sim.release_key(Key.SPACE)
513 runner.advance_frames(1)
514 check("SPACE melees the nearest enemy in range", target.hp < before, f"hp {before} -> {target.hp}")
515
516 # Standing on an enemy costs the player health; the HUD bar tweens after it.
517 scene._player.position = Vec2(target._home.x, target._home.y)
518 runner.advance_frames(2)
519 hp_after_hit = scene._player_hp
520 lagging = scene._shown_hp > hp_after_hit
521 check("contact damage drains the player", hp_after_hit < PLAYER_MAX_HP, f"hp {hp_after_hit:.1f}")
522 check("the HUD bar lags behind on a tween", lagging, f"shown {scene._shown_hp:.1f} vs hp {hp_after_hit:.1f}")
523 scene._player.position = Vec2(target._home.x + 400, target._home.y) # step off before the next tick
524 runner.advance_frames(40)
525 settled = abs(scene._shown_hp - scene._player_hp) < 0.01
526 fill_ok = abs(float(scene._bar_fill.size.x) - (BAR_W - 4) * scene._shown_hp / PLAYER_MAX_HP) < 0.01
527 detail = f"shown {scene._shown_hp:.1f}, fill {scene._bar_fill.size.x:.1f}px"
528 check("then settles on the real value", settled and fill_ok, detail)
529
530 # The HUD is screen-anchored: the world scrolls, the widgets do not.
531 bar_rect = scene._bar_bg.get_global_rect()
532 portrait_rect = scene._portrait.get_global_rect()
533 landmark = scene._enemies[0]._home
534 on_screen_before = scene._camera.world_to_screen(landmark, screen)
535 scene._player.position = Vec2(scene._player.position.x + 500, scene._player.position.y + 300)
536 runner.advance_frames(30)
537 on_screen_after = scene._camera.world_to_screen(landmark, screen)
538 moved = math.hypot(on_screen_after.x - on_screen_before.x, on_screen_after.y - on_screen_before.y)
539 check(
540 "the world scrolls under the camera",
541 moved > 100,
542 f"a fixed landmark moved {moved:.0f}px on screen",
543 )
544 check(
545 "while the HUD holds still",
546 scene._bar_bg.get_global_rect() == bar_rect and scene._portrait.get_global_rect() == portrait_rect,
547 f"bar at {tuple(round(v) for v in bar_rect)}",
548 )
549
550 # Killing the whole wave respawns a fresh one after a pause.
551 clicks = 0
552 while scene._enemies and clicks < 200:
553 click_at(scene._enemies[0].world_position)
554 clicks += 1
555 check("the whole wave can be cleared", not scene._enemies, f"{clicks} clicks, {len(scene._enemies)} left")
556 runner.advance_frames(int(1.5 * 60) + 30)
557 fresh = len(scene._enemies) == WAVE_SIZE and all(e.hp == e.max_hp for e in scene._enemies)
558 check("and a fresh wave respawns after a pause", fresh, f"{len(scene._enemies)} enemies, all at full hp: {fresh}")
559
560 # Finally, prove the HUD is really composited on SCREEN, in pixels. The
561 # layout checks above pass even when the widgets render through the world
562 # camera (their rects are layout-space), which is exactly the failure a
563 # HUD outside a CanvasLayer produces: correct rects, nothing in the corner.
564 from simvx.graphics import App
565
566 class _Panned(HealthBarsDemo):
567 def on_ready(self):
568 super().on_ready()
569 self._player.position = Vec2(self._player.position.x + 900, self._player.position.y + 500)
570
571 frames = App(title="hud pixels", width=WIDTH, height=HEIGHT, visible=False).run_headless(
572 _Panned(name="PixelProbe"), frames=12, capture_frames=None
573 )
574 fill_y = int(HEIGHT - (PAD + PORTRAIT / 2))
575 fill_x = int(PAD + PORTRAIT + 10 + 20)
576 fill_px = frames[-1][fill_y, fill_x, :3]
577 check(
578 "and the HUD is really composited on screen",
579 int(fill_px[1]) > 100 and int(fill_px[1]) > int(fill_px[0]),
580 f"health-bar fill pixel with the camera panned far away: {tuple(int(v) for v in fill_px)}",
581 )
582
583 print("SELFTEST:", "PASS" if ok else "FAIL")
584 return ok
585
586
587if __name__ == "__main__":
588 import sys
589
590 if "--test" in sys.argv:
591 sys.exit(0 if _selftest() else 1)
592 App(title="SimVX Health Bars", width=WIDTH, height=HEIGHT).run(HealthBarsDemo())