mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(*): Replace the test terrain arena with an enclosed standard-size space-platform arena (shared ArenaBoundary floor/walls/ceiling scene, starfield sky, ball CCD) and derive TrainingMode placement bounds from it, dropping the out-of-bounds reward guard
This commit is contained in:
@@ -40,7 +40,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
|
||||
|
||||
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
|
||||
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
|
||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — terrain, lighting, two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
||||
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
||||
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
|
||||
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
@@ -1,41 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cqubdkwj11v1i"
|
||||
path.s3tc="res://.godot/imported/Grass.jpg-6a640b9c5e86d03c7318eda5c14b9c10.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/Grass.jpg"
|
||||
dest_files=["res://.godot/imported/Grass.jpg-6a640b9c5e86d03c7318eda5c14b9c10.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
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=true
|
||||
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=0
|
||||
@@ -1,13 +0,0 @@
|
||||
# Blender MTL File: 'None'
|
||||
# Material Count: 1
|
||||
|
||||
newmtl Grass
|
||||
Ns 0.000000
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Kd 0.800000 0.800000 0.800000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ke 0.000000 0.000000 0.000000
|
||||
Ni 1.450000
|
||||
d 1.000000
|
||||
illum 2
|
||||
map_Kd C:\\Users\\Daniel\\Desktop\\Godot Projects\\OpenWorldGame\\Procedural Generation\\Textures\\Grass.jpg
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="wavefront_obj"
|
||||
importer_version=1
|
||||
type="Mesh"
|
||||
uid="uid://cndcxj6gf0smr"
|
||||
path="res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"
|
||||
|
||||
[deps]
|
||||
|
||||
files=["res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"]
|
||||
|
||||
source_file="res://assets/models/Terrain.obj"
|
||||
dest_files=["res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh", "res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"]
|
||||
|
||||
[params]
|
||||
|
||||
generate_tangents=true
|
||||
generate_lods=true
|
||||
generate_shadow_mesh=true
|
||||
generate_lightmap_uv2=false
|
||||
generate_lightmap_uv2_texel_size=0.2
|
||||
scale_mesh=Vector3(1, 1, 1)
|
||||
offset_mesh=Vector3(0, 0, 0)
|
||||
force_disable_mesh_compression=false
|
||||
@@ -1,6 +0,0 @@
|
||||
[gd_resource type="StandardMaterial3D" load_steps=2 format=3 uid="uid://drm3clvqpis42"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://cqubdkwj11v1i" path="res://assets/models/Grass.jpg" id="1_imo8j"]
|
||||
|
||||
[resource]
|
||||
albedo_texture = ExtResource("1_imo8j")
|
||||
@@ -0,0 +1,94 @@
|
||||
[gd_scene load_steps=11 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/arena_boundary.gd" id="1_bndry"]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
|
||||
albedo_color = Color(0.08, 0.09, 0.12, 1)
|
||||
metallic = 0.3
|
||||
roughness = 0.6
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_field"]
|
||||
transparency = 1
|
||||
shading_mode = 0
|
||||
albedo_color = Color(0, 0.368627, 1, 0.08)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
|
||||
material = SubResource("StandardMaterial3D_floor")
|
||||
size = Vector3(28, 1, 40)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_ceiling"]
|
||||
material = SubResource("StandardMaterial3D_field")
|
||||
size = Vector3(28, 1, 40)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_side_wall"]
|
||||
material = SubResource("StandardMaterial3D_field")
|
||||
size = Vector3(1, 13, 38)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_end_wall"]
|
||||
material = SubResource("StandardMaterial3D_field")
|
||||
size = Vector3(26, 13, 1)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_slab"]
|
||||
size = Vector3(28, 1, 40)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_side_wall"]
|
||||
size = Vector3(1, 13, 38)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_end_wall"]
|
||||
size = Vector3(26, 13, 1)
|
||||
|
||||
[node name="ArenaBoundary" type="StaticBody3D"]
|
||||
script = ExtResource("1_bndry")
|
||||
|
||||
[node name="FloorShape" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
|
||||
shape = SubResource("BoxShape3D_slab")
|
||||
|
||||
[node name="FloorMesh" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
|
||||
mesh = SubResource("BoxMesh_floor")
|
||||
|
||||
[node name="WallPosXShape" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
|
||||
shape = SubResource("BoxShape3D_side_wall")
|
||||
|
||||
[node name="WallPosXMesh" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
|
||||
cast_shadow = 0
|
||||
mesh = SubResource("BoxMesh_side_wall")
|
||||
|
||||
[node name="WallNegXShape" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
|
||||
shape = SubResource("BoxShape3D_side_wall")
|
||||
|
||||
[node name="WallNegXMesh" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
|
||||
cast_shadow = 0
|
||||
mesh = SubResource("BoxMesh_side_wall")
|
||||
|
||||
[node name="WallPosZShape" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
|
||||
shape = SubResource("BoxShape3D_end_wall")
|
||||
|
||||
[node name="WallPosZMesh" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
|
||||
cast_shadow = 0
|
||||
mesh = SubResource("BoxMesh_end_wall")
|
||||
|
||||
[node name="WallNegZShape" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
|
||||
shape = SubResource("BoxShape3D_end_wall")
|
||||
|
||||
[node name="WallNegZMesh" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
|
||||
cast_shadow = 0
|
||||
mesh = SubResource("BoxMesh_end_wall")
|
||||
|
||||
[node name="CeilingShape" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
|
||||
shape = SubResource("BoxShape3D_slab")
|
||||
|
||||
[node name="CeilingMesh" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
|
||||
cast_shadow = 0
|
||||
mesh = SubResource("BoxMesh_ceiling")
|
||||
@@ -11,6 +11,7 @@ friction = 0.3
|
||||
[node name="Ball" type="RigidBody3D" groups=["ball"]]
|
||||
mass = 3
|
||||
physics_material_override = SubResource("PhysicsMaterial_ball")
|
||||
continuous_cd = true
|
||||
inertia = Vector3(3, 3, 3)
|
||||
gravity_scale = 0.8
|
||||
linear_damp = 0.1
|
||||
|
||||
+37
-24
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
class_name ArenaBoundary
|
||||
extends StaticBody3D
|
||||
|
||||
# The standard arena play volume (inner faces of the enclosure). Every arena
|
||||
# instances objects/arena_boundary.tscn so all arenas share one size; code
|
||||
# that needs field dimensions derives them from these constants rather than
|
||||
# restating numbers.
|
||||
const INNER_HALF_X := 12.0
|
||||
const INNER_HALF_Z := 18.0
|
||||
const INNER_HEIGHT := 12.0
|
||||
# Goal-centre distance from arena centre; the end walls sit 1 m behind, so a
|
||||
# ball pinned against them still overlaps the goal sensor.
|
||||
const GOAL_LINE_Z := 17.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://f32otkpo3lvr
|
||||
@@ -11,8 +11,9 @@ extends RefCounted
|
||||
# The same rotation must be inverted when interpreting actions (see canon —
|
||||
# it is its own inverse).
|
||||
|
||||
# Normalization scales. Arena bounds: goals at z ≈ ±15.56, ship spawns at
|
||||
# z = ±12; positions are soft-normalized to roughly [-1, 1].
|
||||
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±12,
|
||||
# z ±18, height 12, goals at z ±17; positions are soft-normalized to roughly
|
||||
# [-1, 1]. Do not retune without retraining every model in Game/bots/.
|
||||
const POSITION_SCALE := Vector3(20.0, 10.0, 20.0)
|
||||
const BALL_SPEED_SCALE := 30.0
|
||||
const GOAL_DISTANCE_SCALE := 40.0
|
||||
|
||||
@@ -24,24 +24,26 @@ extends GameMode
|
||||
@export_range(0.0, 1.0) var kickoff_state_chance := 0.2
|
||||
@export_range(0.0, 1.0) var ball_near_goal_chance := 0.2
|
||||
|
||||
# Placement bounds, inset from the arena (goals at z ≈ ±15.56).
|
||||
const FIELD_HALF_X := 10.0
|
||||
const FIELD_HALF_Z := 13.0
|
||||
# Placement bounds for randomized episode starts, derived from the standard
|
||||
# enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4
|
||||
# box, worst-case half-extent ~2.05) from spawning intersecting the walls,
|
||||
# ceiling, or goal sensors.
|
||||
const SPAWN_INSET := 2.5
|
||||
const FIELD_HALF_X := ArenaBoundary.INNER_HALF_X - SPAWN_INSET
|
||||
const FIELD_HALF_Z := ArenaBoundary.GOAL_LINE_Z - SPAWN_INSET
|
||||
const FIELD_MIN_Y := 1.5
|
||||
const FIELD_MAX_Y := 8.0
|
||||
const FIELD_MAX_Y := ArenaBoundary.INNER_HEIGHT - SPAWN_INSET
|
||||
const MAX_RANDOM_BALL_SPEED := 12.0
|
||||
const MAX_RANDOM_SHIP_SPEED := 8.0
|
||||
|
||||
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
|
||||
const TICKS_PER_SIM_SECOND := 60.0
|
||||
|
||||
# The arena has no walls/ceiling yet (see TODO.md), so an untrained policy can
|
||||
# simply fly away. Leaving this volume ends the episode with a small penalty.
|
||||
const BOUNDS_HALF_X := 25.0
|
||||
const BOUNDS_HALF_Z := 25.0
|
||||
const BOUNDS_MIN_Y := -5.0
|
||||
const BOUNDS_MAX_Y := 25.0
|
||||
@export var out_of_bounds_penalty := 1.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] = []
|
||||
|
||||
@@ -102,13 +104,10 @@ func _attack_goal_position(team: int) -> Vector3:
|
||||
|
||||
|
||||
func _physics_process(_delta):
|
||||
_respawn_escaped_bodies()
|
||||
if _eval:
|
||||
_episode_ticks += 1
|
||||
for ship in ships:
|
||||
if is_instance_valid(ship) and _out_of_bounds(ship.global_position):
|
||||
_place_body(ship, _ship_spawn_transforms[ship], Vector3.ZERO, Vector3.ZERO)
|
||||
var ball_lost := is_instance_valid(ball) and _out_of_bounds(ball.global_position)
|
||||
if _episode_ticks > int(episode_length_seconds * TICKS_PER_SIM_SECOND) or ball_lost:
|
||||
if _episode_ticks > int(episode_length_seconds * TICKS_PER_SIM_SECOND):
|
||||
_eval_draws += 1
|
||||
_end_eval_episode()
|
||||
return
|
||||
@@ -128,23 +127,23 @@ func _physics_process(_delta):
|
||||
_reset_episode()
|
||||
return
|
||||
|
||||
# A ship leaving the play volume is penalized and respawned at its kickoff
|
||||
# spawn (the episode continues); a lost ball ends the episode for both.
|
||||
for agent in _agents:
|
||||
if _out_of_bounds(agent.ship.global_position):
|
||||
agent.reward -= out_of_bounds_penalty
|
||||
_place_body(agent.ship, _ship_spawn_transforms[agent.ship], Vector3.ZERO, Vector3.ZERO)
|
||||
if is_instance_valid(ball) and _out_of_bounds(ball.global_position) and not _agents.is_empty():
|
||||
for agent in _agents:
|
||||
agent.done = true
|
||||
_reset_episode()
|
||||
|
||||
# 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 _out_of_bounds(position: Vector3) -> bool:
|
||||
return absf(position.x) > BOUNDS_HALF_X \
|
||||
or absf(position.z) > BOUNDS_HALF_Z \
|
||||
or position.y < BOUNDS_MIN_Y \
|
||||
or position.y > BOUNDS_MAX_Y
|
||||
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:
|
||||
|
||||
@@ -29,5 +29,4 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
|
||||
## General
|
||||
|
||||
- [ ] No autoloads yet by design — add a singleton only when cross-scene state is actually needed (e.g. passing match settings/results between menu, match, and results screens).
|
||||
- [ ] More arenas: `arena_01.tscn` is the template — an arena is terrain + lighting + two team-tagged goals + spawn markers, with no rules or state.
|
||||
- [ ] Enclose the arena (walls/ceiling): ships and ball can currently fly out of the play volume. TrainingMode papers over this with an out-of-bounds episode-end + penalty; a real enclosed arena fixes it for players too and the training guard can then be removed.
|
||||
- [ ] More arenas: `arena_01.tscn` is the template — an arena is a setting (sky, lighting, decoration) + two team-tagged goals on the standard goal lines + spawn markers + an instance of the shared `objects/arena_boundary.tscn`, with no rules or state. All arenas share the standard play volume defined by `ArenaBoundary`'s constants.
|
||||
|
||||
Reference in New Issue
Block a user