Health bars¶

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

â–¶ Run in browser

Tags: ui hud camera tween signals

Games show health in two places, and they live in two different spaces.

Over each enemy, in the world. An Enemy draws its own bar in on_draw, just above its body. Everything a Node2D draws is in world space, so the bar follows the enemy and scrolls with the Camera2D like the rest of the world. The bar is three rectangles: a dark track, a fill hp / max_hp wide and an outline. Its colour goes green, amber, then red as health falls.

On the screen, for the player. The portrait, the health bar and the kill count are Controls inside a CanvasLayer. A CanvasLayer skips the camera entirely, so they stay glued to their corners while the world pans underneath. Without it a Control parented to a Node2D renders through the world camera and scrolls away. Each one is placed in a single call with anchor= and inset=, and tracks its corner when the window is resized. The bar is a ProgressBar whose fill_colour follows the same colour bands, and the portrait is a small Control that draws a face in the colour of the player’s health.

A few techniques make the feedback read well:

  • Floating damage numbers. Each hit adds a DamageNumber node that runs a coroutine (start_coroutine): every frame it rises and fades a little, and when its time is up it calls destroy().

  • A smooth drain. When the player is hurt, tween() eases the HUD bar’s value down to the new health over 0.4 seconds rather than snapping it, so the loss is visible. A new hit cancels the running tween with stop_coroutine and starts another; between hits the bar tracks the slow regeneration directly.

  • Clean removal through a signal. An enemy at zero health calls destroy() and then emits its died signal, so tree.group("enemies") already leaves it out when the handler runs. The root counts the kill from that signal and, once the group is empty, waits 1.5 seconds with wait() and spawns a fresh wave.

  • Picking through the camera. A click or tap maps the pointer into the world with camera.screen_to_world() and hits the enemy under it. Camera2D.shake() jolts the view when the player takes contact damage.

Move with WASD, the arrow keys or a gamepad stick. Click or tap an enemy to hit it, or press SPACE (or the A button) to swing at the nearest enemy in range. Touching an enemy hurts the player, at most once a second. A TouchController puts a stick and an A button on screen for a finger, so the HUD keeps to the top edge, clear of them.

