mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-17 07:42:02 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 401d882ff0 | |||
| 9bdeb73fa7 | |||
| 3049c42867 | |||
| 18fdb0f232 | |||
| a02e0770af | |||
| 661c588fef | |||
| 0f7603d3cb |
Binary file not shown.
|
Before Width: | Height: | Size: 985 KiB |
@@ -1,40 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="texture"
|
|
||||||
type="CompressedTexture2D"
|
|
||||||
uid="uid://bxsadfd04v6r2"
|
|
||||||
path="res://.godot/imported/planet_surface.png-d3aaf8310508e4c85b26a3d093bcf9c8.ctex"
|
|
||||||
metadata={
|
|
||||||
"vram_texture": false
|
|
||||||
}
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/textures/planet_surface.png"
|
|
||||||
dest_files=["res://.godot/imported/planet_surface.png-d3aaf8310508e4c85b26a3d093bcf9c8.ctex"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
compress/mode=0
|
|
||||||
compress/high_quality=false
|
|
||||||
compress/lossy_quality=0.7
|
|
||||||
compress/uastc_level=0
|
|
||||||
compress/rdo_quality_loss=0.0
|
|
||||||
compress/hdr_compression=1
|
|
||||||
compress/normal_map=0
|
|
||||||
compress/channel_pack=0
|
|
||||||
mipmaps/generate=false
|
|
||||||
mipmaps/limit=-1
|
|
||||||
roughness/mode=0
|
|
||||||
roughness/src_normal=""
|
|
||||||
process/channel_remap/red=0
|
|
||||||
process/channel_remap/green=1
|
|
||||||
process/channel_remap/blue=2
|
|
||||||
process/channel_remap/alpha=3
|
|
||||||
process/fix_alpha_border=true
|
|
||||||
process/premult_alpha=false
|
|
||||||
process/normal_map_invert_y=false
|
|
||||||
process/hdr_as_srgb=false
|
|
||||||
process/hdr_clamp_exposure=false
|
|
||||||
process/size_limit=0
|
|
||||||
detect_3d/compress_to=1
|
|
||||||
+1187
-21
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,13 @@
|
|||||||
goal_mode = 1
|
goal_mode = 1
|
||||||
|
|
||||||
[node name="GoalTeam0" parent="." index="5"]
|
[node name="GoalTeam0" parent="." index="5"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.0, 27)
|
||||||
|
|
||||||
[node name="GoalTeam1" parent="." index="6"]
|
[node name="GoalTeam1" parent="." index="6"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 9.0, -27)
|
||||||
|
|
||||||
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
|
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.0, 2.8, 20.25)
|
||||||
|
|
||||||
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
|
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -6.0, 2.8, -20.25)
|
||||||
|
|||||||
+12
-12
@@ -32,7 +32,7 @@ gradient = SubResource("Gradient_dust")
|
|||||||
|
|
||||||
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_dust"]
|
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_dust"]
|
||||||
emission_shape = 3
|
emission_shape = 3
|
||||||
emission_box_extents = Vector3(26, 11, 28)
|
emission_box_extents = Vector3(39, 16.5, 42)
|
||||||
direction = Vector3(0, 1, 0)
|
direction = Vector3(0, 1, 0)
|
||||||
spread = 180.0
|
spread = 180.0
|
||||||
initial_velocity_min = 0.1
|
initial_velocity_min = 0.1
|
||||||
@@ -68,48 +68,48 @@ light_color = Color(0.55, 0.35, 0.65, 1)
|
|||||||
light_energy = 0.45
|
light_energy = 0.45
|
||||||
|
|
||||||
[node name="NebulaDust" type="GPUParticles3D" parent="."]
|
[node name="NebulaDust" type="GPUParticles3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 7.5, 0)
|
||||||
amount = 500
|
amount = 500
|
||||||
lifetime = 22.0
|
lifetime = 22.0
|
||||||
preprocess = 8.0
|
preprocess = 8.0
|
||||||
randomness = 1.0
|
randomness = 1.0
|
||||||
visibility_aabb = AABB(-30, -14, -32, 60, 28, 64)
|
visibility_aabb = AABB(-45, -21, -48, 90, 42, 96)
|
||||||
process_material = SubResource("ParticleProcessMaterial_dust")
|
process_material = SubResource("ParticleProcessMaterial_dust")
|
||||||
draw_pass_1 = SubResource("QuadMesh_dust")
|
draw_pass_1 = SubResource("QuadMesh_dust")
|
||||||
|
|
||||||
[node name="Decoration" type="Node3D" parent="."]
|
[node name="Decoration" type="Node3D" parent="."]
|
||||||
|
|
||||||
[node name="NebulaStationA" parent="Decoration" instance=ExtResource("8_station")]
|
[node name="NebulaStationA" parent="Decoration" instance=ExtResource("8_station")]
|
||||||
transform = Transform3D(1.8, 0, 0, 0, 1.8, 0, 0, 0, 1.8, 28, 5, -6)
|
transform = Transform3D(1.8, 0, 0, 0, 1.8, 0, 0, 0, 1.8, 42, 7.5, -9)
|
||||||
rotation = Vector3(0, -0.436332, 0)
|
rotation = Vector3(0, -0.436332, 0)
|
||||||
|
|
||||||
[node name="NebulaDebrisA" parent="Decoration" instance=ExtResource("9_debris")]
|
[node name="NebulaDebrisA" parent="Decoration" instance=ExtResource("9_debris")]
|
||||||
transform = Transform3D(1.2, 0, 0, 0, 1.2, 0, 0, 0, 1.2, -22, 9, 11)
|
transform = Transform3D(1.2, 0, 0, 0, 1.2, 0, 0, 0, 1.2, -33, 13.5, 16.5)
|
||||||
rotation = Vector3(0.261799, 0.698132, 0.087266)
|
rotation = Vector3(0.261799, 0.698132, 0.087266)
|
||||||
|
|
||||||
[node name="NebulaDebrisB" parent="Decoration" instance=ExtResource("9_debris")]
|
[node name="NebulaDebrisB" parent="Decoration" instance=ExtResource("9_debris")]
|
||||||
transform = Transform3D(0.8, 0, 0, 0, 0.8, 0, 0, 0, 0.8, 34, 3, 15)
|
transform = Transform3D(0.8, 0, 0, 0, 0.8, 0, 0, 0, 0.8, 51, 4.5, 22.5)
|
||||||
rotation = Vector3(-0.174533, 1.919862, 0.349066)
|
rotation = Vector3(-0.174533, 1.919862, 0.349066)
|
||||||
|
|
||||||
[node name="NebulaDebrisC" parent="Decoration" instance=ExtResource("9_debris")]
|
[node name="NebulaDebrisC" parent="Decoration" instance=ExtResource("9_debris")]
|
||||||
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -30, 11, -21)
|
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -45, 16.5, -31.5)
|
||||||
rotation = Vector3(0.087266, -1.047198, -0.261799)
|
rotation = Vector3(0.087266, -1.047198, -0.261799)
|
||||||
|
|
||||||
[node name="NebulaStationB" parent="Decoration" instance=ExtResource("8_station")]
|
[node name="NebulaStationB" parent="Decoration" instance=ExtResource("8_station")]
|
||||||
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -32, 7, 9)
|
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -48, 10.5, 13.5)
|
||||||
rotation = Vector3(0.05, 1.134464, -0.02)
|
rotation = Vector3(0.05, 1.134464, -0.02)
|
||||||
|
|
||||||
[node name="NebulaStationC" parent="Decoration" instance=ExtResource("8_station")]
|
[node name="NebulaStationC" parent="Decoration" instance=ExtResource("8_station")]
|
||||||
transform = Transform3D(1.3, 0, 0, 0, 1.3, 0, 0, 0, 1.3, 9, 8, 42)
|
transform = Transform3D(1.3, 0, 0, 0, 1.3, 0, 0, 0, 1.3, 13.5, 12, 63)
|
||||||
rotation = Vector3(-0.03, 2.740167, 0.06)
|
rotation = Vector3(-0.03, 2.740167, 0.06)
|
||||||
|
|
||||||
[node name="NebulaDebrisD" parent="Decoration" instance=ExtResource("9_debris")]
|
[node name="NebulaDebrisD" parent="Decoration" instance=ExtResource("9_debris")]
|
||||||
transform = Transform3D(1.0, 0, 0, 0, 1.0, 0, 0, 0, 1.0, 13, 15, -35)
|
transform = Transform3D(1.0, 0, 0, 0, 1.0, 0, 0, 0, 1.0, 19.5, 22.5, -52.5)
|
||||||
rotation = Vector3(0.4, 1.3, -0.2)
|
rotation = Vector3(0.4, 1.3, -0.2)
|
||||||
|
|
||||||
[node name="NebulaDebrisE" parent="Decoration" instance=ExtResource("9_debris")]
|
[node name="NebulaDebrisE" parent="Decoration" instance=ExtResource("9_debris")]
|
||||||
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -11, 4, 33)
|
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -16.5, 6, 49.5)
|
||||||
rotation = Vector3(-0.3, -0.9, 0.15)
|
rotation = Vector3(-0.3, -0.9, 0.15)
|
||||||
|
|
||||||
[node name="NebulaPlanet" parent="Decoration" instance=ExtResource("11_planet")]
|
[node name="NebulaPlanet" parent="Decoration" instance=ExtResource("11_planet")]
|
||||||
transform = Transform3D(30, 0, 0, 0, 30, 0, 0, 0, 30, -55, 38, -80)
|
transform = Transform3D(30, 0, 0, 0, 30, 0, 0, 0, 30, -82.5, 57, -120)
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
goal_mode = 1
|
goal_mode = 1
|
||||||
|
|
||||||
[node name="GoalTeam0" parent="." index="5"]
|
[node name="GoalTeam0" parent="." index="5"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.0, 27)
|
||||||
|
|
||||||
[node name="GoalTeam1" parent="." index="6"]
|
[node name="GoalTeam1" parent="." index="6"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 9.0, -27)
|
||||||
|
|
||||||
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
|
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.0, 2.8, 20.25)
|
||||||
|
|
||||||
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
|
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -6.0, 2.8, -20.25)
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
goal_mode = 1
|
goal_mode = 1
|
||||||
|
|
||||||
[node name="GoalTeam0" parent="." index="5"]
|
[node name="GoalTeam0" parent="." index="5"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.0, 27)
|
||||||
|
|
||||||
[node name="GoalTeam1" parent="." index="6"]
|
[node name="GoalTeam1" parent="." index="6"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 9.0, -27)
|
||||||
|
|
||||||
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
|
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.0, 2.8, 20.25)
|
||||||
|
|
||||||
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
|
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -6.0, 2.8, -20.25)
|
||||||
|
|||||||
@@ -53,16 +53,16 @@ shadow_enabled = false
|
|||||||
environment = SubResource("Environment_base")
|
environment = SubResource("Environment_base")
|
||||||
|
|
||||||
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9, 0)
|
||||||
size = Vector3(26, 14, 40)
|
size = Vector3(38, 20, 58)
|
||||||
box_projection = true
|
box_projection = true
|
||||||
update_mode = 0
|
update_mode = 0
|
||||||
|
|
||||||
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
|
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 27)
|
||||||
|
|
||||||
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
|
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -27)
|
||||||
team = 1
|
team = 1
|
||||||
|
|
||||||
[node name="BallSpawn" type="Marker3D" parent="."]
|
[node name="BallSpawn" type="Marker3D" parent="."]
|
||||||
@@ -71,9 +71,33 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
|
|||||||
[node name="SpawnsTeam0" type="Node3D" parent="."]
|
[node name="SpawnsTeam0" type="Node3D" parent="."]
|
||||||
|
|
||||||
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
|
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 13.5)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9, 2.8, 20.25)
|
||||||
|
|
||||||
|
[node name="Spawn2" type="Marker3D" parent="SpawnsTeam0"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4.5, 2.8, 20.25)
|
||||||
|
|
||||||
|
[node name="Spawn3" type="Marker3D" parent="SpawnsTeam0"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 20.25)
|
||||||
|
|
||||||
|
[node name="Spawn4" type="Marker3D" parent="SpawnsTeam0"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.5, 2.8, 20.25)
|
||||||
|
|
||||||
|
[node name="Spawn5" type="Marker3D" parent="SpawnsTeam0"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 2.8, 20.25)
|
||||||
|
|
||||||
[node name="SpawnsTeam1" type="Node3D" parent="."]
|
[node name="SpawnsTeam1" type="Node3D" parent="."]
|
||||||
|
|
||||||
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
|
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -13.5)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -9, 2.8, -20.25)
|
||||||
|
|
||||||
|
[node name="Spawn2" type="Marker3D" parent="SpawnsTeam1"]
|
||||||
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.5, 2.8, -20.25)
|
||||||
|
|
||||||
|
[node name="Spawn3" type="Marker3D" parent="SpawnsTeam1"]
|
||||||
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -20.25)
|
||||||
|
|
||||||
|
[node name="Spawn4" type="Marker3D" parent="SpawnsTeam1"]
|
||||||
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 4.5, 2.8, -20.25)
|
||||||
|
|
||||||
|
[node name="Spawn5" type="Marker3D" parent="SpawnsTeam1"]
|
||||||
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 9, 2.8, -20.25)
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ var _action := ShipAction.new()
|
|||||||
var _ticks_until_decision := 0
|
var _ticks_until_decision := 0
|
||||||
|
|
||||||
var _ship: Ship
|
var _ship: Ship
|
||||||
var _opponent: Ship
|
var _teammates: Array[Ship] = []
|
||||||
|
var _opponents: Array[Ship] = []
|
||||||
var _ball: RigidBody3D
|
var _ball: RigidBody3D
|
||||||
var _attack_goal_position: Vector3
|
var _attack_goal_position: Vector3
|
||||||
var _scene_refs_ready := false
|
var _scene_refs_ready := false
|
||||||
@@ -60,7 +61,7 @@ func get_action() -> ShipAction:
|
|||||||
|
|
||||||
|
|
||||||
func _decide() -> void:
|
func _decide() -> void:
|
||||||
var obs := ShipObservations.build(_ship, _opponent, _ball, _attack_goal_position)
|
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
|
||||||
var out := _policy.forward(obs)
|
var out := _policy.forward(obs)
|
||||||
# See ShipActionCodec for the decode — the single source of truth shared
|
# See ShipActionCodec for the decode — the single source of truth shared
|
||||||
# with the training side, so this must never reimplement layout/ordering
|
# with the training side, so this must never reimplement layout/ordering
|
||||||
@@ -76,21 +77,40 @@ func _decide() -> void:
|
|||||||
_action = ShipActionCodec.from_logits(out, action_noise)
|
_action = ShipActionCodec.from_logits(out, action_noise)
|
||||||
|
|
||||||
|
|
||||||
# Find ship/ball/opponent/goal once everything is spawned. ShipAction axes
|
# Find ship/ball/teammates/opponents/goal once everything is spawned.
|
||||||
# are body-frame so only observations need team context (ShipObservations).
|
# ShipAction axes are body-frame so only observations need team context
|
||||||
|
# (ShipObservations). Rosters never change mid-match (no despawn path exists
|
||||||
|
# anywhere in this codebase), so this only needs to run once — sorted by
|
||||||
|
# spawn_index so a given ship keeps the same observation slot for the whole
|
||||||
|
# match, matching TrainingMode's identically-sorted lists.
|
||||||
func _discover_scene_refs() -> bool:
|
func _discover_scene_refs() -> bool:
|
||||||
_ship = get_parent() as Ship
|
_ship = get_parent() as Ship
|
||||||
if _ship == null or not is_inside_tree():
|
if _ship == null or not is_inside_tree():
|
||||||
return false
|
return false
|
||||||
_ball = get_tree().get_first_node_in_group("ball")
|
_ball = get_tree().get_first_node_in_group("ball")
|
||||||
|
if _ball == null:
|
||||||
|
return false
|
||||||
|
# Cleared, not just appended to: if an earlier call reached this point but
|
||||||
|
# a later check still failed, a retry must not re-append onto whatever it
|
||||||
|
# already collected — that would duplicate every ship in the roster.
|
||||||
|
_teammates.clear()
|
||||||
|
_opponents.clear()
|
||||||
for node in get_tree().get_nodes_in_group("ship"):
|
for node in get_tree().get_nodes_in_group("ship"):
|
||||||
if node != _ship:
|
var other := node as Ship
|
||||||
_opponent = node
|
if other == _ship:
|
||||||
break
|
continue
|
||||||
|
if other.team == _ship.team:
|
||||||
|
_teammates.append(other)
|
||||||
|
else:
|
||||||
|
_opponents.append(other)
|
||||||
|
_teammates.sort_custom(_by_spawn_index)
|
||||||
|
_opponents.sort_custom(_by_spawn_index)
|
||||||
for goal in get_tree().get_nodes_in_group("goal"):
|
for goal in get_tree().get_nodes_in_group("goal"):
|
||||||
if goal.team == 1 - _ship.team:
|
if goal.team == 1 - _ship.team:
|
||||||
_attack_goal_position = goal.global_position
|
_attack_goal_position = goal.global_position
|
||||||
if _ball == null:
|
|
||||||
return false
|
|
||||||
_scene_refs_ready = true
|
_scene_refs_ready = true
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
static func _by_spawn_index(a: Ship, b: Ship) -> bool:
|
||||||
|
return a.spawn_index < b.spawn_index
|
||||||
|
|||||||
+139
-20
@@ -5,9 +5,9 @@ extends StaticBody3D
|
|||||||
# instances objects/arena_boundary.tscn so all arenas share one size; code
|
# instances objects/arena_boundary.tscn so all arenas share one size; code
|
||||||
# that needs field dimensions derives them from these constants rather than
|
# that needs field dimensions derives them from these constants rather than
|
||||||
# restating numbers.
|
# restating numbers.
|
||||||
const INNER_HALF_X := 12.0
|
const INNER_HALF_X := 18.0
|
||||||
const INNER_HALF_Z := 18.0
|
const INNER_HALF_Z := 27.0
|
||||||
const INNER_HEIGHT := 12.0
|
const INNER_HEIGHT := 18.0
|
||||||
# Goal-centre distance from arena centre. Flush with the end walls (see
|
# Goal-centre distance from arena centre. Flush with the end walls (see
|
||||||
# arena_01.tscn's goal transforms), so there is no floating gap between the
|
# arena_01.tscn's goal transforms), so there is no floating gap between the
|
||||||
# goal and the wall for a ball to ramp across before reaching the sensor.
|
# goal and the wall for a ball to ramp across before reaching the sensor.
|
||||||
@@ -45,14 +45,30 @@ const BASE_RADIUS := 2.0
|
|||||||
# The end-wall fillets stop short of the goal mouth so floor-level shots
|
# The end-wall fillets stop short of the goal mouth so floor-level shots
|
||||||
# roll flat into the goal sensor (3.5 m wide) instead of ramping over it.
|
# roll flat into the goal sensor (3.5 m wide) instead of ramping over it.
|
||||||
const GOAL_MOUTH_HALF_WIDTH := 2.5
|
const GOAL_MOUTH_HALF_WIDTH := 2.5
|
||||||
# The aperture the visual shell leaves for the goal itself, and the opaque
|
# The real navigable hole in the wall's collision (see
|
||||||
# bulkhead surrounding it. Mirrors objects/goal.tscn's 3.5 x 1.5 sensor with a
|
# _build_end_wall_colliders) — must track objects/goal.tscn's BoxShape3D
|
||||||
# little clearance, so no sliver of panel shows through the goal frame. The
|
# (3.5 x 1.5, i.e. half-width 1.75, height 1.5) exactly. Anything wider than
|
||||||
# surround has to be opaque hull: backed by the translucent field panel
|
# the scoring sensor lets the ball cross the wall opening without entering
|
||||||
|
# the sensor and failing to score; this is collision-authoritative, do not
|
||||||
|
# widen it for cosmetic reasons — see GOAL_VISUAL_APERTURE_* below for that.
|
||||||
|
# Half-width matches the sensor exactly; height is a few cm off in FLOOR mode
|
||||||
|
# (the sensor is vertically offset by the goal node's own y=0.79 placement,
|
||||||
|
# while the hole itself is measured from the floor at y=0 — see
|
||||||
|
# _goal_surround_bounds), which is immaterial at the ball's radius but means
|
||||||
|
# "exact match" isn't literally true.
|
||||||
|
const GOAL_APERTURE_HALF_WIDTH := 1.75
|
||||||
|
const GOAL_APERTURE_HEIGHT := 1.5
|
||||||
|
# The (larger) aperture the visual shell/opaque bulkhead actually cuts —
|
||||||
|
# deliberately wider than the true collision hole above, so goal.gd's bezel/
|
||||||
|
# rim frame (built starting exactly at the sensor's own half-extents) has
|
||||||
|
# clearance to be seen against the hull's cut edge instead of sitting flush
|
||||||
|
# with it. Cosmetic only: collision still uses the exact GOAL_APERTURE_*
|
||||||
|
# pair above, so this can't reopen the "ball crosses without scoring" bug.
|
||||||
|
const GOAL_VISUAL_APERTURE_HALF_WIDTH := GOAL_APERTURE_HALF_WIDTH + 0.1
|
||||||
|
const GOAL_VISUAL_APERTURE_HEIGHT := GOAL_APERTURE_HEIGHT + 0.15
|
||||||
|
# The surround has to be opaque hull: backed by the translucent field panel
|
||||||
# instead, the goal's recess and net showed straight through the wall beside
|
# instead, the goal's recess and net showed straight through the wall beside
|
||||||
# the mouth and read as a second, duplicated net.
|
# the mouth and read as a second, duplicated net.
|
||||||
const GOAL_APERTURE_HALF_WIDTH := 1.85
|
|
||||||
const GOAL_APERTURE_HEIGHT := 1.65
|
|
||||||
# ELEVATED only — in FLOOR mode the surround spans the deck to the fillet
|
# ELEVATED only — in FLOOR mode the surround spans the deck to the fillet
|
||||||
# tangent, so its height is BASE_RADIUS and needs no constant.
|
# tangent, so its height is BASE_RADIUS and needs no constant.
|
||||||
const GOAL_SURROUND_HALF_HEIGHT := 1.6
|
const GOAL_SURROUND_HALF_HEIGHT := 1.6
|
||||||
@@ -102,12 +118,28 @@ var _field_material: ShaderMaterial
|
|||||||
# pattern so the viewport lookup isn't repeated every frame.
|
# pattern so the viewport lookup isn't repeated every frame.
|
||||||
var _camera: Camera3D
|
var _camera: Camera3D
|
||||||
|
|
||||||
|
# Group every generated collider is tagged with. A CollisionShape3D only
|
||||||
|
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
|
||||||
|
# earlier version of this code grouped generated colliders under an
|
||||||
|
# intermediate Node3D container for identification, which silently made
|
||||||
|
# every one of them inert (no shape ever reached the StaticBody3D). They must
|
||||||
|
# be direct children of `self`; the group tag is how bake_colliders()
|
||||||
|
# identifies (and clears, for idempotent re-baking) its own prior output
|
||||||
|
# without an intermediate node.
|
||||||
|
const GENERATED_COLLIDER_GROUP := "_arena_generated_collider"
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("arena_boundary")
|
add_to_group("arena_boundary")
|
||||||
_build_corner_colliders()
|
# goal_mode affects the generated geometry itself (see _fillet_runs'
|
||||||
_build_fillet_colliders(BASE_FILLET)
|
# `split` and _build_end_wall_colliders' hole placement), but it's an
|
||||||
_build_fillet_colliders(CEILING_FILLET)
|
# instance-level override each arena_0X_elevated.tscn applies to the
|
||||||
|
# shared arena_boundary.tscn's Boundary node — so a bake done in FLOOR
|
||||||
|
# mode (the shared .tscn's default) must not be silently reused by an
|
||||||
|
# ELEVATED instance. Only skip regenerating when the existing bake was
|
||||||
|
# actually made in the current goal_mode.
|
||||||
|
if get_meta("baked_goal_mode", -1) != goal_mode:
|
||||||
|
bake_colliders()
|
||||||
# Visuals are pure decoration and training spawns many headless instances
|
# Visuals are pure decoration and training spawns many headless instances
|
||||||
# that never render one, so skip the mesh build there entirely. Collision
|
# that never render one, so skip the mesh build there entirely. Collision
|
||||||
# is unaffected either way.
|
# is unaffected either way.
|
||||||
@@ -117,6 +149,29 @@ func _ready() -> void:
|
|||||||
_build_visual_shell()
|
_build_visual_shell()
|
||||||
|
|
||||||
|
|
||||||
|
# Builds every generated collider (corner curves, base/ceiling fillets, end-
|
||||||
|
# wall goal holes) as direct children of this ArenaBoundary (required for
|
||||||
|
# them to register with the StaticBody3D at all) and records the goal_mode
|
||||||
|
# they were built for. Idempotent: clears any of its own prior output first
|
||||||
|
# (identified via GENERATED_COLLIDER_GROUP), so calling this repeatedly — a
|
||||||
|
# stale bake whose goal_mode doesn't match (see _ready), or re-running
|
||||||
|
# tools/bake_arena_boundary.gd on an already-baked scene — replaces rather
|
||||||
|
# than duplicates. _ready() calls this itself for any scene that hasn't been
|
||||||
|
# baked yet, so behaviour is identical either way; baking only skips redoing
|
||||||
|
# this work on every subsequent load, which matters most for the many
|
||||||
|
# parallel headless training envs that would otherwise pay it on every
|
||||||
|
# episode reset.
|
||||||
|
func bake_colliders() -> void:
|
||||||
|
for child in get_children():
|
||||||
|
if child.is_in_group(GENERATED_COLLIDER_GROUP):
|
||||||
|
child.free()
|
||||||
|
_build_corner_colliders()
|
||||||
|
_build_fillet_colliders(BASE_FILLET)
|
||||||
|
_build_fillet_colliders(CEILING_FILLET)
|
||||||
|
_build_end_wall_colliders()
|
||||||
|
set_meta("baked_goal_mode", goal_mode)
|
||||||
|
|
||||||
|
|
||||||
# Wall+ceiling-only proximity force field ("artificial gravity" grav-plating,
|
# Wall+ceiling-only proximity force field ("artificial gravity" grav-plating,
|
||||||
# weaker than the floor's plain default gravity, which this deliberately
|
# weaker than the floor's plain default gravity, which this deliberately
|
||||||
# leaves untouched). Ship and Ball each call this with their own
|
# leaves untouched). Ship and Ball each call this with their own
|
||||||
@@ -248,8 +303,10 @@ func _fillet_runs(rise: float) -> Array[Dictionary]:
|
|||||||
|
|
||||||
|
|
||||||
# --- collision ---------------------------------------------------------------
|
# --- collision ---------------------------------------------------------------
|
||||||
# These build the tangent-box collider rings only. Their geometry is what
|
# Builds the tangent-box collider rings and each end wall's real goal hole.
|
||||||
# trained policies in Game/bots/ were fitted against, so it must not change.
|
# Geometry is free to evolve now that bots are retrained from scratch — just
|
||||||
|
# re-run tools/bake_arena_boundary.gd afterward so the baked scene (see
|
||||||
|
# bake_colliders) reflects the change.
|
||||||
|
|
||||||
|
|
||||||
# Vertical quarter-cylinder curves across the four wall-wall corners.
|
# Vertical quarter-cylinder curves across the four wall-wall corners.
|
||||||
@@ -332,9 +389,65 @@ func _add_curve_collider(
|
|||||||
box.size = Vector3(SURFACE_THICKNESS, run_length, face_width)
|
box.size = Vector3(SURFACE_THICKNESS, run_length, face_width)
|
||||||
collision.shape = box
|
collision.shape = box
|
||||||
collision.transform = Transform3D(segment_basis, origin)
|
collision.transform = Transform3D(segment_basis, origin)
|
||||||
|
collision.add_to_group(GENERATED_COLLIDER_GROUP)
|
||||||
add_child(collision)
|
add_child(collision)
|
||||||
|
|
||||||
|
|
||||||
|
# A plain axis-aligned box collider centred on `center`. Used for the flat
|
||||||
|
# end-wall panels below, which aren't tangent to a curve so don't need
|
||||||
|
# _add_curve_collider's outward/along framing.
|
||||||
|
func _add_box_collider(center: Vector3, size: Vector3) -> void:
|
||||||
|
var collision := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = size
|
||||||
|
collision.shape = box
|
||||||
|
collision.transform = Transform3D(Basis.IDENTITY, center)
|
||||||
|
collision.add_to_group(GENERATED_COLLIDER_GROUP)
|
||||||
|
add_child(collision)
|
||||||
|
|
||||||
|
|
||||||
|
# One rectangular end-wall panel spanning x in [x0, x1] and y in [y0, y1] at
|
||||||
|
# the given wall_z, SURFACE_THICKNESS deep. No-ops for a degenerate (zero or
|
||||||
|
# negative area) range, which happens whenever the goal hole's aperture
|
||||||
|
# extends to the wall's own boundary (e.g. FLOOR mode's aperture bottom sits
|
||||||
|
# at the wall's own y0).
|
||||||
|
func _add_end_wall_panel(wall_z: float, x0: float, x1: float, y0: float, y1: float) -> void:
|
||||||
|
if x1 <= x0 or y1 <= y0:
|
||||||
|
return
|
||||||
|
_add_box_collider(
|
||||||
|
Vector3((x0 + x1) / 2.0, (y0 + y1) / 2.0, wall_z),
|
||||||
|
Vector3(x1 - x0, y1 - y0, SURFACE_THICKNESS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Real navigable hole in each end wall, sized to the goal's actual scoring
|
||||||
|
# aperture (GOAL_APERTURE_HALF_WIDTH/HEIGHT, which now track
|
||||||
|
# objects/goal.tscn's sensor — see that constant's comment) instead of the
|
||||||
|
# previous single solid box that let the ball trigger the goal sensor but
|
||||||
|
# never actually fly through the wall. _goal_surround_bounds() already knows
|
||||||
|
# the aperture's vertical placement for both FLOOR (floor-level) and ELEVATED
|
||||||
|
# (raised) goal modes, so this works unmodified for either. Left/right panels
|
||||||
|
# carry the full wall height; top/bottom panels close off the remainder of
|
||||||
|
# the aperture band above/below the hole. In FLOOR mode the bottom panel
|
||||||
|
# (y -1..0) is a harmless duplicate — FloorShape already occupies that
|
||||||
|
# region — rather than a true no-op: _add_end_wall_panel still emits it,
|
||||||
|
# since aperture_bottom (0) is above y_bottom (-1). Left in rather than
|
||||||
|
# special-cased since it costs one extra collider and can't create a gap.
|
||||||
|
func _build_end_wall_colliders() -> void:
|
||||||
|
var half_width := INNER_HALF_X + 1.0
|
||||||
|
var y_bottom := -1.0
|
||||||
|
var y_top := INNER_HEIGHT
|
||||||
|
var bounds := _goal_surround_bounds(GOAL_APERTURE_HEIGHT)
|
||||||
|
var aperture_bottom: float = bounds.z
|
||||||
|
var aperture_top: float = bounds.w
|
||||||
|
for sz in [-1.0, 1.0]:
|
||||||
|
var wall_z: float = sz * (INNER_HALF_Z + 0.5)
|
||||||
|
_add_end_wall_panel(wall_z, -half_width, -GOAL_APERTURE_HALF_WIDTH, y_bottom, y_top)
|
||||||
|
_add_end_wall_panel(wall_z, GOAL_APERTURE_HALF_WIDTH, half_width, y_bottom, y_top)
|
||||||
|
_add_end_wall_panel(wall_z, -GOAL_APERTURE_HALF_WIDTH, GOAL_APERTURE_HALF_WIDTH, aperture_top, y_top)
|
||||||
|
_add_end_wall_panel(wall_z, -GOAL_APERTURE_HALF_WIDTH, GOAL_APERTURE_HALF_WIDTH, y_bottom, aperture_bottom)
|
||||||
|
|
||||||
|
|
||||||
# --- visual shell ------------------------------------------------------------
|
# --- visual shell ------------------------------------------------------------
|
||||||
# One mesh covering the inner surface of the play volume exactly once. Every
|
# One mesh covering the inner surface of the play volume exactly once. Every
|
||||||
# piece is cut to meet its neighbours edge-on, so no two translucent surfaces
|
# piece is cut to meet its neighbours edge-on, so no two translucent surfaces
|
||||||
@@ -444,21 +557,27 @@ func _add_goal_aprons(st: SurfaceTool) -> void:
|
|||||||
# panel, the recess and net behind it showed through the wall and the net read
|
# panel, the recess and net behind it showed through the wall and the net read
|
||||||
# as duplicated either side of the frame.
|
# as duplicated either side of the frame.
|
||||||
func _add_goal_surrounds(st: SurfaceTool) -> void:
|
func _add_goal_surrounds(st: SurfaceTool) -> void:
|
||||||
var bounds := _goal_surround_bounds()
|
var bounds := _goal_surround_bounds(GOAL_VISUAL_APERTURE_HEIGHT)
|
||||||
for side in [-1.0, 1.0]:
|
for side in [-1.0, 1.0]:
|
||||||
_add_aperture_panel(st,
|
_add_aperture_panel(st,
|
||||||
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
|
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
|
||||||
Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y,
|
Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y,
|
||||||
GOAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w)
|
GOAL_VISUAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w)
|
||||||
|
|
||||||
|
|
||||||
# (surround bottom, surround top, aperture bottom, aperture top) on the end
|
# (surround bottom, surround top, aperture bottom, aperture top) on the end
|
||||||
# wall. In FLOOR mode the surround spans deck to fillet tangent with the goal
|
# wall. In FLOOR mode the surround spans deck to fillet tangent with the goal
|
||||||
# sitting on the deck; in ELEVATED it is a band centred on the raised goal.
|
# sitting on the deck; in ELEVATED it is a band centred on the raised goal.
|
||||||
func _goal_surround_bounds() -> Vector4:
|
# `aperture_height` is the caller's choice of GOAL_APERTURE_HEIGHT (the true,
|
||||||
|
# collision-matching size — see _build_end_wall_colliders) or
|
||||||
|
# GOAL_VISUAL_APERTURE_HEIGHT (the cosmetically-widened cut — see
|
||||||
|
# _add_goal_surrounds); it only affects the returned aperture bottom/top
|
||||||
|
# (z/w), not the surround bottom/top (x/y), which are independent of it in
|
||||||
|
# both modes.
|
||||||
|
func _goal_surround_bounds(aperture_height: float) -> Vector4:
|
||||||
if goal_mode == GoalMode.FLOOR:
|
if goal_mode == GoalMode.FLOOR:
|
||||||
return Vector4(0.0, BASE_RADIUS, 0.0, GOAL_APERTURE_HEIGHT)
|
return Vector4(0.0, BASE_RADIUS, 0.0, aperture_height)
|
||||||
var half := GOAL_APERTURE_HEIGHT / 2.0
|
var half := aperture_height / 2.0
|
||||||
return Vector4(
|
return Vector4(
|
||||||
GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT,
|
GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT,
|
||||||
GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT,
|
GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT,
|
||||||
@@ -485,7 +604,7 @@ func _add_wall_panels(st: SurfaceTool) -> void:
|
|||||||
var y0 := BASE_RADIUS
|
var y0 := BASE_RADIUS
|
||||||
var y1 := INNER_HEIGHT - BASE_RADIUS
|
var y1 := INNER_HEIGHT - BASE_RADIUS
|
||||||
var elevated := goal_mode == GoalMode.ELEVATED
|
var elevated := goal_mode == GoalMode.ELEVATED
|
||||||
var bounds := _goal_surround_bounds()
|
var bounds := _goal_surround_bounds(GOAL_VISUAL_APERTURE_HEIGHT)
|
||||||
for side in [-1.0, 1.0]:
|
for side in [-1.0, 1.0]:
|
||||||
_add_aperture_panel(st,
|
_add_aperture_panel(st,
|
||||||
Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP,
|
Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = nu
|
|||||||
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
||||||
ship.global_transform = spawn_transform
|
ship.global_transform = spawn_transform
|
||||||
ship.team = team
|
ship.team = team
|
||||||
|
ship.spawn_index = spawn_index
|
||||||
if controller:
|
if controller:
|
||||||
ship.set_controller(controller)
|
ship.set_controller(controller)
|
||||||
ships.append(ship)
|
ships.append(ship)
|
||||||
@@ -175,3 +176,37 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
|||||||
func _unhandled_input(event):
|
func _unhandled_input(event):
|
||||||
if event.is_action_pressed("ui_cancel"):
|
if event.is_action_pressed("ui_cancel"):
|
||||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||||
|
|
||||||
|
|
||||||
|
# Escape failsafe. The arena is meant to be fully enclosed, but the goal
|
||||||
|
# mouths are now a real navigable hole in the end walls (see
|
||||||
|
# ArenaBoundary._build_end_wall_colliders) sized to the ball, not the ship —
|
||||||
|
# a ship's 1x1 cross-section fits through it, and there is nothing behind the
|
||||||
|
# net to stop it. Previously this only existed in TrainingMode (a physics
|
||||||
|
# regression there just wastes training time); now that any ship can
|
||||||
|
# genuinely fly out through an open goal, every mode needs it, or a stray
|
||||||
|
# ship/ball falls into the void with no way back short of quitting. Runs by
|
||||||
|
# default every tick; TrainingMode overrides _physics_process entirely and
|
||||||
|
# calls this itself alongside its own episode logic.
|
||||||
|
const ESCAPE_MARGIN := 15.0
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
_respawn_escaped_bodies()
|
||||||
|
|
||||||
|
|
||||||
|
func _respawn_escaped_bodies() -> void:
|
||||||
|
for ship in ships:
|
||||||
|
if is_instance_valid(ship) and _is_escaped(ship.global_position):
|
||||||
|
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
|
||||||
|
_reset_body(ship, _ship_spawn_transforms[ship])
|
||||||
|
if is_instance_valid(ball) and _is_escaped(ball.global_position):
|
||||||
|
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
|
||||||
|
_reset_body(ball, arena.get_ball_spawn())
|
||||||
|
|
||||||
|
|
||||||
|
func _is_escaped(position: Vector3) -> bool:
|
||||||
|
return absf(position.x) > ArenaBoundary.INNER_HALF_X + ESCAPE_MARGIN \
|
||||||
|
or absf(position.z) > ArenaBoundary.INNER_HALF_Z + ESCAPE_MARGIN \
|
||||||
|
or position.y < -ESCAPE_MARGIN \
|
||||||
|
or position.y > ArenaBoundary.INNER_HEIGHT + ESCAPE_MARGIN
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ const KICKOFF_COUNTDOWN_SECONDS := 3
|
|||||||
|
|
||||||
@export var match_length_seconds := 150.0
|
@export var match_length_seconds := 150.0
|
||||||
|
|
||||||
|
# Ships per team. The player always controls one ship on team 0; every other
|
||||||
|
# ship (teammates and the whole opposing team) is AI-controlled — no local
|
||||||
|
# multiplayer input. Plumbing only for this pass: there's no menu UI yet to
|
||||||
|
# pick a team size above 1.
|
||||||
|
@export_range(1, 5) var team_size: int = 1
|
||||||
|
|
||||||
@export_group("AI opponent")
|
@export_group("AI opponent")
|
||||||
# Trained policy for the opponent; empty = inert placeholder ship.
|
# Trained policy for the opponent; empty = inert placeholder ship.
|
||||||
@export_file("*.json") var bot_model_path: String = ""
|
@export_file("*.json") var bot_model_path: String = ""
|
||||||
@@ -41,7 +47,10 @@ func _start() -> void:
|
|||||||
spawn_ball()
|
spawn_ball()
|
||||||
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
|
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
|
||||||
spawn_camera_rig(player_ship)
|
spawn_camera_rig(player_ship)
|
||||||
spawn_ship(1, 0, _make_opponent_controller())
|
for i in range(1, team_size):
|
||||||
|
spawn_ship(0, i, _make_opponent_controller())
|
||||||
|
for i in team_size:
|
||||||
|
spawn_ship(1, i, _make_opponent_controller())
|
||||||
|
|
||||||
await _run_kickoff_countdown()
|
await _run_kickoff_countdown()
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -18,6 +18,7 @@ extends RigidBody3D
|
|||||||
@export var max_angular_speed = 3.0 # Maximum rotation speed
|
@export var max_angular_speed = 3.0 # Maximum rotation speed
|
||||||
@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 var idle_angular_drag = 0.9 # Rotational drag when no rotation input is held
|
||||||
|
|
||||||
@export_group("Surface Pull")
|
@export_group("Surface Pull")
|
||||||
@export var wall_pull_strength = 6.0 # Wall grav-plating strength (m/s^2-equivalent)
|
@export var wall_pull_strength = 6.0 # Wall grav-plating strength (m/s^2-equivalent)
|
||||||
@@ -57,6 +58,13 @@ var team: int = 0:
|
|||||||
team = value
|
team = value
|
||||||
_apply_team_color()
|
_apply_team_color()
|
||||||
|
|
||||||
|
# This ship's index within its team's roster (0, 1, 2, ...), set once by
|
||||||
|
# GameMode.spawn_ship and never changed afterward. The stable identity
|
||||||
|
# AIShipController/ShipAIController sort teammates/opponents by, so both
|
||||||
|
# training and in-game inference assign the same ship to the same
|
||||||
|
# observation-vector slot for the whole match (see ShipObservations).
|
||||||
|
var spawn_index: int = -1
|
||||||
|
|
||||||
# Shared per-team accent material, built once per team and reused by every
|
# Shared per-team accent material, built once per team and reused by every
|
||||||
# ship — avoids allocating a fresh StandardMaterial3D from both _ready and
|
# ship — avoids allocating a fresh StandardMaterial3D from both _ready and
|
||||||
# the team setter (previously ran at least twice per ship).
|
# the team setter (previously ran at least twice per ship).
|
||||||
@@ -279,12 +287,22 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
|||||||
state.apply_torque(torque)
|
state.apply_torque(torque)
|
||||||
|
|
||||||
|
|
||||||
|
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)
|
||||||
|
# by the actual elapsed tick time `step`, so `v *= _tick_scaled(k, step)`
|
||||||
|
# decays at the same rate per second regardless of physics_ticks_per_second.
|
||||||
|
func _tick_scaled(k: float, step: float) -> float:
|
||||||
|
return pow(k, step * 60.0)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
# Simplified: v_new = v_old * drag_coefficient (exponential decay)
|
# Simplified: v_new = v_old * drag_coefficient (exponential decay)
|
||||||
# This simulates air resistance reducing velocity over time
|
# This simulates air resistance reducing velocity over time.
|
||||||
state.linear_velocity *= drag_coefficient
|
# _tick_scaled makes the decay rate invariant to the physics tick rate —
|
||||||
|
# drag_coefficient/angular_drag/idle_angular_drag are all defined as the
|
||||||
|
# per-tick multiplier at a 60 Hz reference rate.
|
||||||
|
state.linear_velocity *= _tick_scaled(drag_coefficient, state.step)
|
||||||
|
|
||||||
# Angular drag (rotational resistance)
|
# Angular drag (rotational resistance)
|
||||||
# Physics: Similar to linear drag but for rotational motion
|
# Physics: Similar to linear drag but for rotational motion
|
||||||
@@ -292,10 +310,10 @@ func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
|||||||
# Simplified: ω_new = ω_old * angular_drag (exponential decay)
|
# Simplified: ω_new = ω_old * angular_drag (exponential decay)
|
||||||
if rotation_input.length() < 0.01:
|
if rotation_input.length() < 0.01:
|
||||||
# More drag when not actively rotating to stop quicker
|
# More drag when not actively rotating to stop quicker
|
||||||
state.angular_velocity *= 0.9
|
state.angular_velocity *= _tick_scaled(idle_angular_drag, state.step)
|
||||||
else:
|
else:
|
||||||
# Normal drag when actively rotating
|
# Normal drag when actively rotating
|
||||||
state.angular_velocity *= angular_drag
|
state.angular_velocity *= _tick_scaled(angular_drag, state.step)
|
||||||
|
|
||||||
# Limit maximum speeds
|
# Limit maximum speeds
|
||||||
# Physics: Terminal velocity concept - maximum achievable speed
|
# Physics: Terminal velocity concept - maximum achievable speed
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ const MAX_BALL_DISTANCE := sqrt(
|
|||||||
var ship: Ship
|
var ship: Ship
|
||||||
var rl_controller: RLShipController
|
var rl_controller: RLShipController
|
||||||
var ball: RigidBody3D
|
var ball: RigidBody3D
|
||||||
var opponent: Ship
|
var teammates: Array[Ship] = []
|
||||||
|
var opponents: Array[Ship] = []
|
||||||
var attack_goal_position: Vector3
|
var attack_goal_position: Vector3
|
||||||
|
|
||||||
# Set directly by TrainingMode (_on_goal_scored / the timeout branch in
|
# Set directly by TrainingMode (_on_goal_scored / the timeout branch in
|
||||||
@@ -146,12 +147,16 @@ var _air_touches := 0
|
|||||||
|
|
||||||
|
|
||||||
# Wire up references after the ship is spawned. `attack_goal` is the goal
|
# Wire up references after the ship is spawned. `attack_goal` is the goal
|
||||||
# this ship scores into (goal.team == opponent's team).
|
# this ship scores into (goal.team == the opposing team's team).
|
||||||
func setup(p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D, p_opponent: Ship, p_attack_goal_position: Vector3) -> void:
|
func setup(
|
||||||
|
p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D,
|
||||||
|
p_teammates: Array[Ship], p_opponents: Array[Ship], p_attack_goal_position: Vector3
|
||||||
|
) -> void:
|
||||||
ship = p_ship
|
ship = p_ship
|
||||||
rl_controller = p_rl_controller
|
rl_controller = p_rl_controller
|
||||||
ball = p_ball
|
ball = p_ball
|
||||||
opponent = p_opponent
|
teammates = p_teammates
|
||||||
|
opponents = p_opponents
|
||||||
attack_goal_position = p_attack_goal_position
|
attack_goal_position = p_attack_goal_position
|
||||||
init(ship)
|
init(ship)
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ func setup(p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D,
|
|||||||
|
|
||||||
|
|
||||||
func get_obs() -> Dictionary:
|
func get_obs() -> Dictionary:
|
||||||
return {"obs": ShipObservations.build(ship, opponent, ball, attack_goal_position)}
|
return {"obs": ShipObservations.build(ship, teammates, opponents, ball, attack_goal_position)}
|
||||||
|
|
||||||
|
|
||||||
func get_reward() -> float:
|
func get_reward() -> float:
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ extends RefCounted
|
|||||||
# The same rotation must be inverted when interpreting actions (see canon —
|
# The same rotation must be inverted when interpreting actions (see canon —
|
||||||
# it is its own inverse).
|
# it is its own inverse).
|
||||||
|
|
||||||
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±12,
|
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±18,
|
||||||
# z ±18, height 12, goals at z ±18 (flush with the end walls); positions are
|
# z ±27, height 18, goals at z ±27 (flush with the end walls); positions are
|
||||||
# soft-normalized to roughly [-1, 1]. Do not retune without retraining every
|
# soft-normalized to roughly [-1, 1]. Do not retune without retraining every
|
||||||
# model in Game/bots/.
|
# model in Game/bots/.
|
||||||
const POSITION_SCALE := Vector3(20.0, 10.0, 20.0)
|
const POSITION_SCALE := Vector3(30.0, 15.0, 30.0)
|
||||||
const BALL_SPEED_SCALE := 30.0
|
const BALL_SPEED_SCALE := 30.0
|
||||||
const GOAL_DISTANCE_SCALE := 40.0
|
const GOAL_DISTANCE_SCALE := 60.0
|
||||||
|
|
||||||
# Contact normals with y above this are floor contact; below it they read as
|
# Contact normals with y above this are floor contact; below it they read as
|
||||||
# wall (sideways) or ceiling (downward) — mirrors
|
# wall (sideways) or ceiling (downward) — mirrors
|
||||||
@@ -26,13 +26,23 @@ const GOAL_DISTANCE_SCALE := 40.0
|
|||||||
# counts as "in contact" for the reward/observation to stay consistent).
|
# counts as "in contact" for the reward/observation to stay consistent).
|
||||||
const FLOOR_NORMAL_MIN_Y := 0.7
|
const FLOOR_NORMAL_MIN_Y := 0.7
|
||||||
|
|
||||||
# Number of floats build() returns; the policy input size. APPEND-ONLY: new
|
# Fixed roster caps for the padded teammate/opponent slots below — the
|
||||||
# features go on the end and existing indices never move, so an old exported
|
# largest supported match size is 5v5. Slots beyond the real teammate/
|
||||||
# model (whose network was trained against a shorter SIZE) still decodes its
|
# opponent count are zero-filled, mirroring the old single-opponent's
|
||||||
# first N inputs identically when SIZE grows — see PolicyNetwork.forward's
|
# null-zero-fill (see build()). Callers must pass teammates/opponents already
|
||||||
# input_size slice/guard. Do not retune an *existing* index without
|
# sorted by Ship.spawn_index, so a given ship occupies the same slot in every
|
||||||
# retraining every model in Game/bots/.
|
# tick's observation for the whole match, in both training and in-game
|
||||||
const SIZE := 35
|
# inference (see AIShipController._discover_scene_refs /
|
||||||
|
# TrainingMode._start).
|
||||||
|
const MAX_TEAMMATES := 4
|
||||||
|
const MAX_OPPONENTS := 5
|
||||||
|
|
||||||
|
# Number of floats build() returns; the policy input size.
|
||||||
|
# 15 (own) + 6 (ball) + 6*MAX_TEAMMATES + 6*MAX_OPPONENTS + 4 (goal) + 4 (contact)
|
||||||
|
# Game/bots/promoted/*.json were exported against the old single-opponent,
|
||||||
|
# SIZE=35 layout and are not migrated — this is a from-scratch retrain, so
|
||||||
|
# those checkpoints are expected to go stale rather than keep decoding.
|
||||||
|
const SIZE := 15 + 6 + 6 * MAX_TEAMMATES + 6 * MAX_OPPONENTS + 4 + 4
|
||||||
|
|
||||||
|
|
||||||
# 180° rotation about Y for team 1; identity for team 0. A proper rotation
|
# 180° rotation about Y for team 1; identity for team 0. A proper rotation
|
||||||
@@ -43,8 +53,14 @@ static func canon(v: Vector3, team: int) -> Vector3:
|
|||||||
|
|
||||||
|
|
||||||
# attack_goal_position: centre of the goal this ship is trying to score in
|
# attack_goal_position: centre of the goal this ship is trying to score in
|
||||||
# (the goal whose `team` == the opponent's team).
|
# (the goal whose `team` == the opponent's team). teammates/opponents must
|
||||||
static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_position: Vector3) -> Array:
|
# already be sorted by Ship.spawn_index (ascending) by the caller — see
|
||||||
|
# MAX_TEAMMATES/MAX_OPPONENTS's comment for why slot stability matters; this
|
||||||
|
# function only pads/truncates to the fixed cap, it doesn't sort.
|
||||||
|
static func build(
|
||||||
|
ship: Ship, teammates: Array[Ship], opponents: Array[Ship],
|
||||||
|
ball: RigidBody3D, attack_goal_position: Vector3
|
||||||
|
) -> Array:
|
||||||
var team := ship.team
|
var team := ship.team
|
||||||
var obs := []
|
var obs := []
|
||||||
|
|
||||||
@@ -60,27 +76,23 @@ static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_pos
|
|||||||
_append(obs, canon(ball_rel, team) / POSITION_SCALE)
|
_append(obs, canon(ball_rel, team) / POSITION_SCALE)
|
||||||
_append(obs, canon(ball.linear_velocity, team) / BALL_SPEED_SCALE)
|
_append(obs, canon(ball.linear_velocity, team) / BALL_SPEED_SCALE)
|
||||||
|
|
||||||
# Opponent, relative to self (zeros if absent, e.g. a 1-ship drill)
|
# Teammates and opponents, relative to self, each padded/truncated to a
|
||||||
if is_instance_valid(opponent):
|
# fixed slot count (zeros past the real roster size, e.g. a 1v1 match or
|
||||||
var opp_rel := opponent.global_position - ship.global_position
|
# a solo drill) so the vector shape never depends on match size.
|
||||||
_append(obs, canon(opp_rel, team) / POSITION_SCALE)
|
_append_ship_slots(obs, ship, team, teammates, MAX_TEAMMATES)
|
||||||
_append(obs, canon(opponent.linear_velocity, team) / ship.max_speed)
|
_append_ship_slots(obs, ship, team, opponents, MAX_OPPONENTS)
|
||||||
else:
|
|
||||||
_append(obs, Vector3.ZERO)
|
|
||||||
_append(obs, Vector3.ZERO)
|
|
||||||
|
|
||||||
# Goal we are attacking, relative to self
|
# Goal we are attacking, relative to self
|
||||||
var goal_rel := attack_goal_position - ship.global_position
|
var goal_rel := attack_goal_position - ship.global_position
|
||||||
_append(obs, canon(goal_rel, team) / POSITION_SCALE)
|
_append(obs, canon(goal_rel, team) / POSITION_SCALE)
|
||||||
obs.append(goal_rel.length() / GOAL_DISTANCE_SCALE)
|
obs.append(goal_rel.length() / GOAL_DISTANCE_SCALE)
|
||||||
|
|
||||||
# Own contact state (appended — see SIZE's append-only invariant).
|
# Own contact state. Added for generation 4: ShipAIController's
|
||||||
# Added for generation 4: ShipAIController's wall_contact_penalty used to
|
# wall_contact_penalty used to fire on a condition the observation vector
|
||||||
# fire on a condition the observation vector couldn't see coming,
|
# couldn't see coming, leaving the value function to predict a reward
|
||||||
# leaving the value function to predict a reward with no supporting
|
# with no supporting signal. Also gives the policy a direct "am I resting
|
||||||
# signal. Also gives the policy a direct "am I resting on a surface"
|
# on a surface" signal it can use to push off (a real aerial mechanic),
|
||||||
# signal it can use to push off (a real aerial mechanic), distinct from
|
# distinct from inferring it indirectly from position/up-vector.
|
||||||
# inferring it indirectly from position/up-vector.
|
|
||||||
var normal := contact_normal(ship)
|
var normal := contact_normal(ship)
|
||||||
_append(obs, canon(normal, team))
|
_append(obs, canon(normal, team))
|
||||||
obs.append(1.0 if normal != Vector3.ZERO else 0.0)
|
obs.append(1.0 if normal != Vector3.ZERO else 0.0)
|
||||||
@@ -88,6 +100,26 @@ static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_pos
|
|||||||
return obs
|
return obs
|
||||||
|
|
||||||
|
|
||||||
|
# Appends up to `slot_count` other ships' (relative position, relative
|
||||||
|
# velocity) — 6 floats each — zero-filling any slots beyond the real roster
|
||||||
|
# size, or beyond slot_count if the roster somehow has more (sorted-by-
|
||||||
|
# spawn_index order means truncation drops the highest spawn_index ships,
|
||||||
|
# not the nearest ones — acceptable since slot_count already covers the
|
||||||
|
# largest supported match size, 5v5).
|
||||||
|
static func _append_ship_slots(
|
||||||
|
obs: Array, ship: Ship, team: int, others: Array[Ship], slot_count: int
|
||||||
|
) -> void:
|
||||||
|
for i in slot_count:
|
||||||
|
if i < others.size() and is_instance_valid(others[i]):
|
||||||
|
var other := others[i]
|
||||||
|
var rel := other.global_position - ship.global_position
|
||||||
|
_append(obs, canon(rel, team) / POSITION_SCALE)
|
||||||
|
_append(obs, canon(other.linear_velocity, team) / ship.max_speed)
|
||||||
|
else:
|
||||||
|
_append(obs, Vector3.ZERO)
|
||||||
|
_append(obs, Vector3.ZERO)
|
||||||
|
|
||||||
|
|
||||||
static func _append(obs: Array, v: Vector3) -> void:
|
static func _append(obs: Array, v: Vector3) -> void:
|
||||||
obs.append(v.x)
|
obs.append(v.x)
|
||||||
obs.append(v.y)
|
obs.append(v.y)
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ extends GameMode
|
|||||||
# _place_air_drill.
|
# _place_air_drill.
|
||||||
@export_range(0.0, 1.0) var air_drill_chance := 0.0
|
@export_range(0.0, 1.0) var air_drill_chance := 0.0
|
||||||
|
|
||||||
|
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
|
||||||
|
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
|
||||||
|
# MAX_OPPONENTS. Plumbing only for this pass — no 2v2+ curriculum/reward
|
||||||
|
# design has been done, so a run above 1 is untested territory.
|
||||||
|
@export_range(1, 5) var team_size: int = 1
|
||||||
|
|
||||||
# Placement bounds for randomized episode starts, derived from the standard
|
# Placement bounds for randomized episode starts, derived from the standard
|
||||||
# enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4
|
# enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4
|
||||||
# box, worst-case half-extent ~2.05) from spawning intersecting the walls,
|
# box, worst-case half-extent ~2.05) from spawning intersecting the walls,
|
||||||
@@ -86,16 +92,17 @@ const CORNER_LIMIT := ArenaBoundary.INNER_HALF_X + ArenaBoundary.INNER_HALF_Z \
|
|||||||
const FILLET_CLEAR_Y := ArenaBoundary.BASE_RADIUS + FIELD_MIN_Y
|
const FILLET_CLEAR_Y := ArenaBoundary.BASE_RADIUS + FIELD_MIN_Y
|
||||||
const MAX_RANDOM_BALL_SPEED := 12.0
|
const MAX_RANDOM_BALL_SPEED := 12.0
|
||||||
const MAX_RANDOM_SHIP_SPEED := 8.0
|
const MAX_RANDOM_SHIP_SPEED := 8.0
|
||||||
|
# Minimum centre-to-centre separation enforced between ships placed in the
|
||||||
|
# same randomized reset (team_size > 1) — without it, _place_ships_random/
|
||||||
|
# _place_air_drill sample each ship independently and can spawn them
|
||||||
|
# interpenetrating. Twice the ~2.05m worst-case rotated half-extent noted
|
||||||
|
# above clears any relative orientation; matches arena_base.tscn's spawn
|
||||||
|
# marker spacing, which uses the same margin for the same reason.
|
||||||
|
const MIN_SHIP_SEPARATION := 4.5
|
||||||
|
|
||||||
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
|
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
|
||||||
const TICKS_PER_SIM_SECOND := 60.0
|
const TICKS_PER_SIM_SECOND := 60.0
|
||||||
|
|
||||||
# The arena is physically enclosed, so nothing should ever get this far out.
|
|
||||||
# If a body escapes anyway (physics regression, boundary edit), it is warned
|
|
||||||
# about and respawned with no reward change and no episode end — a multi-hour
|
|
||||||
# training run must survive it, and the escape must not shape rewards.
|
|
||||||
const ESCAPE_MARGIN := 15.0
|
|
||||||
|
|
||||||
var _agents: Array[ShipAIController] = []
|
var _agents: Array[ShipAIController] = []
|
||||||
|
|
||||||
# Eval mode state (see header comment)
|
# Eval mode state (see header comment)
|
||||||
@@ -152,22 +159,46 @@ func _start() -> void:
|
|||||||
spawn_ship(team, 0, bot)
|
spawn_ship(team, 0, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
var ship_team0 := spawn_ship(0, 0, RLShipController.new())
|
var team0_ships: Array[Ship] = []
|
||||||
var ship_team1: Ship
|
for i in team_size:
|
||||||
match _opponent_mode:
|
team0_ships.append(spawn_ship(0, i, RLShipController.new()))
|
||||||
"inert":
|
|
||||||
ship_team1 = spawn_ship(1, 0, ShipController.new())
|
|
||||||
_inert_ships.append(ship_team1)
|
|
||||||
"frozen":
|
|
||||||
var bot := AIShipController.new()
|
|
||||||
bot.model_path = _opponent_model_path
|
|
||||||
ship_team1 = spawn_ship(1, 0, bot)
|
|
||||||
_:
|
|
||||||
ship_team1 = spawn_ship(1, 0, RLShipController.new())
|
|
||||||
|
|
||||||
_attach_agent(ship_team0, ship_team1)
|
# The opponent_mode branch applies uniformly to every ship on team 1: an
|
||||||
|
# "inert"/"frozen" run means the whole opposing team gets that treatment,
|
||||||
|
# not just one ship.
|
||||||
|
var team1_ships: Array[Ship] = []
|
||||||
|
for i in team_size:
|
||||||
|
var ship1: Ship
|
||||||
|
match _opponent_mode:
|
||||||
|
"inert":
|
||||||
|
ship1 = spawn_ship(1, i, ShipController.new())
|
||||||
|
_inert_ships.append(ship1)
|
||||||
|
"frozen":
|
||||||
|
var bot := AIShipController.new()
|
||||||
|
bot.model_path = _opponent_model_path
|
||||||
|
ship1 = spawn_ship(1, i, bot)
|
||||||
|
_:
|
||||||
|
ship1 = spawn_ship(1, i, RLShipController.new())
|
||||||
|
team1_ships.append(ship1)
|
||||||
|
|
||||||
|
# All ships spawn before any agent attaches, so every agent's
|
||||||
|
# teammates/opponents lists see the other side's full roster.
|
||||||
|
for ship in team0_ships:
|
||||||
|
_attach_agent(ship, _other_ships(team0_ships, ship), team1_ships)
|
||||||
if _opponent_mode == "self_play":
|
if _opponent_mode == "self_play":
|
||||||
_attach_agent(ship_team1, ship_team0)
|
for ship in team1_ships:
|
||||||
|
_attach_agent(ship, _other_ships(team1_ships, ship), team0_ships)
|
||||||
|
|
||||||
|
|
||||||
|
# `roster` minus `ship`, preserving order — rosters are built by spawn_index
|
||||||
|
# already, so this stays spawn_index-sorted (see ShipObservations' slot-
|
||||||
|
# stability requirement).
|
||||||
|
func _other_ships(roster: Array[Ship], ship: Ship) -> Array[Ship]:
|
||||||
|
var others: Array[Ship] = []
|
||||||
|
for s in roster:
|
||||||
|
if s != ship:
|
||||||
|
others.append(s)
|
||||||
|
return others
|
||||||
|
|
||||||
|
|
||||||
# Shared "--key=value" cmdline scan used by both eval and curriculum parsing.
|
# Shared "--key=value" cmdline scan used by both eval and curriculum parsing.
|
||||||
@@ -260,14 +291,14 @@ func _ai_default(name: String) -> Variant:
|
|||||||
_: return null
|
_: return null
|
||||||
|
|
||||||
|
|
||||||
func _attach_agent(ship: Ship, opponent: Ship) -> void:
|
func _attach_agent(ship: Ship, teammates: Array[Ship], opponents: Array[Ship]) -> void:
|
||||||
var agent := ShipAIController.new()
|
var agent := ShipAIController.new()
|
||||||
agent.name = "ShipAIController"
|
agent.name = "ShipAIController"
|
||||||
agent.reset_after = int(episode_length_seconds * TICKS_PER_SIM_SECOND)
|
agent.reset_after = int(episode_length_seconds * TICKS_PER_SIM_SECOND)
|
||||||
for key in _ai_overrides:
|
for key in _ai_overrides:
|
||||||
agent.set(key, _ai_overrides[key])
|
agent.set(key, _ai_overrides[key])
|
||||||
ship.add_child(agent)
|
ship.add_child(agent)
|
||||||
agent.setup(ship, ship.controller as RLShipController, ball, opponent, _attack_goal_position(ship.team))
|
agent.setup(ship, ship.controller as RLShipController, ball, teammates, opponents, _attack_goal_position(ship.team))
|
||||||
_agents.append(agent)
|
_agents.append(agent)
|
||||||
|
|
||||||
|
|
||||||
@@ -315,29 +346,11 @@ func _physics_process(_delta):
|
|||||||
# A goal (_on_goal_scored) does NOT do this — a goal is a
|
# A goal (_on_goal_scored) does NOT do this — a goal is a
|
||||||
# genuine terminal, V(s)=0 is correct there.
|
# genuine terminal, V(s)=0 is correct there.
|
||||||
agent.truncated_this_episode = true
|
agent.truncated_this_episode = true
|
||||||
agent.terminal_obs = ShipObservations.build(agent.ship, agent.opponent, agent.ball, agent.attack_goal_position)
|
agent.terminal_obs = ShipObservations.build(agent.ship, agent.teammates, agent.opponents, agent.ball, agent.attack_goal_position)
|
||||||
_reset_episode()
|
_reset_episode()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# Escape failsafe: see ESCAPE_MARGIN.
|
|
||||||
func _respawn_escaped_bodies() -> void:
|
|
||||||
for ship in ships:
|
|
||||||
if is_instance_valid(ship) and _escaped(ship.global_position):
|
|
||||||
push_warning("TrainingMode: ship escaped the enclosed arena — check boundary colliders")
|
|
||||||
_place_body(ship, _ship_spawn_transforms[ship], Vector3.ZERO, Vector3.ZERO)
|
|
||||||
if is_instance_valid(ball) and _escaped(ball.global_position):
|
|
||||||
push_warning("TrainingMode: ball escaped the enclosed arena — check boundary colliders")
|
|
||||||
_place_body(ball, arena.get_ball_spawn(), Vector3.ZERO, Vector3.ZERO)
|
|
||||||
|
|
||||||
|
|
||||||
func _escaped(position: Vector3) -> bool:
|
|
||||||
return absf(position.x) > ArenaBoundary.INNER_HALF_X + ESCAPE_MARGIN \
|
|
||||||
or absf(position.z) > ArenaBoundary.INNER_HALF_Z + ESCAPE_MARGIN \
|
|
||||||
or position.y < -ESCAPE_MARGIN \
|
|
||||||
or position.y > ArenaBoundary.INNER_HEIGHT + ESCAPE_MARGIN
|
|
||||||
|
|
||||||
|
|
||||||
func _on_goal_scored(conceding_team: int) -> void:
|
func _on_goal_scored(conceding_team: int) -> void:
|
||||||
if _eval:
|
if _eval:
|
||||||
_eval_goals[1 - conceding_team] += 1
|
_eval_goals[1 - conceding_team] += 1
|
||||||
@@ -416,17 +429,27 @@ func _place_air_drill() -> void:
|
|||||||
var ball_velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_BALL_SPEED * 0.5)
|
var ball_velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_BALL_SPEED * 0.5)
|
||||||
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), ball_velocity, Vector3.ZERO)
|
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), ball_velocity, Vector3.ZERO)
|
||||||
|
|
||||||
|
# Each ship's lateral offset is sampled independently, so with more than
|
||||||
|
# one ship per team (team_size > 1) two could otherwise land within their
|
||||||
|
# own hulls of each other — resample against every ship already placed
|
||||||
|
# this reset (see MIN_SHIP_SEPARATION).
|
||||||
|
var placed: Array[Vector3] = []
|
||||||
for ship in ships:
|
for ship in ships:
|
||||||
if ship in _inert_ships:
|
if ship in _inert_ships:
|
||||||
continue
|
continue
|
||||||
var lateral_offset := Vector3(randf_range(-1, 1), 0.0, randf_range(-1, 1))
|
var ship_position := Vector3.ZERO
|
||||||
lateral_offset = lateral_offset.normalized() if lateral_offset.length_squared() > 0.001 else Vector3.FORWARD
|
for _attempt in 20:
|
||||||
lateral_offset *= randf_range(6.0, 14.0)
|
var lateral_offset := Vector3(randf_range(-1, 1), 0.0, randf_range(-1, 1))
|
||||||
var ship_position := Vector3(
|
lateral_offset = lateral_offset.normalized() if lateral_offset.length_squared() > 0.001 else Vector3.FORWARD
|
||||||
clampf(ball_position.x + lateral_offset.x, -FIELD_HALF_X, FIELD_HALF_X),
|
lateral_offset *= randf_range(6.0, 14.0)
|
||||||
randf_range(FIELD_MIN_Y, 4.0),
|
ship_position = Vector3(
|
||||||
clampf(ball_position.z + lateral_offset.z, -FIELD_HALF_Z, FIELD_HALF_Z)
|
clampf(ball_position.x + lateral_offset.x, -FIELD_HALF_X, FIELD_HALF_X),
|
||||||
)
|
randf_range(FIELD_MIN_Y, 4.0),
|
||||||
|
clampf(ball_position.z + lateral_offset.z, -FIELD_HALF_Z, FIELD_HALF_Z)
|
||||||
|
)
|
||||||
|
if _far_enough_from(ship_position, placed):
|
||||||
|
break
|
||||||
|
placed.append(ship_position)
|
||||||
var orientation := Basis.from_euler(Vector3(
|
var orientation := Basis.from_euler(Vector3(
|
||||||
randf_range(-0.4, 0.4), randf_range(-PI, PI), randf_range(-0.4, 0.4)
|
randf_range(-0.4, 0.4), randf_range(-PI, PI), randf_range(-0.4, 0.4)
|
||||||
))
|
))
|
||||||
@@ -453,6 +476,10 @@ func _place_ball_near_goal() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _place_ships_random() -> void:
|
func _place_ships_random() -> void:
|
||||||
|
# Placed one at a time, resampling each against every position already
|
||||||
|
# placed this reset (see MIN_SHIP_SEPARATION) — otherwise a team_size > 1
|
||||||
|
# roster is sampled independently per ship and can spawn interpenetrating.
|
||||||
|
var placed: Array[Vector3] = []
|
||||||
for ship in ships:
|
for ship in ships:
|
||||||
# Inert opponents (opponent_mode=inert) stay parked at their arena
|
# Inert opponents (opponent_mode=inert) stay parked at their arena
|
||||||
# spawn instead of drifting into the play area as a stray obstacle —
|
# spawn instead of drifting into the play area as a stray obstacle —
|
||||||
@@ -465,13 +492,17 @@ func _place_ships_random() -> void:
|
|||||||
randf_range(-0.4, 0.4)
|
randf_range(-0.4, 0.4)
|
||||||
))
|
))
|
||||||
var velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_SHIP_SPEED)
|
var velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_SHIP_SPEED)
|
||||||
_place_body(ship, Transform3D(orientation, _random_position()), velocity, Vector3.ZERO)
|
var position := _random_position(placed)
|
||||||
|
placed.append(position)
|
||||||
|
_place_body(ship, Transform3D(orientation, position), velocity, Vector3.ZERO)
|
||||||
|
|
||||||
|
|
||||||
func _random_position() -> Vector3:
|
func _random_position(exclude: Array[Vector3] = []) -> Vector3:
|
||||||
# Resample anything too close to a corner curve or wall-base fillet (see
|
# Resample anything too close to a corner curve or wall-base fillet (see
|
||||||
# CORNER_LIMIT / FILLET_CLEAR_Y); the violating region is a few percent
|
# CORNER_LIMIT / FILLET_CLEAR_Y), or too close to an already-placed ship
|
||||||
# of the volume, so 20 attempts effectively never fall through.
|
# this same reset (see MIN_SHIP_SEPARATION); the violating region is a
|
||||||
|
# few percent of the volume, so 20 attempts effectively never fall
|
||||||
|
# through even placing a full 5v5 roster one at a time.
|
||||||
var position := Vector3.ZERO
|
var position := Vector3.ZERO
|
||||||
for _attempt in 20:
|
for _attempt in 20:
|
||||||
position = Vector3(
|
position = Vector3(
|
||||||
@@ -479,11 +510,18 @@ func _random_position() -> Vector3:
|
|||||||
randf_range(FIELD_MIN_Y, FIELD_MAX_Y),
|
randf_range(FIELD_MIN_Y, FIELD_MAX_Y),
|
||||||
randf_range(-FIELD_HALF_Z, FIELD_HALF_Z)
|
randf_range(-FIELD_HALF_Z, FIELD_HALF_Z)
|
||||||
)
|
)
|
||||||
if _spawn_position_clear(position):
|
if _spawn_position_clear(position) and _far_enough_from(position, exclude):
|
||||||
break
|
break
|
||||||
return position
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
func _far_enough_from(position: Vector3, others: Array[Vector3]) -> bool:
|
||||||
|
for other in others:
|
||||||
|
if position.distance_squared_to(other) < MIN_SHIP_SEPARATION * MIN_SHIP_SEPARATION:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
func _spawn_position_clear(position: Vector3) -> bool:
|
func _spawn_position_clear(position: Vector3) -> bool:
|
||||||
if absf(position.x) + absf(position.z) > CORNER_LIMIT:
|
if absf(position.x) + absf(position.z) > CORNER_LIMIT:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ uniform float soft_fade_distance : hint_range(0.0, 5.0) = 1.0;
|
|||||||
// see ArenaBoundary.INNER_HALF_X/INNER_HALF_Z/INNER_HEIGHT. Dust is fully
|
// see ArenaBoundary.INNER_HALF_X/INNER_HALF_Z/INNER_HEIGHT. Dust is fully
|
||||||
// hidden inside that box and fades in over arena_clear_distance beyond it,
|
// hidden inside that box and fades in over arena_clear_distance beyond it,
|
||||||
// so it reads as drifting around the station rather than through the pitch.
|
// so it reads as drifting around the station rather than through the pitch.
|
||||||
uniform vec3 arena_half_extents = vec3(12.0, 6.0, 18.0);
|
uniform vec3 arena_half_extents = vec3(18.0, 9.0, 27.0);
|
||||||
uniform vec3 arena_centre = vec3(0.0, 6.0, 0.0);
|
uniform vec3 arena_centre = vec3(0.0, 9.0, 0.0);
|
||||||
uniform float arena_clear_distance : hint_range(0.0, 10.0) = 2.0;
|
uniform float arena_clear_distance : hint_range(0.0, 10.0) = 2.0;
|
||||||
|
|
||||||
varying float v_flicker;
|
varying float v_flicker;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# One-off bake: runs ArenaBoundary's collider generation once and commits the
|
||||||
|
# result into objects/arena_boundary.tscn, so every arena load (and every
|
||||||
|
# parallel headless training env) skips regenerating ~170 CollisionShape3D
|
||||||
|
# nodes at runtime — ArenaBoundary._ready() already skips this itself once a
|
||||||
|
# bake matching the current goal_mode exists (see bake_colliders).
|
||||||
|
#
|
||||||
|
# Safe to re-run any time a geometry constant changes (arena size, corner/
|
||||||
|
# fillet radii, goal aperture, segment counts, etc.) — bake_colliders() clears
|
||||||
|
# its own prior output first, so this replaces rather than duplicates:
|
||||||
|
# godot --headless --path Game --script res://tools/bake_arena_boundary.gd
|
||||||
|
|
||||||
|
const ARENA_BOUNDARY_PATH := "res://objects/arena_boundary.tscn"
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var packed: PackedScene = load(ARENA_BOUNDARY_PATH)
|
||||||
|
var instance := packed.instantiate()
|
||||||
|
instance.bake_colliders()
|
||||||
|
# PackedScene.pack() only serializes nodes whose `owner` is set to the
|
||||||
|
# scene root — newly created nodes have no owner by default, so without
|
||||||
|
# this, pack() silently produces a scene with none of the just-built
|
||||||
|
# colliders in it.
|
||||||
|
_set_owner_recursive(instance, instance)
|
||||||
|
|
||||||
|
var rebaked := PackedScene.new()
|
||||||
|
var pack_result := rebaked.pack(instance)
|
||||||
|
if pack_result != OK:
|
||||||
|
push_error("bake_arena_boundary: pack() failed with error %d" % pack_result)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var save_result := ResourceSaver.save(rebaked, ARENA_BOUNDARY_PATH)
|
||||||
|
if save_result != OK:
|
||||||
|
push_error("bake_arena_boundary: save() failed with error %d" % save_result)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("bake_arena_boundary: baked colliders into %s" % ARENA_BOUNDARY_PATH)
|
||||||
|
quit()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_owner_recursive(node: Node, scene_root: Node) -> void:
|
||||||
|
for child in node.get_children():
|
||||||
|
child.owner = scene_root
|
||||||
|
_set_owner_recursive(child, scene_root)
|
||||||
@@ -10,18 +10,6 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
|
|||||||
- [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs.
|
- [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs.
|
||||||
- [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place).
|
- [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place).
|
||||||
|
|
||||||
## Correctness
|
|
||||||
|
|
||||||
Bugs found in an adversarial review. None are gameplay- or physics-affecting, so all are safe to land against the current `Game/bots/` checkpoints.
|
|
||||||
|
|
||||||
- [ ] 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.
|
|
||||||
- [ ] Delete the duplicate 1 MB texture — `assets/textures/planet_surface.png` and `assets/models/nebula_planet_planet_surface.png` are byte-identical.
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- [ ] 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.
|
|
||||||
|
|
||||||
## Presentation / AAA polish
|
## Presentation / AAA polish
|
||||||
|
|
||||||
The largest gap between this and a AAA-feeling product is presentation, not code. Sequenced after the above for pragmatic reasons, but this is the highest impact per hour.
|
The largest gap between this and a AAA-feeling product is presentation, not code. Sequenced after the above for pragmatic reasons, but this is the highest impact per hour.
|
||||||
@@ -37,15 +25,6 @@ The largest gap between this and a AAA-feeling product is presentation, not code
|
|||||||
- [ ] Custom font + a real `Theme` resource for the HUD. The procedural instruments are well-engineered, but `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.
|
- [ ] Custom font + a real `Theme` resource for the HUD. The procedural instruments are well-engineered, but `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.
|
||||||
- [ ] Post-processing beyond glow: DoF, motion blur, vignette, chromatic aberration on turbo.
|
- [ ] Post-processing beyond glow: DoF, motion blur, vignette, chromatic aberration on turbo.
|
||||||
|
|
||||||
## Open decision — trained-bot compatibility
|
|
||||||
|
|
||||||
Four items collide with the checkpoints in `Game/bots/`. Decide the policy before scheduling any of them; everything in Correctness and DRY above is safe either way.
|
|
||||||
|
|
||||||
- [ ] **Per-tick drag** (`ship.gd`): `state.linear_velocity *= drag_coefficient` and `angular_velocity *= 0.9` aren't delta-scaled, and `project.godot` never pins `physics/common/physics_ticks_per_second`. Correct at 60 Hz, silently different at any other rate. Zero-risk option: pin the tick rate to 60 and document the dependency. Correct-but-breaking option: delta-scale it and retrain.
|
|
||||||
- [ ] **Goal aperture** — cutting a real opening in the end walls so the ball visibly enters the net changes collision geometry the policies were fitted against. Today the walls are solid and the pocket/net are unreachable decoration.
|
|
||||||
- [ ] **Collider bake** (see Performance above) — safe only if byte-identical to the current generated output.
|
|
||||||
- [ ] **Beyond 1v1** — `ai_ship_controller.gd` takes the first non-self ship as "the opponent" and `ship_observations.gd` has room for exactly one. The observation space is the hardest thing to change later, so decide whether 2v2/3v3 is in scope before more training time is spent.
|
|
||||||
|
|
||||||
## Multiplayer (long term)
|
## Multiplayer (long term)
|
||||||
|
|
||||||
- [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation.
|
- [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Generates Game/assets/textures/planet_surface.png (and the byte-identical
|
"""Generates Game/assets/models/nebula_planet_planet_surface.png, the sidecar
|
||||||
Game/assets/models/nebula_planet_planet_surface.png sidecar that actually feeds
|
that actually feeds the live nebula_planet.glb material -- Godot's glTF
|
||||||
the live nebula_planet.glb material -- Godot's glTF importer extracted the
|
importer extracted the embedded image there at import time, so the imported
|
||||||
embedded image there at import time, so the imported scene references that
|
scene references that external file, not the glb's internal binary. A
|
||||||
external file, not the glb's internal binary): a 2048x1024 equirectangular
|
2048x1024 equirectangular decorative planet with latitude bands, storm
|
||||||
decorative planet with latitude bands, storm vortices, and a lit/unlit
|
vortices, and a lit/unlit terminator, via layered FFT/domain-warped noise in
|
||||||
terminator, via layered FFT/domain-warped noise in the same style as
|
the same style as gen_nebula_sky.py (helpers duplicated here to keep both
|
||||||
gen_nebula_sky.py (helpers duplicated here to keep both scripts standalone).
|
scripts standalone).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -178,12 +178,8 @@ color = np.clip(color, 0.0, 1.0)
|
|||||||
|
|
||||||
print("saving planet surface...")
|
print("saving planet surface...")
|
||||||
root = Path(__file__).resolve().parents[2]
|
root = Path(__file__).resolve().parents[2]
|
||||||
out_paths = [
|
out_path = root / "Game" / "assets" / "models" / "nebula_planet_planet_surface.png"
|
||||||
root / "Game" / "assets" / "textures" / "planet_surface.png",
|
|
||||||
root / "Game" / "assets" / "models" / "nebula_planet_planet_surface.png",
|
|
||||||
]
|
|
||||||
img = Image.fromarray((color * 255).astype(np.uint8), "RGB")
|
img = Image.fromarray((color * 255).astype(np.uint8), "RGB")
|
||||||
for p in out_paths:
|
img.save(out_path)
|
||||||
img.save(p)
|
print("saved:", out_path)
|
||||||
print("saved:", p)
|
|
||||||
print("done")
|
print("done")
|
||||||
|
|||||||
Reference in New Issue
Block a user