Health bars¶
world-space bars over enemies, floating damage numbers and a fixed HUD.
â–¶ Run in browserTags: 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
DamageNumbernode that runs a coroutine (start_coroutine): every frame it rises and fades a little, and when its time is up it callsdestroy().A smooth drain. When the player is hurt,
tween()eases the HUD bar’svaluedown to the new health over 0.4 seconds rather than snapping it, so the loss is visible. A new hit cancels the running tween withstop_coroutineand 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 itsdiedsignal, sotree.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 withwait()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())