mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat(training): add generation 5 curriculum
This commit is contained in:
@@ -34,6 +34,11 @@ var _policy: PolicyNetwork
|
||||
var _action := ShipAction.new()
|
||||
var _ticks_until_decision := 0
|
||||
|
||||
# League mode revisits a small model pool every episode. Policies are
|
||||
# immutable after load, so share parsed networks by path instead of reading
|
||||
# and flattening the same 100KB+ JSON on every reset for every frozen ship.
|
||||
static var _policy_cache: Dictionary = {}
|
||||
|
||||
var _ship: Ship
|
||||
var _teammates: Array[Ship] = []
|
||||
var _opponents: Array[Ship] = []
|
||||
@@ -44,7 +49,24 @@ var _scene_refs_ready := false
|
||||
|
||||
func _ready():
|
||||
if not model_path.is_empty():
|
||||
load_policy(model_path)
|
||||
|
||||
|
||||
# League training swaps a frozen opponent's policy between episodes without
|
||||
# despawning the ship. Reset the held action/decision cadence with the model
|
||||
# so no command from the previous opponent leaks into the next episode.
|
||||
func load_policy(path: String) -> void:
|
||||
model_path = path
|
||||
if model_path.is_empty():
|
||||
_policy = null
|
||||
elif _policy_cache.has(model_path):
|
||||
_policy = _policy_cache[model_path]
|
||||
else:
|
||||
_policy = PolicyNetwork.load_from_file(model_path)
|
||||
if _policy != null:
|
||||
_policy_cache[model_path] = _policy
|
||||
_action = ShipAction.new()
|
||||
_ticks_until_decision = 0
|
||||
|
||||
|
||||
func get_action() -> ShipAction:
|
||||
|
||||
@@ -39,6 +39,13 @@ extends AIController3D
|
||||
# the objective" framing.
|
||||
@export_range(0.0, 1.0) var ball_touch_direction_floor := 0.3
|
||||
@export var velocity_to_ball_weight := 0.02
|
||||
# Dense reward for approaching the ball *nose first* near the floor. Unlike
|
||||
# velocity_to_ball_weight, sideways/reverse closing velocity earns nothing:
|
||||
# the planar ship-forward vector must face the ball and planar velocity must
|
||||
# have a positive component along it. Default off so existing curricula and
|
||||
# frozen checkpoints keep their original objective; generation 5 handling
|
||||
# turns it on while reducing the orientation-agnostic term.
|
||||
@export var forward_velocity_to_ball_weight := 0.0
|
||||
@export var ball_velocity_to_goal_weight := 0.004
|
||||
# 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
|
||||
@@ -62,6 +69,11 @@ extends AIController3D
|
||||
# to teach. Not removed outright — an always-inverted bot still looks bad in
|
||||
# a shipped game.
|
||||
@export var tilt_penalty := 0.0005
|
||||
# Additional tilt cost that fades to zero over the first few metres above the
|
||||
# floor. This can teach readable, upright ground handling without opposing
|
||||
# pitch/roll during a real aerial. Generation 5 uses this instead of raising
|
||||
# the global tilt_penalty back to its pre-flight value.
|
||||
@export var ground_tilt_penalty := 0.0
|
||||
# Per-tick bonus for own speed: 0 stationary, full value (+0.24/s) at
|
||||
# max_speed. Run07 lesson: after the kickoff flurry both ships parked next to
|
||||
# a cornered ball — with every other dense term near zero there, standing
|
||||
@@ -90,6 +102,15 @@ extends AIController3D
|
||||
# air-touch reward.
|
||||
const AIR_TOUCH_HEIGHT := 5.0
|
||||
|
||||
# Generation-5 ground-handling telemetry/reward thresholds. Fixed constants
|
||||
# keep the logged metrics comparable across stages; changing one starts a new
|
||||
# metric definition and therefore requires a fresh baseline.
|
||||
const GROUND_HANDLING_HEIGHT := 3.0
|
||||
const UPRIGHT_DOT_THRESHOLD := 0.7
|
||||
const FORWARD_MOTION_DOT_THRESHOLD := 0.7
|
||||
const MIN_HANDLING_SPEED := 1.0
|
||||
const PRODUCTIVE_AIR_TOUCH_ALIGNMENT := 0.5
|
||||
|
||||
# Contact normals with y above this are floor contact (exempt from the wall
|
||||
# penalty); below it they read as wall (sideways) or ceiling (downward).
|
||||
# Mirrors ShipObservations.FLOOR_NORMAL_MIN_Y (see that file's comment).
|
||||
@@ -144,6 +165,11 @@ var _altitude_sum := 0.0
|
||||
var _thrust_y_sum := 0.0
|
||||
var _touches := 0
|
||||
var _air_touches := 0
|
||||
var _productive_air_touches := 0
|
||||
var _ground_ticks := 0
|
||||
var _upright_ground_ticks := 0
|
||||
var _moving_ground_ticks := 0
|
||||
var _forward_moving_ground_ticks := 0
|
||||
|
||||
|
||||
# Wire up references after the ship is spawned. `attack_goal` is the goal
|
||||
@@ -201,6 +227,9 @@ func get_info() -> Dictionary:
|
||||
info["mean_altitude"] = _altitude_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
|
||||
info["vertical_thrust_mean"] = _thrust_y_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
|
||||
info["air_touch_fraction"] = float(_air_touches) / _touches if _touches > 0 else 0.0
|
||||
info["productive_air_touch_fraction"] = float(_productive_air_touches) / _touches if _touches > 0 else 0.0
|
||||
info["upright_fraction"] = float(_upright_ground_ticks) / _ground_ticks if _ground_ticks > 0 else 0.0
|
||||
info["forward_motion_fraction"] = float(_forward_moving_ground_ticks) / _moving_ground_ticks if _moving_ground_ticks > 0 else 0.0
|
||||
return info
|
||||
|
||||
|
||||
@@ -223,6 +252,11 @@ func reset():
|
||||
_thrust_y_sum = 0.0
|
||||
_touches = 0
|
||||
_air_touches = 0
|
||||
_productive_air_touches = 0
|
||||
_ground_ticks = 0
|
||||
_upright_ground_ticks = 0
|
||||
_moving_ground_ticks = 0
|
||||
_forward_moving_ground_ticks = 0
|
||||
|
||||
|
||||
func _physics_process(delta):
|
||||
@@ -240,6 +274,20 @@ func _physics_process(delta):
|
||||
var closing_speed := ship.linear_velocity.dot(to_ball.normalized())
|
||||
reward += velocity_to_ball_weight * closing_speed / ship.max_speed
|
||||
|
||||
# Ground-handling shaping: forward planar motion while the nose faces the
|
||||
# ball. It fades out with altitude so an aerial remains free to approach a
|
||||
# ball using whatever body attitude is effective.
|
||||
if forward_velocity_to_ball_weight > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT:
|
||||
var planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z)
|
||||
var planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z)
|
||||
var planar_to_ball := Vector3(to_ball.x, 0.0, to_ball.z)
|
||||
if planar_forward.length_squared() > 0.0001 and planar_to_ball.length_squared() > 0.0001:
|
||||
planar_forward = planar_forward.normalized()
|
||||
var facing_ball: float = maxf(planar_forward.dot(planar_to_ball.normalized()), 0.0)
|
||||
var forward_speed: float = maxf(planar_velocity.dot(planar_forward), 0.0) / ship.max_speed
|
||||
var handling_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0)
|
||||
reward += forward_velocity_to_ball_weight * forward_speed * facing_ball * handling_ground_factor
|
||||
|
||||
# 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:
|
||||
@@ -270,6 +318,12 @@ func _physics_process(delta):
|
||||
var uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP)
|
||||
reward -= tilt_penalty * (1.0 - uprightness) * 0.5
|
||||
|
||||
# Low-altitude-only posture pressure (see ground_tilt_penalty).
|
||||
if ground_tilt_penalty > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT:
|
||||
var ground_uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP)
|
||||
var tilt_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0)
|
||||
reward -= ground_tilt_penalty * (1.0 - ground_uprightness) * 0.5 * tilt_ground_factor
|
||||
|
||||
# Dense penalty: height above the floor (see airborne_penalty). The
|
||||
# floor sits at world y = 0 (see training_mode.gd's FIELD_MIN_Y/
|
||||
# _escaped bounds); normalized so the worst case is pinned at the
|
||||
@@ -285,6 +339,17 @@ func _physics_process(delta):
|
||||
if ship.global_position.y > AIRBORNE_ALTITUDE_THRESHOLD:
|
||||
_airborne_ticks += 1
|
||||
_thrust_y_sum += rl_controller.action.thrust.y
|
||||
if ship.global_position.y < GROUND_HANDLING_HEIGHT:
|
||||
_ground_ticks += 1
|
||||
if ship.global_transform.basis.y.dot(Vector3.UP) >= UPRIGHT_DOT_THRESHOLD:
|
||||
_upright_ground_ticks += 1
|
||||
var planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z)
|
||||
if planar_velocity.length() >= MIN_HANDLING_SPEED:
|
||||
_moving_ground_ticks += 1
|
||||
var planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z)
|
||||
if planar_forward.length_squared() > 0.0001 \
|
||||
and planar_velocity.normalized().dot(planar_forward.normalized()) >= FORWARD_MOTION_DOT_THRESHOLD:
|
||||
_forward_moving_ground_ticks += 1
|
||||
|
||||
|
||||
func _wall_or_ceiling_contact() -> bool:
|
||||
@@ -311,3 +376,5 @@ func _on_ship_body_entered(body: Node) -> void:
|
||||
_touches += 1
|
||||
if ball.global_position.y > AIR_TOUCH_HEIGHT:
|
||||
_air_touches += 1
|
||||
if alignment >= PRODUCTIVE_AIR_TOUCH_ALIGNMENT:
|
||||
_productive_air_touches += 1
|
||||
|
||||
@@ -64,6 +64,11 @@ extends GameMode
|
||||
# stayed floor-pinned even though the initial one wasn't. See
|
||||
# _place_air_drill.
|
||||
@export_range(0.0, 1.0) var air_drill_chance := 0.0
|
||||
# Moving-ball aerial interception branch used by generation 5. Unlike the
|
||||
# stationary/random air drill, the ball follows a reachable trajectory toward
|
||||
# a real goal and ships start low behind/lateral to it, so a useful touch is
|
||||
# naturally reinforced by the existing goal-directed ball rewards.
|
||||
@export_range(0.0, 1.0) var air_intercept_chance := 0.0
|
||||
|
||||
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
|
||||
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
|
||||
@@ -129,6 +134,8 @@ var _episode_ticks := 0
|
||||
# already uses, just for one side of a live training episode.
|
||||
var _opponent_mode := "self_play"
|
||||
var _opponent_model_path := ""
|
||||
var _opponent_model_pool: Array[String] = []
|
||||
var _frozen_opponent_bots: Array[AIShipController] = []
|
||||
# ShipAIController @export overrides collected from --ai_<name>=<value> args,
|
||||
# applied to every ShipAIController this run creates (see _attach_agent).
|
||||
var _ai_overrides := {}
|
||||
@@ -164,7 +171,7 @@ func _start() -> void:
|
||||
team0_ships.append(spawn_ship(0, i, RLShipController.new()))
|
||||
|
||||
# The opponent_mode branch applies uniformly to every ship on team 1: an
|
||||
# "inert"/"frozen" run means the whole opposing team gets that treatment,
|
||||
# "inert"/"frozen"/"league" run means the whole opposing team gets that treatment,
|
||||
# not just one ship.
|
||||
var team1_ships: Array[Ship] = []
|
||||
for i in team_size:
|
||||
@@ -177,6 +184,12 @@ func _start() -> void:
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = _opponent_model_path
|
||||
ship1 = spawn_ship(1, i, bot)
|
||||
_frozen_opponent_bots.append(bot)
|
||||
"league":
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = _opponent_model_pool[0] if not _opponent_model_pool.is_empty() else ""
|
||||
ship1 = spawn_ship(1, i, bot)
|
||||
_frozen_opponent_bots.append(bot)
|
||||
_:
|
||||
ship1 = spawn_ship(1, i, RLShipController.new())
|
||||
team1_ships.append(ship1)
|
||||
@@ -230,13 +243,15 @@ func _parse_eval_args() -> void:
|
||||
const TRAINING_MODE_OVERRIDES := [
|
||||
"goal_reward", "draw_penalty", "kickoff_state_chance",
|
||||
"ball_near_goal_chance", "attack_goal_bias", "air_drill_chance",
|
||||
"air_intercept_chance", "team_size",
|
||||
]
|
||||
# ShipAIController @export names a curriculum run may override, read as
|
||||
# --ai_<name>=<value> to avoid colliding with the names above.
|
||||
const SHIP_AI_OVERRIDES := [
|
||||
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor",
|
||||
"velocity_to_ball_weight", "ball_velocity_to_goal_weight", "ball_distance_penalty",
|
||||
"wall_contact_penalty", "tilt_penalty", "speed_reward_weight", "time_penalty",
|
||||
"forward_velocity_to_ball_weight", "wall_contact_penalty", "tilt_penalty",
|
||||
"ground_tilt_penalty", "speed_reward_weight", "time_penalty",
|
||||
"airborne_penalty",
|
||||
]
|
||||
|
||||
@@ -246,10 +261,20 @@ func _parse_curriculum_args() -> void:
|
||||
if args.has("opponent_mode"):
|
||||
_opponent_mode = args["opponent_mode"]
|
||||
_opponent_model_path = args.get("opponent_model", _opponent_model_path)
|
||||
if args.has("opponent_model_pool"):
|
||||
for path in String(args["opponent_model_pool"]).split(",", false):
|
||||
if not path.is_empty():
|
||||
_opponent_model_pool.append(path)
|
||||
if _opponent_mode == "league" and _opponent_model_pool.is_empty():
|
||||
push_error("TrainingMode: opponent_mode=league requires --opponent_model_pool=path,path")
|
||||
|
||||
for name in TRAINING_MODE_OVERRIDES:
|
||||
if args.has(name):
|
||||
set(name, _typed_like(args[name], get(name)))
|
||||
var start_probability := kickoff_state_chance + ball_near_goal_chance \
|
||||
+ air_drill_chance + air_intercept_chance
|
||||
if start_probability > 1.0:
|
||||
push_error("TrainingMode: episode-start probabilities sum to %.3f (> 1.0)" % start_probability)
|
||||
|
||||
for name in SHIP_AI_OVERRIDES:
|
||||
var key := "ai_%s" % name
|
||||
@@ -281,10 +306,12 @@ func _ai_default(name: String) -> Variant:
|
||||
"ball_touch_cooldown_ticks": return 60
|
||||
"ball_touch_direction_floor": return 0.3
|
||||
"velocity_to_ball_weight": return 0.02
|
||||
"forward_velocity_to_ball_weight": return 0.0
|
||||
"ball_velocity_to_goal_weight": return 0.004
|
||||
"ball_distance_penalty": return 0.002
|
||||
"wall_contact_penalty": return 0.0025
|
||||
"tilt_penalty": return 0.0005
|
||||
"ground_tilt_penalty": return 0.0
|
||||
"speed_reward_weight": return 0.004
|
||||
"time_penalty": return 0.001
|
||||
"airborne_penalty": return 0.0
|
||||
@@ -386,6 +413,7 @@ func _end_eval_episode() -> void:
|
||||
func _reset_episode() -> void:
|
||||
for agent in _agents:
|
||||
agent.reset()
|
||||
_select_league_opponent()
|
||||
|
||||
var roll := randf()
|
||||
if roll < kickoff_state_chance:
|
||||
@@ -396,6 +424,8 @@ func _reset_episode() -> void:
|
||||
_place_ball_near_goal()
|
||||
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance:
|
||||
_place_air_drill()
|
||||
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance + air_intercept_chance:
|
||||
_place_air_intercept()
|
||||
else:
|
||||
_place_ships_random()
|
||||
_place_ball_random()
|
||||
@@ -416,6 +446,15 @@ func _place_ball_random() -> void:
|
||||
# out via reward shaping.
|
||||
const AIR_DRILL_BALL_WALL_CLEARANCE := 5.0
|
||||
|
||||
|
||||
func _select_league_opponent() -> void:
|
||||
if _opponent_mode != "league" or _opponent_model_pool.is_empty():
|
||||
return
|
||||
var path := _opponent_model_pool[randi() % _opponent_model_pool.size()]
|
||||
for bot in _frozen_opponent_bots:
|
||||
bot.load_policy(path)
|
||||
|
||||
|
||||
# Air drill state (see air_drill_chance): ball spawned high, both ships
|
||||
# spawned low and lateral, so the state is unsolvable without climbing.
|
||||
func _place_air_drill() -> void:
|
||||
@@ -456,6 +495,42 @@ func _place_air_drill() -> void:
|
||||
_place_body(ship, Transform3D(orientation, ship_position), Vector3.ZERO, Vector3.ZERO)
|
||||
|
||||
|
||||
# Goal-relevant aerial intercept: a high ball is already travelling toward a
|
||||
# randomly selected goal, while ships begin low and behind/lateral to its
|
||||
# path. The generous wall clearance prevents rebound farming and an upright
|
||||
# yaw-only spawn avoids wasting the short drill window on random recovery.
|
||||
func _place_air_intercept() -> void:
|
||||
var goal := _goal_for_team(randi() % 2)
|
||||
var ball_position := Vector3(
|
||||
randf_range(-8.0, 8.0),
|
||||
randf_range(6.0, minf(12.0, FIELD_MAX_Y)),
|
||||
randf_range(-10.0, 10.0)
|
||||
)
|
||||
var to_goal := (goal.global_position - ball_position).normalized()
|
||||
var ball_velocity := (to_goal + Vector3(randf_range(-0.15, 0.15), randf_range(0.0, 0.15), 0.0)).normalized() \
|
||||
* randf_range(6.0, 11.0)
|
||||
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), ball_velocity, Vector3.ZERO)
|
||||
|
||||
var placed: Array[Vector3] = []
|
||||
var behind := -Vector3(ball_velocity.x, 0.0, ball_velocity.z).normalized()
|
||||
for ship in ships:
|
||||
if ship in _inert_ships:
|
||||
continue
|
||||
var ship_position := Vector3.ZERO
|
||||
for _attempt in 20:
|
||||
var lateral := Vector3(-behind.z, 0.0, behind.x) * randf_range(-7.0, 7.0)
|
||||
ship_position = ball_position + behind * randf_range(7.0, 13.0) + lateral
|
||||
ship_position.x = clampf(ship_position.x, -FIELD_HALF_X, FIELD_HALF_X)
|
||||
ship_position.y = randf_range(FIELD_MIN_Y, 3.0)
|
||||
ship_position.z = clampf(ship_position.z, -FIELD_HALF_Z, FIELD_HALF_Z)
|
||||
if _spawn_position_clear(ship_position) and _far_enough_from(ship_position, placed):
|
||||
break
|
||||
placed.append(ship_position)
|
||||
var face_ball := ball_position - ship_position
|
||||
var yaw := atan2(-face_ball.x, -face_ball.z)
|
||||
_place_body(ship, Transform3D(Basis.from_euler(Vector3(0.0, yaw, 0.0)), ship_position), Vector3.ZERO, Vector3.ZERO)
|
||||
|
||||
|
||||
# Attacking/defending drill states: ball close to a goal, moving toward it.
|
||||
# Which goal is picked is biased by attack_goal_bias (0.5 = uniform between
|
||||
# both, matching historical behaviour; 1.0 = always the goal team 0 attacks).
|
||||
|
||||
Reference in New Issue
Block a user