HUD Anchors

a game HUD pinned to screen corners that scales with the window.

▶ Run in browser

Tags: 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 absolute position.

  • 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())