HUD Anchors¶
a game HUD pinned to screen corners that scales with the window.
▶ Run in browserTags: ui hud anchors layout responsive
A game-style heads-up display whose pieces are anchored to the viewport edges, not placed at absolute coordinates: a health bar hugs the top-left, the score sits in the top-right, and a hint label is centred along the bottom. Resize the window and every element stays glued to its corner while the bar and labels keep their margins, because each top-level Control uses an AnchorPreset plus symmetric margins rather than a fixed position.
What it demonstrates¶
Anchoring top-level Controls to viewport corners with
set_anchor_preset():TOP_LEFT,TOP_RIGHT,CENTER_BOTTOM, never an absoluteposition.Margins as pixel offsets from the chosen anchor, so a corner gadget keeps a consistent gutter as the window grows or shrinks.
A live health bar (background Panel + foreground fill Panel) whose width tracks a value, driven each frame from
on_update.A score readout and a centred hint that follow the bottom/top edges on resize.
Controls: Up / Down - Heal / take damage (animate the health bar) Click/touch - Hold on the health bar to heal, anywhere else to take damage ESC - Quit
Run: uv run python examples/features/ui/hud_anchors.py Headless self-check: uv run python examples/features/ui/hud_anchors.py –test
Source¶
1"""HUD Anchors: a game HUD pinned to screen corners that scales with the window.
2
3A game-style heads-up display whose pieces are anchored to the viewport edges,
4not placed at absolute coordinates: a health bar hugs the top-left, the score
5sits in the top-right, and a hint label is centred along the bottom. Resize the
6window and every element stays glued to its corner while the bar and labels
7keep their margins, because each top-level Control uses an AnchorPreset plus
8symmetric margins rather than a fixed position.
9
10# /// simvx
11# tags = ["ui", "hud", "anchors", "layout", "responsive"]
12# web = { root = "HudAnchorsDemo", width = 800, height = 600, responsive = true }
13# ///
14
15## What it demonstrates
16
17- Anchoring top-level Controls to viewport corners with `set_anchor_preset()`:
18 `TOP_LEFT`, `TOP_RIGHT`, `CENTER_BOTTOM`, never an absolute `position`.
19- Margins as pixel offsets from the chosen anchor, so a corner gadget keeps a
20 consistent gutter as the window grows or shrinks.
21- A live health bar (background Panel + foreground fill Panel) whose width
22 tracks a value, driven each frame from `on_update`.
23- A score readout and a centred hint that follow the bottom/top edges on resize.
24
25Controls:
26 Up / Down - Heal / take damage (animate the health bar)
27 Click/touch - Hold on the health bar to heal, anywhere else to take damage
28 ESC - Quit
29
30Run: uv run python examples/features/ui/hud_anchors.py
31Headless self-check: uv run python examples/features/ui/hud_anchors.py --test
32"""
33
34from simvx.core import (
35 AnchorPreset,
36 Colour,
37 Input,
38 InputMap,
39 Key,
40 Label,
41 MouseButton,
42 Node,
43 Panel,
44 Vec2,
45)
46from simvx.graphics import App
47
48WIDTH, HEIGHT = 800, 600
49BAR_W, BAR_H = 220.0, 22.0 # health bar size in pixels
50PAD = 16.0 # gutter from the anchored corner
51
52
53class HudAnchorsDemo(Node):
54 """Root node: builds a corner-anchored HUD and animates the health bar."""
55
56 def on_ready(self):
57 InputMap.add_action("heal", [Key.UP])
58 InputMap.add_action("hurt", [Key.DOWN])
59 InputMap.add_action("click", [MouseButton.LEFT])
60 InputMap.add_action("quit", [Key.ESCAPE])
61
62 self._health = 0.75 # 0..1
63 self._score = 0
64
65 # --- Health bar, top-left ---------------------------------------
66 # The bar background is the anchored top-level Control; the fill is a
67 # child that the on_update loop resizes to reflect current health.
68 self._bar_bg = bar_bg = Panel(name="HealthBarBG")
69 bar_bg.set_anchor_preset(AnchorPreset.TOP_LEFT)
70 bar_bg.margin_left = PAD
71 bar_bg.margin_top = PAD
72 bar_bg.size = Vec2(BAR_W, BAR_H)
73 bar_bg.bg_colour = Colour.hex("#202028")
74 self.add_child(bar_bg)
75
76 self._bar_fill = Panel(name="HealthBarFill")
77 self._bar_fill.set_anchor_preset(AnchorPreset.TOP_LEFT)
78 self._bar_fill.margin_left = 2
79 self._bar_fill.margin_top = 2
80 self._bar_fill.size = Vec2((BAR_W - 4) * self._health, BAR_H - 4)
81 self._bar_fill.bg_colour = Colour.hex("#43C463")
82 bar_bg.add_child(self._bar_fill)
83
84 # CENTER puts both anchors at the bar's midpoint; symmetric margins
85 # expand that point into a box the size of the bar, so the text
86 # centres inside it.
87 self._hp_label = Label("HP")
88 self._hp_label.set_anchor_preset(AnchorPreset.CENTER)
89 self._hp_label.margin_left = -BAR_W / 2
90 self._hp_label.margin_right = BAR_W / 2
91 self._hp_label.margin_top = -BAR_H / 2
92 self._hp_label.margin_bottom = BAR_H / 2
93 self._hp_label.font_size = 12.0
94 self._hp_label.text_colour = Colour.WHITE
95 self._hp_label.alignment = "center"
96 bar_bg.add_child(self._hp_label)
97
98 # --- Score, top-right -------------------------------------------
99 # TOP_RIGHT collapses both axes onto the corner, so each margin pair
100 # (not size) defines the box: the horizontal pair gives a 180px width
101 # inset PAD from the right edge, the vertical pair a 24px height PAD
102 # below the top. Both edges then track the corner on resize.
103 self._score_label = Label("Score: 0")
104 self._score_label.set_anchor_preset(AnchorPreset.TOP_RIGHT)
105 self._score_label.margin_left = -180 - PAD
106 self._score_label.margin_right = -PAD
107 self._score_label.margin_top = PAD
108 self._score_label.margin_bottom = PAD + 24
109 self._score_label.font_size = 16.0
110 self._score_label.text_colour = Colour.hex("#FFD166")
111 self._score_label.alignment = "right"
112 self.add_child(self._score_label)
113
114 # --- Hint, bottom-centre ----------------------------------------
115 # CENTER_BOTTOM keeps the label horizontally centred and pinned to the
116 # bottom edge; symmetric left/right margins centre a fixed-width box.
117 self._hint = hint = Label("Up / Down or hold the bar to heal, click elsewhere to hurt | ESC to quit")
118 hint.set_anchor_preset(AnchorPreset.CENTER_BOTTOM)
119 hint.margin_left = -240
120 hint.margin_right = 240
121 hint.margin_top = -36
122 hint.margin_bottom = -12
123 hint.font_size = 13.0
124 hint.text_colour = Colour.LIGHT_GRAY
125 hint.alignment = "center"
126 self.add_child(hint)
127
128 def on_update(self, dt: float):
129 if Input.is_action_pressed("quit"):
130 self.app.quit()
131 return
132
133 # Animate health and bump the score so the HUD is visibly live.
134 if Input.is_action_pressed("heal"):
135 self._health = min(1.0, self._health + dt * 0.6)
136 if Input.is_action_pressed("hurt"):
137 self._health = max(0.0, self._health - dt * 0.6)
138
139 # Mouse/touch: holding on the health bar heals, anywhere else hurts.
140 if Input.is_action_pressed("click"):
141 if self._bar_bg.is_point_inside(Input.mouse_position):
142 self._health = min(1.0, self._health + dt * 0.6)
143 else:
144 self._health = max(0.0, self._health - dt * 0.6)
145
146 self._score += 1
147 self._score_label.text = f"Score: {self._score}"
148
149 # Resize the fill to match health and recolour toward red when low.
150 self._bar_fill.size = Vec2((BAR_W - 4) * self._health, BAR_H - 4)
151 if self._health < 0.3:
152 self._bar_fill.bg_colour = Colour.hex("#E63946")
153 elif self._health < 0.6:
154 self._bar_fill.bg_colour = Colour.hex("#F4A261")
155 else:
156 self._bar_fill.bg_colour = Colour.hex("#43C463")
157 self._hp_label.text = f"HP {int(self._health * 100)}"
158
159
160def _selftest() -> bool:
161 """Headless: check the HUD stays glued to its corners, and that health responds.
162
163 The anchoring claim is a claim about resizing, so every gadget's gutter is
164 measured at one screen size and again at another, and it is the gutter that
165 has to be unchanged, not the coordinates. Health is only ever moved by the
166 real actions -- a held key, a held pointer -- so what is checked is the path a
167 player uses, and the bar's fill and colour are read back off the widgets.
168 """
169 from simvx.core.testing import InputSimulator
170 from simvx.core.ui.testing import UITestHarness
171
172 harness = UITestHarness(HudAnchorsDemo(name="HudAnchorsDemo"), screen_size=(WIDTH, HEIGHT))
173 scene = harness.tree.root
174 sim = InputSimulator(tree=harness.tree)
175 ok = True
176
177 def check(label: str, passed: bool, detail: str) -> None:
178 nonlocal ok
179 ok = ok and passed
180 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
181
182 def gutters() -> dict[str, tuple[float, float]]:
183 """Each gadget's distance from the screen edge it is anchored to."""
184 harness.tick()
185 sw, sh = harness.tree.screen_size
186 bx, by, _, _ = scene._bar_bg.get_global_rect()
187 sx, sy, sw_, _ = scene._score_label.get_global_rect()
188 hx, hy, hw, hh = scene._hint.get_global_rect()
189 return {
190 "bar from top-left": (bx, by),
191 "score from top-right": (sw - (sx + sw_), sy),
192 "hint from bottom-centre": (hx + hw / 2 - sw / 2, sh - (hy + hh)),
193 }
194
195 small = gutters()
196 harness.tree.screen_size = (1280, 720)
197 large = gutters()
198 check(
199 "every gadget keeps its gutter when the window changes size",
200 small == large,
201 " | ".join(f"{k} {v[0]:.0f},{v[1]:.0f}" for k, v in large.items()),
202 )
203 check(
204 "and those gutters are the ones the layout asked for",
205 large["bar from top-left"] == (PAD, PAD)
206 and large["score from top-right"] == (PAD, PAD)
207 and abs(large["hint from bottom-centre"][0]) < 0.01,
208 f"PAD is {PAD:.0f} and the hint is centred to {large['hint from bottom-centre'][0]:.2f}px",
209 )
210 harness.tree.screen_size = (WIDTH, HEIGHT)
211 harness.tick()
212
213 def hold(press, release, frames: int) -> float:
214 """Hold one input for a number of frames and report the health it leaves."""
215 press()
216 harness.tick(count=frames)
217 release()
218 return scene._health
219
220 start = scene._health
221 hurt_40 = hold(lambda: sim.press_key(Key.DOWN), lambda: sim.release_key(Key.DOWN), 40)
222 check(
223 "Down drains health while it is held",
224 hurt_40 < start,
225 f"{start:.2f} -> {hurt_40:.2f} over 40 frames",
226 )
227 healed = hold(lambda: sim.press_key(Key.UP), lambda: sim.release_key(Key.UP), 20)
228 check("and Up puts it back", healed > hurt_40, f"{hurt_40:.2f} -> {healed:.2f} over 20 frames")
229
230 # The pointer is the same mechanic by a different route: on the bar it heals,
231 # anywhere else it hurts, so the hit-test on the anchored rect is what decides.
232 bx, by, bw, bh = scene._bar_bg.get_global_rect()
233 on_bar = hold(lambda: sim.press_mouse(MouseButton.LEFT, (bx + bw / 2, by + bh / 2)), sim.release_mouse, 20)
234 off_bar = hold(lambda: sim.press_mouse(MouseButton.LEFT, (WIDTH / 2, HEIGHT / 2)), sim.release_mouse, 20)
235 check(
236 "holding the pointer on the bar heals, and holding it elsewhere hurts",
237 on_bar > healed and off_bar < on_bar,
238 f"{healed:.2f} -> {on_bar:.2f} on the bar -> {off_bar:.2f} off it",
239 )
240
241 # The fill is the health made visible: its width is the fraction of the track,
242 # and the colour steps through its two thresholds on the way down.
243 seen: list[tuple[float, float, tuple]] = []
244 hold(lambda: sim.press_key(Key.UP), lambda: sim.release_key(Key.UP), 60) # back to full, then all the way down
245 sim.press_key(Key.DOWN)
246 for _ in range(9):
247 harness.tick(count=10)
248 seen.append((scene._health, float(scene._bar_fill.size.x), tuple(scene._bar_fill.bg_colour)))
249 sim.release_key(Key.DOWN)
250 track = BAR_W - 4
251 check(
252 "the fill width is always the health fraction of the track",
253 all(abs(w - track * h) < 0.01 for h, w, _ in seen),
254 f"{seen[0][1]:.1f}px at {seen[0][0]:.2f} health down to {seen[-1][1]:.1f}px at {seen[-1][0]:.2f}",
255 )
256 bands = {("red" if h < 0.3 else "amber" if h < 0.6 else "green") for h, _, _ in seen}
257 colours = {("red" if h < 0.3 else "amber" if h < 0.6 else "green"): c for h, _, c in seen}
258 check(
259 "and it recolours as health crosses each threshold",
260 bands == {"green", "amber", "red"} and len(set(colours.values())) == 3,
261 ", ".join(f"{k} {tuple(round(v, 2) for v in c[:3])}" for k, c in colours.items()),
262 )
263
264 harness.teardown()
265 print("SELFTEST:", "PASS" if ok else "FAIL")
266 return ok
267
268
269if __name__ == "__main__":
270 import sys
271
272 if "--test" in sys.argv:
273 sys.exit(0 if _selftest() else 1)
274 app = App(title="SimVX HUD Anchors", width=WIDTH, height=HEIGHT)
275 app.run(HudAnchorsDemo())