From 884b7799a0eed3ce73abc51db1dd10d543f3dd72 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:54:55 +0100 Subject: [PATCH] fix(hud): have game mode hand HUD its target ship instead of group lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HUDController found its ship via get_first_node_in_group("ship"), a group that has 2+ members once a match has an AI opponent — it only worked because the player ship happened to spawn first. spawn_camera_rig now wires the HUD's ship the same way it already wires the camera rig's target. --- CLAUDE.md | 2 +- Game/scripts/HUDController.gd | 17 ++++++++++------- Game/scripts/game_mode.gd | 8 +++++++- TODO.md | 2 +- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c3c196c..f44b74ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m - **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it. - **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants. - **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`). -- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the ship, camera rig, and game mode via groups (`"ship"`, `"ship_camera"`, `"game"`), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling. +- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig` (mirroring the camera rig's `target`, not group lookup — the `"ship"` group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling. - **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks. - Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`). diff --git a/Game/scripts/HUDController.gd b/Game/scripts/HUDController.gd index c7e08ea0..606bdc81 100644 --- a/Game/scripts/HUDController.gd +++ b/Game/scripts/HUDController.gd @@ -1,7 +1,9 @@ -# HUD Controller - discovers the ship/camera/game mode via groups and routes -# their signals to the display widgets. All flight data is rendered by -# aircraft-style instruments (attitude indicator, heading tape, bar gauges) — -# this node is only the wiring hub; the instruments are dumb displays. +# HUD Controller - receives its target ship from the game mode (see +# GameMode.spawn_camera_rig), discovers the camera rig/game mode via groups, +# and routes their signals to the display widgets. All flight data is +# rendered by aircraft-style instruments (attitude indicator, heading tape, +# bar gauges) — this node is only the wiring hub; the instruments are dumb +# displays. extends CanvasLayer class_name HUDController @@ -33,10 +35,11 @@ func _ready(): _initialize_hud() func _initialize_hud(): - # Find the ship - ship = get_tree().get_first_node_in_group("ship") + # `ship` is assigned by the game mode (GameMode.spawn_camera_rig) before + # this runs — not discovered via group, since the "ship" group can have + # 2+ members and there's no reliable way to tell which one is "ours". if not ship: - push_error("HUDController: No ship found in 'ship' group") + push_error("HUDController: No ship assigned") return print("HUDController: Found ship: ", ship.name) diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index 88ca765b..a403928f 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -13,6 +13,7 @@ extends Node3D const CAMERA_RIG_SCENE = preload("res://scenes/ship_camera_rig.tscn") var arena: Arena +var hud: HUDController var ball: RigidBody3D var ships: Array[Ship] = [] var _ship_spawn_transforms := {} @@ -30,7 +31,8 @@ func _ready(): for child in get_children(): if child is Arena: arena = child - break + elif child is HUDController: + hud = child if not arena: var path := _get_arena_scene_path() if not path.is_empty(): @@ -99,6 +101,10 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig: var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate() add_child(rig) rig.target = target + # Also wires the scene's static HUD (if any) to the same ship, rather + # than letting it guess via the "ship" group. + if hud: + hud.ship = target return rig diff --git a/TODO.md b/TODO.md index 0a7df77f..3cc0717b 100644 --- a/TODO.md +++ b/TODO.md @@ -19,7 +19,7 @@ Bugs found in an adversarial review. None are gameplay- or physics-affecting, so - [ ] Goal scoring volume (3.5 x 1.5, `objects/goal.tscn`) is smaller than the drawn mouth (3.7 x 1.65, `ArenaBoundary.GOAL_APERTURE_*`) — a ball crossing the visible edge doesn't score. Derive the aperture constants from the goal's collision shape, the way `goal.gd:53` already measures its own visuals. - [x] `match_mode.gd`: full time can fire mid-kickoff-countdown, and the stalled coroutine resumes into the dying scene (can re-emit `kickoff_countdown` / unfreeze bodies for a frame). Guard `_run_kickoff_countdown` with a match-over flag. - [x] `match_mode.gd` emits `timer_updated` every frame for a value that changes once a second; the HUD re-formats and re-shapes the label each time. Emit only on change, matching `ship.gd`'s threshold-gated telemetry discipline. -- [ ] `HUDController` binds to `get_first_node_in_group("ship")` in a group that always has 2+ members — works only because the player ship happens to spawn first. Have the game mode hand the HUD its target ship. +- [x] `HUDController` binds to `get_first_node_in_group("ship")` in a group that always has 2+ members — works only because the player ship happens to spawn first. Have the game mode hand the HUD its target ship. - [ ] Reuse a member `ShipAction` in `ship.gd` (controllerless path) and `player_ship_controller.gd` instead of allocating one per physics tick; `ai_ship_controller.gd` already does this correctly. - [ ] Delete the duplicate 1 MB texture — `assets/textures/planet_surface.png` and `assets/models/nebula_planet_planet_surface.png` are byte-identical.