Source¶

  1# /// simvx
  2# tags = ["ui", "hud", "camera", "tween", "signals"]
  3# web = { width = 960, height = 540, responsive = true }
  4# ///
  5"""Health bars: world-space bars over enemies, floating damage numbers and a fixed HUD.
  6
  7Games show health in two places, and they live in two different spaces.
  8
  9**Over each enemy, in the world.** An `Enemy` draws its own bar in `on_draw`, just above its
 10body. Everything a `Node2D` draws is in world space, so the bar follows the enemy and scrolls
 11with the `Camera2D` like the rest of the world. The bar is three rectangles: a dark track, a fill
 12`hp / max_hp` wide and an outline. Its colour goes green, amber, then red as health falls.
 13
 14**On the screen, for the player.** The portrait, the health bar and the kill count are
 15`Control`s inside a `CanvasLayer`. A `CanvasLayer` skips the camera entirely, so they stay glued
 16to their corners while the world pans underneath. Without it a `Control` parented to a `Node2D`
 17renders through the world camera and scrolls away. Each one is placed in a single call with
 18`anchor=` and `inset=`, and tracks its corner when the window is resized. The bar is a
 19`ProgressBar` whose `fill_colour` follows the same colour bands, and the portrait is a small
 20`Control` that draws a face in the colour of the player's health.
 21
 22A few techniques make the feedback read well:
 23
 24- **Floating damage numbers.** Each hit adds a `DamageNumber` node that runs a coroutine
 25  (`start_coroutine`): every frame it rises and fades a little, and when its time is up it calls
 26  `destroy()`.
 27- **A smooth drain.** When the player is hurt, `tween()` eases the HUD bar's `value` down to the
 28  new health over 0.4 seconds rather than snapping it, so the loss is visible. A new hit cancels
 29  the running tween with `stop_coroutine` and starts another; between hits the bar tracks the
 30  slow regeneration directly.
 31- **Clean removal through a signal.** An enemy at zero health calls `destroy()` and then emits
 32  its `died` signal, so `tree.group("enemies")` already leaves it out when the handler runs. The
 33  root counts the kill from that signal and, once the group is empty, waits 1.5 seconds with
 34  `wait()` and spawns a fresh wave.
 35- **Picking through the camera.** A click or tap maps the pointer into the world with
 36  `camera.screen_to_world()` and hits the enemy under it. `Camera2D.shake()` jolts the view when
 37  the player takes contact damage.
 38
 39Move with WASD, the arrow keys or a gamepad stick. Click or tap an enemy to hit it, or press SPACE
 40(or the A button) to swing at the nearest enemy in range. Touching an enemy hurts the player, at
 41most once a second. A `TouchController` puts a stick and an A button on screen for a finger, so
 42the HUD keeps to the top edge, clear of them.
 43"""
 44
 45import math
 46import random
 47
 48from simvx.core import (
 49    Anchor,
 50    Axis2D,
 51    Camera2D,
 52    CanvasLayer,
 53    Control,
 54    Controller,
 55    Input,
 56    Key,
 57    Label,
 58    Node2D,
 59    Pointer,
 60    ProgressBar,
 61    Property,
 62    Signal,
 63    TouchController,
 64    Vec2,
 65    easing,
 66    tween,
 67    wait,
 68)
 69from simvx.graphics import App
 70
 71WORLD = 900  # the walkable world spans -WORLD..WORLD on both axes
 72PLAYER_SPEED = 300.0
 73PLAYER_RADIUS = 14.0
 74PLAYER_MAX_HP = 100.0
 75PLAYER_REGEN = 4.0  # hp per second
 76CONTACT_DAMAGE = 10
 77MELEE_RANGE = 150.0
 78WAVE_SIZE = 5
 79ENEMY_KINDS = [(40, (0.55, 0.78, 0.42, 1.0)), (60, (0.72, 0.55, 0.90, 1.0)), (80, (0.88, 0.47, 0.36, 1.0))]
 80DARK = (0.08, 0.08, 0.11, 1.0)
 81
 82
 83def health_colour(fraction: float) -> tuple[float, float, float, float]:
 84    """Green when healthy, amber when hurt, red when critical."""
 85    if fraction > 0.6:
 86        return (0.26, 0.77, 0.39, 1.0)
 87    if fraction > 0.3:
 88        return (0.96, 0.64, 0.38, 1.0)
 89    return (0.90, 0.22, 0.27, 1.0)
 90
 91
 92class DamageNumber(Node2D):
 93    """A number that rises, fades out and removes itself."""
 94
 95    dynamic = True
 96    LIFETIME = 0.9  # seconds
 97    RISE = 46.0  # world units
 98
 99    def __init__(self, amount: int, colour, scale: float = 1.4, **kwargs):
