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
+72 -7
View File
@@ -1,5 +1,6 @@
class_name Ship
extends RigidBody3D
const SimConstants = preload("res://scripts/sim_constants.gd")
# Physics-driven spaceship. All movement is force/torque-based, applied in
# _integrate_forces from a ShipAction supplied by a pluggable ShipController
@@ -44,7 +45,8 @@ extends RigidBody3D
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
# _apply_team_color() retints them per-team and must keep addressing them by
# name). Verified via get_surface_count()/surface_get_material() before
# name, under $Visual — see that function). Verified via
# get_surface_count()/surface_get_material() before
# writing this: hull and canopy are each a single surface with their own
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
# the name), and engine_l/engine_r are each 2 surfaces, also all distinct
@@ -105,6 +107,43 @@ var _current_action: ShipAction = ShipAction.new()
var _inert_action: ShipAction = ShipAction.new()
var _boundary: ArenaBoundary
var _pending_teleport: Transform3D
var _has_pending_teleport := false
# Queues an authoritative teleport, applied at the top of the next
# _integrate_forces — the only Jolt-safe place to write state.transform
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
# physics step via set_deferred("global_transform", ...).
func queue_teleport(to: Transform3D) -> void:
_pending_teleport = to
_has_pending_teleport = true
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
# hook in _integrate_forces below is a no-op today.
# Velocity delta from a soft correction, consumed once then cleared —
# applied in full immediately (invisible to the player, and it's the
# *cause* of future position error, so blending it just prolongs
# divergence).
var net_vel_correction := Vector3.ZERO
# Rendered offset between the body and $Visual while a soft correction
# decays away, so a position correction moves the collider in full without
# visibly teleporting the mesh. Same decay convention as drag/righting
# torque (_tick_scaled) above.
var net_visual_offset := Vector3.ZERO
const NET_VISUAL_OFFSET_DECAY := 0.88
# Feeds thrust_z/turbo into the movement VFX for a ship with no local
# controller driving _integrate_forces (a frozen remote ship never calls
# get_action(), so _update_movement_vfx's engine glow/flame would otherwise
# read a stale or zeroed action and show dead engines).
func set_visual_action(thrust_z: float, turbo: bool) -> void:
_current_action.thrust.z = thrust_z
_current_action.turbo = turbo
# Instrument signals for efficient data distribution
signal speed_changed(speed: float)
signal attitude_changed(pitch: float, roll: float, yaw: float)
@@ -135,6 +174,13 @@ var _engine_cores: Array[MeshInstance3D] = []
var _engine_flames: Array[MeshInstance3D] = []
var _engine_lights: Array[OmniLight3D] = []
# All rendered geometry (hull, canopy, engine cores/flames/lights, Nose,
# TailFin) parents under this instead of the RigidBody3D directly, so a
# future prediction correction (task 0.14) can offset the visual without
# moving the collider — see multiplayer-todo.md task 0.2. CollisionShape3D
# and the controller child correctly stay on the body itself.
@onready var visual: Node3D = $Visual
func _ready():
# Add ship to group for instrument discovery
@@ -177,7 +223,7 @@ func _apply_team_color() -> void:
return
var accent := _get_team_material(team)
for mesh_name in ["Nose", "TailFin"]:
var mesh := get_node_or_null(mesh_name) as MeshInstance3D
var mesh := get_node_or_null("Visual/" + mesh_name) as MeshInstance3D
if mesh:
mesh.material_override = accent
@@ -205,7 +251,7 @@ func _build_merged_hull() -> void:
var instance := MeshInstance3D.new()
instance.name = "MergedHull"
instance.mesh = mesh
add_child(instance)
visual.add_child(instance)
# Attach the node that drives this ship (player, AI, or network). Replaces
@@ -238,7 +284,7 @@ func _build_movement_vfx() -> void:
core.position = engine_pos
core.mesh = core_mesh
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(core)
visual.add_child(core)
_engine_cores.append(core)
# A single conventional orange flame replaces the layered particle plume
@@ -265,7 +311,7 @@ func _build_movement_vfx() -> void:
flame.mesh = flame_mesh
flame.visible = false
flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(flame)
visual.add_child(flame)
_engine_flames.append(flame)
var light := OmniLight3D.new()
@@ -275,7 +321,7 @@ func _build_movement_vfx() -> void:
light.omni_range = 3.5
light.omni_attenuation = 2.0
light.shadow_enabled = false
add_child(light)
visual.add_child(light)
_engine_lights.append(light)
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
@@ -343,6 +389,25 @@ func _has_telemetry_listeners() -> bool:
func _integrate_forces(state):
if _has_pending_teleport:
_has_pending_teleport = false
state.transform = _pending_teleport
state.linear_velocity = Vector3.ZERO
state.angular_velocity = Vector3.ZERO
reset_physics_interpolation()
# --- Netcode correction hook (Phase 4) --- guarded: both fields default
# to Vector3.ZERO and nothing writes them yet, so neither branch runs
# today.
if net_vel_correction != Vector3.ZERO:
state.linear_velocity += net_vel_correction
net_vel_correction = Vector3.ZERO
if net_visual_offset != Vector3.ZERO:
net_visual_offset *= _tick_scaled(NET_VISUAL_OFFSET_DECAY, state.step)
if net_visual_offset.length_squared() < 0.0001:
net_visual_offset = Vector3.ZERO
visual.position = net_visual_offset
# One action per physics tick, pulled from the controller (deterministic)
_current_action = controller.get_action() if controller else _inert_action
@@ -448,7 +513,7 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
# by the actual elapsed tick time `step`, so `v *= _tick_scaled(k, step)`
# decays at the same rate per second regardless of physics_ticks_per_second.
func _tick_scaled(k: float, step: float) -> float:
return pow(k, step * 60.0)
return pow(k, step * SimConstants.TICK_HZ)
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):