mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:53:42 +00:00
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:
+83
-72
@@ -17,16 +17,10 @@ var hud: HUDController
|
||||
var ball: RigidBody3D
|
||||
var ships: Array[Ship] = []
|
||||
var _ship_spawn_transforms := {}
|
||||
var _hit_stop_generation := 0
|
||||
var _hit_stop_active := false
|
||||
var _time_scale_before_hit_stop := 1.0
|
||||
var _camera_rig: ShipCameraRig
|
||||
var _goal_slowmo_active := false
|
||||
var _time_scale_before_goal := 1.0
|
||||
var _goal_in_progress := false
|
||||
|
||||
const GOAL_CELEBRATION_SECONDS := 1.6
|
||||
const GOAL_SLOWMO_SCALE := 0.22
|
||||
|
||||
# Shared by modes that keep score (Match, Spectate); Free Play never
|
||||
# references this or emits a score_changed signal, and HUDController relies
|
||||
@@ -38,6 +32,18 @@ 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
|
||||
@@ -51,8 +57,9 @@ func _ready():
|
||||
if not arena:
|
||||
push_error("GameMode: scene has no Arena child")
|
||||
return
|
||||
for goal in arena.get_goals():
|
||||
goal.goal_scored.connect(_handle_goal_scored)
|
||||
if _owns_goal_logic():
|
||||
for goal in arena.get_goals():
|
||||
goal.goal_scored.connect(_handle_goal_scored)
|
||||
_start()
|
||||
|
||||
|
||||
@@ -68,6 +75,31 @@ 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
|
||||
@@ -88,7 +120,14 @@ func _handle_goal_scored(conceding_team: int) -> void:
|
||||
_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
|
||||
|
||||
|
||||
@@ -97,34 +136,23 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
|
||||
# real-time presentation delay between episodes.
|
||||
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
||||
return
|
||||
_restore_hit_stop()
|
||||
_goal_slowmo_active = true
|
||||
_time_scale_before_goal = Engine.time_scale
|
||||
Engine.time_scale = minf(Engine.time_scale, GOAL_SLOWMO_SCALE)
|
||||
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_CELEBRATION_SECONDS, true, false, true).timeout
|
||||
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()
|
||||
# Defensive unwind: impact feedback is suppressed while goal slow-mo owns
|
||||
# time scale, but restore any hit-stop that was already queued this frame.
|
||||
_restore_hit_stop()
|
||||
_restore_goal_slowmo()
|
||||
|
||||
|
||||
func _restore_goal_slowmo() -> void:
|
||||
if not _goal_slowmo_active:
|
||||
return
|
||||
Engine.time_scale = _time_scale_before_goal
|
||||
_goal_slowmo_active = false
|
||||
|
||||
|
||||
func spawn_ball() -> RigidBody3D:
|
||||
@@ -136,7 +164,7 @@ func spawn_ball() -> RigidBody3D:
|
||||
|
||||
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
|
||||
var ship: Ship = ship_scene.instantiate()
|
||||
ship.name = "ShipTeam%d_%d" % [team, ships.size()]
|
||||
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
|
||||
@@ -155,7 +183,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||
add_child(rig)
|
||||
_camera_rig = rig
|
||||
rig.target = target
|
||||
rig.impact_feedback.connect(_on_player_impact)
|
||||
# Also wires the scene's static HUD (if any) to the same ship, rather
|
||||
# than letting it guess via the "ship" group.
|
||||
if hud:
|
||||
@@ -163,42 +190,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||
return rig
|
||||
|
||||
|
||||
func _on_player_impact(intensity: float) -> void:
|
||||
if not _goal_slowmo_active:
|
||||
_run_hit_stop(intensity)
|
||||
|
||||
|
||||
func _run_hit_stop(intensity: float) -> void:
|
||||
if _goal_slowmo_active:
|
||||
return
|
||||
_hit_stop_generation += 1
|
||||
var generation := _hit_stop_generation
|
||||
if not _hit_stop_active:
|
||||
_time_scale_before_hit_stop = Engine.time_scale
|
||||
_hit_stop_active = true
|
||||
Engine.time_scale = minf(
|
||||
Engine.time_scale, lerpf(0.22, 0.06, clampf(intensity, 0.0, 1.0))
|
||||
)
|
||||
await get_tree().create_timer(
|
||||
lerpf(0.025, 0.065, clampf(intensity, 0.0, 1.0)), true, false, true
|
||||
).timeout
|
||||
if generation == _hit_stop_generation:
|
||||
_restore_hit_stop()
|
||||
|
||||
|
||||
func _restore_hit_stop() -> void:
|
||||
if not _hit_stop_active:
|
||||
return
|
||||
Engine.time_scale = _time_scale_before_hit_stop
|
||||
_hit_stop_active = false
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
# A scene change during the unscaled timer must never strand global time.
|
||||
_restore_hit_stop()
|
||||
_restore_goal_slowmo()
|
||||
|
||||
|
||||
# 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
|
||||
@@ -233,6 +224,16 @@ func _record_goal(scoring_team: int) -> void:
|
||||
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):
|
||||
@@ -243,24 +244,33 @@ 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(randf_range(-position_jitter, position_jitter), 0.0, randf_range(-position_jitter, position_jitter))
|
||||
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, randf_range(-yaw_jitter, yaw_jitter))
|
||||
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:
|
||||
# Deferred: a RigidBody3D transform can't be set mid-physics-step
|
||||
body.set_deferred("global_transform", to)
|
||||
body.set_deferred("linear_velocity", Vector3.ZERO)
|
||||
body.set_deferred("angular_velocity", Vector3.ZERO)
|
||||
# A kickoff reset is a teleport: without this, physics interpolation
|
||||
# smears the body across the arena for a frame
|
||||
body.call_deferred("reset_physics_interpolation")
|
||||
# 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):
|
||||
@@ -282,7 +292,8 @@ const ESCAPE_MARGIN := 15.0
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_respawn_escaped_bodies()
|
||||
if _owns_world_simulation():
|
||||
_respawn_escaped_bodies()
|
||||
|
||||
|
||||
func _respawn_escaped_bodies() -> void:
|
||||
|
||||
Reference in New Issue
Block a user