Health bars¶

world-space bars over enemies, damage numbers, and a fixed HUD

â–¶ Run in browser

Tags: 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())