Compare commits

...

11 Commits

Author SHA1 Message Date
Josh Creek 4f075b352f feat(*): Add orientation-readable team-coloured ship meshes, smooth the ball-cam orbit, and hide arena walls the camera is outside of 2026-07-19 22:18:21 +01:00
CosmicClash Training Bot dfb7285704 chore(training): Add run05 checkpoints, logs, and exported policy 2026-07-19 21:58:08 +01:00
Josh Creek d917d2742b feat(training): Add next_run.sh to idempotently kick off the next run resuming from the newest final.zip 2026-07-19 19:16:01 +01:00
Josh Creek cd94252f92 chore(*): Remove run05 artifacts trained with collapsed exploration 2026-07-19 19:16:01 +01:00
CosmicClash Training Bot c517a85508 chore(training): Add run05 checkpoints, logs, and exported policy 2026-07-19 19:11:31 +01:00
Josh Creek 4c227c24b4 feat(*): Add a distance-to-ball penalty so idling scores negative, boost ball-seeking rewards, and halve the wall-contact penalty 2026-07-19 18:04:36 +01:00
CosmicClash Training Bot 80e9b676d5 chore(training): Add run04 checkpoints, logs, and exported policy 2026-07-19 17:54:36 +01:00
Josh Creek c8f052854c fix(*): Detect wall contact by contact normal so floor contact anywhere on the pitch is exempt from the wall penalty 2026-07-19 15:53:30 +01:00
Josh Creek 4e2d406aa2 chore(*): Remove run03 artifacts trained against the floor-taxed reward 2026-07-19 15:35:55 +01:00
Josh Creek 7777280062 feat(*): Exempt the floor from the wall-contact penalty, add a tilt penalty for non-upright flight, and double velocity-to-ball shaping 2026-07-19 15:34:34 +01:00
CosmicClash Training Bot 5973d0893b chore(training): Add run03 checkpoints, logs, and exported policy 2026-07-19 15:21:06 +01:00
415 changed files with 288 additions and 68 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+66 -5
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=5 format=3 uid="uid://p07epxnh8wwp"]
[gd_scene load_steps=13 format=3 uid="uid://p07epxnh8wwp"]
[ext_resource type="Script" uid="uid://dyq1n7q1bqjps" path="res://scripts/ship.gd" id="1_efag7"]
@@ -6,8 +6,52 @@
friction = 0.1
bounce = 0.2
[sub_resource type="BoxMesh" id="BoxMesh_efag7"]
size = Vector3(1, 1, 4)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hull"]
albedo_color = Color(0.35, 0.37, 0.42, 1)
metallic = 0.6
roughness = 0.4
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_accent"]
albedo_color = Color(0.25, 0.55, 1, 1)
metallic = 0.3
roughness = 0.5
emission_enabled = true
emission = Color(0.25, 0.55, 1, 1)
emission_energy_multiplier = 0.35
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_canopy"]
albedo_color = Color(0.15, 0.85, 1, 1)
metallic = 0.8
roughness = 0.1
emission_enabled = true
emission = Color(0.15, 0.85, 1, 1)
emission_energy_multiplier = 0.5
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_engine"]
albedo_color = Color(1, 0.55, 0.15, 1)
emission_enabled = true
emission = Color(1, 0.55, 0.15, 1)
emission_energy_multiplier = 2.0
[sub_resource type="BoxMesh" id="BoxMesh_hull"]
material = SubResource("StandardMaterial3D_hull")
size = Vector3(1, 0.75, 2.7)
[sub_resource type="PrismMesh" id="PrismMesh_nose"]
material = SubResource("StandardMaterial3D_accent")
size = Vector3(1, 1.4, 0.75)
[sub_resource type="BoxMesh" id="BoxMesh_canopy"]
material = SubResource("StandardMaterial3D_canopy")
size = Vector3(0.55, 0.28, 0.8)
[sub_resource type="BoxMesh" id="BoxMesh_fin"]
material = SubResource("StandardMaterial3D_accent")
size = Vector3(0.1, 0.5, 0.8)
[sub_resource type="BoxMesh" id="BoxMesh_engine"]
material = SubResource("StandardMaterial3D_engine")
size = Vector3(0.7, 0.5, 0.25)
[sub_resource type="BoxShape3D" id="BoxShape3D_dsjou"]
size = Vector3(1, 1, 4)
@@ -18,8 +62,25 @@ physics_material_override = SubResource("PhysicsMaterial_ship")
inertia = Vector3(1, 1, 1)
script = ExtResource("1_efag7")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("BoxMesh_efag7")
[node name="Hull" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.125, 0.35)
mesh = SubResource("BoxMesh_hull")
[node name="Nose" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0, 1, 0, -1, 0, 0, -0.125, -1.3)
mesh = SubResource("PrismMesh_nose")
[node name="Canopy" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.36, -0.5)
mesh = SubResource("BoxMesh_canopy")
[node name="TailFin" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.25, 1.25)
mesh = SubResource("BoxMesh_fin")
[node name="EngineGlow" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.125, 1.85)
mesh = SubResource("BoxMesh_engine")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_dsjou")
+2
View File
@@ -119,3 +119,5 @@ roll_right={
+1 -1
View File
@@ -6,7 +6,7 @@
[node name="Match" type="Node3D"]
script = ExtResource("1_m")
bot_model_path = "res://bots/rookie.json"
bot_model_path = "res://bots/run03.json"
[node name="Arena" parent="." instance=ExtResource("2_m")]
+22
View File
@@ -11,3 +11,25 @@ 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
@onready var _wall_pos_x: MeshInstance3D = $WallPosXMesh
@onready var _wall_neg_x: MeshInstance3D = $WallNegXMesh
@onready var _wall_pos_z: MeshInstance3D = $WallPosZMesh
@onready var _wall_neg_z: MeshInstance3D = $WallNegZMesh
@onready var _ceiling: MeshInstance3D = $CeilingMesh
func _process(_delta: float) -> void:
# The translucent field material tints everything behind it, so any face
# the camera has crossed to the outside of is hidden entirely — looking
# into the arena from outside stays clear, while faces seen from inside
# keep their tint. Collision is untouched; only the meshes toggle.
var camera := get_viewport().get_camera_3d()
if camera == null:
return # headless (RL/CI) has no camera
var p := to_local(camera.global_position)
_wall_pos_x.visible = p.x < INNER_HALF_X
_wall_neg_x.visible = p.x > -INNER_HALF_X
_wall_pos_z.visible = p.z < INNER_HALF_Z
_wall_neg_z.visible = p.z > -INNER_HALF_Z
_ceiling.visible = p.y < INNER_HEIGHT
+30 -1
View File
@@ -19,8 +19,18 @@ extends RigidBody3D
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
@export var angular_drag = 0.95 # Rotational drag
# Accent colours per team, applied to the nose and tail fin meshes so the
# two sides are tellable apart at a glance.
const TEAM_COLORS := {
0: Color(0.25, 0.55, 1.0),
1: Color(1.0, 0.5, 0.15),
}
# Which team this ship plays for (0 or 1). Set by the game mode on spawn.
var team: int = 0
var team: int = 0:
set(value):
team = value
_apply_team_color()
var controller: ShipController
var _current_action: ShipAction = ShipAction.new()
@@ -62,6 +72,25 @@ func _ready():
controller = child
break
_apply_team_color()
func _apply_team_color() -> void:
if not is_inside_tree():
return
var color: Color = TEAM_COLORS.get(team, TEAM_COLORS[0])
var accent := StandardMaterial3D.new()
accent.albedo_color = color
accent.metallic = 0.3
accent.roughness = 0.5
accent.emission_enabled = true
accent.emission = color
accent.emission_energy_multiplier = 0.35
for mesh_name in ["Nose", "TailFin"]:
var mesh := get_node_or_null(mesh_name) as MeshInstance3D
if mesh:
mesh.material_override = accent
# Attach the node that drives this ship (player, AI, or network). Replaces
# any existing controller; parents the new one under the ship if needed.
+63 -14
View File
@@ -15,14 +15,39 @@ extends AIController3D
# Reward shaping weights. Dense terms accrue per physics tick (60 sim-ticks
# per sim-second); event terms fire once. Exported so tuning needs no code
# edits. Goal rewards are added by TrainingMode, which owns goal events.
@export var ball_touch_reward := 0.25
@export var velocity_to_ball_weight := 0.001
@export var ball_touch_reward := 1.0
@export var velocity_to_ball_weight := 0.02
@export var ball_velocity_to_goal_weight := 0.004
# Per-tick penalty while touching the arena enclosure (walls/floor/ceiling).
# At 60 ticks/sim-second this is -0.3/s: a ship parked on a wall for a full
# 30 s episode loses ~9 — comparable to conceding — while a brief graze
# costs almost nothing.
@export var wall_contact_penalty := 0.005
# Per-tick penalty scaled by distance to the ball (full value at the arena's
# far diagonal, 0 on top of the ball). Run04 lesson: with idling worth a flat
# 0, camping in a corner strictly dominated risking the wall/tilt penalties
# to chase the ball — this makes "do nothing far from the ball" the worst
# option instead of the safest. A penalty, not a proximity bonus, so orbiting
# the ball farms nothing.
@export var ball_distance_penalty := 0.002
# Per-tick penalty while pressed against a side wall, end wall, or the
# ceiling — NOT the floor (run03 lesson: taxing floor contact punishes the
# ship's natural low flight and drowns every other signal). At 60 ticks per
# sim-second this is -0.15/s. Halved for run05: the ball lives near walls,
# and the old -0.3/s made the productive region of the pitch aversive
# relative to the (then far weaker) ball-seeking shaping.
@export var wall_contact_penalty := 0.0025
# Per-tick penalty for not being upright, scaled by tilt: 0 when flat, full
# value (-0.12/s) when inverted. A penalty rather than an upright bonus so a
# flat, idle ship farms nothing.
@export var tilt_penalty := 0.002
# Contact normals with y above this are floor contact (exempt from the wall
# penalty); below it they read as wall (sideways) or ceiling (downward).
const FLOOR_NORMAL_MIN_Y := 0.7
# Longest possible ship-to-ball separation: the enclosure's interior diagonal.
# Normalizes ball_distance_penalty so its export is the worst-case per-tick cost.
const MAX_BALL_DISTANCE := sqrt(
(2.0 * ArenaBoundary.INNER_HALF_X) ** 2
+ (2.0 * ArenaBoundary.INNER_HALF_Z) ** 2
+ ArenaBoundary.INNER_HEIGHT ** 2
)
var ship: Ship
var rl_controller: RLShipController
@@ -83,20 +108,44 @@ func _physics_process(delta):
var closing_speed := ship.linear_velocity.dot(to_ball.normalized())
reward += velocity_to_ball_weight * closing_speed / ship.max_speed
# Dense penalty: distance to the ball, so idling far away bleeds reward
# instead of scoring a safe zero (see ball_distance_penalty).
if ball_distance_penalty > 0.0:
reward -= ball_distance_penalty * to_ball.length() / MAX_BALL_DISTANCE
# Dense shaping: ball velocity toward the goal we attack
var ball_to_goal := attack_goal_position - ball.global_position
if ball_to_goal.length_squared() > 0.0001:
var ball_progress := ball.linear_velocity.dot(ball_to_goal.normalized())
reward += ball_velocity_to_goal_weight * ball_progress / ShipObservations.BALL_SPEED_SCALE
# Dense penalty: every tick spent in contact with the arena enclosure
# Dense penalty: every tick spent pressed against a wall or the ceiling
# (contact monitoring is already on for the ball-touch reward). Ships
# bumping each other or the ball is fine — only the boundary counts.
if wall_contact_penalty > 0.0:
for body in ship.get_colliding_bodies():
if body is ArenaBoundary:
reward -= wall_contact_penalty
break
# bumping each other, the ball, or the floor is fine. The boundary is one
# body, so the contact normal tells us which surface: floor contact
# pushes the ship up (+Y), walls push sideways, the ceiling down.
if wall_contact_penalty > 0.0 and _wall_or_ceiling_contact():
reward -= wall_contact_penalty
# Dense penalty: tilt away from upright (0 flat, max when inverted) —
# discourages ending up on a side or roof without rewarding idleness.
if tilt_penalty > 0.0:
var uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP)
reward -= tilt_penalty * (1.0 - uprightness) * 0.5
func _wall_or_ceiling_contact() -> bool:
var state := PhysicsServer3D.body_get_direct_state(ship.get_rid())
if state == null:
return false
for i in state.get_contact_count():
if not state.get_contact_collider_object(i) is ArenaBoundary:
continue
# Normal points from the surface into the ship: floor ≈ +Y (exempt),
# anything flatter or downward is a wall or the ceiling.
if state.get_contact_local_normal(i).y < FLOOR_NORMAL_MIN_Y:
return true
return false
func _on_ship_body_entered(body: Node) -> void:
+51 -46
View File
@@ -9,8 +9,11 @@ extends Node3D
signal camera_mode_changed(is_ball_cam: bool)
@export var camera_distance := 8.0
@export var camera_height := 4.0
@export var camera_height := 3.0
@export var camera_smoothing := 10.0
@export var orbit_smoothing := 6.0 # How fast the camera swings around the ship in ball cam
@export var look_smoothing := 20.0 # How fast the camera re-centres its look target
@export var min_camera_height := 1.0 # Keeps the camera from dipping through the arena floor
var target: Ship
var ball_cam_enabled := true
@@ -18,6 +21,10 @@ var ball_cam_enabled := true
@onready var camera: Camera3D = $Camera3D
var _ball: Node3D
# Smoothed horizontal direction from ball to ship; the ball-cam orbits along
# this so the camera swings smoothly around the ship instead of chasing a
# raw position (which whips when ball and ship are close together).
var _orbit_dir := Vector3.BACK
func _ready():
@@ -48,60 +55,58 @@ func _get_ball() -> Node3D:
func _update_ball_cam(delta, ball: Node3D):
# In ball cam, camera positions itself so the ship is between camera and ball
# Physics: Vector mathematics for 3D positioning
var ship_pos = target.global_transform.origin
var ball_pos = ball.global_transform.origin
# Ball cam keeps the ship between the camera and the ball: the camera sits
# on the ball→ship line (horizontal component only), looking at the ball,
# so the ship stays low-centre in frame and the ball stays centred.
var ship_pos: Vector3 = target.global_transform.origin
var ball_pos: Vector3 = ball.global_transform.origin
# Calculate direction from ball to ship
# Physics: Vector subtraction and normalization
# Direction vector: d̂ = (P₂ - P₁) / |P₂ - P₁|
var ball_to_ship = (ship_pos - ball_pos).normalized()
# Smooth the orbit direction in angle space rather than lerping the camera
# position directly — when ball and ship pass close to each other the raw
# direction flips instantly, and slerping the direction turns that into a
# controlled swing around the ship. The vertical component is dropped so
# a ball flying overhead pitches the camera up instead of shoving it into
# the floor or the sky.
var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z)
if flat.length() > 0.25:
var t := 1.0 - exp(-orbit_smoothing * delta) # frame-rate independent
_orbit_dir = _orbit_dir.slerp(flat.normalized(), t).normalized()
# Position camera behind the ship relative to the ball's position
# This ensures the ship is always between the camera and ball
# Physics: Vector addition for position calculation
# P_camera = P_ship + d̂ * distance + height_offset
var camera_target_pos = ship_pos + ball_to_ship * camera_distance + Vector3.UP * camera_height
var camera_target_pos := ship_pos + _orbit_dir * camera_distance + Vector3.UP * camera_height
camera_target_pos.y = maxf(camera_target_pos.y, min_camera_height)
# Smoothly move camera to target position
# Physics: Linear interpolation (LERP) for smooth motion
# P(t) = P₀ + t * (P₁ - P₀), where t ∈ [0,1]
# This creates exponential approach to target position
camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta)
var pos_t := 1.0 - exp(-camera_smoothing * delta)
camera.global_position = camera.global_position.lerp(camera_target_pos, pos_t)
# Make camera look at the ball
if camera.global_transform.origin.distance_to(ball_pos) > 0.1:
# Calculate direction to ball
var camera_pos = camera.global_transform.origin
var to_ball = (ball_pos - camera_pos).normalized()
# Create look-at transform manually
# Physics: 3D rotation matrices and basis vectors
# Uses right-hand rule: forward = -Z, up = Y, right = X
# Basis matrix transforms local coordinates to world coordinates
var camera_transform = Transform3D()
camera_transform.origin = camera_pos
camera_transform.basis = Basis.looking_at(to_ball, Vector3.UP)
# Apply the rotation smoothly
# Physics: Spherical linear interpolation (SLERP) for rotation
# SLERP provides smooth rotation along great circle on unit sphere
# Maintains constant angular velocity during interpolation
camera.global_transform.basis = camera.global_transform.basis.slerp(camera_transform.basis, camera_smoothing * delta)
# Look slightly above the ball's centre; look smoothing is faster than
# position smoothing so the ball never drifts out of frame while the
# camera is still swinging into place.
_smooth_look_at(ball_pos + Vector3.UP * 0.5, delta)
func _update_ship_cam(delta):
# In ship cam, camera follows and looks in the same direction as the ship
var ship_pos = target.global_transform.origin
var ship_forward = -target.global_transform.basis.z
var ship_pos: Vector3 = target.global_transform.origin
var ship_forward: Vector3 = -target.global_transform.basis.z
# Position camera behind and above the ship
var camera_target_pos = ship_pos - ship_forward * camera_distance + Vector3.UP * camera_height
var camera_target_pos := ship_pos - ship_forward * camera_distance + Vector3.UP * camera_height
camera_target_pos.y = maxf(camera_target_pos.y, min_camera_height)
# Smoothly move camera
camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta)
var pos_t := 1.0 - exp(-camera_smoothing * delta)
camera.global_position = camera.global_position.lerp(camera_target_pos, pos_t)
# Make camera look in the same direction as the ship
var look_target = ship_pos + ship_forward * 10.0 # Look ahead of the ship
camera.look_at(look_target, Vector3.UP)
# Look ahead of the ship
_smooth_look_at(ship_pos + ship_forward * 10.0, delta)
func _smooth_look_at(point: Vector3, delta: float) -> void:
var to_point := point - camera.global_position
if to_point.length() < 0.1:
return
var dir := to_point.normalized()
if absf(dir.dot(Vector3.UP)) > 0.99:
return # Nearly vertical: looking_at would be degenerate, keep last frame
var look_basis := Basis.looking_at(dir, Vector3.UP)
var look_t := 1.0 - exp(-look_smoothing * delta)
camera.global_basis = camera.global_basis.slerp(look_basis, look_t).orthonormalized()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More