mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 09:22:21 +00:00
refactor(*): Restructure game into reusable Arena/GameMode architecture with controller-driven ships, adding Free Play and Match modes
This commit is contained in:
@@ -6,7 +6,7 @@ Important rule: never create co-authored commits. Never mention Claude in commit
|
|||||||
|
|
||||||
## Project overview
|
## 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.
|
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.
|
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.
|
- **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 Game/scenes/main_menu.tscn`.
|
- **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.
|
- 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
|
## 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`).
|
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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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)`.
|
||||||
- **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.
|
- **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"`).
|
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
bounce = 0.8
|
bounce = 0.8
|
||||||
friction = 0.3
|
friction = 0.3
|
||||||
|
|
||||||
[node name="Ball" type="RigidBody3D"]
|
[node name="Ball" type="RigidBody3D" groups=["ball"]]
|
||||||
mass = 3
|
mass = 3
|
||||||
physics_material_override = SubResource("PhysicsMaterial_ball")
|
physics_material_override = SubResource("PhysicsMaterial_ball")
|
||||||
inertia = Vector3(3, 3, 3)
|
inertia = Vector3(3, 3, 3)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=5 format=3 uid="uid://cofdcxo5170rs"]
|
[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"]
|
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ptddx"]
|
||||||
transparency = 1
|
transparency = 1
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
[gd_scene load_steps=5 format=3 uid="uid://p07epxnh8wwp"]
|
[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="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"]
|
[sub_resource type="BoxMesh" id="BoxMesh_efag7"]
|
||||||
size = Vector3(1, 1, 4)
|
size = Vector3(1, 1, 4)
|
||||||
@@ -10,8 +13,9 @@ size = Vector3(1, 1, 4)
|
|||||||
size = Vector3(1, 1, 4)
|
size = Vector3(1, 1, 4)
|
||||||
|
|
||||||
[node name="Ship" type="RigidBody3D"]
|
[node name="Ship" type="RigidBody3D"]
|
||||||
mass = 10.0
|
mass = 5.0
|
||||||
gravity_scale = 10.0
|
physics_material_override = SubResource("PhysicsMaterial_ship")
|
||||||
|
inertia = Vector3(1, 1, 1)
|
||||||
script = ExtResource("1_efag7")
|
script = ExtResource("1_efag7")
|
||||||
|
|
||||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
||||||
@@ -19,8 +23,3 @@ mesh = SubResource("BoxMesh_efag7")
|
|||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
shape = SubResource("BoxShape3D_dsjou")
|
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")]
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
+7
-5
@@ -31,6 +31,8 @@ window/stretch/aspect="expand"
|
|||||||
|
|
||||||
[input]
|
[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={
|
move_forward={
|
||||||
"deadzone": 0.2,
|
"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)
|
"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)
|
"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={
|
move_up={
|
||||||
"deadzone": 0.2,
|
"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)
|
"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]
|
[physics]
|
||||||
|
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
|
|
||||||
|
[autoload]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=3 format=3 uid="uid://c8kak2l3m4n5"]
|
[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"]
|
[sub_resource type="LabelSettings" id="LabelSettings_hud"]
|
||||||
font_size = 32
|
font_size = 32
|
||||||
|
|||||||
@@ -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="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="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"]
|
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
|
||||||
|
|
||||||
[sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_kldst"]
|
[sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_kldst"]
|
||||||
@@ -19,7 +17,7 @@ sky_material = SubResource("ProceduralSkyMaterial_iukft")
|
|||||||
background_mode = 2
|
background_mode = 2
|
||||||
sky = SubResource("Sky_gl6un")
|
sky = SubResource("Sky_gl6un")
|
||||||
|
|
||||||
[node name="Game" type="Node3D"]
|
[node name="Arena" type="Node3D"]
|
||||||
script = ExtResource("1_iywne")
|
script = ExtResource("1_iywne")
|
||||||
|
|
||||||
[node name="Terrain" type="StaticBody3D" parent="."]
|
[node name="Terrain" type="StaticBody3D" parent="."]
|
||||||
@@ -38,19 +36,22 @@ shadow_enabled = true
|
|||||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
environment = SubResource("Environment_j5yw3")
|
environment = SubResource("Environment_j5yw3")
|
||||||
|
|
||||||
[node name="Timer" type="Timer" parent="."]
|
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
|
||||||
process_callback = 0
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29962, 0.790881, 15.5633)
|
||||||
autostart = true
|
|
||||||
|
|
||||||
[node name="Ship" parent="." instance=ExtResource("4_iywne")]
|
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
|
||||||
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")]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29962, 0.790881, -15.5633)
|
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)
|
||||||
@@ -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")]
|
||||||
+35
-36
@@ -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="Control"]
|
||||||
|
layout_mode = 3
|
||||||
[node name="MainMenu" type="Node2D"]
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
[node name="Label" type="Label" parent="."]
|
anchor_bottom = 1.0
|
||||||
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
|
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 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"
|
text = "Cosmic Clash"
|
||||||
label_settings = SubResource("LabelSettings_erv1k")
|
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
vertical_alignment = 1
|
|
||||||
|
|
||||||
[node name="Button" type="Button" parent="."]
|
[node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
anchors_preset = 8
|
custom_minimum_size = Vector2(330, 60)
|
||||||
anchor_left = 0.5
|
layout_mode = 2
|
||||||
anchor_top = 0.5
|
text = "Free Play"
|
||||||
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")
|
|
||||||
|
|
||||||
[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"]
|
||||||
|
|||||||
@@ -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")]
|
||||||
@@ -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
|
||||||
@@ -25,22 +25,26 @@ func _initialize_hud():
|
|||||||
# Find the ship
|
# Find the ship
|
||||||
ship = get_tree().get_first_node_in_group("ship")
|
ship = get_tree().get_first_node_in_group("ship")
|
||||||
if not 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")
|
push_error("HUDController: No ship found in 'ship' group")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("HUDController: Found ship: ", ship.name)
|
print("HUDController: Found ship: ", ship.name)
|
||||||
_connect_ship_signals()
|
_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")
|
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)
|
game_manager.timer_updated.connect(_on_timer_updated)
|
||||||
print("HUDController: Connected to game timer")
|
print("HUDController: Connected to game timer")
|
||||||
|
if timer_label and is_instance_valid(timer_label):
|
||||||
|
timer_label.visible = has_timer
|
||||||
|
|
||||||
func _connect_ship_signals():
|
func _connect_ship_signals():
|
||||||
# Connect ship signals to label update methods
|
# Connect ship signals to label update methods
|
||||||
@@ -51,8 +55,6 @@ func _connect_ship_signals():
|
|||||||
ship.altitude_changed.connect(_on_ship_altitude_changed)
|
ship.altitude_changed.connect(_on_ship_altitude_changed)
|
||||||
if ship.has_signal("angular_velocity_changed"):
|
if ship.has_signal("angular_velocity_changed"):
|
||||||
ship.angular_velocity_changed.connect(_on_ship_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"):
|
if ship.has_signal("attitude_changed"):
|
||||||
ship.attitude_changed.connect(_on_ship_attitude_changed)
|
ship.attitude_changed.connect(_on_ship_attitude_changed)
|
||||||
if ship.has_signal("heading_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"
|
var camera_mode = "Ball Cam" if is_ball_cam else "Ship Cam"
|
||||||
camera_mode_label.text = "Camera: %s" % camera_mode
|
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):
|
if attitude_label and is_instance_valid(attitude_label):
|
||||||
attitude_label.text = "Pitch: %.0f° Roll: %.0f°" % [pitch, roll]
|
attitude_label.text = "Pitch: %.0f° Roll: %.0f°" % [pitch, roll]
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
uid://scnbnslvru0b
|
|
||||||
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://byy2mu4mxdlgl
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c1kq2m6gnwjxo
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://coirkjf1pbhi7
|
||||||
@@ -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")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bpxm8ge52w5g8
|
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c6qkhqup6h0pk
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bcas8toqyr1qb
|
||||||
@@ -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)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://2xvkyfw3v1ui
|
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://xcqhku3vnp8v
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
extends Button
|
|
||||||
|
|
||||||
|
|
||||||
func _on_pressed() -> void:
|
|
||||||
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://c1tfuurttt8ft
|
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cgoqkvyoal2iw
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cprbkhe46c1be
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bdda0ybrdo3or
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://gxdj34cnymoy
|
||||||
+41
-169
@@ -1,9 +1,16 @@
|
|||||||
class_name Ship
|
class_name Ship
|
||||||
extends RigidBody3D
|
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_group("Movement")
|
||||||
@export var thrust_power = 150.0 # Main thruster power
|
@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 vertical_thrust = 120.0 # Up/down thruster power
|
||||||
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
|
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
|
||||||
@export var max_speed = 35.0 # Maximum velocity
|
@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 drag_coefficient = 0.98 # Linear drag (air resistance)
|
||||||
@export var angular_drag = 0.95 # Rotational drag
|
@export var angular_drag = 0.95 # Rotational drag
|
||||||
|
|
||||||
@export_group("Camera")
|
# Which team this ship plays for (0 or 1). Set by the game mode on spawn.
|
||||||
var camera_distance = 8.0
|
var team: int = 0
|
||||||
var camera_height = 4.0
|
|
||||||
var camera_smoothing = 10.0
|
|
||||||
|
|
||||||
@onready var camera : Camera3D = get_node("Camera3D")
|
var controller: ShipController
|
||||||
@onready var ball = get_parent().get_node("Ball")
|
var _current_action: ShipAction = ShipAction.new()
|
||||||
|
|
||||||
var ball_cam_enabled = true
|
|
||||||
|
|
||||||
# Instrument signals for efficient data distribution
|
# Instrument signals for efficient data distribution
|
||||||
signal speed_changed(speed: float)
|
signal speed_changed(speed: float)
|
||||||
@@ -28,7 +31,6 @@ signal attitude_changed(pitch: float, roll: float, yaw: float)
|
|||||||
signal altitude_changed(altitude: float)
|
signal altitude_changed(altitude: float)
|
||||||
signal thrust_changed(thrust_percent: float)
|
signal thrust_changed(thrust_percent: float)
|
||||||
signal angular_velocity_changed(angular_speed: float)
|
signal angular_velocity_changed(angular_speed: float)
|
||||||
signal camera_mode_changed(is_ball_cam: bool)
|
|
||||||
signal heading_changed(heading_degrees: float)
|
signal heading_changed(heading_degrees: float)
|
||||||
|
|
||||||
# Performance optimization - track last emitted values to avoid unnecessary signals
|
# 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_yaw: float = -999.0
|
||||||
var _last_heading: float = -999.0
|
var _last_heading: float = -999.0
|
||||||
var _last_thrust: float = -1.0
|
var _last_thrust: float = -1.0
|
||||||
var _last_camera_mode: bool = true
|
|
||||||
|
|
||||||
# Thresholds for signal emission (only emit if change is significant)
|
# Thresholds for signal emission (only emit if change is significant)
|
||||||
const SPEED_THRESHOLD = 0.1 # m/s
|
const SPEED_THRESHOLD = 0.1 # m/s
|
||||||
@@ -49,173 +50,49 @@ const ANGULAR_THRESHOLD = 0.01 # rad/s
|
|||||||
const ATTITUDE_THRESHOLD = 1.0 # degrees
|
const ATTITUDE_THRESHOLD = 1.0 # degrees
|
||||||
const THRUST_THRESHOLD = 1.0 # percent
|
const THRUST_THRESHOLD = 1.0 # percent
|
||||||
|
|
||||||
func _ready():
|
|
||||||
mass = 5
|
|
||||||
gravity_scale = 1.0
|
|
||||||
|
|
||||||
|
func _ready():
|
||||||
# Add ship to group for instrument discovery
|
# Add ship to group for instrument discovery
|
||||||
add_to_group("ship")
|
add_to_group("ship")
|
||||||
|
|
||||||
# Set custom inertia for better rotation
|
# Pick up a controller placed in the scene, if any; game modes usually
|
||||||
# Physics: I = m * r² (moment of inertia = mass × radius²)
|
# attach one at spawn time via set_controller instead.
|
||||||
# Lower inertia = easier to rotate, higher inertia = more stable
|
for child in get_children():
|
||||||
inertia = Vector3(1.0, 1.0, 1.0)
|
if child is ShipController:
|
||||||
|
controller = child
|
||||||
|
break
|
||||||
|
|
||||||
# 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
|
# Attach the node that drives this ship (player, AI, or network). Replaces
|
||||||
freeze = false
|
# any existing controller; parents the new one under the ship if needed.
|
||||||
lock_rotation = false
|
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)
|
||||||
|
|
||||||
# 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)
|
func _physics_process(_delta):
|
||||||
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)
|
|
||||||
|
|
||||||
func _physics_process(delta):
|
|
||||||
if camera:
|
|
||||||
_update_camera(delta)
|
|
||||||
_emit_telemetry_data()
|
_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):
|
func _integrate_forces(state):
|
||||||
# Get thruster input
|
# One action per physics tick, pulled from the controller (deterministic)
|
||||||
var thrust_input = get_thrust_input()
|
_current_action = controller.get_action() if controller else ShipAction.new()
|
||||||
var rotation_input = get_rotation_input()
|
|
||||||
|
|
||||||
# === TRANSLATION (Movement) ===
|
# === TRANSLATION (Movement) ===
|
||||||
apply_thruster_forces(state, thrust_input)
|
apply_thruster_forces(state, _current_action)
|
||||||
|
|
||||||
# === ROTATION (Turning) ===
|
# === ROTATION (Turning) ===
|
||||||
apply_rotation_forces(state, rotation_input)
|
apply_rotation_forces(state, _current_action.rotation)
|
||||||
|
|
||||||
# === DRAG AND LIMITS ===
|
# === 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)
|
func apply_thruster_forces(state: PhysicsDirectBodyState3D, action: ShipAction):
|
||||||
if Input.is_action_pressed("move_forward"):
|
var thrust_input := action.thrust
|
||||||
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):
|
|
||||||
if thrust_input.length() < 0.01:
|
if thrust_input.length() < 0.01:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -238,15 +115,15 @@ func apply_thruster_forces(state: PhysicsDirectBodyState3D, thrust_input: Vector
|
|||||||
# Vertical thrust (up/down thrusters relative to ship orientation)
|
# Vertical thrust (up/down thrusters relative to ship orientation)
|
||||||
world_thrust += ship_basis.y * thrust_input.y * vertical_thrust
|
world_thrust += ship_basis.y * thrust_input.y * vertical_thrust
|
||||||
|
|
||||||
# Check for turbo
|
# Turbo only boosts forward thrust
|
||||||
var is_turbo = Input.is_action_pressed("turbo") and thrust_input.z > 0
|
if action.turbo and thrust_input.z > 0:
|
||||||
if is_turbo:
|
|
||||||
world_thrust *= turbo_multiplier
|
world_thrust *= turbo_multiplier
|
||||||
|
|
||||||
# Apply the force
|
# Apply the force
|
||||||
# Physics: Δv = F * Δt / m (change in velocity = force × time / mass)
|
# Physics: Δv = F * Δt / m (change in velocity = force × time / mass)
|
||||||
state.apply_central_force(world_thrust)
|
state.apply_central_force(world_thrust)
|
||||||
|
|
||||||
|
|
||||||
func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
||||||
if rotation_input.length() < 0.01:
|
if rotation_input.length() < 0.01:
|
||||||
return
|
return
|
||||||
@@ -264,6 +141,7 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
|||||||
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
|
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
|
||||||
state.apply_torque(torque)
|
state.apply_torque(torque)
|
||||||
|
|
||||||
|
|
||||||
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
||||||
# Linear drag (air resistance)
|
# Linear drag (air resistance)
|
||||||
# Physics: F_drag = -½ * ρ * v² * C_d * A (drag force equation)
|
# Physics: F_drag = -½ * ρ * v² * C_d * A (drag force equation)
|
||||||
@@ -295,6 +173,7 @@ func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
|||||||
# Physics: ω̂ = ω / |ω|, ω_limited = ω̂ * ω_max
|
# Physics: ω̂ = ω / |ω|, ω_limited = ω̂ * ω_max
|
||||||
state.angular_velocity = state.angular_velocity.normalized() * max_angular_speed
|
state.angular_velocity = state.angular_velocity.normalized() * max_angular_speed
|
||||||
|
|
||||||
|
|
||||||
func _emit_telemetry_data():
|
func _emit_telemetry_data():
|
||||||
# Ship only calculates and emits data - HUD handles display
|
# Ship only calculates and emits data - HUD handles display
|
||||||
# Performance optimization: only emit signals when values change significantly
|
# Performance optimization: only emit signals when values change significantly
|
||||||
@@ -320,11 +199,6 @@ func _emit_telemetry_data():
|
|||||||
angular_velocity_changed.emit(angular_speed)
|
angular_velocity_changed.emit(angular_speed)
|
||||||
_last_angular_speed = 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)
|
# Attitude telemetry (pitch, roll, yaw from ship orientation)
|
||||||
# Physics: Euler angles from rotation matrix
|
# Physics: Euler angles from rotation matrix
|
||||||
# Pitch = rotation around X-axis, Roll = rotation around Z-axis
|
# Pitch = rotation around X-axis, Roll = rotation around Z-axis
|
||||||
@@ -351,9 +225,7 @@ func _emit_telemetry_data():
|
|||||||
|
|
||||||
# Thrust telemetry
|
# Thrust telemetry
|
||||||
# Physics: Thrust output as percentage of maximum available thrust
|
# Physics: Thrust output as percentage of maximum available thrust
|
||||||
var thrust_input = get_thrust_input()
|
var thrust_percent = _current_action.thrust.length() * 100.0
|
||||||
var thrust_magnitude = thrust_input.length()
|
|
||||||
var thrust_percent = thrust_magnitude * 100.0
|
|
||||||
if abs(thrust_percent - _last_thrust) > THRUST_THRESHOLD:
|
if abs(thrust_percent - _last_thrust) > THRUST_THRESHOLD:
|
||||||
thrust_changed.emit(thrust_percent)
|
thrust_changed.emit(thrust_percent)
|
||||||
_last_thrust = thrust_percent
|
_last_thrust = thrust_percent
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://oh6cx0f2gt6a
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bl1upxgeuj8to
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://hnusbl8a0rvj
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user