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)