mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 09:32:07 +00:00
feat(training): support N-vs-M matches with persistent per-ship spawn IDs
Extends ShipObservations beyond the old self+1-opponent layout to padded teammate/opponent arrays (MAX_TEAMMATES=4, MAX_OPPONENTS=5, SIZE=83), zero-filling slots past the real roster size the same way the old single- opponent slot was zero-filled when absent. Slot stability across ticks requires a persistent identity: Ship gains spawn_index (set once by GameMode.spawn_ship, never reassigned — there's no despawn path anywhere in this codebase, so a roster is fixed for the whole episode/match). ai_ship_controller.gd's opponent discovery is rewritten from "first non-self ship" to classify every other ship by team and sort by spawn_index; training_mode.gd/ship_ai_controller.gd carry the equivalent sorted lists through the training path so both agree on slot assignment for the same roster. training_mode.gd and match_mode.gd both gain a team_size export (default 1, so every existing curriculum script and match keeps today's 1v1 behaviour unchanged). This is plumbing only: no 2v2+ curriculum or reward design, and no match-mode UI to pick team size, has been done yet. The two checkpoints in Game/bots/promoted/ are fitted to the old 35-float layout and are not migrated — expected to go stale until the next training run.
This commit is contained in:
@@ -11,13 +11,13 @@ extends RefCounted
|
||||
# The same rotation must be inverted when interpreting actions (see canon —
|
||||
# it is its own inverse).
|
||||
|
||||
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±12,
|
||||
# z ±18, height 12, goals at z ±18 (flush with the end walls); positions are
|
||||
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±18,
|
||||
# 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
|
||||
# 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 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
|
||||
# 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).
|
||||
const FLOOR_NORMAL_MIN_Y := 0.7
|
||||
|
||||
# Number of floats build() returns; the policy input size. APPEND-ONLY: new
|
||||
# features go on the end and existing indices never move, so an old exported
|
||||
# model (whose network was trained against a shorter SIZE) still decodes its
|
||||
# first N inputs identically when SIZE grows — see PolicyNetwork.forward's
|
||||
# input_size slice/guard. Do not retune an *existing* index without
|
||||
# retraining every model in Game/bots/.
|
||||
const SIZE := 35
|
||||
# Fixed roster caps for the padded teammate/opponent slots below — the
|
||||
# largest supported match size is 5v5. Slots beyond the real teammate/
|
||||
# opponent count are zero-filled, mirroring the old single-opponent's
|
||||
# null-zero-fill (see build()). Callers must pass teammates/opponents already
|
||||
# sorted by Ship.spawn_index, so a given ship occupies the same slot in every
|
||||
# tick's observation for the whole match, in both training and in-game
|
||||
# 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
|
||||
@@ -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
|
||||
# (the goal whose `team` == the opponent's team).
|
||||
static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_position: Vector3) -> Array:
|
||||
# (the goal whose `team` == the opponent's team). teammates/opponents must
|
||||
# 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 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.linear_velocity, team) / BALL_SPEED_SCALE)
|
||||
|
||||
# Opponent, relative to self (zeros if absent, e.g. a 1-ship drill)
|
||||
if is_instance_valid(opponent):
|
||||
var opp_rel := opponent.global_position - ship.global_position
|
||||
_append(obs, canon(opp_rel, team) / POSITION_SCALE)
|
||||
_append(obs, canon(opponent.linear_velocity, team) / ship.max_speed)
|
||||
else:
|
||||
_append(obs, Vector3.ZERO)
|
||||
_append(obs, Vector3.ZERO)
|
||||
# Teammates and opponents, relative to self, each padded/truncated to a
|
||||
# fixed slot count (zeros past the real roster size, e.g. a 1v1 match or
|
||||
# a solo drill) so the vector shape never depends on match size.
|
||||
_append_ship_slots(obs, ship, team, teammates, MAX_TEAMMATES)
|
||||
_append_ship_slots(obs, ship, team, opponents, MAX_OPPONENTS)
|
||||
|
||||
# Goal we are attacking, relative to self
|
||||
var goal_rel := attack_goal_position - ship.global_position
|
||||
_append(obs, canon(goal_rel, team) / POSITION_SCALE)
|
||||
obs.append(goal_rel.length() / GOAL_DISTANCE_SCALE)
|
||||
|
||||
# Own contact state (appended — see SIZE's append-only invariant).
|
||||
# Added for generation 4: ShipAIController's wall_contact_penalty used to
|
||||
# fire on a condition the observation vector couldn't see coming,
|
||||
# leaving the value function to predict a reward with no supporting
|
||||
# signal. Also gives the policy a direct "am I resting on a surface"
|
||||
# signal it can use to push off (a real aerial mechanic), distinct from
|
||||
# inferring it indirectly from position/up-vector.
|
||||
# Own contact state. Added for generation 4: ShipAIController's
|
||||
# wall_contact_penalty used to fire on a condition the observation vector
|
||||
# couldn't see coming, leaving the value function to predict a reward
|
||||
# with no supporting signal. Also gives the policy a direct "am I resting
|
||||
# on a surface" signal it can use to push off (a real aerial mechanic),
|
||||
# distinct from inferring it indirectly from position/up-vector.
|
||||
var normal := contact_normal(ship)
|
||||
_append(obs, canon(normal, team))
|
||||
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
|
||||
|
||||
|
||||
# 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:
|
||||
obs.append(v.x)
|
||||
obs.append(v.y)
|
||||
|
||||
Reference in New Issue
Block a user