mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
a5cbc977b5
Completes Phase 5's implementation. Every task is verified at 1v1; the 3v3 phase gate itself has not been run and remains outstanding. 5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and swaps the controller (--fill-bots gives it a bot, the default leaves it inert per §1.4), sets `stalled` immediately so the nameplate greys out rather than waiting ~500ms for the abandoned jitter buffer to starve, and reserves the slot for 30s keyed by player name so a reconnect gets the same ship back. 5.7 was a real bug, found by the test rather than by review: SlotInfo.controller was declared RLShipController, but the takeover swaps in an AIShipController or the base controller - the narrower type makes that assignment fail its type check, leaving the field pointing at the controller set_controller() just queue_free()d. It surfaced as controller_valid=false on the first run. The per-tick action write is now also gated on `is RLShipController`, since a disconnected slot's bot drives itself and overwriting it from a starving buffer would pin it to the departed player's last input. §6.4's two rules conflict: reserve for 30s, but abort when the last human leaves. Applied naively the abort wins instantly in a 1v1 and the reservation can never be redeemed, making reconnect unreachable exactly when it matters. Abort now waits for no connections AND no outstanding reservations. 5.8 spectators: a slotless peer spawns no ship and receives the same snapshot broadcast. HUDController.spectator_mode keeps the clock, score and goal celebration and hides only the ship instrument cluster - it previously push_error'd and bailed, leaving a spectator with a dead HUD. Camera cycles ships in slot order then the ball. --max-spectators caps it, counted from the live peer list so a dropped spectator cannot leak a unit of the cap. 5.9 escape respawn: new GameMode._on_bodies_respawned() virtual; NetworkedMatch bumps reset_gen through Phase 2's deferred path so the bump and the respawned pose land in the same broadcast. Single-player modes are unaffected - the base is a no-op. 5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the wire bytes verbatim in both directions rather than re-serialising - a re-encode would launder away precisely the malformed payload being chased. A live 6s match recorded 1115 records (557 inputs / 558 snapshots) and a stored snapshot decodes back to server_tick=100 match_state=WARMUP bodies=2. Note for future work: --check-only --script is the only thing that catches a parse error in networked_match.gd, because the unit runner never loads it. Two separate breakages passed the full unit suite while breaking every two-process run. A new class_name also needs --import before it resolves. Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario), --match-length=<s>, --replay-log, --fill-bots/--no-fill-bots, --max-spectators. The ball-contact scenario now steers at the ball with closed-loop real input instead of a hand-tuned fixed heading, which 5.3 broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from 2/3 to 5/5. Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps; transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI.
327 lines
13 KiB
GDScript
327 lines
13 KiB
GDScript
class_name GameMode
|
|
extends Node3D
|
|
|
|
# Base for game modes (Free Play, Match; later Vs-AI and multiplayer).
|
|
# A mode's scene contains an Arena (the stadium) and a HUD; the mode itself
|
|
# spawns the ball, ships, controllers, and camera in code — variable ship
|
|
# counts with mixed controller types (player/AI/network) is exactly what
|
|
# future modes need. Subclasses override _start() and _on_goal_scored().
|
|
|
|
@export var ship_scene: PackedScene = preload("res://objects/ship.tscn")
|
|
@export var ball_scene: PackedScene = preload("res://objects/ball.tscn")
|
|
|
|
const CAMERA_RIG_SCENE = preload("res://scenes/ship_camera_rig.tscn")
|
|
|
|
var arena: Arena
|
|
var hud: HUDController
|
|
var ball: RigidBody3D
|
|
var ships: Array[Ship] = []
|
|
var _ship_spawn_transforms := {}
|
|
var _camera_rig: ShipCameraRig
|
|
var _goal_in_progress := false
|
|
|
|
const GOAL_CELEBRATION_SECONDS := 1.6
|
|
|
|
# Shared by modes that keep score (Match, Spectate); Free Play never
|
|
# references this or emits a score_changed signal, and HUDController relies
|
|
# on has_signal("score_changed") to decide whether to show a score row — so
|
|
# that signal stays declared per-subclass, not here.
|
|
var score := {0: 0, 1: 0}
|
|
|
|
|
|
func _ready():
|
|
# Group lets the HUD discover the game mode for timer/score signals
|
|
add_to_group("game")
|
|
# At the default 8, a client hitching to ~20 fps runs up to 8 physics
|
|
# ticks in one rendered frame — and each of those ticks costs roughly as
|
|
# much as the frame that caused the hitch, so the client can spiral
|
|
# further behind instead of recovering. 4 trades a lower worst-case
|
|
# catch-up rate for bounded per-frame cost.
|
|
Engine.max_physics_steps_per_frame = 4
|
|
# A fresh RandomNumberGenerator defaults to a fixed internal state (unlike
|
|
# the global randf_range, which Godot auto-randomizes at startup), so an
|
|
# explicit randomize() is required unless a seed was set for reproducible
|
|
# kickoffs (see kickoff_rng_seed above).
|
|
if kickoff_rng_seed == 0:
|
|
_kickoff_rng.randomize()
|
|
for child in get_children():
|
|
if child is Arena:
|
|
arena = child
|
|
elif child is HUDController:
|
|
hud = child
|
|
if not arena:
|
|
var path := _get_arena_scene_path()
|
|
if not path.is_empty():
|
|
arena = (load(path) as PackedScene).instantiate()
|
|
add_child(arena)
|
|
if not arena:
|
|
push_error("GameMode: scene has no Arena child")
|
|
return
|
|
if _owns_goal_logic():
|
|
for goal in arena.get_goals():
|
|
goal.goal_scored.connect(_handle_goal_scored)
|
|
_start()
|
|
|
|
|
|
# Virtual: subclasses whose scene has no fixed Arena child override this to
|
|
# pick which arena scene to instantiate in code (see ArenaRegistry). Modes
|
|
# that keep a fixed Arena child (training.tscn) never call this.
|
|
func _get_arena_scene_path() -> String:
|
|
return ""
|
|
|
|
|
|
# Virtual: subclasses spawn their ball/ships/camera here.
|
|
func _start() -> void:
|
|
pass
|
|
|
|
|
|
# Virtual: whether this mode decides goals from its own local Goal sensors.
|
|
# True for every mode today. A future networked client mode overrides this
|
|
# false — it must learn a goal happened from an authoritative server message,
|
|
# not from an interpolated remote ball wandering through its local Goal
|
|
# Area3D, which would score client-side against no one.
|
|
func _owns_goal_logic() -> bool:
|
|
return true
|
|
|
|
|
|
# Virtual: whether this mode simulates and enforces its own world (escaped-
|
|
# body respawn runs locally in _physics_process). True for every mode today.
|
|
# A future networked client mode overrides this false — the server is
|
|
# authoritative for body positions, and a client respawning a body itself
|
|
# would fight that authority.
|
|
func _owns_world_simulation() -> bool:
|
|
return true
|
|
|
|
|
|
# Virtual: how long the goal cinematic holds before resuming play. Subclasses
|
|
# that want a different cadence override this instead of touching
|
|
# GOAL_CELEBRATION_SECONDS directly.
|
|
func _goal_pause_seconds() -> float:
|
|
return GOAL_CELEBRATION_SECONDS
|
|
|
|
|
|
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
|
|
func _on_goal_scored(_conceding_team: int) -> void:
|
|
pass
|
|
|
|
|
|
# Virtual: immediate, non-presentational consequences at sensor time. Modes
|
|
# with a score/clock override this so a last-second goal is authoritative
|
|
# before the cinematic; reset/kickoff remains in _on_goal_scored afterwards.
|
|
func _on_goal_registered(_conceding_team: int) -> void:
|
|
pass
|
|
|
|
|
|
# Debounce: a fast ball can re-trigger the goal area before the deferred
|
|
# reset teleports it away, which would double-count the goal.
|
|
func _handle_goal_scored(conceding_team: int) -> void:
|
|
if _goal_in_progress:
|
|
return
|
|
_goal_in_progress = true
|
|
_on_goal_registered(conceding_team)
|
|
await _play_goal_celebration(1 - conceding_team, conceding_team)
|
|
# A scene change (Esc, match end) queued during the celebration removes
|
|
# this node from the tree before the await chain finishes; resuming past
|
|
# that point would touch arena/hud state that is mid-teardown.
|
|
if not is_inside_tree():
|
|
return
|
|
await _on_goal_scored(conceding_team)
|
|
if not is_inside_tree():
|
|
return
|
|
_goal_in_progress = false
|
|
|
|
|
|
func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
|
|
# Training/headless modes never spawn a camera or HUD and must not pay a
|
|
# real-time presentation delay between episodes.
|
|
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
|
return
|
|
var goal_position := Vector3.ZERO
|
|
for goal in arena.get_goals():
|
|
if goal.team == conceding_team:
|
|
goal_position = goal.global_position
|
|
break
|
|
# The cinematic camera cut itself (hard FOV change, cut to a fixed angle)
|
|
# carries the "moment" that Engine.time_scale slow-mo used to sell —
|
|
# world simulation speed is never touched, so this behaves identically
|
|
# for a future networked client watching a shared server sim.
|
|
_camera_rig.begin_goal_cut(goal_position)
|
|
if hud:
|
|
hud.show_goal_celebration(scoring_team)
|
|
await get_tree().create_timer(_goal_pause_seconds(), true, false, true).timeout
|
|
if is_instance_valid(_camera_rig):
|
|
_camera_rig.end_goal_cut()
|
|
if hud and is_instance_valid(hud):
|
|
hud.hide_goal_celebration()
|
|
|
|
|
|
func spawn_ball() -> RigidBody3D:
|
|
ball = ball_scene.instantiate()
|
|
add_child(ball)
|
|
ball.global_transform = arena.get_ball_spawn()
|
|
return ball
|
|
|
|
|
|
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
|
|
var ship: Ship = ship_scene.instantiate()
|
|
ship.name = "Ship_T%d_S%d" % [team, spawn_index]
|
|
add_child(ship)
|
|
var spawns := arena.get_ship_spawns(team)
|
|
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
|
ship.global_transform = spawn_transform
|
|
ship.team = team
|
|
ship.spawn_index = spawn_index
|
|
if controller:
|
|
ship.set_controller(controller)
|
|
ships.append(ship)
|
|
_ship_spawn_transforms[ship] = spawn_transform
|
|
return ship
|
|
|
|
|
|
func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
|
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
|
|
add_child(rig)
|
|
_camera_rig = rig
|
|
rig.target = target
|
|
# Also wires the scene's static HUD (if any) to the same ship, rather
|
|
# than letting it guess via the "ship" group.
|
|
if hud:
|
|
hud.ship = target
|
|
return rig
|
|
|
|
|
|
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
|
# apply their own GameSettings-override logic first, which differs between
|
|
# modes (Match lets GameSettings override all three fields, Spectate only
|
|
# the path) — builds a trained-policy controller, or an inert placeholder if
|
|
# the path is empty or missing.
|
|
func _build_opponent(model_path: String, reaction_ticks: int, action_noise: float, label: String = "GameMode") -> ShipController:
|
|
if not model_path.is_empty() and FileAccess.file_exists(model_path):
|
|
var bot := AIShipController.new()
|
|
bot.model_path = model_path
|
|
bot.reaction_ticks = reaction_ticks
|
|
bot.action_noise = action_noise
|
|
return bot
|
|
if not model_path.is_empty():
|
|
push_warning("%s: bot model not found at %s, spawning inert opponent" % [label, model_path])
|
|
return ShipController.new() # inert placeholder
|
|
|
|
|
|
func _record_goal(scoring_team: int) -> void:
|
|
score[scoring_team] += 1
|
|
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
|
|
|
|
|
# Tiny per-reset randomization, well below anything a player would notice as
|
|
# "not a real kickoff" — just enough that two ships running the identical
|
|
# deterministic AI policy (action_noise = 0) don't start every kickoff from a
|
|
# bit-for-bit mirror-symmetric state. A perfectly symmetric state feeds both
|
|
# controllers identical (canonicalized) observations, so they emit mirrored
|
|
# actions and can lock into a repetitive, non-scoring stalemate — much more
|
|
# visible bot-vs-bot (same model both sides) than bot-vs-human, since a human
|
|
# never satisfies "identical policy" in the first place. See training_mode.gd's
|
|
# _end_eval_episode, which randomizes eval episode states for the same reason.
|
|
const KICKOFF_POSITION_JITTER := 0.3
|
|
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
|
|
|
|
# Owned rather than global `randf_range`, so a fixed seed makes kickoffs
|
|
# exactly reproducible (replay logs, deterministic tests) without disturbing
|
|
# any other system's random stream.
|
|
@export var kickoff_rng_seed: int = 0:
|
|
set(value):
|
|
kickoff_rng_seed = value
|
|
if value != 0:
|
|
_kickoff_rng.seed = value
|
|
var _kickoff_rng := RandomNumberGenerator.new()
|
|
|
|
|
|
func reset_ball() -> void:
|
|
if is_instance_valid(ball):
|
|
_reset_body(ball, _jittered(arena.get_ball_spawn(), KICKOFF_POSITION_JITTER, 0.0))
|
|
|
|
|
|
func reset_ships() -> void:
|
|
for ship in ships:
|
|
if is_instance_valid(ship):
|
|
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
|
|
if is_instance_valid(_camera_rig):
|
|
# _reset_body's queue_teleport defers the actual transform write to
|
|
# the ship's next _integrate_forces (task 0.15) — snapping the camera
|
|
# now would read the pre-teleport position. Wait one physics tick so
|
|
# the teleport has already landed; without this the camera would also
|
|
# smoothly chase the teleported ship across the arena instead of
|
|
# cutting with it.
|
|
await get_tree().physics_frame
|
|
if is_instance_valid(_camera_rig):
|
|
_camera_rig.snap_to_target()
|
|
|
|
|
|
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
|
|
var offset := Vector3(_kickoff_rng.randf_range(-position_jitter, position_jitter), 0.0, _kickoff_rng.randf_range(-position_jitter, position_jitter))
|
|
var basis := to.basis
|
|
if yaw_jitter > 0.0:
|
|
basis = basis.rotated(Vector3.UP, _kickoff_rng.randf_range(-yaw_jitter, yaw_jitter))
|
|
return Transform3D(basis, to.origin + offset)
|
|
|
|
|
|
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
|
# Queued and applied inside the body's own _integrate_forces — the only
|
|
# Jolt-safe place to write state.transform — instead of racing the
|
|
# physics step via set_deferred (task 0.15). Dynamic dispatch: Ship and
|
|
# Ball both implement queue_teleport(), but RigidBody3D itself doesn't,
|
|
# so a statically-typed call here won't resolve.
|
|
body.call("queue_teleport", to)
|
|
|
|
|
|
func _unhandled_input(event):
|
|
if event.is_action_pressed("ui_cancel"):
|
|
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:
|
|
if _owns_world_simulation():
|
|
_respawn_escaped_bodies()
|
|
|
|
|
|
func _respawn_escaped_bodies() -> void:
|
|
var respawned := false
|
|
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])
|
|
respawned = true
|
|
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())
|
|
respawned = true
|
|
if respawned:
|
|
_on_bodies_respawned()
|
|
|
|
|
|
# Virtual (task 5.9). An escape respawn is a teleport, and a networked client
|
|
# interpolating toward it would smoothly slide a body the width of the arena
|
|
# and then fight the correction. NetworkedMatch overrides this to bump
|
|
# reset_gen so clients hard-snap instead. Single-player modes need nothing.
|
|
func _on_bodies_respawned() -> void:
|
|
pass
|
|
|
|
|
|
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
|