chore(multiplayer): Phase 0 refactors + graphics/perf settings groundwork

Lands the non-networked Phase 0 tasks from multiplayer-todo.md (ship/camera/
arena refactors, sim constants, background FPS handling) plus a first pass
at exposing graphics/performance settings (presets, resolution scaling,
vsync, FPS cap, perf overlay) and a GPU profiling harness for the
real-hardware follow-up in task 0.15b.
This commit is contained in:
Josh Creek
2026-08-19 22:37:17 +01:00
parent 88591e031f
commit 04691aaa48
29 changed files with 2212 additions and 134 deletions
+19
View File
@@ -1,6 +1,13 @@
class_name AIShipController
extends ShipController
# Emitted if a cached teammate/opponent reference is found freed and dropped
# from the roster (see _decide). No despawn path exists anywhere in this
# codebase today — rosters are fixed at match start — so this never fires in
# practice; it's cheap insurance against ShipObservations.build() crashing on
# a stale reference if that ever changes.
signal roster_changed
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
# same canonical observation as training (ShipObservations) and runs the
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
@@ -50,6 +57,13 @@ var _scene_refs_ready := false
func _ready():
if not model_path.is_empty():
load_policy(model_path)
# Stagger the first decision across [1, reaction_ticks] so bots sharing a
# reaction cadence don't all run policy inference on the same physics
# tick — six bots landing together is a ~2.4 ms spike in a 16.7 ms budget
# (policy_network.gd's forward pass). The phase offset this establishes
# persists across subsequent decisions since each one re-arms the same
# period from wherever _ticks_until_decision currently sits.
_ticks_until_decision = randi_range(1, maxi(reaction_ticks, 1))
# League training swaps a frozen opponent's policy between episodes without
@@ -83,6 +97,11 @@ func get_action() -> ShipAction:
func _decide() -> void:
if _teammates.any(func(s): return not is_instance_valid(s)) \
or _opponents.any(func(s): return not is_instance_valid(s)):
_teammates = _teammates.filter(is_instance_valid)
_opponents = _opponents.filter(is_instance_valid)
roster_changed.emit()
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
var out := _policy.forward(obs)
# See ShipActionCodec for the decode — the single source of truth shared