From d9a59fff03e9afa13ae2691148ce317467ad9c4a Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 3 Sep 2026 16:58:42 +0200 Subject: [PATCH] Fix lobby/HUD UI pinned at (0,0); shrink player hitbox below visual size The lobby connect menu and three pieces of the HUD (hit-flash overlay, boss bar centering, death-message centering, hint label) were all silently broken by the same Godot gotcha: set_anchors_preset(preset) with the default keep_offsets=false does NOT zero the offsets to the preset's margins -- it recomputes them to preserve the control's *current* rect, which for a freshly constructed Control is (0,0). Anchors end up correct; the actual rect stays pinned to the top-left corner regardless. Fixed by switching to set_anchors_and_offsets_preset() everywhere a Control is built in code, and by using offset_left/offset_top (anchor-relative) instead of .position (absolute) for the HUD hint label. Documented as gotcha #5 in CLAUDE.md. Also split PLAYER_RADIUS into two constants: PLAYER_RADIUS (6.0, the authoritative hitbox used by SimWorld) and PLAYER_VISUAL_RADIUS (13.0, view-only, used by world_view.gd). The client renders every ship a little late relative to the server -- interpolation delay, reconciliation smoothing -- so a hitbox that matched the sprite would let bullets connect against a ship the player watched dodge clear of them. A smaller hitbox means the occasional bullet visibly clips the sprite without a hit, which reads as more forgiving of latency than the reverse. Verified headlessly: a throwaway scene instantiating MainMenu/HUD under a real 1280x720 viewport, asserting the panel is centered and _canvas.size / hint position resolve correctly, before and after each fix. check.sh, test.sh (78/78) and smoke.sh all pass. --- CLAUDE.md | 11 ++++++++++- src/core/sim_config.gd | 11 ++++++++++- src/ui/hud.gd | 18 +++++++++++++++--- src/ui/main_menu.gd | 22 ++++++++++++++++++---- src/view/world_view.gd | 9 +++++++-- 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 895ea7a..14fb0f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,16 @@ ticks in milliseconds with no SceneTree. 4. **Input events default to `device = 16`, which matches nothing.** Bindings must use `device = -1`. `tools/setup_input_map.gd` generates the input map correctly; edit that file, not the `[input]` block in `project.godot`. -5. **`ProjectSettings.save()` drops settings equal to the engine default** and +5. **`set_anchors_preset(preset)` does not zero the offsets.** `keep_offsets` + defaults to `false`, which despite the name means "recompute offsets to keep + the control's *current* rect on screen" — for a freshly created Control that + rect is `(0,0)`-sized, so it comes out pinned to the top-left corner + regardless of the anchors. Use `set_anchors_and_offsets_preset()` for any + Control built in code. Separately: `.position` assigns an *absolute* + coordinate even on an anchored control; `offset_left`/`offset_top` are the + anchor-relative ones. This combination silently broke the main menu and + three pieces of the HUD. +6. **`ProjectSettings.save()` drops settings equal to the engine default** and strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code in `src/main.gd` instead of trusted to `project.godot`. diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index f53480f..9b23b46 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -19,7 +19,16 @@ const BULLET_CULL_MARGIN := 64.0 # --- Player ----------------------------------------------------------------- const PLAYER_SPEED := 240.0 -const PLAYER_RADIUS := 9.0 +## Authoritative hitbox. Smaller than PLAYER_VISUAL_RADIUS on purpose: a client +## renders its own and everyone else's ship a little late (interpolation delay, +## reconciliation smoothing), so a hitbox that matched the sprite exactly would +## let bullets connect against a ship the player watched dodge clear of them. +## Erring small means the occasional bullet visibly clips the ship without a +## hit -- a client-side illusion, but the one that reads as fair, versus a +## bullet that visibly missed still registering as a hit. +const PLAYER_RADIUS := 6.0 +## Render-only. Used by src/view/, never by anything under src/sim/. +const PLAYER_VISUAL_RADIUS := 13.0 const PLAYER_MAX_HP := 100 const PLAYER_FIRE_COOLDOWN := 7 # ticks const PLAYER_BULLET_SPEED := 620.0 diff --git a/src/ui/hud.gd b/src/ui/hud.gd index 60a6b79..f334e44 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -16,15 +16,27 @@ var client: ClientRuntime = null func _ready() -> void: layer = 10 _canvas = Control.new() - _canvas.set_anchors_preset(Control.PRESET_FULL_RECT) + # set_anchors_preset() alone leaves the offsets at whatever preserves the + # control's rect at the moment of the call -- (0,0)-sized for a brand new + # node, regardless of the anchors -- so _canvas.size silently stayed + # (0, 0) forever. That took the hit-flash overlay, the boss bar centering + # and the death-message centering down with it, since all three read + # _canvas.size. set_anchors_and_offsets_preset() sets both correctly. + _canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE _canvas.draw.connect(_draw_hud) add_child(_canvas) _status = _make_label(Vector2(MARGIN, MARGIN)) _hint = _make_label(Vector2(MARGIN, 0.0)) - _hint.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) - _hint.position = Vector2(MARGIN, -56.0) + _hint.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT) + # `.position` assigns an ABSOLUTE coordinate regardless of anchors (that's + # what stranded this label off-screen despite the anchor being correct). + # offset_left/offset_top are the anchor-relative ones -- negative offset_top + # here means "56px above the bottom anchor", which is what "-56" was meant + # to say in the first place. + _hint.offset_left = MARGIN + _hint.offset_top = -56.0 func _make_label(pos: Vector2) -> Label: diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd index 05b70c2..e99d75c 100644 --- a/src/ui/main_menu.gd +++ b/src/ui/main_menu.gd @@ -12,13 +12,27 @@ var _status: Label func _ready() -> void: - set_anchors_preset(Control.PRESET_FULL_RECT) + # set_anchors_preset(preset, keep_offsets=false) does NOT zero the offsets + # to the preset's canonical margins -- despite the parameter name, false + # means "recompute offsets to keep the control's CURRENT rect visually + # unchanged". For a freshly constructed Control that rect is (0,0)-sized + # at the origin, so the offsets come out as whatever preserves exactly + # that: a zero rect pinned to (0,0), regardless of the anchors. This is + # what actually caused the "menu stuck in the top-left corner" bug -- + # set_anchors_and_offsets_preset() sets both halves correctly in one call. + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + # A CenterContainer recomputes its child's centered position itself, every + # time the child's minimum size or the window size changes -- no manual + # offset math to get wrong. + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(center) + var panel := VBoxContainer.new() - panel.set_anchors_preset(Control.PRESET_CENTER) panel.custom_minimum_size = Vector2(360.0, 0.0) panel.add_theme_constant_override("separation", 8) - panel.position = Vector2(-180.0, -160.0) - add_child(panel) + center.add_child(panel) var title := Label.new() title.text = "TRANSCIENCE" diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 49febf8..33f083d 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -103,8 +103,13 @@ func _draw_local_player() -> void: _draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL) +## Drawn at PLAYER_VISUAL_RADIUS, larger than the PLAYER_RADIUS hitbox actually +## used for hits -- see the comment on those constants in sim_config.gd. The +## mismatch is deliberate, not a placeholder: a bullet can visibly clip the +## sprite without registering a hit, which reads as more forgiving of latency +## than the reverse. func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void: - var r := SimConfig.PLAYER_RADIUS + var r := SimConfig.PLAYER_VISUAL_RADIUS draw_circle(pos, r, Color(col, 0.4 if alive else 0.2)) draw_arc(pos, r, 0.0, TAU, 20, col, 2.0) if alive: @@ -115,5 +120,5 @@ func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void: ## The escape channel is drawn on the player, not just in the HUD, so other ## players can see someone is about to leave and react. func _draw_escape_ring(pos: Vector2, progress: float, col: Color) -> void: - draw_arc(pos, SimConfig.PLAYER_RADIUS + 8.0, -PI * 0.5, + draw_arc(pos, SimConfig.PLAYER_VISUAL_RADIUS + 8.0, -PI * 0.5, -PI * 0.5 + TAU * progress, 32, Color(col, 0.9), 3.0)