diff --git a/CLAUDE.md b/CLAUDE.md index 6af3b8b0..5be2a3c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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"`). diff --git a/Game/objects/ball.tscn b/Game/objects/ball.tscn index 5c3cca7a..5fdb9429 100644 --- a/Game/objects/ball.tscn +++ b/Game/objects/ball.tscn @@ -8,7 +8,7 @@ bounce = 0.8 friction = 0.3 -[node name="Ball" type="RigidBody3D"] +[node name="Ball" type="RigidBody3D" groups=["ball"]] mass = 3 physics_material_override = SubResource("PhysicsMaterial_ball") inertia = Vector3(3, 3, 3) diff --git a/Game/objects/goal.tscn b/Game/objects/goal.tscn index cb7bdef9..5e52c38c 100644 --- a/Game/objects/goal.tscn +++ b/Game/objects/goal.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=5 format=3 uid="uid://cofdcxo5170rs"] -[ext_resource type="Script" path="res://scripts/goal2.gd" id="1_v8ikr"] +[ext_resource type="Script" path="res://scripts/goal.gd" id="1_v8ikr"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ptddx"] transparency = 1 diff --git a/Game/objects/ship.tscn b/Game/objects/ship.tscn index 902eaea8..59c4cc26 100644 --- a/Game/objects/ship.tscn +++ b/Game/objects/ship.tscn @@ -1,7 +1,10 @@ [gd_scene load_steps=5 format=3 uid="uid://p07epxnh8wwp"] [ext_resource type="Script" uid="uid://dyq1n7q1bqjps" path="res://scripts/ship.gd" id="1_efag7"] -[ext_resource type="PackedScene" uid="uid://c8kak2l3m4n5" path="res://scenes/HUD.tscn" id="2_hud_scene"] + +[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_ship"] +friction = 0.1 +bounce = 0.2 [sub_resource type="BoxMesh" id="BoxMesh_efag7"] size = Vector3(1, 1, 4) @@ -10,8 +13,9 @@ size = Vector3(1, 1, 4) size = Vector3(1, 1, 4) [node name="Ship" type="RigidBody3D"] -mass = 10.0 -gravity_scale = 10.0 +mass = 5.0 +physics_material_override = SubResource("PhysicsMaterial_ship") +inertia = Vector3(1, 1, 1) script = ExtResource("1_efag7") [node name="MeshInstance3D" type="MeshInstance3D" parent="."] @@ -19,8 +23,3 @@ mesh = SubResource("BoxMesh_efag7") [node name="CollisionShape3D" type="CollisionShape3D" parent="."] shape = SubResource("BoxShape3D_dsjou") - -[node name="Camera3D" type="Camera3D" parent="."] -transform = Transform3D(1, 0, 0, 0, 0.867366, 0.497671, 0, -0.497671, 0.867366, 0, 2.04336, 3.31556) - -[node name="HUD" parent="." instance=ExtResource("2_hud_scene")] diff --git a/Game/objects/vehicle.tscn b/Game/objects/vehicle.tscn deleted file mode 100644 index 58dcda88..00000000 --- a/Game/objects/vehicle.tscn +++ /dev/null @@ -1,30 +0,0 @@ -[gd_scene load_steps=5 format=3 uid="uid://dnra1buk328d"] - -[ext_resource type="Script" uid="uid://byy2mu4mxdlgl" path="res://scripts/Vehicle.gd" id="1_5snef"] - -[sub_resource type="BoxShape3D" id="BoxShape3D_0otfk"] -size = Vector3(0.5, 0.5, 1) - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hrmfk"] -albedo_color = Color(0.670588, 0, 0, 1) - -[sub_resource type="BoxMesh" id="BoxMesh_uwum7"] -material = SubResource("StandardMaterial3D_hrmfk") -size = Vector3(0.5, 0.5, 1) - -[node name="Vehicle" type="RigidBody3D"] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.08165e-12, 0.5, 2) -mass = 10.0 -gravity_scale = 10.0 -lock_rotation = true -script = ExtResource("1_5snef") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="."] -shape = SubResource("BoxShape3D_0otfk") - -[node name="MeshInstance3D" type="MeshInstance3D" parent="."] -mesh = SubResource("BoxMesh_uwum7") - -[node name="Camera3D" type="Camera3D" parent="."] -transform = Transform3D(1, -4.68079e-16, 3.27752e-16, 3.27752e-16, 0.939693, 0.34202, -4.68079e-16, -0.34202, 0.939693, 2.08165e-12, 0.8, 1.2) -current = true diff --git a/Game/project.godot b/Game/project.godot index 6fc6d00f..ca364c98 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -31,6 +31,8 @@ window/stretch/aspect="expand" [input] +reset_ball={"deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]} + move_forward={ "deadzone": 0.2, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) @@ -51,11 +53,6 @@ move_right={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) ] } -move_backward={ -"deadzone": 0.2, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) -] -} move_up={ "deadzone": 0.2, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) @@ -112,3 +109,8 @@ roll_right={ [physics] 3d/physics_engine="Jolt Physics" + +[autoload] + + + diff --git a/Game/scenes/HUD.tscn b/Game/scenes/HUD.tscn index 0a0fbebd..4b34b52e 100644 --- a/Game/scenes/HUD.tscn +++ b/Game/scenes/HUD.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=3 format=3 uid="uid://c8kak2l3m4n5"] -[ext_resource type="Script" uid="uid://bx9j8k7l6m5n" path="res://scripts/HUDController.gd" id="1_hud_controller"] +[ext_resource type="Script" uid="uid://du7y176h5aaq4" path="res://scripts/HUDController.gd" id="1_hud_controller"] [sub_resource type="LabelSettings" id="LabelSettings_hud"] font_size = 32 diff --git a/Game/scenes/Game.tscn b/Game/scenes/arena_01.tscn similarity index 99% rename from Game/scenes/Game.tscn rename to Game/scenes/arena_01.tscn index ce7630ed..a41c2902 100644 --- a/Game/scenes/Game.tscn +++ b/Game/scenes/arena_01.tscn @@ -1,10 +1,8 @@ -[gd_scene load_steps=11 format=3 uid="uid://b82sy3js0vncb"] +[gd_scene load_steps=9 format=3] -[ext_resource type="Script" uid="uid://bpxm8ge52w5g8" path="res://scripts/game.gd" id="1_iywne"] +[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"] [ext_resource type="Material" uid="uid://drm3clvqpis42" path="res://assets/models/TerrainMaterial.tres" id="1_lnu2h"] [ext_resource type="ArrayMesh" uid="uid://cndcxj6gf0smr" path="res://assets/models/Terrain.obj" id="2_lbhrr"] -[ext_resource type="PackedScene" uid="uid://p07epxnh8wwp" path="res://objects/ship.tscn" id="4_iywne"] -[ext_resource type="PackedScene" uid="uid://27u3tdc5yqnl" path="res://objects/ball.tscn" id="5_iywne"] [ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"] [sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_kldst"] @@ -19,7 +17,7 @@ sky_material = SubResource("ProceduralSkyMaterial_iukft") background_mode = 2 sky = SubResource("Sky_gl6un") -[node name="Game" type="Node3D"] +[node name="Arena" type="Node3D"] script = ExtResource("1_iywne") [node name="Terrain" type="StaticBody3D" parent="."] @@ -38,19 +36,22 @@ shadow_enabled = true [node name="WorldEnvironment" type="WorldEnvironment" parent="."] environment = SubResource("Environment_j5yw3") -[node name="Timer" type="Timer" parent="."] -process_callback = 0 -autostart = true +[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29962, 0.790881, 15.5633) -[node name="Ship" parent="." instance=ExtResource("4_iywne")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.497204, 2.82216, 27.8553) -lock_rotation = true - -[node name="Ball" parent="." instance=ExtResource("5_iywne")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.464103, 1.46401, -2.82393) -mass = 3.0 - -[node name="Goal" parent="." instance=ExtResource("6_p57ef")] +[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29962, 0.790881, -15.5633) +team = 1 -[connection signal="timeout" from="Timer" to="." method="_on_timer_timeout"] +[node name="BallSpawn" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0) + +[node name="SpawnsTeam0" type="Node3D" parent="."] + +[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 12) + +[node name="SpawnsTeam1" type="Node3D" parent="."] + +[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"] +transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -12) diff --git a/Game/scenes/free_play.tscn b/Game/scenes/free_play.tscn new file mode 100644 index 00000000..93b4fc7e --- /dev/null +++ b/Game/scenes/free_play.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scripts/free_play.gd" id="1_fp"] +[ext_resource type="PackedScene" path="res://scenes/arena_01.tscn" id="2_fp"] +[ext_resource type="PackedScene" uid="uid://c8kak2l3m4n5" path="res://scenes/HUD.tscn" id="3_fp"] + +[node name="FreePlay" type="Node3D"] +script = ExtResource("1_fp") + +[node name="Arena" parent="." instance=ExtResource("2_fp")] + +[node name="HUD" parent="." instance=ExtResource("3_fp")] diff --git a/Game/scenes/main_menu.tscn b/Game/scenes/main_menu.tscn index d784b2d5..fe13d39e 100644 --- a/Game/scenes/main_menu.tscn +++ b/Game/scenes/main_menu.tscn @@ -1,44 +1,43 @@ -[gd_scene load_steps=3 format=3 uid="uid://bcq14356s3e2i"] +[gd_scene load_steps=2 format=3 uid="uid://bcq14356s3e2i"] -[ext_resource type="Script" uid="uid://c1tfuurttt8ft" path="res://scripts/main_menu_play_button.gd" id="1_28flt"] +[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"] -[sub_resource type="LabelSettings" id="LabelSettings_erv1k"] - -[node name="MainMenu" type="Node2D"] - -[node name="Label" type="Label" parent="."] -anchors_preset = 8 -anchor_left = 0.5 -anchor_top = 0.5 -anchor_right = 0.5 -anchor_bottom = 0.5 -offset_left = -51.0 -offset_top = -11.0 -offset_right = 253.0 -offset_bottom = 109.0 +[node name="MainMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -size_flags_horizontal = 4 +script = ExtResource("1_menu") + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"] +layout_mode = 2 +theme_override_constants/separation = 24 + +[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 48 text = "Cosmic Clash" -label_settings = SubResource("LabelSettings_erv1k") horizontal_alignment = 1 -vertical_alignment = 1 -[node name="Button" type="Button" parent="."] -anchors_preset = 8 -anchor_left = 0.5 -anchor_top = 0.5 -anchor_right = 0.5 -anchor_bottom = 0.5 -offset_left = 347.0 -offset_top = 233.0 -offset_right = 676.0 -offset_bottom = 373.0 -grow_horizontal = 2 -grow_vertical = 2 -size_flags_horizontal = 4 -size_flags_vertical = 4 -text = "Play" -script = ExtResource("1_28flt") +[node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"] +custom_minimum_size = Vector2(330, 60) +layout_mode = 2 +text = "Free Play" -[connection signal="pressed" from="Button" to="Button" method="_on_pressed"] +[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"] +custom_minimum_size = Vector2(330, 60) +layout_mode = 2 +text = "Match" + +[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"] diff --git a/Game/scenes/match.tscn b/Game/scenes/match.tscn new file mode 100644 index 00000000..3319effa --- /dev/null +++ b/Game/scenes/match.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scripts/match_mode.gd" id="1_m"] +[ext_resource type="PackedScene" path="res://scenes/arena_01.tscn" id="2_m"] +[ext_resource type="PackedScene" uid="uid://c8kak2l3m4n5" path="res://scenes/HUD.tscn" id="3_m"] + +[node name="Match" type="Node3D"] +script = ExtResource("1_m") + +[node name="Arena" parent="." instance=ExtResource("2_m")] + +[node name="HUD" parent="." instance=ExtResource("3_m")] diff --git a/Game/scenes/ship_camera_rig.tscn b/Game/scenes/ship_camera_rig.tscn new file mode 100644 index 00000000..2ded671f --- /dev/null +++ b/Game/scenes/ship_camera_rig.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ship_camera.gd" id="1_rig"] + +[node name="ShipCameraRig" type="Node3D"] +script = ExtResource("1_rig") + +[node name="Camera3D" type="Camera3D" parent="."] +current = true diff --git a/Game/scripts/HUDController.gd b/Game/scripts/HUDController.gd index f6375b87..b9ef21a8 100644 --- a/Game/scripts/HUDController.gd +++ b/Game/scripts/HUDController.gd @@ -25,22 +25,26 @@ func _initialize_hud(): # Find the ship ship = get_tree().get_first_node_in_group("ship") if not ship: - # Try to find it as our parent (ship contains this HUD) - var parent_node = get_parent() - if parent_node and parent_node.is_in_group("ship"): - ship = parent_node - else: - push_error("HUDController: No ship found in 'ship' group") - return - + push_error("HUDController: No ship found in 'ship' group") + return + print("HUDController: Found ship: ", ship.name) _connect_ship_signals() - - # Connect to game manager's timer signal + + # Camera mode comes from the camera rig, not the ship + var camera_rig = get_tree().get_first_node_in_group("ship_camera") + if camera_rig and camera_rig.has_signal("camera_mode_changed"): + camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed) + + # Connect to game manager's timer signal; modes without a timer + # (e.g. free play) just don't show one var game_manager = get_tree().get_first_node_in_group("game") - if game_manager and game_manager.has_signal("timer_updated"): + var has_timer = game_manager and game_manager.has_signal("timer_updated") + if has_timer: game_manager.timer_updated.connect(_on_timer_updated) print("HUDController: Connected to game timer") + if timer_label and is_instance_valid(timer_label): + timer_label.visible = has_timer func _connect_ship_signals(): # Connect ship signals to label update methods @@ -51,8 +55,6 @@ func _connect_ship_signals(): ship.altitude_changed.connect(_on_ship_altitude_changed) if ship.has_signal("angular_velocity_changed"): ship.angular_velocity_changed.connect(_on_ship_angular_velocity_changed) - if ship.has_signal("camera_mode_changed"): - ship.camera_mode_changed.connect(_on_ship_camera_mode_changed) if ship.has_signal("attitude_changed"): ship.attitude_changed.connect(_on_ship_attitude_changed) if ship.has_signal("heading_changed"): @@ -79,7 +81,7 @@ func _on_ship_camera_mode_changed(is_ball_cam: bool): var camera_mode = "Ball Cam" if is_ball_cam else "Ship Cam" camera_mode_label.text = "Camera: %s" % camera_mode -func _on_ship_attitude_changed(pitch: float, roll: float, yaw: float): +func _on_ship_attitude_changed(pitch: float, roll: float, _yaw: float): if attitude_label and is_instance_valid(attitude_label): attitude_label.text = "Pitch: %.0f° Roll: %.0f°" % [pitch, roll] diff --git a/Game/scripts/MatchCamera3D.gd.uid b/Game/scripts/MatchCamera3D.gd.uid deleted file mode 100644 index d0278da8..00000000 --- a/Game/scripts/MatchCamera3D.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://scnbnslvru0b diff --git a/Game/scripts/Vehicle.gd b/Game/scripts/Vehicle.gd deleted file mode 100644 index 02471bab..00000000 --- a/Game/scripts/Vehicle.gd +++ /dev/null @@ -1,30 +0,0 @@ -extends RigidBody3D - -# Speed variable can be adjusted by subclasses -var speed = 10.0 - -func _integrate_forces(state): - # input_vector represents the movement input relative to the vehicle - var input_vector = get_input_vector() - - # Scale the input_vector by speed - input_vector = input_vector.normalized() * speed - - # Set the linear velocity based on the input - state.linear_velocity = input_vector - -# Get the input vector from subclasses -func get_input_vector() -> Vector3: - var input_vector = Vector3.ZERO - - # Subclasses will implement this function to provide their input mappings - return input_vector - -# Called when the node enters the scene tree for the first time. -func _ready(): - pass # Replace with function body. - - -# Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(delta): - pass diff --git a/Game/scripts/Vehicle.gd.uid b/Game/scripts/Vehicle.gd.uid deleted file mode 100644 index 5a4e9927..00000000 --- a/Game/scripts/Vehicle.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://byy2mu4mxdlgl diff --git a/Game/scripts/arena.gd b/Game/scripts/arena.gd new file mode 100644 index 00000000..9d8625de --- /dev/null +++ b/Game/scripts/arena.gd @@ -0,0 +1,32 @@ +class_name Arena +extends Node3D + +# A reusable stadium: terrain, lighting, environment, two team goals, and +# spawn markers. An arena holds no rules and no state — game modes query it +# for spawn transforms and goals, then spawn ships/ball themselves. + + +func _ready(): + add_to_group("arena") + + +func get_ball_spawn() -> Transform3D: + return $BallSpawn.global_transform + + +func get_ship_spawns(team: int) -> Array[Transform3D]: + var spawns: Array[Transform3D] = [] + var container := get_node_or_null("SpawnsTeam%d" % team) + if container: + for child in container.get_children(): + if child is Marker3D: + spawns.append(child.global_transform) + return spawns + + +func get_goals() -> Array[Goal]: + var goals: Array[Goal] = [] + for child in get_children(): + if child is Goal: + goals.append(child) + return goals diff --git a/Game/scripts/arena.gd.uid b/Game/scripts/arena.gd.uid new file mode 100644 index 00000000..73b65379 --- /dev/null +++ b/Game/scripts/arena.gd.uid @@ -0,0 +1 @@ +uid://c1kq2m6gnwjxo diff --git a/Game/scripts/free_play.gd b/Game/scripts/free_play.gd new file mode 100644 index 00000000..a6534cdd --- /dev/null +++ b/Game/scripts/free_play.gd @@ -0,0 +1,22 @@ +extends GameMode + +# Free Play: one player ship, one ball, no timer, no score — practice like +# Rocket League's free play. R resets the ball, Esc returns to the menu. + + +func _start() -> void: + spawn_ball() + var player_ship := spawn_ship(0, 0, PlayerShipController.new()) + spawn_camera_rig(player_ship) + + +func _on_goal_scored(conceding_team: int) -> void: + print("Goal! (into team %d's goal)" % conceding_team) + reset_ball() + + +func _unhandled_input(event): + if event.is_action_pressed("reset_ball"): + reset_ball() + else: + super(event) diff --git a/Game/scripts/free_play.gd.uid b/Game/scripts/free_play.gd.uid new file mode 100644 index 00000000..42a70771 --- /dev/null +++ b/Game/scripts/free_play.gd.uid @@ -0,0 +1 @@ +uid://coirkjf1pbhi7 diff --git a/Game/scripts/game.gd b/Game/scripts/game.gd deleted file mode 100644 index c3484c41..00000000 --- a/Game/scripts/game.gd +++ /dev/null @@ -1,26 +0,0 @@ -extends Node3D - -@onready var game_timer: Timer = get_node("Timer") -signal timer_updated(minutes: int, seconds: int) - -func _ready(): - # Add to group for discovery by HUD - add_to_group("game") - - # Set timer to 2 minutes 30 seconds (150 seconds) - game_timer.wait_time = 150.0 - game_timer.one_shot = true # Timer runs once - game_timer.start() - -func _process(_delta): - if game_timer.time_left > 0: - var time_left = game_timer.time_left - var minutes = int(time_left) / 60 - var seconds = int(time_left) % 60 - timer_updated.emit(minutes, seconds) - else: - print("Timer finished!") - -func _on_timer_timeout() -> void: - if game_timer.time_left == 0: - get_tree().change_scene_to_file("res://scenes/main_menu.tscn") diff --git a/Game/scripts/game.gd.uid b/Game/scripts/game.gd.uid deleted file mode 100644 index 523d3555..00000000 --- a/Game/scripts/game.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bpxm8ge52w5g8 diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd new file mode 100644 index 00000000..3bd38f01 --- /dev/null +++ b/Game/scripts/game_mode.gd @@ -0,0 +1,108 @@ +class_name GameMode +extends Node3D + +# Base for game modes (Free Play, Match; later Vs-AI and multiplayer). +# A mode's scene contains an Arena (the stadium) and a HUD; the mode itself +# spawns the ball, ships, controllers, and camera in code — variable ship +# counts with mixed controller types (player/AI/network) is exactly what +# future modes need. Subclasses override _start() and _on_goal_scored(). + +@export var ship_scene: PackedScene = preload("res://objects/ship.tscn") +@export var ball_scene: PackedScene = preload("res://objects/ball.tscn") + +const CAMERA_RIG_SCENE = preload("res://scenes/ship_camera_rig.tscn") +const MAIN_MENU_SCENE_PATH = "res://scenes/main_menu.tscn" + +var arena: Arena +var ball: RigidBody3D +var ships: Array[Ship] = [] +var _ship_spawn_transforms := {} + + +func _ready(): + # Group lets the HUD discover the game mode for timer/score signals + add_to_group("game") + for child in get_children(): + if child is Arena: + arena = child + break + if not arena: + push_error("GameMode: scene has no Arena child") + return + for goal in arena.get_goals(): + goal.goal_scored.connect(_handle_goal_scored) + _start() + + +# Virtual: subclasses spawn their ball/ships/camera here. +func _start() -> void: + pass + + +# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`. +func _on_goal_scored(_conceding_team: int) -> void: + pass + + +# Debounce: a fast ball can re-trigger the goal area before the deferred +# reset teleports it away, which would double-count the goal. +var _goal_cooldown := false + +func _handle_goal_scored(conceding_team: int) -> void: + if _goal_cooldown: + return + _goal_cooldown = true + get_tree().create_timer(0.5).timeout.connect(func(): _goal_cooldown = false) + _on_goal_scored(conceding_team) + + +func spawn_ball() -> RigidBody3D: + ball = ball_scene.instantiate() + add_child(ball) + ball.global_transform = arena.get_ball_spawn() + return ball + + +func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship: + var ship: Ship = ship_scene.instantiate() + ship.name = "ShipTeam%d_%d" % [team, ships.size()] + add_child(ship) + var spawns := arena.get_ship_spawns(team) + var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY + ship.global_transform = spawn_transform + ship.team = team + if controller: + ship.set_controller(controller) + ships.append(ship) + _ship_spawn_transforms[ship] = spawn_transform + return ship + + +func spawn_camera_rig(target: Ship) -> ShipCameraRig: + var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate() + add_child(rig) + rig.target = target + return rig + + +func reset_ball() -> void: + if is_instance_valid(ball): + _reset_body(ball, arena.get_ball_spawn()) + + +func reset_ships() -> void: + for ship in ships: + if is_instance_valid(ship): + _reset_body(ship, _ship_spawn_transforms[ship]) + + +func _reset_body(body: RigidBody3D, to: Transform3D) -> void: + # Deferred: a RigidBody3D transform can't be set mid-physics-step + body.set_deferred("global_transform", to) + body.set_deferred("linear_velocity", Vector3.ZERO) + body.set_deferred("angular_velocity", Vector3.ZERO) + + +func _unhandled_input(event): + if event.is_action_pressed("ui_cancel"): + get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH) diff --git a/Game/scripts/game_mode.gd.uid b/Game/scripts/game_mode.gd.uid new file mode 100644 index 00000000..b1b96868 --- /dev/null +++ b/Game/scripts/game_mode.gd.uid @@ -0,0 +1 @@ +uid://c6qkhqup6h0pk diff --git a/Game/scripts/goal.gd b/Game/scripts/goal.gd new file mode 100644 index 00000000..0c8610a6 --- /dev/null +++ b/Game/scripts/goal.gd @@ -0,0 +1,21 @@ +class_name Goal +extends Area3D + +# A goal is a dumb sensor: it detects the ball crossing its plane and emits +# goal_scored. The game mode owns all consequences (score, resets). Two of +# these live in each arena, one per team. + +# The team that concedes when the ball enters this goal. +@export var team: int = 0 + +signal goal_scored(team: int) + + +func _ready(): + # Group lets AI controllers and game modes discover goals + add_to_group("goal") + + +func _on_body_entered(body): + if body.is_in_group("ball"): + goal_scored.emit(team) diff --git a/Game/scripts/goal.gd.uid b/Game/scripts/goal.gd.uid new file mode 100644 index 00000000..f4df0348 --- /dev/null +++ b/Game/scripts/goal.gd.uid @@ -0,0 +1 @@ +uid://bcas8toqyr1qb diff --git a/Game/scripts/goal2.gd b/Game/scripts/goal2.gd deleted file mode 100644 index c5119913..00000000 --- a/Game/scripts/goal2.gd +++ /dev/null @@ -1,19 +0,0 @@ -extends Area3D - -@onready var ball = get_parent().get_node("Ball") -@export var player1: RigidBody3D -#@export var player2: RigidBody3D - - -# Called when the node enters the scene tree for the first time. -func _ready(): - pass # Replace with function body. - -# Called when an object with collision enters the bounds of this object -func _on_body_entered(body): - # Only care if it's the ball - if(body == ball): - print("Goal scored in goal 2") - # Reset the ball and player to their starting locations just for testing - ball.global_transform.origin = Vector3(0, 2, 0) - #player1.global_transform.origin = Vector3(0, 0.5, 2) diff --git a/Game/scripts/goal2.gd.uid b/Game/scripts/goal2.gd.uid deleted file mode 100644 index 1475431b..00000000 --- a/Game/scripts/goal2.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://2xvkyfw3v1ui diff --git a/Game/scripts/main_menu.gd b/Game/scripts/main_menu.gd new file mode 100644 index 00000000..8f3cfb35 --- /dev/null +++ b/Game/scripts/main_menu.gd @@ -0,0 +1,12 @@ +extends Control + +# Main menu: one handler per game mode. Adding a mode later (e.g. Vs AI) +# is a new button + a one-line handler pointing at its scene. + + +func _on_free_play_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/free_play.tscn") + + +func _on_match_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/match.tscn") diff --git a/Game/scripts/main_menu.gd.uid b/Game/scripts/main_menu.gd.uid new file mode 100644 index 00000000..bd8cec93 --- /dev/null +++ b/Game/scripts/main_menu.gd.uid @@ -0,0 +1 @@ +uid://xcqhku3vnp8v diff --git a/Game/scripts/main_menu_play_button.gd b/Game/scripts/main_menu_play_button.gd deleted file mode 100644 index 108fffbc..00000000 --- a/Game/scripts/main_menu_play_button.gd +++ /dev/null @@ -1,5 +0,0 @@ -extends Button - - -func _on_pressed() -> void: - get_tree().change_scene_to_file("res://scenes/game.tscn") diff --git a/Game/scripts/main_menu_play_button.gd.uid b/Game/scripts/main_menu_play_button.gd.uid deleted file mode 100644 index 5e94dd12..00000000 --- a/Game/scripts/main_menu_play_button.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c1tfuurttt8ft diff --git a/Game/scripts/match_mode.gd b/Game/scripts/match_mode.gd new file mode 100644 index 00000000..e0d71798 --- /dev/null +++ b/Game/scripts/match_mode.gd @@ -0,0 +1,48 @@ +extends GameMode + +# Timed match: two teams, score tracking, kickoff resets after each goal. +# The opponent ship is currently inert (base ShipController, zero action) — +# it becomes the AI opponent once an AIShipController exists (see TODO.md), +# and additional player ships once multiplayer lands. + +signal timer_updated(minutes: int, seconds: int) +signal score_changed(score: Dictionary) + +@export var match_length_seconds := 150.0 + +var score := {0: 0, 1: 0} +var match_timer: Timer + + +func _start() -> void: + spawn_ball() + var player_ship := spawn_ship(0, 0, PlayerShipController.new()) + spawn_camera_rig(player_ship) + spawn_ship(1, 0, ShipController.new()) # inert placeholder opponent + + match_timer = Timer.new() + match_timer.one_shot = true + match_timer.wait_time = match_length_seconds + match_timer.timeout.connect(_on_match_timer_timeout) + add_child(match_timer) + match_timer.start() + + +func _process(_delta): + if match_timer and match_timer.time_left > 0: + var remaining := ceili(match_timer.time_left) + timer_updated.emit(floori(remaining / 60.0), remaining % 60) + + +func _on_goal_scored(conceding_team: int) -> void: + var scoring_team := 1 - conceding_team + score[scoring_team] += 1 + score_changed.emit(score.duplicate()) + print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]]) + reset_ball() + reset_ships() + + +func _on_match_timer_timeout() -> void: + print("Full time! Final score: %d - %d" % [score[0], score[1]]) + get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH) diff --git a/Game/scripts/match_mode.gd.uid b/Game/scripts/match_mode.gd.uid new file mode 100644 index 00000000..bdef0fa8 --- /dev/null +++ b/Game/scripts/match_mode.gd.uid @@ -0,0 +1 @@ +uid://cgoqkvyoal2iw diff --git a/Game/scripts/player1.gd.uid b/Game/scripts/player1.gd.uid deleted file mode 100644 index 38bcee31..00000000 --- a/Game/scripts/player1.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cprbkhe46c1be diff --git a/Game/scripts/player2.gd.uid b/Game/scripts/player2.gd.uid deleted file mode 100644 index 71ef2c41..00000000 --- a/Game/scripts/player2.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bdda0ybrdo3or diff --git a/Game/scripts/player_ship_controller.gd b/Game/scripts/player_ship_controller.gd new file mode 100644 index 00000000..ea1fce7c --- /dev/null +++ b/Game/scripts/player_ship_controller.gd @@ -0,0 +1,49 @@ +class_name PlayerShipController +extends ShipController + +# Drives a Ship from the local player's input actions (see project.godot +# [input] and FLIGHT_MANUAL.md). + + +func get_action() -> ShipAction: + var action := ShipAction.new() + + # Forward/Backward thrust (main engines) + if Input.is_action_pressed("move_forward"): + action.thrust.z += 1.0 + if Input.is_action_pressed("move_back"): + action.thrust.z -= 1.0 + + # Strafe thrusters (left/right) + if Input.is_action_pressed("move_left"): + action.thrust.x -= 1.0 + if Input.is_action_pressed("move_right"): + action.thrust.x += 1.0 + + # Vertical thrusters (up/down) + if Input.is_action_pressed("move_up"): + action.thrust.y += 1.0 + if Input.is_action_pressed("move_down"): + action.thrust.y -= 1.0 + + # Yaw (turn left/right around Y axis) + if Input.is_action_pressed("turn_left"): + action.rotation.y += 1.0 + if Input.is_action_pressed("turn_right"): + action.rotation.y -= 1.0 + + # Pitch (nose up/down around X axis) + if Input.is_action_pressed("pitch_up"): + action.rotation.x -= 1.0 + if Input.is_action_pressed("pitch_down"): + action.rotation.x += 1.0 + + # Roll (bank left/right around Z axis) + if Input.is_action_pressed("roll_left"): + action.rotation.z += 1.0 + if Input.is_action_pressed("roll_right"): + action.rotation.z -= 1.0 + + action.turbo = Input.is_action_pressed("turbo") + + return action diff --git a/Game/scripts/player_ship_controller.gd.uid b/Game/scripts/player_ship_controller.gd.uid new file mode 100644 index 00000000..ec60f24f --- /dev/null +++ b/Game/scripts/player_ship_controller.gd.uid @@ -0,0 +1 @@ +uid://gxdj34cnymoy diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index 43509048..323900ab 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -1,9 +1,16 @@ class_name Ship extends RigidBody3D +# Physics-driven spaceship. All movement is force/torque-based, applied in +# _integrate_forces from a ShipAction supplied by a pluggable ShipController +# child node (player input, AI policy, or network replication — see +# set_controller). A ship without a controller is inert but still simulated, +# which is what a placeholder opponent or a headless RL ship needs. +# Physics properties (mass, inertia, friction material) live in ship.tscn. + @export_group("Movement") @export var thrust_power = 150.0 # Main thruster power -@export var maneuvering_thrust = 75.0 # Side/vertical thruster power +@export var maneuvering_thrust = 75.0 # Side thruster power @export var vertical_thrust = 120.0 # Up/down thruster power @export var turbo_multiplier = 2.5 # Turbo boost multiplier @export var max_speed = 35.0 # Maximum velocity @@ -12,15 +19,11 @@ extends RigidBody3D @export var drag_coefficient = 0.98 # Linear drag (air resistance) @export var angular_drag = 0.95 # Rotational drag -@export_group("Camera") -var camera_distance = 8.0 -var camera_height = 4.0 -var camera_smoothing = 10.0 +# Which team this ship plays for (0 or 1). Set by the game mode on spawn. +var team: int = 0 -@onready var camera : Camera3D = get_node("Camera3D") -@onready var ball = get_parent().get_node("Ball") - -var ball_cam_enabled = true +var controller: ShipController +var _current_action: ShipAction = ShipAction.new() # Instrument signals for efficient data distribution signal speed_changed(speed: float) @@ -28,7 +31,6 @@ signal attitude_changed(pitch: float, roll: float, yaw: float) signal altitude_changed(altitude: float) signal thrust_changed(thrust_percent: float) signal angular_velocity_changed(angular_speed: float) -signal camera_mode_changed(is_ball_cam: bool) signal heading_changed(heading_degrees: float) # Performance optimization - track last emitted values to avoid unnecessary signals @@ -40,7 +42,6 @@ var _last_roll: float = -999.0 var _last_yaw: float = -999.0 var _last_heading: float = -999.0 var _last_thrust: float = -1.0 -var _last_camera_mode: bool = true # Thresholds for signal emission (only emit if change is significant) const SPEED_THRESHOLD = 0.1 # m/s @@ -49,208 +50,84 @@ const ANGULAR_THRESHOLD = 0.01 # rad/s const ATTITUDE_THRESHOLD = 1.0 # degrees const THRUST_THRESHOLD = 1.0 # percent + func _ready(): - mass = 5 - gravity_scale = 1.0 - # Add ship to group for instrument discovery add_to_group("ship") - - # Set custom inertia for better rotation - # Physics: I = m * r² (moment of inertia = mass × radius²) - # Lower inertia = easier to rotate, higher inertia = more stable - inertia = Vector3(1.0, 1.0, 1.0) - - # Create and apply low-friction physics material - # Physics: F_friction = μ * N (friction force = coefficient × normal force) - # Lower μ (friction coefficient) = less resistance to sliding - var ship_material = PhysicsMaterial.new() - ship_material.friction = 0.1 # Very low friction - ship_material.bounce = 0.2 # Slight bounce - physics_material_override = ship_material - - # Make sure the RigidBody is completely free to move and rotate - freeze = false - lock_rotation = false - - # Ensure all axes can rotate - axis_lock_angular_x = false - axis_lock_angular_y = false - axis_lock_angular_z = false - - print("Ship physics configured - Mass: ", mass, " Gravity scale: ", gravity_scale, " Inertia: ", inertia) - print("Rotation locks - X:", axis_lock_angular_x, " Y:", axis_lock_angular_y, " Z:", axis_lock_angular_z) -func _input(event): - if event.is_action_pressed("ui_accept"): # Enter key - ball_cam_enabled = !ball_cam_enabled - camera_mode_changed.emit(ball_cam_enabled) + # Pick up a controller placed in the scene, if any; game modes usually + # attach one at spawn time via set_controller instead. + for child in get_children(): + if child is ShipController: + controller = child + break -func _physics_process(delta): - if camera: - _update_camera(delta) + +# Attach the node that drives this ship (player, AI, or network). Replaces +# any existing controller; parents the new one under the ship if needed. +func set_controller(new_controller: ShipController) -> void: + if is_instance_valid(controller) and controller.get_parent() == self: + controller.queue_free() + controller = new_controller + if new_controller and new_controller.get_parent() == null: + add_child(new_controller) + + +func _physics_process(_delta): _emit_telemetry_data() -func _update_camera(delta): - if ball_cam_enabled and ball: - _update_ball_cam(delta) - else: - _update_ship_cam(delta) - -func _update_ball_cam(delta): - # In ball cam, camera positions itself so the ship is between camera and ball - # Physics: Vector mathematics for 3D positioning - var ship_pos = global_transform.origin - var ball_pos = ball.global_transform.origin - - # Calculate direction from ball to ship - # Physics: Vector subtraction and normalization - # Direction vector: d̂ = (P₂ - P₁) / |P₂ - P₁| - var ball_to_ship = (ship_pos - ball_pos).normalized() - - # Position camera behind the ship relative to the ball's position - # This ensures the ship is always between the camera and ball - # Physics: Vector addition for position calculation - # P_camera = P_ship + d̂ * distance + height_offset - var camera_target_pos = ship_pos + ball_to_ship * camera_distance + Vector3.UP * camera_height - - # Smoothly move camera to target position - # Physics: Linear interpolation (LERP) for smooth motion - # P(t) = P₀ + t * (P₁ - P₀), where t ∈ [0,1] - # This creates exponential approach to target position - camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta) - - # Make camera look at the ball - if camera.global_transform.origin.distance_to(ball_pos) > 0.1: - # Calculate direction to ball - var camera_pos = camera.global_transform.origin - var to_ball = (ball_pos - camera_pos).normalized() - - # Create look-at transform manually - # Physics: 3D rotation matrices and basis vectors - # Uses right-hand rule: forward = -Z, up = Y, right = X - # Basis matrix transforms local coordinates to world coordinates - var camera_transform = Transform3D() - camera_transform.origin = camera_pos - camera_transform.basis = Basis.looking_at(to_ball, Vector3.UP) - - # Apply the rotation smoothly - # Physics: Spherical linear interpolation (SLERP) for rotation - # SLERP provides smooth rotation along great circle on unit sphere - # Maintains constant angular velocity during interpolation - camera.global_transform.basis = camera.global_transform.basis.slerp(camera_transform.basis, camera_smoothing * delta) - -func _update_ship_cam(delta): - # In ship cam, camera follows and looks in the same direction as the ship - var ship_pos = global_transform.origin - var ship_forward = -global_transform.basis.z - - # Position camera behind and above the ship - var camera_target_pos = ship_pos - ship_forward * camera_distance + Vector3.UP * camera_height - - # Smoothly move camera - camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta) - - # Make camera look in the same direction as the ship - var look_target = ship_pos + ship_forward * 10.0 # Look ahead of the ship - camera.look_at(look_target, Vector3.UP) func _integrate_forces(state): - # Get thruster input - var thrust_input = get_thrust_input() - var rotation_input = get_rotation_input() - + # One action per physics tick, pulled from the controller (deterministic) + _current_action = controller.get_action() if controller else ShipAction.new() + # === TRANSLATION (Movement) === - apply_thruster_forces(state, thrust_input) - + apply_thruster_forces(state, _current_action) + # === ROTATION (Turning) === - apply_rotation_forces(state, rotation_input) - + apply_rotation_forces(state, _current_action.rotation) + # === DRAG AND LIMITS === - apply_drag_and_limits(state, rotation_input) + apply_drag_and_limits(state, _current_action.rotation) -func get_thrust_input() -> Vector3: - var thrust = Vector3.ZERO - - # Forward/Backward thrust (main engines) - if Input.is_action_pressed("move_forward"): - thrust.z += 1.0 - if Input.is_action_pressed("move_back"): - thrust.z -= 1.0 - - # Strafe thrusters (left/right) - if Input.is_action_pressed("move_left"): - thrust.x -= 1.0 - if Input.is_action_pressed("move_right"): - thrust.x += 1.0 - - # Vertical thrusters (up/down) - if Input.is_action_pressed("move_up"): - thrust.y += 1.0 - if Input.is_action_pressed("move_down"): - thrust.y -= 1.0 - - return thrust -func get_rotation_input() -> Vector3: - var rotation = Vector3.ZERO - - # Yaw (turn left/right around Y axis) - only use these if they exist - if Input.is_action_pressed("turn_left"): - rotation.y += 1.0 - if Input.is_action_pressed("turn_right"): - rotation.y -= 1.0 - - # Pitch (nose up/down around X axis) - if Input.is_action_pressed("pitch_up"): - rotation.x -= 1.0 - if Input.is_action_pressed("pitch_down"): - rotation.x += 1.0 - - # Roll (bank left/right around Z axis) - if Input.is_action_pressed("roll_left"): - rotation.z += 1.0 - if Input.is_action_pressed("roll_right"): - rotation.z -= 1.0 - - return rotation - -func apply_thruster_forces(state: PhysicsDirectBodyState3D, thrust_input: Vector3): +func apply_thruster_forces(state: PhysicsDirectBodyState3D, action: ShipAction): + var thrust_input := action.thrust if thrust_input.length() < 0.01: return - + # Convert thrust input to world space forces based on ship orientation # Physics: F = m * a (Newton's Second Law: Force = mass × acceleration) # World force = Local force × Rotation matrix (basis transformation) var ship_basis = global_transform.basis var world_thrust = Vector3.ZERO - + # All thrusters should work relative to ship orientation # Physics: Vector transformation from local to world coordinates # F_world = R * F_local (where R is rotation matrix) - + # Forward/backward thrust (main engines) world_thrust += -ship_basis.z * thrust_input.z * thrust_power - + # Strafe thrust (left/right maneuvering thrusters) world_thrust += ship_basis.x * thrust_input.x * maneuvering_thrust - + # Vertical thrust (up/down thrusters relative to ship orientation) world_thrust += ship_basis.y * thrust_input.y * vertical_thrust - - # Check for turbo - var is_turbo = Input.is_action_pressed("turbo") and thrust_input.z > 0 - if is_turbo: + + # Turbo only boosts forward thrust + if action.turbo and thrust_input.z > 0: world_thrust *= turbo_multiplier - + # Apply the force # Physics: Δv = F * Δt / m (change in velocity = force × time / mass) state.apply_central_force(world_thrust) + func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vector3): if rotation_input.length() < 0.01: return - + # Apply torque for rotation - simple and effective # Physics: τ = I * α (torque = moment of inertia × angular acceleration) # Also: α = τ / I (angular acceleration = torque / moment of inertia) @@ -260,17 +137,18 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect rotation_input.y * rotation_power, # Yaw (rotation around Y-axis) rotation_input.z * rotation_power # Roll (rotation around Z-axis) ) - + # Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia) state.apply_torque(torque) + func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3): # Linear drag (air resistance) # Physics: F_drag = -½ * ρ * v² * C_d * A (drag force equation) # Simplified: v_new = v_old * drag_coefficient (exponential decay) # This simulates air resistance reducing velocity over time state.linear_velocity *= drag_coefficient - + # Angular drag (rotational resistance) # Physics: Similar to linear drag but for rotational motion # τ_drag = -C_angular * ω² (angular drag torque) @@ -281,7 +159,7 @@ func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vect else: # Normal drag when actively rotating state.angular_velocity *= angular_drag - + # Limit maximum speeds # Physics: Terminal velocity concept - maximum achievable speed # When thrust force = drag force, acceleration = 0, velocity = constant @@ -289,50 +167,46 @@ func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vect # Normalize to unit vector, then scale to max speed # Physics: v̂ = v / |v| (unit vector), v_limited = v̂ * v_max state.linear_velocity = state.linear_velocity.normalized() * max_speed - + if state.angular_velocity.length() > max_angular_speed: # Same concept for angular velocity # Physics: ω̂ = ω / |ω|, ω_limited = ω̂ * ω_max state.angular_velocity = state.angular_velocity.normalized() * max_angular_speed + func _emit_telemetry_data(): # Ship only calculates and emits data - HUD handles display # Performance optimization: only emit signals when values change significantly - + # Speed telemetry # Physics: |v| = √(vₓ² + vᵧ² + vᵤ²) (magnitude of velocity vector) var current_speed = linear_velocity.length() if abs(current_speed - _last_speed) > SPEED_THRESHOLD: speed_changed.emit(current_speed) _last_speed = current_speed - - # Altitude telemetry + + # Altitude telemetry # Physics: Height measurement from reference point (y = 0) var current_altitude = global_transform.origin.y if abs(current_altitude - _last_altitude) > ALTITUDE_THRESHOLD: altitude_changed.emit(current_altitude) _last_altitude = current_altitude - + # Angular velocity telemetry # Physics: |ω| = √(ωₓ² + ωᵧ² + ωᵤ²) (magnitude of angular velocity vector) var angular_speed = angular_velocity.length() if abs(angular_speed - _last_angular_speed) > ANGULAR_THRESHOLD: angular_velocity_changed.emit(angular_speed) _last_angular_speed = angular_speed - - # Camera mode telemetry (only emit when it actually changes) - if ball_cam_enabled != _last_camera_mode: - camera_mode_changed.emit(ball_cam_enabled) - _last_camera_mode = ball_cam_enabled - + # Attitude telemetry (pitch, roll, yaw from ship orientation) # Physics: Euler angles from rotation matrix # Pitch = rotation around X-axis, Roll = rotation around Z-axis var ship_rotation = global_transform.basis.get_euler(EULER_ORDER_XYZ) var pitch_deg = rad_to_deg(ship_rotation.x) - var roll_deg = rad_to_deg(ship_rotation.z) + var roll_deg = rad_to_deg(ship_rotation.z) var yaw_deg = rad_to_deg(ship_rotation.y) - + if abs(pitch_deg - _last_pitch) > ATTITUDE_THRESHOLD or \ abs(roll_deg - _last_roll) > ATTITUDE_THRESHOLD or \ abs(yaw_deg - _last_yaw) > ATTITUDE_THRESHOLD: @@ -340,7 +214,7 @@ func _emit_telemetry_data(): _last_pitch = pitch_deg _last_roll = roll_deg _last_yaw = yaw_deg - + # Heading telemetry (yaw - direction ship is facing) # Physics: Yaw = rotation around Y-axis (compass heading) # Convert to 0-360° range for traditional compass display @@ -348,12 +222,10 @@ func _emit_telemetry_data(): if abs(heading - _last_heading) > ATTITUDE_THRESHOLD: heading_changed.emit(heading) _last_heading = heading - + # Thrust telemetry # Physics: Thrust output as percentage of maximum available thrust - var thrust_input = get_thrust_input() - var thrust_magnitude = thrust_input.length() - var thrust_percent = thrust_magnitude * 100.0 + var thrust_percent = _current_action.thrust.length() * 100.0 if abs(thrust_percent - _last_thrust) > THRUST_THRESHOLD: thrust_changed.emit(thrust_percent) _last_thrust = thrust_percent diff --git a/Game/scripts/ship_action.gd b/Game/scripts/ship_action.gd new file mode 100644 index 00000000..0ab8f5a6 --- /dev/null +++ b/Game/scripts/ship_action.gd @@ -0,0 +1,11 @@ +class_name ShipAction +extends RefCounted + +# A single physics tick's worth of control input for a Ship. +# Produced by a ShipController each tick, consumed by Ship._integrate_forces. +# This is deliberately shaped as the future RL action space (a flat 7-value +# box) and the future network-replicated input payload. + +var thrust := Vector3.ZERO # Per-axis -1..1: x = strafe, y = vertical, z = forward/back +var rotation := Vector3.ZERO # Per-axis -1..1: x = pitch, y = yaw, z = roll +var turbo := false diff --git a/Game/scripts/ship_action.gd.uid b/Game/scripts/ship_action.gd.uid new file mode 100644 index 00000000..a44ffc00 --- /dev/null +++ b/Game/scripts/ship_action.gd.uid @@ -0,0 +1 @@ +uid://oh6cx0f2gt6a diff --git a/Game/scripts/ship_camera.gd b/Game/scripts/ship_camera.gd new file mode 100644 index 00000000..47be3be0 --- /dev/null +++ b/Game/scripts/ship_camera.gd @@ -0,0 +1,107 @@ +class_name ShipCameraRig +extends Node3D + +# Third-person camera for a Ship. Ball Cam keeps the ship between the camera +# and the ball; Ship Cam chases behind the ship. The game mode spawns this +# rig and assigns `target` after spawning the player's ship — ships +# themselves are camera-free (a headless/AI ship never needs one). + +signal camera_mode_changed(is_ball_cam: bool) + +@export var camera_distance := 8.0 +@export var camera_height := 4.0 +@export var camera_smoothing := 10.0 + +var target: Ship +var ball_cam_enabled := true + +@onready var camera: Camera3D = $Camera3D + +var _ball: Node3D + + +func _ready(): + # Group lets the HUD discover the rig for the camera-mode instrument + add_to_group("ship_camera") + + +func _input(event): + if event.is_action_pressed("ui_accept"): # Enter key + ball_cam_enabled = !ball_cam_enabled + camera_mode_changed.emit(ball_cam_enabled) + + +func _physics_process(delta): + if not is_instance_valid(target): + return + var ball := _get_ball() + if ball_cam_enabled and ball: + _update_ball_cam(delta, ball) + else: + _update_ship_cam(delta) + + +func _get_ball() -> Node3D: + if not is_instance_valid(_ball): + _ball = get_tree().get_first_node_in_group("ball") + return _ball + + +func _update_ball_cam(delta, ball: Node3D): + # In ball cam, camera positions itself so the ship is between camera and ball + # Physics: Vector mathematics for 3D positioning + var ship_pos = target.global_transform.origin + var ball_pos = ball.global_transform.origin + + # Calculate direction from ball to ship + # Physics: Vector subtraction and normalization + # Direction vector: d̂ = (P₂ - P₁) / |P₂ - P₁| + var ball_to_ship = (ship_pos - ball_pos).normalized() + + # Position camera behind the ship relative to the ball's position + # This ensures the ship is always between the camera and ball + # Physics: Vector addition for position calculation + # P_camera = P_ship + d̂ * distance + height_offset + var camera_target_pos = ship_pos + ball_to_ship * camera_distance + Vector3.UP * camera_height + + # Smoothly move camera to target position + # Physics: Linear interpolation (LERP) for smooth motion + # P(t) = P₀ + t * (P₁ - P₀), where t ∈ [0,1] + # This creates exponential approach to target position + camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta) + + # Make camera look at the ball + if camera.global_transform.origin.distance_to(ball_pos) > 0.1: + # Calculate direction to ball + var camera_pos = camera.global_transform.origin + var to_ball = (ball_pos - camera_pos).normalized() + + # Create look-at transform manually + # Physics: 3D rotation matrices and basis vectors + # Uses right-hand rule: forward = -Z, up = Y, right = X + # Basis matrix transforms local coordinates to world coordinates + var camera_transform = Transform3D() + camera_transform.origin = camera_pos + camera_transform.basis = Basis.looking_at(to_ball, Vector3.UP) + + # Apply the rotation smoothly + # Physics: Spherical linear interpolation (SLERP) for rotation + # SLERP provides smooth rotation along great circle on unit sphere + # Maintains constant angular velocity during interpolation + camera.global_transform.basis = camera.global_transform.basis.slerp(camera_transform.basis, camera_smoothing * delta) + + +func _update_ship_cam(delta): + # In ship cam, camera follows and looks in the same direction as the ship + var ship_pos = target.global_transform.origin + var ship_forward = -target.global_transform.basis.z + + # Position camera behind and above the ship + var camera_target_pos = ship_pos - ship_forward * camera_distance + Vector3.UP * camera_height + + # Smoothly move camera + camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta) + + # Make camera look in the same direction as the ship + var look_target = ship_pos + ship_forward * 10.0 # Look ahead of the ship + camera.look_at(look_target, Vector3.UP) diff --git a/Game/scripts/ship_camera.gd.uid b/Game/scripts/ship_camera.gd.uid new file mode 100644 index 00000000..434cc865 --- /dev/null +++ b/Game/scripts/ship_camera.gd.uid @@ -0,0 +1 @@ +uid://bl1upxgeuj8to diff --git a/Game/scripts/ship_controller.gd b/Game/scripts/ship_controller.gd new file mode 100644 index 00000000..9d977bc4 --- /dev/null +++ b/Game/scripts/ship_controller.gd @@ -0,0 +1,12 @@ +class_name ShipController +extends Node + +# Base class for anything that drives a Ship: local player input, an AI +# policy, or network replication. The ship pulls exactly one action per +# physics tick, so controllers stay deterministic and ordering-free. +# The base implementation returns a zero action — a ship with a base +# controller (or none) is inert but still physically simulated. + + +func get_action() -> ShipAction: + return ShipAction.new() diff --git a/Game/scripts/ship_controller.gd.uid b/Game/scripts/ship_controller.gd.uid new file mode 100644 index 00000000..9c481015 --- /dev/null +++ b/Game/scripts/ship_controller.gd.uid @@ -0,0 +1 @@ +uid://hnusbl8a0rvj diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..218d303a --- /dev/null +++ b/TODO.md @@ -0,0 +1,30 @@ +# TODO + +Deferred work, in rough priority order. The current architecture (ShipAction/ShipController seam, Arena/GameMode split, code-driven spawning, group-tagged ball/goals) was chosen specifically so these bolt on without rework. + +## AI opponent (reinforcement learning) + +- [ ] `AIShipController extends ShipController` — produces a `ShipAction` per physics tick from observations instead of keyboard input. +- [ ] Observation builder: self ship state (position, orientation, velocities) + ball state (group `"ball"`) + goal positions/teams (group `"goal"`), normalized for the policy. +- [ ] Reward shaping: goals scored/conceded, ball touches, ball-toward-opponent-goal velocity, etc. +- [ ] Headless training scene: a `GameMode` subclass with no HUD/camera, run via `godot --headless`, stepping the sim for training (consider godot-rl-agents or a custom socket bridge). +- [ ] Swap the inert placeholder opponent in Match mode for the trained `AIShipController`. + +## Match mode polish + +- [ ] Kickoff countdown (3-2-1) before play starts and after each goal, instead of instant reset. +- [ ] HUD scoreboard widget consuming the existing `score_changed` signal. +- [ ] Results screen on timer expiry (winner, final score) instead of dumping straight back to the main menu. +- [ ] Overtime / golden-goal rule on a draw. + +## Multiplayer (long term) + +- [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation. +- [ ] Networked `GameMode` subclass: per-peer ship spawning (MultiplayerSpawner or custom), authoritative server for ball/score. +- [ ] C# backend / online servers per README roadmap (not started). +- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this). + +## General + +- [ ] No autoloads yet by design — add a singleton only when cross-scene state is actually needed (e.g. passing match settings/results between menu, match, and results screens). +- [ ] More arenas: `arena_01.tscn` is the template — an arena is terrain + lighting + two team-tagged goals + spawn markers, with no rules or state.