100        super().__init__(**kwargs)
101        self.text, self.colour, self.scale_factor = str(amount), colour, scale
102        self.alpha = 1.0
103
104    def on_ready(self):
105        self.start_coroutine(self.float_away())
106
107    def float_away(self):
108        start_y, elapsed = self.position.y, 0.0
109        while elapsed < self.LIFETIME:
110            elapsed += yield  # the coroutine is sent each frame's dt
111            t = min(elapsed / self.LIFETIME, 1.0)
112            self.position = (self.position.x, start_y - self.RISE * easing.ease_out_cubic(t))
113            self.alpha = (1.0 - t) ** 1.5
114        self.destroy()
115
116    def on_draw(self, renderer):
117        fill = (*self.colour, self.alpha)
118        renderer.text(self.text, (0, 0), fill=fill, scale=self.scale_factor, align="centre", stroke_width=0.08)
119
120
121class Enemy(Node2D):
122    """A wandering enemy with hit points and a health bar drawn in world space."""
123
124    dynamic = True
125    died = Signal()
126
127    def __init__(self, home: Vec2, max_hp: int, colour, **kwargs):
128        super().__init__(position=home, **kwargs)
129        self.home, self.max_hp, self.hp, self.colour = Vec2(home), max_hp, max_hp, colour
130        self.radius = 14.0 + max_hp * 0.1
131        self.clock = random.uniform(0.0, 10.0)
132        self.pace = random.uniform(0.8, 1.4)
133        self.flash = 0.0  # seconds of white hit flash left
134        self.add_to_group("enemies")
135
136    def on_update(self, dt):
137        self.clock += dt
138        phase = self.clock * self.pace
139        self.position = self.home + Vec2(math.sin(phase) * 14.0, math.sin(phase * 0.7 + 1.7) * 9.0)
140        self.flash = max(0.0, self.flash - dt)
141
142    def take_damage(self, amount: int):
143        self.hp = max(0, self.hp - amount)
144        self.flash = 0.12
145        killed = self.hp == 0
146        self.parent.add_child(
147            DamageNumber(
148                amount,
149                (1.0, 0.36, 0.30) if killed else (1.0, 0.85, 0.40),
150                scale=1.9 if killed else 1.4,
151                position=self.position + Vec2(random.uniform(-8, 8), -self.radius - 22),
152            )
153        )
154        if killed:
155            self.destroy()  # first, so the died handler no longer counts it in tree.group("enemies")
156            self.died.emit()
157
158    def on_draw(self, renderer):
159        renderer.circle((0, 0), self.radius, fill=(1.0, 1.0, 1.0, 1.0) if self.flash > 0 else self.colour)
160        renderer.circle((0, 0), self.radius, stroke=(0.10, 0.10, 0.14, 1.0))
161
162        fraction = self.hp / self.max_hp
163        width, height = 46.0, 5.0
164        corner = (-width / 2, -self.radius - 14)
165        renderer.rect(corner, (width, height), fill=DARK)
166        renderer.rect(corner, (width * fraction, height), fill=health_colour(fraction))
167        renderer.rect(corner, (width, height), stroke=(0.85, 0.9, 1.0, 0.5))
168
169
170class Player(Node2D):
171    def on_draw(self, renderer):
172        renderer.circle((0, 0), PLAYER_RADIUS, fill=(0.36, 0.72, 1.0, 1.0))
173        renderer.circle((0, 0), PLAYER_RADIUS * 0.45, fill=(0.9, 0.97, 1.0, 1.0))
174
175
176class Portrait(Control):
177    """The player's face, coloured by their health."""
178
179    health = Property(1.0, range=(0.0, 1.0))
180
181    def on_draw(self, renderer):
182        w, h = self.size
183        renderer.rect((0, 0), (w, h), fill=DARK)
184        renderer.rect((6, 6), (w - 12, h - 12), fill=health_colour(self.health))
185        for eye_x in (0.36, 0.64):
186            renderer.rect((w * eye_x - 3, h * 0.38), (6, 8), fill=DARK)
187        renderer.rect((w * 0.35, h * 0.68), (w * 0.3, 4), fill=DARK)
188
189
190class HealthBarsDemo(Node2D):
191    dynamic = True  # draws the melee swing ring
192
193    input_context = {
194        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
195        "hit": [Pointer.PRIMARY],
196        "attack": [Key.SPACE, Controller.A],
197    }
198
199    def on_ready(self):
200        self.hp = PLAYER_MAX_HP
201        self.kills = 0
202        self.hurt_cooldown = 0.0
203        self.swing = 0.0  # seconds of melee ring left
204        self.drain = None  # the running HUD tween, if any
205
206        self.add_child(Ground())
207        self.player = self.add_child(Player())
208        self.camera = self.add_child(Camera2D(smoothing=5.0))
209        self.camera.target = self.player
210
211        hud = self.add_child(CanvasLayer())
212        self.portrait = hud.add_child(Portrait(anchor=Anchor.TOP_LEFT, inset=14, size=(56, 56)))
213        self.bar = hud.add_child(
214            ProgressBar(anchor=Anchor.TOP_LEFT, inset=(80, 34), size=(200, 16), max_value=PLAYER_MAX_HP, value=self.hp)
215        )
216        self.bar.show_percentage = False
217        self.hp_label = self.bar.add_child(Label(anchor=Anchor.CENTER, font_size=11))
218        self.kills_label = hud.add_child(Label("Kills: 0", anchor=Anchor.TOP_RIGHT, inset=14, font_size=16))
219        self.kills_label.text_colour = (1.0, 0.82, 0.4, 1.0)
220        self.add_child(TouchController(buttons=(Controller.A,)))
221
222        self.spawn_wave()
223
224        # Example help overlay: not part of the technique shown.
225        help_text = "WASD / stick: move    Click, tap or SPACE: attack"
226        hud.add_child(Label(help_text, anchor=Anchor.CENTER_TOP, inset=14, font_size=13))
227
228    def spawn_wave(self):
229        for i in range(WAVE_SIZE):
230            angle = i * math.tau / WAVE_SIZE + random.uniform(-0.4, 0.4)
231            offset = Vec2(math.cos(angle), math.sin(angle)) * random.uniform(170, 330)
232            home = (self.player.position + offset).clamped((50 - WORLD, 50 - WORLD), (WORLD - 50, WORLD - 50))
233            enemy = self.add_child(Enemy(home, *random.choice(ENEMY_KINDS)))
234            enemy.died.connect(self.on_enemy_died)
235
236    def on_enemy_died(self):
237        self.kills += 1
238        self.kills_label.text = f"Kills: {self.kills}"
239        if not self.tree.group("enemies"):
240            self.start_coroutine(self.next_wave())
241
242    def next_wave(self):
243        yield from wait(1.5)
244        self.spawn_wave()
245
246    def hurt_player(self, amount: float):
247        self.hp = max(0.0, self.hp - amount)
248        self.camera.shake(4.0, 0.25)
249        self.add_child(DamageNumber(int(amount), (1.0, 0.36, 0.30), position=self.player.position - (0, 34)))
250        if self.drain is not None:
251            self.stop_coroutine(self.drain)
252        self.drain = self.start_coroutine(
253            tween(self.bar, "value", self.hp, 0.4, easing=easing.ease_out_cubic, on_complete=self.drain_finished)
254        )
255
256    def drain_finished(self):
257        self.drain = None
258
259    def on_update(self, dt):
260        step = Input.value("move") * PLAYER_SPEED * dt
261        self.player.position = (self.player.position + step).clamped((-WORLD, -WORLD), (WORLD, WORLD))
262        if Input.just_pressed("hit"):
263            point = self.camera.screen_to_world(Input.mouse_position)
264            hits = (e for e in self.tree.group("enemies") if e.position.distance_to(point) <= e.radius + 6)
265            target = next(hits, None)
266            if target:
267                target.take_damage(random.randint(12, 24))
268
269        if Input.just_pressed("attack"):
270            self.swing = 0.18
271            distance = self.player.position.distance_to
272            # Query again: a click this frame may have just killed an enemy, and the group skips it.
273            nearest = min(self.tree.group("enemies"), key=lambda e: distance(e.position), default=None)
274            if nearest and distance(nearest.position) <= MELEE_RANGE:
275                nearest.take_damage(random.randint(15, 25))
276        self.swing = max(0.0, self.swing - dt)
277
278        self.hurt_cooldown = max(0.0, self.hurt_cooldown - dt)
279        touching = any(
280            e.position.distance_to(self.player.position) < e.radius + PLAYER_RADIUS + 4
281            for e in self.tree.group("enemies")
282        )
283        if touching and self.hurt_cooldown == 0.0:
284            self.hurt_cooldown = 1.0
285            self.hurt_player(CONTACT_DAMAGE)
286
287        self.hp = min(PLAYER_MAX_HP, self.hp + PLAYER_REGEN * dt)
288        if self.drain is None:
289            self.bar.value = self.hp
290        self.bar.fill_colour = health_colour(self.bar.percentage)
291        self.portrait.health = self.hp / PLAYER_MAX_HP
292        self.hp_label.text = f"HP {round(self.hp)} / {PLAYER_MAX_HP:.0f}"
293
294    def on_draw(self, renderer):
295        if self.swing > 0:
296            renderer.circle(self.player.position, MELEE_RANGE, stroke=(0.9, 0.95, 1.0, self.swing * 2.5))
297
298
299class Ground(Node2D):
300    """A dotted grid and the world's border, so the scrolling has something to read against."""
301
302    def on_draw(self, renderer):
303        for x in range(-WORLD, WORLD + 1, 120):
304            for y in range(-WORLD, WORLD + 1, 120):
305                renderer.circle((x, y), 2.5, fill=(0.28, 0.29, 0.35, 1.0))
306        renderer.rect((-WORLD, -WORLD), (WORLD * 2, WORLD * 2), stroke=(0.45, 0.48, 0.6, 1.0))
307
308
309if __name__ == "__main__":
310    App(title="Health Bars", width=960, height=540).run(HealthBarsDemo())