mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 12:32:04 +00:00
Compare commits
15 Commits
b285d012dc
...
8551d9e835
| Author | SHA1 | Date | |
|---|---|---|---|
| 8551d9e835 | |||
| 6198dc67fc | |||
| 884b7799a0 | |||
| 5193776d86 | |||
| c96325144a | |||
| fa9590d9c3 | |||
| ff2e40198f | |||
| c75e142c8a | |||
| 96ff503fa8 | |||
| 7b086fbd8f | |||
| 0603452264 | |||
| b01d5a68d9 | |||
| bb56f69edc | |||
| fbb783b947 | |||
| 08a0f74391 |
@@ -59,7 +59,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
|
||||
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
|
||||
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
|
||||
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
|
||||
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the ship, camera rig, and game mode via groups (`"ship"`, `"ship_camera"`, `"game"`), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
|
||||
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig` (mirroring the camera rig's `target`, not group lookup — the `"ship"` group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
|
||||
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks.
|
||||
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,8 @@ size = Vector3(1, 13, 38)
|
||||
size = Vector3(26, 13, 1)
|
||||
|
||||
[node name="ArenaBoundary" type="StaticBody3D"]
|
||||
collision_layer = 4
|
||||
collision_mask = 3
|
||||
script = ExtResource("1_bndry")
|
||||
|
||||
[node name="FloorShape" type="CollisionShape3D" parent="."]
|
||||
|
||||
@@ -10,6 +10,8 @@ bounce = 0.5
|
||||
friction = 0.4
|
||||
|
||||
[node name="Ball" type="RigidBody3D" groups=["ball"]]
|
||||
collision_layer = 2
|
||||
collision_mask = 13
|
||||
mass = 3
|
||||
physics_material_override = SubResource("PhysicsMaterial_ball")
|
||||
continuous_cd = true
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
size = Vector3(3.5, 1.5, 0.1)
|
||||
|
||||
[node name="Goal" type="Area3D"]
|
||||
collision_layer = 8
|
||||
collision_mask = 2
|
||||
script = ExtResource("1_v8ikr")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
|
||||
+2
-20
@@ -1,12 +1,8 @@
|
||||
[gd_scene load_steps=13 format=3 uid="uid://p07epxnh8wwp"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://dyq1n7q1bqjps" path="res://scripts/ship.gd" id="1_efag7"]
|
||||
[ext_resource type="ArrayMesh" path="res://assets/models/ship_hull.res" id="2_hull"]
|
||||
[ext_resource type="ArrayMesh" path="res://assets/models/ship_nose.res" id="3_nose"]
|
||||
[ext_resource type="ArrayMesh" path="res://assets/models/ship_canopy.res" id="4_canopy"]
|
||||
[ext_resource type="ArrayMesh" path="res://assets/models/ship_tailfin.res" id="5_talfin"]
|
||||
[ext_resource type="ArrayMesh" path="res://assets/models/ship_engine_l.res" id="6_enginel"]
|
||||
[ext_resource type="ArrayMesh" path="res://assets/models/ship_engine_r.res" id="7_enginer"]
|
||||
|
||||
[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_ship"]
|
||||
friction = 0.1
|
||||
@@ -16,34 +12,20 @@ bounce = 0.15
|
||||
size = Vector3(1, 1, 4)
|
||||
|
||||
[node name="Ship" type="RigidBody3D"]
|
||||
collision_layer = 1
|
||||
collision_mask = 7
|
||||
mass = 5.0
|
||||
physics_material_override = SubResource("PhysicsMaterial_ship")
|
||||
inertia = Vector3(1, 1, 1)
|
||||
script = ExtResource("1_efag7")
|
||||
|
||||
[node name="Hull" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
|
||||
mesh = ExtResource("2_hull")
|
||||
|
||||
[node name="Nose" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
|
||||
mesh = ExtResource("3_nose")
|
||||
|
||||
[node name="Canopy" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.31, -0.55)
|
||||
mesh = ExtResource("4_canopy")
|
||||
|
||||
[node name="TailFin" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
|
||||
mesh = ExtResource("5_talfin")
|
||||
|
||||
[node name="EngineGlowL" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -0.42, -0.05, 0.95)
|
||||
mesh = ExtResource("6_enginel")
|
||||
|
||||
[node name="EngineGlowR" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0.42, -0.05, 0.95)
|
||||
mesh = ExtResource("7_enginer")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("BoxShape3D_dsjou")
|
||||
|
||||
@@ -119,6 +119,13 @@ roll_right={
|
||||
]
|
||||
}
|
||||
|
||||
[layer_names]
|
||||
|
||||
3d_physics/layer_1/name="Ships"
|
||||
3d_physics/layer_2/name="Ball"
|
||||
3d_physics/layer_3/name="Arena"
|
||||
3d_physics/layer_4/name="GoalSensor"
|
||||
|
||||
[physics]
|
||||
|
||||
3d/physics_engine="Jolt Physics"
|
||||
|
||||
@@ -71,15 +71,12 @@ theme_override_constants/separation = 16
|
||||
[node name="Team0Name" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_override_colors/font_color = Color(0.25, 0.55, 1, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
horizontal_alignment = 1
|
||||
text = "BLUE"
|
||||
|
||||
[node name="Team0Score" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_override_colors/font_color = Color(0.25, 0.55, 1, 1)
|
||||
theme_override_font_sizes/font_size = 32
|
||||
horizontal_alignment = 1
|
||||
text = "0"
|
||||
@@ -93,7 +90,6 @@ horizontal_alignment = 1
|
||||
[node name="Team1Score" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_override_colors/font_color = Color(1, 0.5, 0.15, 1)
|
||||
theme_override_font_sizes/font_size = 32
|
||||
horizontal_alignment = 1
|
||||
text = "0"
|
||||
@@ -101,10 +97,8 @@ text = "0"
|
||||
[node name="Team1Name" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_override_colors/font_color = Color(1, 0.5, 0.15, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
horizontal_alignment = 1
|
||||
text = "ORANGE"
|
||||
|
||||
[node name="KickoffLabel" type="Label" parent="Control"]
|
||||
visible = false
|
||||
@@ -171,6 +165,7 @@ text = "0 - 0"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Instruments" type="Control" parent="Control"]
|
||||
process_mode = 1
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
[gd_scene load_steps=8 format=3]
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
|
||||
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
|
||||
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/arena_base.tscn" id="1_base"]
|
||||
|
||||
[sub_resource type="Shader" id="Shader_stars"]
|
||||
code = "shader_type sky;
|
||||
@@ -27,74 +25,5 @@ void sky() {
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_stars"]
|
||||
shader = SubResource("Shader_stars")
|
||||
|
||||
[sub_resource type="Sky" id="Sky_space"]
|
||||
[node name="Arena" instance=ExtResource("1_base")]
|
||||
sky_material = SubResource("ShaderMaterial_stars")
|
||||
|
||||
[sub_resource type="Environment" id="Environment_space"]
|
||||
background_mode = 2
|
||||
sky = SubResource("Sky_space")
|
||||
ambient_light_source = 2
|
||||
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
|
||||
ambient_light_energy = 0.4
|
||||
glow_enabled = true
|
||||
glow_levels/1 = 0.0
|
||||
glow_levels/2 = 0.4
|
||||
glow_levels/3 = 0.8
|
||||
glow_levels/4 = 0.6
|
||||
glow_levels/5 = 0.2
|
||||
glow_intensity = 0.9
|
||||
glow_strength = 1.2
|
||||
glow_bloom = 0.05
|
||||
glow_blend_mode = 1
|
||||
glow_hdr_threshold = 1.0
|
||||
glow_hdr_scale = 2.0
|
||||
adjustment_enabled = true
|
||||
adjustment_brightness = 1.0
|
||||
adjustment_contrast = 1.05
|
||||
adjustment_saturation = 1.08
|
||||
|
||||
[node name="Arena" type="Node3D"]
|
||||
script = ExtResource("1_iywne")
|
||||
|
||||
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
|
||||
field_tint = Color(0.5, 0.7, 1, 1)
|
||||
field_intensity = 0.09
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="FillLight" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
light_color = Color(0.55, 0.65, 0.85, 1)
|
||||
light_energy = 0.4
|
||||
shadow_enabled = false
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("Environment_space")
|
||||
|
||||
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
|
||||
size = Vector3(26, 14, 40)
|
||||
box_projection = true
|
||||
update_mode = 0
|
||||
|
||||
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
|
||||
|
||||
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
|
||||
team = 1
|
||||
|
||||
[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, 13.5)
|
||||
|
||||
[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, -13.5)
|
||||
|
||||
+13
-68
@@ -1,8 +1,6 @@
|
||||
[gd_scene load_steps=16 format=3]
|
||||
[gd_scene load_steps=13 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
|
||||
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
|
||||
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/arena_base.tscn" id="1_base"]
|
||||
[ext_resource type="Texture2D" path="res://assets/textures/sky_nebula.png" id="7_nebula"]
|
||||
[ext_resource type="PackedScene" path="res://assets/models/nebula_station.glb" id="8_station"]
|
||||
[ext_resource type="PackedScene" path="res://assets/models/nebula_debris.glb" id="9_debris"]
|
||||
@@ -13,32 +11,6 @@
|
||||
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_nebula"]
|
||||
panorama = ExtResource("7_nebula")
|
||||
|
||||
[sub_resource type="Sky" id="Sky_nebula"]
|
||||
sky_material = SubResource("PanoramaSkyMaterial_nebula")
|
||||
|
||||
[sub_resource type="Environment" id="Environment_nebula"]
|
||||
background_mode = 2
|
||||
sky = SubResource("Sky_nebula")
|
||||
ambient_light_source = 2
|
||||
ambient_light_color = Color(0.65, 0.35, 0.75, 1)
|
||||
ambient_light_energy = 0.45
|
||||
glow_enabled = true
|
||||
glow_levels/1 = 0.0
|
||||
glow_levels/2 = 0.4
|
||||
glow_levels/3 = 0.8
|
||||
glow_levels/4 = 0.6
|
||||
glow_levels/5 = 0.2
|
||||
glow_intensity = 0.8
|
||||
glow_strength = 1.0
|
||||
glow_bloom = 0.03
|
||||
glow_blend_mode = 1
|
||||
glow_hdr_threshold = 1.1
|
||||
glow_hdr_scale = 2.0
|
||||
adjustment_enabled = true
|
||||
adjustment_brightness = 1.0
|
||||
adjustment_contrast = 1.05
|
||||
adjustment_saturation = 1.08
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_dust"]
|
||||
shader = ExtResource("12_dust_shader")
|
||||
shader_parameter/albedo_tex = ExtResource("10_dust")
|
||||
@@ -75,52 +47,25 @@ scale_min = 0.5
|
||||
scale_max = 1.6
|
||||
color_ramp = SubResource("GradientTexture1D_dust")
|
||||
|
||||
[node name="Arena" type="Node3D"]
|
||||
script = ExtResource("1_iywne")
|
||||
[node name="Arena" instance=ExtResource("1_base")]
|
||||
sky_material = SubResource("PanoramaSkyMaterial_nebula")
|
||||
ambient_light_color = Color(0.65, 0.35, 0.75, 1)
|
||||
ambient_light_energy = 0.45
|
||||
glow_intensity = 0.8
|
||||
glow_strength = 1.0
|
||||
glow_bloom = 0.03
|
||||
glow_hdr_threshold = 1.1
|
||||
|
||||
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
|
||||
[node name="Boundary" parent="." index="0"]
|
||||
field_tint = Color(0.78, 0.55, 1, 1)
|
||||
field_intensity = 0.1
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
[node name="DirectionalLight3D" parent="." index="1"]
|
||||
light_color = Color(0.85, 0.75, 1, 1)
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="FillLight" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
[node name="FillLight" parent="." index="2"]
|
||||
light_color = Color(0.55, 0.35, 0.65, 1)
|
||||
light_energy = 0.45
|
||||
shadow_enabled = false
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("Environment_nebula")
|
||||
|
||||
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
|
||||
size = Vector3(26, 14, 40)
|
||||
box_projection = true
|
||||
update_mode = 0
|
||||
|
||||
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
|
||||
|
||||
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
|
||||
team = 1
|
||||
|
||||
[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, 13.5)
|
||||
|
||||
[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, -13.5)
|
||||
|
||||
[node name="NebulaDust" type="GPUParticles3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 0)
|
||||
|
||||
@@ -1,84 +1,24 @@
|
||||
[gd_scene load_steps=6 format=3]
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
|
||||
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
|
||||
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/arena_base.tscn" id="1_base"]
|
||||
[ext_resource type="Shader" path="res://shaders/sky_asteroid_field.gdshader" id="7_asteroid"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_asteroid"]
|
||||
shader = ExtResource("7_asteroid")
|
||||
|
||||
[sub_resource type="Sky" id="Sky_asteroid"]
|
||||
[node name="Arena" instance=ExtResource("1_base")]
|
||||
sky_material = SubResource("ShaderMaterial_asteroid")
|
||||
|
||||
[sub_resource type="Environment" id="Environment_asteroid"]
|
||||
background_mode = 2
|
||||
sky = SubResource("Sky_asteroid")
|
||||
ambient_light_source = 2
|
||||
ambient_light_color = Color(0.6, 0.45, 0.3, 1)
|
||||
ambient_light_energy = 0.3
|
||||
glow_enabled = true
|
||||
glow_levels/1 = 0.0
|
||||
glow_levels/2 = 0.4
|
||||
glow_levels/3 = 0.8
|
||||
glow_levels/4 = 0.6
|
||||
glow_levels/5 = 0.2
|
||||
glow_intensity = 0.9
|
||||
glow_strength = 1.2
|
||||
glow_bloom = 0.05
|
||||
glow_blend_mode = 1
|
||||
glow_hdr_threshold = 1.0
|
||||
glow_hdr_scale = 2.0
|
||||
adjustment_enabled = true
|
||||
adjustment_brightness = 1.0
|
||||
adjustment_contrast = 1.05
|
||||
adjustment_saturation = 1.08
|
||||
|
||||
[node name="Arena" type="Node3D"]
|
||||
script = ExtResource("1_iywne")
|
||||
|
||||
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
|
||||
[node name="Boundary" parent="." index="0"]
|
||||
field_tint = Color(1, 0.72, 0.4, 1)
|
||||
field_intensity = 0.09
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
[node name="DirectionalLight3D" parent="." index="1"]
|
||||
light_color = Color(1, 0.85, 0.65, 1)
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="FillLight" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
[node name="FillLight" parent="." index="2"]
|
||||
light_color = Color(0.55, 0.4, 0.3, 1)
|
||||
light_energy = 0.4
|
||||
shadow_enabled = false
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("Environment_asteroid")
|
||||
|
||||
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
|
||||
size = Vector3(26, 14, 40)
|
||||
box_projection = true
|
||||
update_mode = 0
|
||||
|
||||
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
|
||||
|
||||
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
|
||||
team = 1
|
||||
|
||||
[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, 13.5)
|
||||
|
||||
[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, -13.5)
|
||||
|
||||
[node name="Decoration" type="Node3D" parent="."]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
[gd_scene load_steps=6 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
|
||||
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
|
||||
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
|
||||
|
||||
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_base"]
|
||||
|
||||
[sub_resource type="Sky" id="Sky_base"]
|
||||
sky_material = SubResource("ProceduralSkyMaterial_base")
|
||||
|
||||
[sub_resource type="Environment" id="Environment_base"]
|
||||
background_mode = 2
|
||||
sky = SubResource("Sky_base")
|
||||
ambient_light_source = 2
|
||||
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
|
||||
ambient_light_energy = 0.4
|
||||
glow_enabled = true
|
||||
glow_levels/1 = 0.0
|
||||
glow_levels/2 = 0.4
|
||||
glow_levels/3 = 0.8
|
||||
glow_levels/4 = 0.6
|
||||
glow_levels/5 = 0.2
|
||||
glow_intensity = 0.9
|
||||
glow_strength = 1.2
|
||||
glow_bloom = 0.05
|
||||
glow_blend_mode = 1
|
||||
glow_hdr_threshold = 1.0
|
||||
glow_hdr_scale = 2.0
|
||||
adjustment_enabled = true
|
||||
adjustment_brightness = 1.0
|
||||
adjustment_contrast = 1.05
|
||||
adjustment_saturation = 1.08
|
||||
|
||||
[node name="Arena" type="Node3D"]
|
||||
script = ExtResource("1_iywne")
|
||||
|
||||
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
|
||||
field_tint = Color(0.5, 0.7, 1, 1)
|
||||
field_intensity = 0.09
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="FillLight" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
|
||||
light_color = Color(0.55, 0.65, 0.85, 1)
|
||||
light_energy = 0.4
|
||||
shadow_enabled = false
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("Environment_base")
|
||||
|
||||
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
|
||||
size = Vector3(26, 14, 40)
|
||||
box_projection = true
|
||||
update_mode = 0
|
||||
|
||||
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
|
||||
|
||||
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
|
||||
team = 1
|
||||
|
||||
[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, 13.5)
|
||||
|
||||
[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, -13.5)
|
||||
@@ -1,7 +1,9 @@
|
||||
# HUD Controller - discovers the ship/camera/game mode via groups and routes
|
||||
# their signals to the display widgets. All flight data is rendered by
|
||||
# aircraft-style instruments (attitude indicator, heading tape, bar gauges) —
|
||||
# this node is only the wiring hub; the instruments are dumb displays.
|
||||
# HUD Controller - receives its target ship from the game mode (see
|
||||
# GameMode.spawn_camera_rig), discovers the camera rig/game mode via groups,
|
||||
# and routes their signals to the display widgets. All flight data is
|
||||
# rendered by aircraft-style instruments (attitude indicator, heading tape,
|
||||
# bar gauges) — this node is only the wiring hub; the instruments are dumb
|
||||
# displays.
|
||||
extends CanvasLayer
|
||||
class_name HUDController
|
||||
|
||||
@@ -24,10 +26,6 @@ class_name HUDController
|
||||
@onready var thrust_bar: HudGauge = get_node_or_null("Control/Instruments/Cluster/ThrustBar")
|
||||
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
|
||||
|
||||
# Team identity, mirroring TEAM_COLORS in ship.gd
|
||||
const TEAM_NAMES := {0: "Blue", 1: "Orange"}
|
||||
const TEAM_COLORS := {0: Color(0.25, 0.55, 1.0), 1: Color(1.0, 0.5, 0.15)}
|
||||
|
||||
var ship: Node
|
||||
var _last_score := {0: 0, 1: 0}
|
||||
|
||||
@@ -37,10 +35,11 @@ func _ready():
|
||||
_initialize_hud()
|
||||
|
||||
func _initialize_hud():
|
||||
# Find the ship
|
||||
ship = get_tree().get_first_node_in_group("ship")
|
||||
# `ship` is assigned by the game mode (GameMode.spawn_camera_rig) before
|
||||
# this runs — not discovered via group, since the "ship" group can have
|
||||
# 2+ members and there's no reliable way to tell which one is "ours".
|
||||
if not ship:
|
||||
push_error("HUDController: No ship found in 'ship' group")
|
||||
push_error("HUDController: No ship assigned")
|
||||
return
|
||||
|
||||
print("HUDController: Found ship: ", ship.name)
|
||||
@@ -66,6 +65,20 @@ func _initialize_hud():
|
||||
if timer_label and is_instance_valid(timer_label):
|
||||
timer_label.visible = has_timer
|
||||
|
||||
# Team identity (name + color) is fixed regardless of whether this
|
||||
# mode tracks score, so set it here rather than baking it into the
|
||||
# scene where it can't track TeamColors.
|
||||
if team0_name_label and is_instance_valid(team0_name_label):
|
||||
team0_name_label.text = TeamColors.TEAM_NAMES[0]
|
||||
team0_name_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[0])
|
||||
if team0_score_label and is_instance_valid(team0_score_label):
|
||||
team0_score_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[0])
|
||||
if team1_score_label and is_instance_valid(team1_score_label):
|
||||
team1_score_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[1])
|
||||
if team1_name_label and is_instance_valid(team1_name_label):
|
||||
team1_name_label.text = TeamColors.TEAM_NAMES[1]
|
||||
team1_name_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[1])
|
||||
|
||||
# Score display follows the same pattern: modes without scoring
|
||||
# (e.g. free play) just don't show it
|
||||
var has_score = game_manager and game_manager.has_signal("score_changed")
|
||||
@@ -177,9 +190,9 @@ func _on_match_ended(winning_team: int, score: Dictionary):
|
||||
result_label.remove_theme_color_override("font_color")
|
||||
_set_result_panel_accent(Color(1, 1, 1, 0.2))
|
||||
else:
|
||||
result_label.text = "%s team wins!" % TEAM_NAMES[winning_team]
|
||||
result_label.add_theme_color_override("font_color", TEAM_COLORS[winning_team])
|
||||
_set_result_panel_accent(TEAM_COLORS[winning_team])
|
||||
result_label.text = "%s team wins!" % TeamColors.TEAM_NAMES[winning_team]
|
||||
result_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[winning_team])
|
||||
_set_result_panel_accent(TeamColors.TEAM_COLORS[winning_team])
|
||||
final_score_label.text = "%d - %d" % [score.get(0, 0), score.get(1, 0)]
|
||||
result_overlay.visible = true
|
||||
_animate_result_panel_in()
|
||||
|
||||
+26
-1
@@ -5,12 +5,37 @@ extends Node3D
|
||||
# spawn markers. An arena holds no rules and no state — game modes query it
|
||||
# for spawn transforms and goals, then spawn ships/ball themselves.
|
||||
|
||||
# Per-arena Environment tuning. These live as exports (rather than baked
|
||||
# into each arena's Environment sub-resource) because Godot's scene
|
||||
# inheritance can only override whole top-level node properties, not
|
||||
# properties nested inside a sub-resource — so each arena_0N.tscn overrides
|
||||
# these on the Arena root instead, and _ready() applies them here.
|
||||
@export var sky_material: Material
|
||||
@export var ambient_light_color := Color(0.55, 0.6, 0.75, 1)
|
||||
@export var ambient_light_energy := 0.4
|
||||
@export var glow_intensity := 0.9
|
||||
@export var glow_strength := 1.2
|
||||
@export var glow_bloom := 0.05
|
||||
@export var glow_hdr_threshold := 1.0
|
||||
|
||||
|
||||
func _ready():
|
||||
add_to_group("arena")
|
||||
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
|
||||
if world_env and world_env.environment:
|
||||
VideoSettings.apply_to_environment(world_env.environment)
|
||||
var env := world_env.environment.duplicate(true) as Environment
|
||||
world_env.environment = env
|
||||
if sky_material:
|
||||
if not env.sky:
|
||||
env.sky = Sky.new()
|
||||
env.sky.sky_material = sky_material
|
||||
env.ambient_light_color = ambient_light_color
|
||||
env.ambient_light_energy = ambient_light_energy
|
||||
env.glow_intensity = glow_intensity
|
||||
env.glow_strength = glow_strength
|
||||
env.glow_bloom = glow_bloom
|
||||
env.glow_hdr_threshold = glow_hdr_threshold
|
||||
VideoSettings.apply_to_environment(env)
|
||||
|
||||
|
||||
func get_ball_spawn() -> Transform3D:
|
||||
|
||||
@@ -91,13 +91,16 @@ const DECK_SHADER_PATH := "res://shaders/arena_deck.gdshader"
|
||||
|
||||
@export var field_tint := Color(0.45, 0.65, 1.0)
|
||||
@export var field_intensity := 0.09
|
||||
@export var team0_tint := Color(0.15, 0.45, 1.0)
|
||||
@export var team1_tint := Color(1.0, 0.35, 0.25)
|
||||
@export var team0_tint := TeamColors.TEAM_COLORS[0]
|
||||
@export var team1_tint := TeamColors.TEAM_COLORS[1]
|
||||
|
||||
# The single merged surface shell and the material whose camera-side fade
|
||||
# _process() drives. Both stay null in headless runs, which never render.
|
||||
var _shell: MeshInstance3D
|
||||
var _field_material: ShaderMaterial
|
||||
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
|
||||
# pattern so the viewport lookup isn't repeated every frame.
|
||||
var _camera: Camera3D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -153,12 +156,26 @@ func _process(_delta: float) -> void:
|
||||
# individually. Collision is untouched.
|
||||
if _field_material == null:
|
||||
return
|
||||
var camera := get_viewport().get_camera_3d()
|
||||
var camera := _get_camera()
|
||||
if camera == null:
|
||||
return # headless (RL/CI) has no camera
|
||||
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
|
||||
|
||||
|
||||
# Caches the viewport's active camera; a plain is_instance_valid revalidation
|
||||
# is enough because exactly one ship_camera_rig (Camera3D) is spawned per
|
||||
# game-mode run (GameMode.spawn_camera_rig, called once each from
|
||||
# free_play.gd/match_mode.gd/spectate_mode.gd) and never re-spawned mid-match
|
||||
# — there's no active-camera-switch scenario today. ArenaBoundary itself is
|
||||
# destroyed/recreated per scene change, so the cache naturally resets with it;
|
||||
# there's no stale-cache-across-scenes concern. Revisit this if split-screen
|
||||
# or multiplayer camera-switching lands (see TODO.md's multiplayer section).
|
||||
func _get_camera() -> Camera3D:
|
||||
if not is_instance_valid(_camera):
|
||||
_camera = get_viewport().get_camera_3d()
|
||||
return _camera
|
||||
|
||||
|
||||
# --- shared layout -----------------------------------------------------------
|
||||
# Consumed by both the collider rings and the visual shell, so the two can
|
||||
# never end up describing different geometry.
|
||||
|
||||
@@ -11,13 +11,19 @@ extends Node3D
|
||||
@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 hud: HUDController
|
||||
var ball: RigidBody3D
|
||||
var ships: Array[Ship] = []
|
||||
var _ship_spawn_transforms := {}
|
||||
|
||||
# Shared by modes that keep score (Match, Spectate); Free Play never
|
||||
# references this or emits a score_changed signal, and HUDController relies
|
||||
# on has_signal("score_changed") to decide whether to show a score row — so
|
||||
# that signal stays declared per-subclass, not here.
|
||||
var score := {0: 0, 1: 0}
|
||||
|
||||
|
||||
func _ready():
|
||||
# Group lets the HUD discover the game mode for timer/score signals
|
||||
@@ -25,7 +31,8 @@ func _ready():
|
||||
for child in get_children():
|
||||
if child is Arena:
|
||||
arena = child
|
||||
break
|
||||
elif child is HUDController:
|
||||
hud = child
|
||||
if not arena:
|
||||
var path := _get_arena_scene_path()
|
||||
if not path.is_empty():
|
||||
@@ -94,9 +101,35 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
|
||||
add_child(rig)
|
||||
rig.target = target
|
||||
# Also wires the scene's static HUD (if any) to the same ship, rather
|
||||
# than letting it guess via the "ship" group.
|
||||
if hud:
|
||||
hud.ship = target
|
||||
return rig
|
||||
|
||||
|
||||
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
||||
# apply their own GameSettings-override logic first, which differs between
|
||||
# modes (Match lets GameSettings override all three fields, Spectate only
|
||||
# the path) — builds a trained-policy controller, or an inert placeholder if
|
||||
# the path is empty or missing.
|
||||
func _build_opponent(model_path: String, reaction_ticks: int, action_noise: float, label: String = "GameMode") -> ShipController:
|
||||
if not model_path.is_empty() and FileAccess.file_exists(model_path):
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = model_path
|
||||
bot.reaction_ticks = reaction_ticks
|
||||
bot.action_noise = action_noise
|
||||
return bot
|
||||
if not model_path.is_empty():
|
||||
push_warning("%s: bot model not found at %s, spawning inert opponent" % [label, model_path])
|
||||
return ShipController.new() # inert placeholder
|
||||
|
||||
|
||||
func _record_goal(scoring_team: int) -> void:
|
||||
score[scoring_team] += 1
|
||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||
|
||||
|
||||
# Tiny per-reset randomization, well below anything a player would notice as
|
||||
# "not a real kickoff" — just enough that two ships running the identical
|
||||
# deterministic AI policy (action_noise = 0) don't start every kickoff from a
|
||||
@@ -141,4 +174,4 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
||||
|
||||
func _unhandled_input(event):
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
+105
-31
@@ -10,9 +10,6 @@ extends Area3D
|
||||
|
||||
signal goal_scored(team: int)
|
||||
|
||||
# Frame tints, indexed by team.
|
||||
const TEAM_COLORS := [Color(0.2, 0.55, 1.0), Color(1.0, 0.4, 0.28)]
|
||||
|
||||
# The pocket is sunk into the end wall, so it can be at most as deep as that
|
||||
# wall is thick or it pokes out the back of the arena. ArenaBoundary cuts the
|
||||
# matching aperture in the hull (see its GOAL_APERTURE_* constants).
|
||||
@@ -52,50 +49,127 @@ func _on_body_entered(body):
|
||||
func _build_visuals() -> void:
|
||||
var mouth := ($CollisionShape3D.shape as BoxShape3D).size
|
||||
var half := Vector2(mouth.x, mouth.y) / 2.0
|
||||
var tint: Color = TEAM_COLORS[team % TEAM_COLORS.size()]
|
||||
var tint: Color = TeamColors.TEAM_COLORS[team % TeamColors.TEAM_COLORS.size()]
|
||||
|
||||
# Pocket shell, seen from the inside (hence CULL_FRONT) so it reads as a
|
||||
# void carved into the hull rather than a box stuck onto it.
|
||||
var pocket := _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9)
|
||||
pocket.cull_mode = BaseMaterial3D.CULL_FRONT
|
||||
_add_box(
|
||||
# One ArrayMesh, four surfaces (pocket, net, bezel, rim). They stay separate
|
||||
# surfaces rather than sharing materials because they aren't visually
|
||||
# interchangeable: the rim is a team-tinted emitter at a tuned energy, the
|
||||
# net carries its own discard-based shader, and the pocket/bezel differ in
|
||||
# albedo/metallic/roughness. Merging those would be a visual regression,
|
||||
# not a free draw-call win — this still takes the goal from 10 nodes/4
|
||||
# materials down to 1 node/4 materials.
|
||||
var mesh := ArrayMesh.new()
|
||||
|
||||
# Pocket shell. Built with inverted winding so it reads correctly from the
|
||||
# inside (a void carved into the hull) without needing a material-level
|
||||
# CULL_FRONT override.
|
||||
var pocket_st := SurfaceTool.new()
|
||||
pocket_st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
_add_box_to_surface(pocket_st,
|
||||
Vector3(mouth.x + POCKET_CLEARANCE * 2.0, mouth.y + POCKET_CLEARANCE * 2.0, POCKET_DEPTH),
|
||||
Vector3(0, 0, POCKET_DEPTH / 2.0), pocket)
|
||||
Vector3(0, 0, POCKET_DEPTH / 2.0), true)
|
||||
pocket_st.commit(mesh)
|
||||
mesh.surface_set_material(0, _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9))
|
||||
|
||||
# The net is the same trick: a box viewed from inside gives a five-sided
|
||||
# pocket of netting from one mesh, instead of a flat panel across the back.
|
||||
_add_box(Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88),
|
||||
Vector3(0, 0, POCKET_DEPTH / 2.0), _net_material(tint))
|
||||
# The net is the same trick, but the "seen from inside" flip is baked into
|
||||
# goal_net.gdshader's own `render_mode cull_front` (and its FRONT_FACING
|
||||
# normal flip), so this geometry must keep standard, non-inverted winding
|
||||
# to match what that shader expects.
|
||||
var net_st := SurfaceTool.new()
|
||||
net_st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
_add_box_to_surface(net_st, Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88),
|
||||
Vector3(0, 0, POCKET_DEPTH / 2.0))
|
||||
net_st.commit(mesh)
|
||||
mesh.surface_set_material(1, _net_material(tint))
|
||||
|
||||
# Bezel lines the opening (inset into the wall); the rim sits flush with
|
||||
# the wall face and carries the team colour.
|
||||
_add_frame_ring(half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0,
|
||||
_surface(Color(0.05, 0.055, 0.07), 0.75, 0.32))
|
||||
_add_frame_ring(half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0, _emissive(tint))
|
||||
var bezel_st := SurfaceTool.new()
|
||||
bezel_st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
_add_frame_ring(bezel_st, half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0)
|
||||
bezel_st.commit(mesh)
|
||||
mesh.surface_set_material(2, _surface(Color(0.05, 0.055, 0.07), 0.75, 0.32))
|
||||
|
||||
var rim_st := SurfaceTool.new()
|
||||
rim_st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
_add_frame_ring(rim_st, half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0)
|
||||
rim_st.commit(mesh)
|
||||
mesh.surface_set_material(3, _emissive(tint))
|
||||
|
||||
var visuals := MeshInstance3D.new()
|
||||
visuals.name = "Visuals"
|
||||
visuals.mesh = mesh
|
||||
visuals.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(visuals)
|
||||
|
||||
|
||||
# Four bars around the mouth. The uprights run the full outer height so each
|
||||
# corner is covered exactly once.
|
||||
func _add_frame_ring(
|
||||
half: Vector2, thickness: float, depth: float, z: float, material: Material
|
||||
st: SurfaceTool, half: Vector2, thickness: float, depth: float, z: float
|
||||
) -> void:
|
||||
for sx in [-1.0, 1.0]:
|
||||
_add_box(Vector3(thickness, (half.y + thickness) * 2.0, depth),
|
||||
Vector3(sx * (half.x + thickness / 2.0), 0.0, z), material)
|
||||
_add_box_to_surface(st, Vector3(thickness, (half.y + thickness) * 2.0, depth),
|
||||
Vector3(sx * (half.x + thickness / 2.0), 0.0, z))
|
||||
for sy in [-1.0, 1.0]:
|
||||
_add_box(Vector3(half.x * 2.0, thickness, depth),
|
||||
Vector3(0.0, sy * (half.y + thickness / 2.0), z), material)
|
||||
_add_box_to_surface(st, Vector3(half.x * 2.0, thickness, depth),
|
||||
Vector3(0.0, sy * (half.y + thickness / 2.0), z))
|
||||
|
||||
|
||||
func _add_box(size: Vector3, pos: Vector3, material: Material) -> void:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.mesh = mesh
|
||||
instance.position = pos
|
||||
instance.material_override = material
|
||||
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(instance)
|
||||
# Emits a box's 6 faces as quads into st, centered at pos. With invert, face
|
||||
# winding/normals are flipped — used for the pocket so it renders correctly
|
||||
# as seen from inside without a material-level CULL_FRONT override.
|
||||
func _add_box_to_surface(
|
||||
st: SurfaceTool, size: Vector3, pos: Vector3, invert: bool = false
|
||||
) -> void:
|
||||
var h := size / 2.0
|
||||
var n := -1.0 if invert else 1.0
|
||||
|
||||
# The 8 corners of the box, named by the sign of each axis.
|
||||
var nnn := pos + Vector3(-h.x, -h.y, -h.z)
|
||||
var nnp := pos + Vector3(-h.x, -h.y, h.z)
|
||||
var npn := pos + Vector3(-h.x, h.y, -h.z)
|
||||
var npp := pos + Vector3(-h.x, h.y, h.z)
|
||||
var pnn := pos + Vector3(h.x, -h.y, -h.z)
|
||||
var pnp := pos + Vector3(h.x, -h.y, h.z)
|
||||
var ppn := pos + Vector3(h.x, h.y, -h.z)
|
||||
var ppp := pos + Vector3(h.x, h.y, h.z)
|
||||
|
||||
_add_quad(st, pnn, Vector3(n, 0, 0), ppn, Vector3(n, 0, 0), ppp, Vector3(n, 0, 0), pnp, Vector3(n, 0, 0))
|
||||
_add_quad(st, nnn, Vector3(-n, 0, 0), nnp, Vector3(-n, 0, 0), npp, Vector3(-n, 0, 0), npn, Vector3(-n, 0, 0))
|
||||
_add_quad(st, npn, Vector3(0, n, 0), npp, Vector3(0, n, 0), ppp, Vector3(0, n, 0), ppn, Vector3(0, n, 0))
|
||||
_add_quad(st, nnn, Vector3(0, -n, 0), pnn, Vector3(0, -n, 0), pnp, Vector3(0, -n, 0), nnp, Vector3(0, -n, 0))
|
||||
_add_quad(st, nnp, Vector3(0, 0, n), pnp, Vector3(0, 0, n), ppp, Vector3(0, 0, n), npp, Vector3(0, 0, n))
|
||||
_add_quad(st, nnn, Vector3(0, 0, -n), npn, Vector3(0, 0, -n), ppn, Vector3(0, 0, -n), pnn, Vector3(0, 0, -n))
|
||||
|
||||
|
||||
# Quad a-b-c-d with per-vertex normals, wound so the front faces the normals
|
||||
# (Godot front faces wind clockwise when seen from the normal side). Copied
|
||||
# from arena_boundary.gd's _add_quad/_add_tri rather than shared, since that
|
||||
# file's geometry is collision-adjacent and not worth coupling to.
|
||||
func _add_quad(
|
||||
st: SurfaceTool,
|
||||
a: Vector3, na: Vector3, b: Vector3, nb: Vector3,
|
||||
c: Vector3, nc: Vector3, d: Vector3, nd: Vector3
|
||||
) -> void:
|
||||
if (b - a).cross(c - a).dot(na + nb + nc + nd) < 0.0:
|
||||
_add_tri(st, a, na, b, nb, c, nc)
|
||||
_add_tri(st, a, na, c, nc, d, nd)
|
||||
else:
|
||||
_add_tri(st, a, na, d, nd, c, nc)
|
||||
_add_tri(st, a, na, c, nc, b, nb)
|
||||
|
||||
|
||||
func _add_tri(
|
||||
st: SurfaceTool,
|
||||
a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3
|
||||
) -> void:
|
||||
st.set_normal(na)
|
||||
st.add_vertex(a)
|
||||
st.set_normal(nb)
|
||||
st.add_vertex(b)
|
||||
st.set_normal(nc)
|
||||
st.add_vertex(c)
|
||||
|
||||
|
||||
func _surface(albedo: Color, metallic: float, roughness: float) -> StandardMaterial3D:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name HudAttitudeIndicator
|
||||
extends Control
|
||||
extends HudInstrument
|
||||
|
||||
# Aircraft-style attitude indicator (artificial horizon): the sky/ground disc
|
||||
# rolls and the pitch ladder scrolls behind a fixed ship symbol, so pitch and
|
||||
@@ -14,8 +14,6 @@ const RING_COLOR := Color(0.5, 0.85, 1.0, 0.7)
|
||||
|
||||
# Instrument scale: degrees of pitch visible from centre to rim.
|
||||
const PITCH_RANGE := 45.0
|
||||
# Display smoothing rate (per second); high = snappy, low = floaty.
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
var _target_pitch := 0.0
|
||||
var _target_roll := 0.0
|
||||
@@ -28,15 +26,19 @@ func set_attitude(pitch_deg: float, roll_deg: float) -> void:
|
||||
_target_roll = roll_deg
|
||||
|
||||
|
||||
# Below this, both axes have visually settled — skip the redraw.
|
||||
const REDRAW_EPSILON_DEG := 0.05
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
_pitch = lerpf(_pitch, _target_pitch, t)
|
||||
_roll = lerp_angle_deg(_roll, _target_roll, t)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
||||
return rad_to_deg(lerp_angle(deg_to_rad(from), deg_to_rad(to), weight))
|
||||
var t := _smoothing_weight(delta)
|
||||
var new_pitch := lerpf(_pitch, _target_pitch, t)
|
||||
var new_roll := lerp_angle_deg(_roll, _target_roll, t)
|
||||
var changed := absf(new_pitch - _pitch) > REDRAW_EPSILON_DEG \
|
||||
or absf(angle_delta_deg(_roll, new_roll)) > REDRAW_EPSILON_DEG
|
||||
_pitch = new_pitch
|
||||
_roll = new_roll
|
||||
if changed:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name HudGauge
|
||||
extends Control
|
||||
extends HudInstrument
|
||||
|
||||
# Generic bar gauge (vertical or horizontal) with a short label and numeric
|
||||
# readout — used for speed, altitude, and thrust. A dumb display fed by
|
||||
@@ -9,8 +9,6 @@ const BG_COLOR := Color(0.05, 0.07, 0.11, 0.55)
|
||||
const FRAME_COLOR := Color(0.5, 0.85, 1.0, 0.7)
|
||||
const TEXT_COLOR := Color(0.85, 0.92, 1.0, 0.9)
|
||||
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
@export var label := "SPD"
|
||||
@export var max_value := 100.0
|
||||
@export var horizontal := false
|
||||
@@ -27,9 +25,15 @@ func set_value(value: float) -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
_value = lerpf(_value, _target, t)
|
||||
queue_redraw()
|
||||
var t := _smoothing_weight(delta)
|
||||
var new_value := lerpf(_value, _target, t)
|
||||
# Redraw threshold scales with the gauge's own range so it stays
|
||||
# imperceptible whether this is a 0-12 altitude gauge or a 0-100 thrust
|
||||
# bar, while skipping the redraw once the lerp has visually settled.
|
||||
var changed := absf(new_value - _value) > max_value * 0.001
|
||||
_value = new_value
|
||||
if changed:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name HudHeadingTape
|
||||
extends Control
|
||||
extends HudInstrument
|
||||
|
||||
# Aircraft-style heading tape: a strip of compass ticks slides under a fixed
|
||||
# centre marker, with cardinal letters so the player always knows which way
|
||||
@@ -11,7 +11,6 @@ const MARKER_COLOR := Color(1.0, 0.8, 0.2)
|
||||
|
||||
# Degrees of heading visible across the full tape width.
|
||||
const VISIBLE_RANGE := 90.0
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
const CARDINALS := {0: "N", 45: "NE", 90: "E", 135: "SE", 180: "S", 225: "SW", 270: "W", 315: "NW"}
|
||||
|
||||
@@ -27,10 +26,16 @@ func set_heading(heading_deg: float) -> void:
|
||||
_target_heading = heading_deg
|
||||
|
||||
|
||||
# Below this, the tape has visually settled — skip the redraw.
|
||||
const REDRAW_EPSILON_DEG := 0.05
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
_heading = fmod(HudAttitudeIndicator.lerp_angle_deg(_heading, _target_heading, t) + 360.0, 360.0)
|
||||
queue_redraw()
|
||||
var t := _smoothing_weight(delta)
|
||||
var new_heading := fmod(lerp_angle_deg(_heading, _target_heading, t) + 360.0, 360.0)
|
||||
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
|
||||
_heading = new_heading
|
||||
if changed:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class_name HudInstrument
|
||||
extends Control
|
||||
|
||||
# Shared base for the HUD's procedural instruments (heading tape, attitude
|
||||
# indicator, gauges): the exponential-smoothing weight calc and the
|
||||
# angle-aware lerp helper they all use. Each instrument still owns its own
|
||||
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
|
||||
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
|
||||
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
||||
return rad_to_deg(lerp_angle(deg_to_rad(from), deg_to_rad(to), weight))
|
||||
|
||||
|
||||
# Shortest signed distance from `from` to `to` in degrees, wrapped to
|
||||
# [-180, 180]. Use this (never a plain subtraction) to decide whether an
|
||||
# angle that wraps around (heading, roll) has moved enough to redraw —
|
||||
# a naive delta gives a false-large jump at the 0/360 boundary.
|
||||
static func angle_delta_deg(from: float, to: float) -> float:
|
||||
return wrapf(to - from, -180.0, 180.0)
|
||||
|
||||
|
||||
func _smoothing_weight(delta: float) -> float:
|
||||
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfjncr0nnakun
|
||||
@@ -11,6 +11,10 @@ extends Control
|
||||
|
||||
const BOTS_DIR := "res://bots"
|
||||
|
||||
# All three tiers deliberately point at the one trained checkpoint
|
||||
# (easy.json) and differ only by reaction_ticks/action_noise handicap, not by
|
||||
# model skill. Superseded once real medium/hard checkpoints are promoted into
|
||||
# res://bots/promoted/ — see the "AI opponent" section of TODO.md.
|
||||
const DIFFICULTIES := [
|
||||
{"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 24, "action_noise": 0.35},
|
||||
{"name": "Medium", "model": "res://bots/promoted/easy.json", "reaction_ticks": 14, "action_noise": 0.15},
|
||||
|
||||
+20
-15
@@ -27,9 +27,10 @@ const KICKOFF_COUNTDOWN_SECONDS := 3
|
||||
@export_range(1, 60) var bot_reaction_ticks: int = 8
|
||||
@export_range(0.0, 1.0) var bot_action_noise: float = 0.0
|
||||
|
||||
var score := {0: 0, 1: 0}
|
||||
var match_timer: Timer
|
||||
var _in_overtime := false
|
||||
var _match_over := false
|
||||
var _last_timer_remaining := -1
|
||||
|
||||
|
||||
func _get_arena_scene_path() -> String:
|
||||
@@ -56,28 +57,25 @@ func _make_opponent_controller() -> ShipController:
|
||||
# The main menu's dropdown selection (GameSettings autoload) wins over the
|
||||
# scene's export, which stays as the fallback for direct-scene runs.
|
||||
var path := bot_model_path if GameSettings.selected_bot_path.is_empty() else GameSettings.selected_bot_path
|
||||
if not path.is_empty() and FileAccess.file_exists(path):
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = path
|
||||
bot.reaction_ticks = bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
|
||||
bot.action_noise = bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise
|
||||
return bot
|
||||
if not path.is_empty():
|
||||
push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % path)
|
||||
return ShipController.new() # inert placeholder
|
||||
var reaction_ticks := bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
|
||||
var action_noise := bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise
|
||||
return _build_opponent(path, reaction_ticks, action_noise, "MatchMode")
|
||||
|
||||
|
||||
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)
|
||||
if remaining != _last_timer_remaining:
|
||||
_last_timer_remaining = remaining
|
||||
timer_updated.emit(floori(remaining / 60.0), remaining % 60)
|
||||
|
||||
|
||||
func _on_goal_scored(conceding_team: int) -> void:
|
||||
if _match_over:
|
||||
return
|
||||
var scoring_team := 1 - conceding_team
|
||||
score[scoring_team] += 1
|
||||
_record_goal(scoring_team)
|
||||
score_changed.emit(score.duplicate())
|
||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||
if _in_overtime:
|
||||
# Golden goal: the first score after a draw ends the match outright.
|
||||
_end_match(scoring_team)
|
||||
@@ -90,6 +88,8 @@ func _on_goal_scored(conceding_team: int) -> void:
|
||||
# changes to ship.gd/ball.gd/ship_controller.gd, keeping training_mode.gd
|
||||
# (which never calls into this file) completely untouched.
|
||||
func _run_kickoff_countdown() -> void:
|
||||
if _match_over:
|
||||
return
|
||||
reset_ball()
|
||||
reset_ships()
|
||||
_set_frozen(true)
|
||||
@@ -98,8 +98,12 @@ func _run_kickoff_countdown() -> void:
|
||||
# process_always=false: if full-time fires mid-countdown (see
|
||||
# _on_match_timer_timeout's get_tree().paused = true), this stalls
|
||||
# harmlessly in lockstep with the pause instead of ticking a
|
||||
# countdown label over the results screen.
|
||||
# countdown label over the results screen. _end_match unpauses the
|
||||
# tree before the scene teardown actually lands, so the _match_over
|
||||
# check below is what stops this from resuming into a dying scene.
|
||||
await get_tree().create_timer(1.0, false).timeout
|
||||
if _match_over:
|
||||
return
|
||||
kickoff_countdown.emit(0)
|
||||
_set_frozen(false)
|
||||
|
||||
@@ -129,6 +133,7 @@ func _start_overtime() -> void:
|
||||
|
||||
|
||||
func _end_match(winning_team: int) -> void:
|
||||
_match_over = true
|
||||
print("Match over! Final score: %d - %d" % [score[0], score[1]])
|
||||
match_ended.emit(winning_team, score.duplicate())
|
||||
# Freeze gameplay while the HUD (process_mode ALWAYS) shows the result.
|
||||
@@ -136,4 +141,4 @@ func _end_match(winning_team: int) -> void:
|
||||
await get_tree().create_timer(RESULT_SCREEN_SECONDS).timeout
|
||||
# Unpause before leaving, or the menu arrives paused and unresponsive.
|
||||
get_tree().paused = false
|
||||
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
@@ -4,46 +4,37 @@ extends ShipController
|
||||
# Drives a Ship from the local player's input actions (see project.godot
|
||||
# [input] and FLIGHT_MANUAL.md).
|
||||
|
||||
var _action := ShipAction.new()
|
||||
|
||||
|
||||
func get_action() -> ShipAction:
|
||||
var action := ShipAction.new()
|
||||
# Full overwrite per axis (not +=/-=): _action is reused across ticks, so
|
||||
# fields must not depend on starting from a fresh Vector3.ZERO each call.
|
||||
|
||||
# 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
|
||||
_action.thrust.z = (1.0 if Input.is_action_pressed("move_forward") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_back") else 0.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
|
||||
_action.thrust.x = (1.0 if Input.is_action_pressed("move_right") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_left") else 0.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
|
||||
_action.thrust.y = (1.0 if Input.is_action_pressed("move_up") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_down") else 0.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
|
||||
_action.rotation.y = (1.0 if Input.is_action_pressed("turn_left") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("turn_right") else 0.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
|
||||
_action.rotation.x = (1.0 if Input.is_action_pressed("pitch_down") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("pitch_up") else 0.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.rotation.z = (1.0 if Input.is_action_pressed("roll_left") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("roll_right") else 0.0)
|
||||
|
||||
action.turbo = Input.is_action_pressed("turbo")
|
||||
_action.turbo = Input.is_action_pressed("turbo")
|
||||
|
||||
return action
|
||||
return _action
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
class_name ScenePaths
|
||||
|
||||
const MAIN_MENU := "res://scenes/main_menu.tscn"
|
||||
@@ -0,0 +1 @@
|
||||
uid://5msygpcnvgfd
|
||||
@@ -6,8 +6,6 @@ extends Control
|
||||
# apply the next time an arena loads (see arena.gd), since they scale each
|
||||
# arena's own tuned Environment values rather than something viewport-wide.
|
||||
|
||||
const MAIN_MENU_SCENE_PATH := "res://scenes/main_menu.tscn"
|
||||
|
||||
const AA_OPTIONS := [
|
||||
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
|
||||
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
|
||||
@@ -62,7 +60,7 @@ func _on_brightness_slider_value_changed(value: float) -> void:
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
VideoSettings.save()
|
||||
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
+94
-16
@@ -25,12 +25,31 @@ extends RigidBody3D
|
||||
@export var ceiling_pull_strength = 11.5 # Ceiling grav-plating strength; nets above gravity so a ship can hold a ceiling
|
||||
@export var ceiling_pull_range = 3.0 # Metres from the ceiling where pull begins
|
||||
|
||||
# Accent colours per team, applied to the nose and tail fin meshes so the
|
||||
# two sides are tellable apart at a glance.
|
||||
const TEAM_COLORS := {
|
||||
0: Color(0.25, 0.55, 1.0),
|
||||
1: Color(1.0, 0.5, 0.15),
|
||||
}
|
||||
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
|
||||
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
|
||||
# _apply_team_color() retints them per-team and must keep addressing them by
|
||||
# name). Verified via get_surface_count()/surface_get_material() before
|
||||
# writing this: hull and canopy are each a single surface with their own
|
||||
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
|
||||
# the name), and engine_l/engine_r are each 2 surfaces, also all distinct
|
||||
# materials — none of the 6 source surfaces share a material with any other,
|
||||
# including the L/R engine pair. So this merge does not collapse draw calls
|
||||
# the way TODO.md's "6 draw calls down to 3" assumed (Godot still issues one
|
||||
# draw call per surface regardless of how many MeshInstance3Ds they're spread
|
||||
# across); the real win is scene-tree node count, 4 MeshInstance3D children
|
||||
# down to 1, cutting per-frame transform/visibility overhead.
|
||||
const MERGED_MESH_PATHS := [
|
||||
"res://assets/models/ship_hull.res",
|
||||
"res://assets/models/ship_canopy.res",
|
||||
"res://assets/models/ship_engine_l.res",
|
||||
"res://assets/models/ship_engine_r.res",
|
||||
]
|
||||
const MERGED_MESH_TRANSFORMS := [
|
||||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0, 0, 0)), # Hull
|
||||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0, 0.31, -0.55)), # Canopy
|
||||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(-0.42, -0.05, 0.95)), # EngineGlowL
|
||||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0.42, -0.05, 0.95)), # EngineGlowR
|
||||
]
|
||||
|
||||
# Which team this ship plays for (0 or 1). Set by the game mode on spawn.
|
||||
var team: int = 0:
|
||||
@@ -38,8 +57,29 @@ var team: int = 0:
|
||||
team = value
|
||||
_apply_team_color()
|
||||
|
||||
# Shared per-team accent material, built once per team and reused by every
|
||||
# ship — avoids allocating a fresh StandardMaterial3D from both _ready and
|
||||
# the team setter (previously ran at least twice per ship).
|
||||
static var _team_materials: Dictionary = {} # team:int -> StandardMaterial3D
|
||||
|
||||
|
||||
static func _get_team_material(team: int) -> StandardMaterial3D:
|
||||
if _team_materials.has(team):
|
||||
return _team_materials[team]
|
||||
var color: Color = TeamColors.TEAM_COLORS.get(team, TeamColors.TEAM_COLORS[0])
|
||||
var accent := StandardMaterial3D.new()
|
||||
accent.albedo_color = color
|
||||
accent.metallic = 0.3
|
||||
accent.roughness = 0.5
|
||||
accent.emission_enabled = true
|
||||
accent.emission = color
|
||||
accent.emission_energy_multiplier = 0.35
|
||||
_team_materials[team] = accent
|
||||
return accent
|
||||
|
||||
var controller: ShipController
|
||||
var _current_action: ShipAction = ShipAction.new()
|
||||
var _inert_action: ShipAction = ShipAction.new()
|
||||
var _boundary: ArenaBoundary
|
||||
|
||||
# Instrument signals for efficient data distribution
|
||||
@@ -83,24 +123,51 @@ func _ready():
|
||||
|
||||
_boundary = get_tree().get_first_node_in_group("arena_boundary")
|
||||
|
||||
# Telemetry emission and the merged-hull visual mesh are both render-only
|
||||
# work; headless (RL/CI) instances never render or have a HUD watching
|
||||
# them, so skip both rather than relying on later no-ops.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
set_physics_process(false)
|
||||
else:
|
||||
_build_merged_hull()
|
||||
|
||||
|
||||
func _apply_team_color() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var color: Color = TEAM_COLORS.get(team, TEAM_COLORS[0])
|
||||
var accent := StandardMaterial3D.new()
|
||||
accent.albedo_color = color
|
||||
accent.metallic = 0.3
|
||||
accent.roughness = 0.5
|
||||
accent.emission_enabled = true
|
||||
accent.emission = color
|
||||
accent.emission_energy_multiplier = 0.35
|
||||
var accent := _get_team_material(team)
|
||||
for mesh_name in ["Nose", "TailFin"]:
|
||||
var mesh := get_node_or_null(mesh_name) as MeshInstance3D
|
||||
if mesh:
|
||||
mesh.material_override = accent
|
||||
|
||||
|
||||
# Runtime-bakes Hull/Canopy/EngineGlowL/EngineGlowR (see MERGED_MESH_PATHS
|
||||
# comment above) into one ArrayMesh, one destination surface per source
|
||||
# surface via SurfaceTool.append_from, each keeping its own original
|
||||
# material — preserves current visuals exactly regardless of surface count.
|
||||
# Mirrors the runtime-bake pattern already used by goal.gd/arena_boundary.gd.
|
||||
# Skipped in headless mode (see _ready): purely visual, costs nothing
|
||||
# physics/RL cares about.
|
||||
func _build_merged_hull() -> void:
|
||||
var mesh := ArrayMesh.new()
|
||||
var dest_idx := 0
|
||||
for i in MERGED_MESH_PATHS.size():
|
||||
var src: ArrayMesh = load(MERGED_MESH_PATHS[i])
|
||||
var xform: Transform3D = MERGED_MESH_TRANSFORMS[i]
|
||||
for surf in src.get_surface_count():
|
||||
var st := SurfaceTool.new()
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
st.append_from(src, surf, xform)
|
||||
st.commit(mesh)
|
||||
mesh.surface_set_material(dest_idx, src.surface_get_material(surf))
|
||||
dest_idx += 1
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.name = "MergedHull"
|
||||
instance.mesh = mesh
|
||||
add_child(instance)
|
||||
|
||||
|
||||
# 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:
|
||||
@@ -112,12 +179,23 @@ func set_controller(new_controller: ShipController) -> void:
|
||||
|
||||
|
||||
func _physics_process(_delta):
|
||||
_emit_telemetry_data()
|
||||
if _has_telemetry_listeners():
|
||||
_emit_telemetry_data()
|
||||
|
||||
|
||||
# Covers a second/AI ship with no HUD watching it - headless mode is already
|
||||
# handled by disabling _physics_process entirely in _ready.
|
||||
func _has_telemetry_listeners() -> bool:
|
||||
return speed_changed.get_connections().size() > 0 \
|
||||
or altitude_changed.get_connections().size() > 0 \
|
||||
or attitude_changed.get_connections().size() > 0 \
|
||||
or heading_changed.get_connections().size() > 0 \
|
||||
or thrust_changed.get_connections().size() > 0
|
||||
|
||||
|
||||
func _integrate_forces(state):
|
||||
# One action per physics tick, pulled from the controller (deterministic)
|
||||
_current_action = controller.get_action() if controller else ShipAction.new()
|
||||
_current_action = controller.get_action() if controller else _inert_action
|
||||
|
||||
# === TRANSLATION (Movement) ===
|
||||
apply_thruster_forces(state, _current_action)
|
||||
|
||||
@@ -16,9 +16,6 @@ signal score_changed(score: Dictionary)
|
||||
@export_range(1, 60) var bot_b_reaction_ticks: int = 8
|
||||
@export_range(0.0, 1.0) var bot_b_action_noise: float = 0.0
|
||||
|
||||
var score := {0: 0, 1: 0}
|
||||
|
||||
|
||||
func _get_arena_scene_path() -> String:
|
||||
return ArenaRegistry.random_path()
|
||||
|
||||
@@ -29,28 +26,15 @@ func _start() -> void:
|
||||
# scene's exports, which stay as the fallback for direct-scene runs.
|
||||
var path_a := bot_a_model_path if GameSettings.spectate_bot_a_path.is_empty() else GameSettings.spectate_bot_a_path
|
||||
var path_b := bot_b_model_path if GameSettings.spectate_bot_b_path.is_empty() else GameSettings.spectate_bot_b_path
|
||||
var ship_a := spawn_ship(0, 0, _make_bot(path_a, bot_a_reaction_ticks, bot_a_action_noise))
|
||||
spawn_ship(1, 0, _make_bot(path_b, bot_b_reaction_ticks, bot_b_action_noise))
|
||||
var ship_a := spawn_ship(0, 0, _build_opponent(path_a, bot_a_reaction_ticks, bot_a_action_noise, "SpectateMode"))
|
||||
spawn_ship(1, 0, _build_opponent(path_b, bot_b_reaction_ticks, bot_b_action_noise, "SpectateMode"))
|
||||
spawn_camera_rig(ship_a)
|
||||
|
||||
|
||||
func _make_bot(model_path: String, reaction_ticks: int, action_noise: float) -> ShipController:
|
||||
if not model_path.is_empty() and FileAccess.file_exists(model_path):
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = model_path
|
||||
bot.reaction_ticks = reaction_ticks
|
||||
bot.action_noise = action_noise
|
||||
return bot
|
||||
if not model_path.is_empty():
|
||||
push_warning("SpectateMode: bot model not found at %s, spawning inert ship" % model_path)
|
||||
return ShipController.new() # inert placeholder
|
||||
|
||||
|
||||
func _on_goal_scored(conceding_team: int) -> void:
|
||||
var scoring_team := 1 - conceding_team
|
||||
score[scoring_team] += 1
|
||||
_record_goal(scoring_team)
|
||||
score_changed.emit(score.duplicate())
|
||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||
reset_ball()
|
||||
reset_ships()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class_name TeamColors
|
||||
|
||||
# Single source of truth for team identity. Ship nose/tailfin, HUD result
|
||||
# panel, goal rim/net, and the arena end-zone floor shader all read from
|
||||
# here — do not restate these values locally (see TODO.md history).
|
||||
const TEAM_NAMES := {0: "Purple", 1: "Green"}
|
||||
const TEAM_COLORS := {
|
||||
0: Color(0.48, 0.18, 0.88),
|
||||
1: Color(0.24, 0.86, 0.42),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6r8q1dpd8klq
|
||||
@@ -21,8 +21,12 @@ uniform vec3 panel_color : source_color = vec3(0.104, 0.116, 0.146);
|
||||
uniform float hull_fill : hint_range(0.0, 2.0) = 0.55;
|
||||
uniform float panel_variation : hint_range(0.0, 1.0) = 0.4;
|
||||
uniform vec3 line_color : source_color = vec3(0.62, 0.78, 1.0);
|
||||
uniform vec3 team0_color : source_color = vec3(0.15, 0.45, 1.0);
|
||||
uniform vec3 team1_color : source_color = vec3(1.0, 0.35, 0.25);
|
||||
// Must track TeamColors.TEAM_COLORS in scripts/team_colors.gd — shaders can't
|
||||
// import GDScript consts, and arena_boundary.gd always overrides these at
|
||||
// runtime, but keep the literal defaults in sync for anyone inspecting the
|
||||
// shader/material directly.
|
||||
uniform vec3 team0_color : source_color = vec3(0.48, 0.18, 0.88);
|
||||
uniform vec3 team1_color : source_color = vec3(0.24, 0.86, 0.42);
|
||||
uniform vec3 seam_color : source_color = vec3(0.35, 0.75, 1.0);
|
||||
|
||||
// Play-volume dimensions, from ArenaBoundary's constants.
|
||||
|
||||
@@ -14,33 +14,11 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
|
||||
|
||||
Bugs found in an adversarial review. None are gameplay- or physics-affecting, so all are safe to land against the current `Game/bots/` checkpoints.
|
||||
|
||||
- [ ] `VideoSettings.apply_to_environment()` (`scripts/video_settings.gd`) compounds on every arena load: `env.glow_intensity *= glow_scale` mutates an `Environment` that is a `[sub_resource]` of the arena scene, and Godot shares sub-resources across instantiations of a cached `PackedScene`. Glow at 50% becomes 25% then 12.5% across repeat entries. Fix by duplicating the Environment in `Arena._ready()` (`scripts/arena.gd`). Regression test: set glow to 50%, enter/leave Free Play three times, confirm it's still 50%.
|
||||
- [ ] Collapse the four disagreeing team palettes into one source of truth — `ship.gd`, `HUDController.gd` and `goal.gd` each declare `TEAM_COLORS`, `arena_boundary.gd` exports `team0_tint`/`team1_tint`, and `arena_deck.gdshader` defaults to a fifth pair. Three of them disagree, so nose, goal rim, end zone and scoreboard are all different blues.
|
||||
- [ ] Goal scoring volume (3.5 x 1.5, `objects/goal.tscn`) is smaller than the drawn mouth (3.7 x 1.65, `ArenaBoundary.GOAL_APERTURE_*`) — a ball crossing the visible edge doesn't score. Derive the aperture constants from the goal's collision shape, the way `goal.gd:53` already measures its own visuals.
|
||||
- [ ] `match_mode.gd`: full time can fire mid-kickoff-countdown, and the stalled coroutine resumes into the dying scene (can re-emit `kickoff_countdown` / unfreeze bodies for a frame). Guard `_run_kickoff_countdown` with a match-over flag.
|
||||
- [ ] `match_mode.gd` emits `timer_updated` every frame for a value that changes once a second; the HUD re-formats and re-shapes the label each time. Emit only on change, matching `ship.gd`'s threshold-gated telemetry discipline.
|
||||
- [ ] `HUDController` binds to `get_first_node_in_group("ship")` in a group that always has 2+ members — works only because the player ship happens to spawn first. Have the game mode hand the HUD its target ship.
|
||||
- [ ] Reuse a member `ShipAction` in `ship.gd` (controllerless path) and `player_ship_controller.gd` instead of allocating one per physics tick; `ai_ship_controller.gd` already does this correctly.
|
||||
- [ ] Delete the duplicate 1 MB texture — `assets/textures/planet_surface.png` and `assets/models/nebula_planet_planet_surface.png` are byte-identical.
|
||||
|
||||
## DRY / structure
|
||||
|
||||
- [ ] `arena_base.tscn` + inherited themes. `arena_01/02/03.tscn` each restate ~40 identical lines (both lights, the reflection probe, goal transforms, ball/ship spawn markers); only sky, ambient, three glow numbers, tint and decoration differ. The `_elevated` variants already prove the inherited-scene pattern works here — `arena_01_elevated.tscn` is 20 lines to `arena_01.tscn`'s 100. Unblocks cheap new arenas.
|
||||
- [ ] Hoist bot construction into `GameMode` — `match_mode._make_opponent_controller` and `spectate_mode._make_bot` are the same function (same existence check, same three fields, same warning + inert fallback).
|
||||
- [ ] Hoist score-keeping into `GameMode` — both modes declare `score := {0:0, 1:0}`, a `score_changed` signal, and the identical increment/emit/print block.
|
||||
- [ ] `HudInstrument extends Control` base for the three HUD widgets: each repeats `const SMOOTHING := 12.0` and the same `1.0 - exp(-SMOOTHING * delta)` lerp/redraw `_process`, and `hud_heading_tape.gd` reaches across to a static angle helper parked on `HudAttitudeIndicator`.
|
||||
- [ ] `MAIN_MENU_SCENE_PATH` is declared in both `game_mode.gd` and `settings_menu.gd`.
|
||||
- [ ] Comment `main_menu.gd`'s `DIFFICULTIES` to say the tiers deliberately share `easy.json` and differ only by handicap (superseded once real `medium`/`hard` tiers land — see the AI section above).
|
||||
|
||||
## Performance
|
||||
|
||||
- [ ] Gate `Ship._emit_telemetry_data()` — it runs `get_euler()` + trig per ship per physics tick for every ship, including AI ships nobody displays and `--headless` training where no HUD exists. Gate on having signal connections, and `set_physics_process(false)` when headless (`arena_boundary.gd` already does this correctly for its `_process`).
|
||||
- [ ] Stop the HUD instruments redrawing once settled: all five `queue_redraw()` every frame forever, re-recording canvas items with `draw_string` glyph work, and `HUD.tscn` sets `process_mode = 3` so it continues while paused.
|
||||
- [ ] Shared per-team materials instead of `Ship._apply_team_color()` allocating a fresh `StandardMaterial3D` and assigning it as `material_override` (and running at least twice per ship — once from `_ready`, once from the `team` setter). Removes the allocation and lets same-team ships batch.
|
||||
- [ ] Merge each goal's visuals into one `ArrayMesh` with a hull surface and a net surface — currently 11 `MeshInstance3D`s and 4 materials per goal, ~22 draw calls for a static prop. `arena_boundary.gd:333` already demonstrates the `SurfaceTool` technique in this codebase.
|
||||
- [ ] Merge the four non-tinted ship meshes (Hull/Canopy/EngineGlowL/R) into one — 6 draw calls per ship down to 3. Irrelevant at 1v1; 36 calls before VFX at 3v3.
|
||||
- [ ] Cache the camera in `ArenaBoundary._process` instead of a `get_viewport().get_camera_3d()` tree lookup every frame (`ship_camera.gd` already caches the ball this way).
|
||||
- [ ] Name collision layers in `project.godot` and assign them — nothing configures `collision_layer`/`collision_mask` today, so every body tests against every other.
|
||||
- [ ] Measure `nebula_dust.gdshader`'s per-fragment depth-texture sample across 500 large soft billboards before adding more particle work.
|
||||
- [ ] Bake `ArenaBoundary`'s ~160 runtime-generated `CollisionShape3D` nodes into the scene. Costs a load hitch on every arena entry and repeats in every parallel headless training env. **Blocked on the trained-bot decision below** — `arena_boundary.gd:235` notes this geometry is what the shipped policies were fitted against, so the bake must be verified byte-identical.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user