mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +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:
+111
-8
@@ -24,6 +24,15 @@ signal impact_feedback(intensity: float)
|
||||
@export_group("Impact Shake")
|
||||
@export var max_shake_offset := 0.32
|
||||
@export var shake_decay := 8.0
|
||||
@export_group("Impact Punch")
|
||||
# Replaces Engine.time_scale hit-stop / goal slow-mo (see game_mode.gd):
|
||||
# a camera-only FOV kick + PostFX flash that decays over real time, so it
|
||||
# works identically for hit-stop and goal moments without touching global
|
||||
# simulation speed — which a networked client could never do to a shared sim.
|
||||
@export var punch_fov_kick := 9.0
|
||||
@export var punch_vignette_kick := 0.28
|
||||
@export var punch_chroma_kick := 0.012
|
||||
@export var punch_decay := 5.0
|
||||
|
||||
var target: Ship:
|
||||
set(value):
|
||||
@@ -33,6 +42,16 @@ var target: Ship:
|
||||
target.ball_contact.disconnect(_on_target_ball_contact)
|
||||
target = value
|
||||
_connect_target()
|
||||
# Priming: a freshly assigned target's Visual has no interpolation
|
||||
# history yet (or is about to be reparented mid-spawn), and the rig
|
||||
# itself would otherwise lerp in from wherever it was previously
|
||||
# (world origin on first spawn, the old target on a Spectate switch)
|
||||
# over camera_smoothing seconds. Both read as a visible swoop/smear;
|
||||
# neither is a real camera move.
|
||||
if is_instance_valid(target) and is_instance_valid(target.visual):
|
||||
target.visual.reset_physics_interpolation()
|
||||
if is_inside_tree():
|
||||
snap_to_target()
|
||||
var ball_cam_enabled := true
|
||||
|
||||
@onready var camera: Camera3D = $Camera3D
|
||||
@@ -50,14 +69,22 @@ var _shake_strength := 0.0
|
||||
var _shake_noise := FastNoiseLite.new()
|
||||
var _shake_time := 0.0
|
||||
var _last_shake_offset := Vector3.ZERO
|
||||
var _punch_strength := 0.0
|
||||
var _goal_cut_active := false
|
||||
var _goal_cut_position := Vector3.ZERO
|
||||
var _goal_cut_look_at := Vector3.ZERO
|
||||
|
||||
const SHAKE_UPDATE_HZ := 60.0
|
||||
|
||||
|
||||
func _ready():
|
||||
# Group lets the HUD discover the rig for the camera-mode instrument
|
||||
add_to_group("ship_camera")
|
||||
# The rig moves itself every rendered frame in _process now (task 0.16),
|
||||
# not on the physics tick — Godot's built-in physics interpolation would
|
||||
# otherwise smooth between _physics_process-era transforms this rig no
|
||||
# longer writes, fighting the manual smoothing below.
|
||||
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
|
||||
camera.fov = base_fov
|
||||
_effective_distance = camera_distance
|
||||
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||
@@ -83,7 +110,7 @@ func _input(event):
|
||||
camera_mode_changed.emit(ball_cam_enabled)
|
||||
|
||||
|
||||
func _physics_process(delta):
|
||||
func _process(delta):
|
||||
# Camera position smoothing operates on the unshaken chase position. Remove
|
||||
# last frame's presentation-only offset first so shake never accumulates or
|
||||
# exceeds its configured amplitude during a low-time-scale hit-stop.
|
||||
@@ -93,6 +120,10 @@ func _physics_process(delta):
|
||||
return
|
||||
if _goal_cut_active:
|
||||
_smooth_look_at(_goal_cut_look_at, delta)
|
||||
# The cinematic cut freezes camera position, but leftover shake from
|
||||
# an impact right before the goal must still bleed off in real time —
|
||||
# otherwise it resumes at full pre-cut magnitude when play returns.
|
||||
_decay_shake(delta)
|
||||
return
|
||||
_update_speed_feel(delta)
|
||||
var ball := _get_ball()
|
||||
@@ -100,6 +131,7 @@ func _physics_process(delta):
|
||||
_update_ball_cam(delta, ball)
|
||||
else:
|
||||
_update_ship_cam(delta)
|
||||
_apply_punch(delta)
|
||||
|
||||
|
||||
func _get_ball() -> Node3D:
|
||||
@@ -112,7 +144,14 @@ func _update_ball_cam(delta, ball: Node3D):
|
||||
# Ball cam keeps the ship between the camera and the ball: the camera sits
|
||||
# on the ball→ship line (horizontal component only), looking at the ball,
|
||||
# so the ship stays low-centre in frame and the ball stays centred.
|
||||
var ship_pos: Vector3 = target.global_transform.origin
|
||||
# Reads target.visual, not target itself: once prediction correction
|
||||
# (task 0.14) lands, the body can be offset from what's on screen — the
|
||||
# camera must always frame what the player sees, not the collider.
|
||||
# get_global_transform_interpolated() (not global_transform) because this
|
||||
# now runs in _process: the physics body only moves once per 60Hz tick,
|
||||
# so sampling its raw transform every rendered frame at 240fps would
|
||||
# repeat the same value 4 times in a row and read as stutter.
|
||||
var ship_pos: Vector3 = target.visual.get_global_transform_interpolated().origin
|
||||
var ball_pos: Vector3 = ball.global_transform.origin
|
||||
|
||||
# Smooth the orbit direction in angle space rather than lerping the camera
|
||||
@@ -145,9 +184,12 @@ func _update_ball_cam(delta, ball: Node3D):
|
||||
|
||||
|
||||
func _update_ship_cam(delta):
|
||||
# In ship cam, camera follows and looks in the same direction as the ship
|
||||
var ship_pos: Vector3 = target.global_transform.origin
|
||||
var ship_forward: Vector3 = -target.global_transform.basis.z
|
||||
# In ship cam, camera follows and looks in the same direction as the ship.
|
||||
# Reads target.visual, not target itself, via the interpolated transform —
|
||||
# see _update_ball_cam.
|
||||
var visual_xform := target.visual.get_global_transform_interpolated()
|
||||
var ship_pos: Vector3 = visual_xform.origin
|
||||
var ship_forward: Vector3 = -visual_xform.basis.z
|
||||
|
||||
# Position camera behind and above the ship
|
||||
var camera_target_pos := ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
|
||||
@@ -189,6 +231,7 @@ func _update_speed_feel(delta: float) -> void:
|
||||
|
||||
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
|
||||
_shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0))
|
||||
_punch_strength = maxf(_punch_strength, clampf(intensity, 0.0, 1.0))
|
||||
for device in Input.get_connected_joypads():
|
||||
Input.start_joy_vibration(
|
||||
device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity),
|
||||
@@ -201,17 +244,77 @@ func _apply_shake(delta: float) -> void:
|
||||
if _shake_strength <= 0.001:
|
||||
_shake_strength = 0.0
|
||||
return
|
||||
_shake_time += delta * 60.0
|
||||
_shake_time += delta
|
||||
# Quantized to a fixed 60Hz cadence rather than sampled once per rendered
|
||||
# frame. The noise domain's total distance travelled per second is the
|
||||
# same either way, but that's not what "reads the same" means here: at
|
||||
# 60fps, consecutive samples are frequency (2.5) domain-units apart —
|
||||
# far enough that FastNoiseLite's simplex correlation has decayed, so it
|
||||
# reads as sharp, uncorrelated jitter. At 240fps the same per-second
|
||||
# distance is split across 4x the samples, so consecutive samples are
|
||||
# ~4x closer together and highly correlated — a completely different,
|
||||
# much gentler wobble. Freezing the domain input to whole 60Hz ticks
|
||||
# makes every render frame within one tick reuse the exact same sample,
|
||||
# so the perceived shake texture is identical at any frame rate.
|
||||
var tick: float = floori(_shake_time * SHAKE_UPDATE_HZ)
|
||||
var amplitude := max_shake_offset * _shake_strength * _shake_strength
|
||||
var noise := Vector2(
|
||||
_shake_noise.get_noise_1d(_shake_time),
|
||||
_shake_noise.get_noise_1d(_shake_time + 100.0)
|
||||
_shake_noise.get_noise_1d(tick),
|
||||
_shake_noise.get_noise_1d(tick + 100.0)
|
||||
).limit_length(1.0) * amplitude
|
||||
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
|
||||
camera.global_position += _last_shake_offset
|
||||
_decay_shake(delta)
|
||||
|
||||
|
||||
func _decay_shake(delta: float) -> void:
|
||||
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
|
||||
|
||||
|
||||
# Additive FOV kick + PostFX flash on top of _update_speed_feel's base
|
||||
# values, decaying over real time. Replaces the weight that Engine.time_scale
|
||||
# hit-stop used to sell on ball impact — see the Impact Punch export group.
|
||||
func _apply_punch(delta: float) -> void:
|
||||
if _punch_strength <= 0.001:
|
||||
_punch_strength = 0.0
|
||||
return
|
||||
camera.fov += punch_fov_kick * _punch_strength
|
||||
var chroma: float = post_material.get_shader_parameter("chromatic_aberration")
|
||||
var vignette: float = post_material.get_shader_parameter("vignette_strength")
|
||||
post_material.set_shader_parameter("chromatic_aberration", chroma + punch_chroma_kick * _punch_strength)
|
||||
post_material.set_shader_parameter("vignette_strength", vignette + punch_vignette_kick * _punch_strength)
|
||||
_punch_strength = move_toward(_punch_strength, 0.0, punch_decay * delta)
|
||||
|
||||
|
||||
# Places the camera at its resting chase position instantly, bypassing
|
||||
# camera_smoothing/orbit_smoothing/look_smoothing entirely. Used whenever the
|
||||
# thing being framed just teleported (kickoff, target reassignment) — without
|
||||
# this the rig would lerp smoothly across the whole arena over
|
||||
# camera_smoothing seconds, which reads as an unintended camera move rather
|
||||
# than a reset.
|
||||
func snap_to_target() -> void:
|
||||
if not is_instance_valid(target) or not is_instance_valid(camera):
|
||||
return
|
||||
_shake_strength = 0.0
|
||||
camera.global_position -= _last_shake_offset
|
||||
_last_shake_offset = Vector3.ZERO
|
||||
var visual_xform := target.visual.get_global_transform_interpolated()
|
||||
var ship_pos: Vector3 = visual_xform.origin
|
||||
var ball := _get_ball()
|
||||
if ball_cam_enabled and ball:
|
||||
var ball_pos: Vector3 = ball.global_transform.origin
|
||||
var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z)
|
||||
_orbit_dir = flat.normalized() if flat.length() > 0.01 else Vector3.BACK
|
||||
camera.global_position = ship_pos + _orbit_dir * _effective_distance + Vector3.UP * camera_height
|
||||
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
|
||||
camera.look_at(ball_pos + Vector3.UP * 0.5, Vector3.UP)
|
||||
else:
|
||||
var ship_forward: Vector3 = -visual_xform.basis.z
|
||||
camera.global_position = ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
|
||||
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
|
||||
camera.look_at(ship_pos + ship_forward * 10.0, Vector3.UP)
|
||||
|
||||
|
||||
func begin_goal_cut(goal_position: Vector3) -> void:
|
||||
_goal_cut_active = true
|
||||
# The hard cut replaces the chase camera's presentation offset entirely;
|
||||
|
||||
Reference in New Issue
Block a user