refactor(*): Restructure game into reusable Arena/GameMode architecture with controller-driven ships, adding Free Play and Match modes

This commit is contained in:
Josh Creek
2026-07-18 15:34:11 +01:00
parent 14a8907038
commit 328831df1f
46 changed files with 664 additions and 406 deletions
+14 -9
View File
@@ -6,7 +6,7 @@ Important rule: never create co-authored commits. Never mention Claude in commit
## Project overview
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.4, using space ships instead of cars. The project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
@@ -29,16 +29,21 @@ npm run build
There is no build step, linter, or automated test suite for the GDScript project itself — Godot projects run directly from source.
- **Open the project**: open `Game/` as a project in the Godot 4.4 editor, or run `godot --path Game` from the repo root.
- **Run the game**: press Play in the editor, or `godot --path Game Game/scenes/main_menu.tscn`.
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
## Architecture
- **Scene flow**: `main_menu.tscn` → (Play button, `main_menu_play_button.gd`) → `scenes/Game.tscn` → on match timer expiry, back to `main_menu.tscn`. The match is a fixed 150-second timer (`game.gd`).
- **Game.tscn** instances `objects/ship.tscn` (player-controlled ship), `objects/ball.tscn`, and `objects/goal.tscn` over a static terrain body.
- **Ship physics** (`scripts/ship.gd`, class `Ship`, extends `RigidBody3D`): all movement is force/torque-based (`_integrate_forces`), not kinematic — thrust and rotation inputs are converted to world-space forces/torques relative to the ship's orientation, with manual drag and speed/angular-speed clamping applied each physics tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing explanation of controls and flight model.
- **Vehicle.gd** (`scripts/Vehicle.gd`) is a separate, simpler `RigidBody3D` base class used by `objects/vehicle.tscn`. It is *not* currently the base class for `Ship``ship.gd` reimplements similar logic directly. Treat these as two independent, currently-diverging implementations rather than a class hierarchy.
- **HUD / telemetry pattern**: `Ship` computes flight data (speed, altitude, attitude, heading, thrust, angular velocity, camera mode) once per physics tick and emits it via signals, only when the value changes past a threshold (see the `_last_*` fields and `*_THRESHOLD` constants in `ship.gd`). `HUDController` (`scripts/HUDController.gd`, on `scenes/HUD.tscn`, instanced inside `ship.tscn`) discovers the ship and game manager via Godot groups (`"ship"`, `"game"`) rather than direct node paths, connects to their signals, and only updates label text in response — it does no polling. Follow this discovery-by-group + signal-push pattern when adding new instruments or cross-node communication, rather than `get_node` with hardcoded paths or per-frame polling.
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, etc.) and read via `Input.is_action_pressed(...)` — add new controls there rather than hardcoding key/button checks.
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework (see `TODO.md` for the deferred work). The three load-bearing seams are the controller abstraction, the arena/game-mode split, and code-driven spawning.
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — terrain, lighting, two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
- **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.
- **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.
- **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"`).