mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
75f485667b
Closes Phase 4's outstanding action-sequence-correctness invariant, then fixes two server-side bugs an adversarial review of that work uncovered. Server simulation, bot observations, collision resources and tick rate are unchanged: the server_physics_parity trace is byte-for-byte identical to HEAD across 360 ticks including both ships' full observation vectors. 4.11 - prediction history filed under the ISSUING sequence _send_local_input filed each post-step predicted state under the timeline's estimate of the sequence the server would consume this tick, trailing issuance by input_lead. The body had integrated the intent issued under _input_seq, so predicted[S] held "state after the intent from now" while the server's authority for S is "state after action(S)". They agree only while the stick is still. Filing under _input_seq costs nothing: which action the ship uses is decided in LocalNetShipController.get_action() and is untouched. Every prior Phase 4 gate held its input steady, and a steady input cannot falsify a sequence label - the 60s runs honestly reported marker=0/3784. New --exercise-input-transitions role toggles thrust every 6 ticks; it is the only gate that can catch a label regression. Verified non-vacuous: the old label fails it at 50%. 4.12 - issued-but-unsimulated sequences, and the release path An attack (delta > 1) issues and sends several sequences for one local physics step. Those gap sequences had no recorded prediction, so a server ack of one reported missing_not_recorded - indistinguishable from ring loss, costing a teleport and resync suppression several times a minute. They are now recorded stateless via record_unsimulated() and answered with a new "skip" decision mode. Free-flight hard snaps: 25/8/4 -> 0/0/0. A release (delta == 0) re-recorded at the unchanged _input_seq, filing the current intent under a sequence that went out carrying a different action; LocalInputTimeline deliberately refuses to mutate an issued sequence, so the ring contradicted the wire. Recording is now skipped on release ticks. 4.13 - two Phase 3 bugs silently killing player input (a) InputJitterBuffer.consume() advanced last_applied_seq on every tick including a starve. Since ingest() discards seq <= last_applied_seq, one starve on a sequence the client had not sent yet stranded the stream one ahead of arrivals permanently - both sides advancing in lockstep, every honest packet discarded on arrival. The client's own input_lead release is enough to trigger it, so input died for ~30 ticks roughly every 6.5s on a clean LAN. Now only gives up on a sequence once strictly newer data proves it lost. Silent-client stall and ring-overflow resync are unchanged. (b) The seq-range guard bounded incoming seq against highest_ingested_seq, which only advances inside ingest(), which that guard gates. After a ~2s host hitch every packet was rejected forever with no diagnostic (600+ consecutive rejections reproduced via SIGSTOP). Third iteration of this guard; each previous version bounded against a value only the accepted path could advance. Adds an escape after 10 consecutive rejections, which grants an attacker nothing the rate limiter does not already bound. (c) The transitions gate reported PASS at 3.76% while input was completely dead, because suppression stops _record_metrics - a worse outage yields fewer samples and a LOWER rate. Now scales the required sample count with run length and asserts the wire's server_stalled bit. Reverting both fixes makes it fail at samples 292/600, server_stalled=true, input_lead=12. Fixing (a) also explained a residual the review had already traced: 151 of 151 action-marker mismatches were the server repeating a stale action on a starve, not a prediction defect. Marker is now 0.00% in all three conditions (was 1.7-2.5%), and free-flight p99 improved to 0.141/0.168/0.154m from 0.170/0.176/0.184m. Two pre-existing test defects fixed alongside: the ball gate asserted RTT-masking on a link with no RTT (flaked 2 in 5; now asserted only at rtt >= 20ms, 5/5 under latency), and the two-bot CI compared scores across a 3-5s window (now polls the scores the server actually held; note score_changed is emitted only on the client path). QA: 72 unit tests; 60s free-flight at LAN/80+-20ms/5% loss; transition gate in all three; 2.0s and 3.5s host-freeze recovery; ball contact x5; two-bot CI x3; all three abuse roles; net/match_net/clock/lobby smokes. Phase 4 sign-off still pending a human playtest at ~100ms RTT - the milestone asks how it feels, which no gate here answers.
662 lines
28 KiB
GDScript
662 lines
28 KiB
GDScript
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
|
||
# child node (player input, AI policy, or network replication — see
|
||
# set_controller). A ship without a controller is inert but still simulated,
|
||
# which is what a placeholder opponent or a headless RL ship needs.
|
||
# Physics properties (mass, inertia, friction material) live in ship.tscn.
|
||
|
||
@export_group("Movement")
|
||
@export var thrust_power = 150.0 # Main thruster power
|
||
@export var maneuvering_thrust = 75.0 # Side thruster power
|
||
@export var vertical_thrust = 120.0 # Up/down thruster power
|
||
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
|
||
@export var max_speed = 35.0 # Maximum velocity
|
||
@export var rotation_power = 20.0 # Angular thrust power
|
||
@export var max_angular_speed = 3.0 # Maximum rotation speed
|
||
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
|
||
@export var angular_drag = 0.95 # Rotational drag
|
||
@export var idle_angular_drag = 0.9 # Rotational drag when no rotation input is held
|
||
|
||
@export_group("Surface Pull")
|
||
@export var wall_pull_strength = 6.0 # Wall grav-plating strength (m/s^2-equivalent)
|
||
@export var wall_pull_range = 3.0 # Metres from a wall where pull begins
|
||
@export var ceiling_pull_strength = 11.5 # Ceiling grav-plating strength; nets above gravity so a ship can hold a ceiling
|
||
@export var ceiling_pull_range = 3.0 # Metres from the ceiling where pull begins
|
||
|
||
# Grav-plating righting torque: a spring-damper that rolls/pitches the hull
|
||
# back toward belly-down, strongest at floor level and faded to nothing by
|
||
# righting_range so genuine aerials keep full attitude freedom. Without it
|
||
# "upright" is not a physically distinguished state at all — the hull is a
|
||
# box with no restoring torque, so belly-down and rolled-90 are equally
|
||
# stable and a policy has no dynamics-level reason to prefer either. Six
|
||
# rounds of RL reward shaping (see TRAINING.md) failed to buy upright
|
||
# ground handling for exactly this reason; the fix belongs in the physics,
|
||
# not the reward. Same idea as the wall/ceiling pull above — the plating
|
||
# orients you, not just attracts you — and it helps human pilots land
|
||
# cleanly too.
|
||
@export var righting_strength: float = 20.0 # Righting spring gain (0 disables)
|
||
@export var righting_damping: float = 6.0 # Opposes tumble while righting
|
||
@export var righting_range: float = 3.0 # Metres above the floor where righting fades out
|
||
|
||
# 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, 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
|
||
# materials — none of the 6 source surfaces share a material with any other,
|
||
# including the L/R engine pair. So this merge does not collapse draw calls
|
||
# the way TODO.md's "6 draw calls down to 3" assumed (Godot still issues one
|
||
# draw call per surface regardless of how many MeshInstance3Ds they're spread
|
||
# across); the real win is scene-tree node count, 4 MeshInstance3D children
|
||
# down to 1, cutting per-frame transform/visibility overhead.
|
||
const MERGED_MESH_PATHS := [
|
||
"res://assets/models/ship_hull.res",
|
||
"res://assets/models/ship_canopy.res",
|
||
"res://assets/models/ship_engine_l.res",
|
||
"res://assets/models/ship_engine_r.res",
|
||
]
|
||
const MERGED_MESH_TRANSFORMS := [
|
||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0, 0, 0)), # Hull
|
||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0, 0.31, -0.55)), # Canopy
|
||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(-0.42, -0.05, 0.95)), # EngineGlowL
|
||
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0.42, -0.05, 0.95)), # EngineGlowR
|
||
]
|
||
|
||
# Which team this ship plays for (0 or 1). Set by the game mode on spawn.
|
||
var team: int = 0:
|
||
set(value):
|
||
team = value
|
||
_apply_team_color()
|
||
|
||
# This ship's index within its team's roster (0, 1, 2, ...), set once by
|
||
# GameMode.spawn_ship and never changed afterward. The stable identity
|
||
# AIShipController/ShipAIController sort teammates/opponents by, so both
|
||
# training and in-game inference assign the same ship to the same
|
||
# observation-vector slot for the whole match (see ShipObservations).
|
||
var spawn_index: int = -1
|
||
|
||
# Shared per-team accent material, built once per team and reused by every
|
||
# ship — avoids allocating a fresh StandardMaterial3D from both _ready and
|
||
# the team setter (previously ran at least twice per ship).
|
||
static var _team_materials: Dictionary = {} # team:int -> StandardMaterial3D
|
||
|
||
|
||
static func _get_team_material(team: int) -> StandardMaterial3D:
|
||
if _team_materials.has(team):
|
||
return _team_materials[team]
|
||
var color: Color = TeamColors.TEAM_COLORS.get(team, TeamColors.TEAM_COLORS[0])
|
||
var accent := StandardMaterial3D.new()
|
||
accent.albedo_color = color
|
||
accent.metallic = 0.3
|
||
accent.roughness = 0.5
|
||
accent.emission_enabled = true
|
||
accent.emission = color
|
||
accent.emission_energy_multiplier = 0.35
|
||
_team_materials[team] = accent
|
||
return accent
|
||
|
||
var controller: ShipController
|
||
var _current_action: ShipAction = ShipAction.new()
|
||
var _inert_action: ShipAction = ShipAction.new()
|
||
var _boundary: ArenaBoundary
|
||
|
||
var _pending_teleport: Transform3D
|
||
var _has_pending_teleport := false
|
||
var _pending_teleport_linear_velocity := Vector3.ZERO
|
||
var _pending_teleport_angular_velocity := Vector3.ZERO
|
||
var _pending_teleport_has_velocity := 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
|
||
_pending_teleport_has_velocity = false
|
||
|
||
|
||
# Network hard snaps need the server velocity as their new starting point,
|
||
# unlike gameplay resets which deliberately zero it. Keep the write queued:
|
||
# Jolt only permits state mutation from _integrate_forces.
|
||
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||
_pending_teleport = to
|
||
_pending_teleport_linear_velocity = new_linear_velocity
|
||
_pending_teleport_angular_velocity = new_angular_velocity
|
||
_pending_teleport_has_velocity = true
|
||
_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
|
||
var net_visual_rotation_offset := Quaternion.IDENTITY
|
||
const NET_VISUAL_OFFSET_DECAY := 0.88
|
||
const MAX_VISUAL_OFFSET := 0.4
|
||
var net_prediction_contact_window := false # client telemetry only
|
||
var net_visual_offset_decay := NET_VISUAL_OFFSET_DECAY
|
||
var net_visual_offset_max := MAX_VISUAL_OFFSET
|
||
|
||
|
||
func set_network_visual_tuning(decay: float, max_offset: float) -> void:
|
||
# Called only by the local client debug overlay. Server/training ships keep
|
||
# the constants above and therefore retain their exact existing behavior.
|
||
net_visual_offset_decay = clampf(decay, 0.5, 0.99)
|
||
net_visual_offset_max = clampf(max_offset, 0.05, 2.0)
|
||
|
||
|
||
# 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
|
||
|
||
|
||
# The local network sender reads this after this tick's _integrate_forces,
|
||
# rather than pulling PlayerShipController a second time. That preserves the
|
||
# one get_action() call per physics tick contract.
|
||
func get_current_action_copy() -> ShipAction:
|
||
return _current_action.copy()
|
||
|
||
|
||
# Instrument signals for efficient data distribution
|
||
signal speed_changed(speed: float)
|
||
signal attitude_changed(pitch: float, roll: float, yaw: float)
|
||
signal altitude_changed(altitude: float)
|
||
signal thrust_changed(thrust_percent: float)
|
||
signal angular_velocity_changed(angular_speed: float)
|
||
signal heading_changed(heading_degrees: float)
|
||
signal ball_contact(intensity: float, world_position: Vector3)
|
||
|
||
# Performance optimization - track last emitted values to avoid unnecessary signals
|
||
var _last_speed: float = -1.0
|
||
var _last_altitude: float = -999999.0
|
||
var _last_angular_speed: float = -1.0
|
||
var _last_pitch: float = -999.0
|
||
var _last_roll: float = -999.0
|
||
var _last_yaw: float = -999.0
|
||
var _last_heading: float = -999.0
|
||
var _last_thrust: float = -1.0
|
||
|
||
# Thresholds for signal emission (only emit if change is significant)
|
||
const SPEED_THRESHOLD = 0.1 # m/s
|
||
const ALTITUDE_THRESHOLD = 0.5 # meters
|
||
const ANGULAR_THRESHOLD = 0.01 # rad/s
|
||
const ATTITUDE_THRESHOLD = 1.0 # degrees
|
||
const THRUST_THRESHOLD = 1.0 # percent
|
||
|
||
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
|
||
add_to_group("ship")
|
||
|
||
# Pick up a controller placed in the scene, if any; game modes usually
|
||
# attach one at spawn time via set_controller instead.
|
||
for child in get_children():
|
||
if child is ShipController:
|
||
controller = child
|
||
break
|
||
|
||
# Always on (moved here from ShipAIController.setup, which only enabled
|
||
# it for training-side ships): ShipObservations now reads own-contact
|
||
# state (see its "contact" section) for every ship, training or shipped,
|
||
# so the RigidBody3D contact list must exist unconditionally rather than
|
||
# only for whichever ship happened to be a training agent. Cheap — a
|
||
# short per-tick contact list from the physics engine, not a rendering
|
||
# cost like the headless skips just below.
|
||
contact_monitor = true
|
||
max_contacts_reported = 8
|
||
|
||
_apply_team_color()
|
||
|
||
_boundary = get_tree().get_first_node_in_group("arena_boundary")
|
||
|
||
# Telemetry emission and the merged-hull visual mesh are both render-only
|
||
# work; headless (RL/CI) instances never render or have a HUD watching
|
||
# them, so skip both rather than relying on later no-ops.
|
||
if DisplayServer.get_name() == "headless":
|
||
set_physics_process(false)
|
||
else:
|
||
_build_merged_hull()
|
||
_build_movement_vfx()
|
||
body_entered.connect(_on_body_entered)
|
||
|
||
|
||
func _apply_team_color() -> void:
|
||
if not is_inside_tree():
|
||
return
|
||
var accent := _get_team_material(team)
|
||
for mesh_name in ["Nose", "TailFin"]:
|
||
var mesh := get_node_or_null("Visual/" + mesh_name) as MeshInstance3D
|
||
if mesh:
|
||
mesh.material_override = accent
|
||
|
||
|
||
# Runtime-bakes Hull/Canopy/EngineGlowL/EngineGlowR (see MERGED_MESH_PATHS
|
||
# comment above) into one ArrayMesh, one destination surface per source
|
||
# surface via SurfaceTool.append_from, each keeping its own original
|
||
# material — preserves current visuals exactly regardless of surface count.
|
||
# Mirrors the runtime-bake pattern already used by goal.gd/arena_boundary.gd.
|
||
# Skipped in headless mode (see _ready): purely visual, costs nothing
|
||
# physics/RL cares about.
|
||
func _build_merged_hull() -> void:
|
||
var mesh := ArrayMesh.new()
|
||
var dest_idx := 0
|
||
for i in MERGED_MESH_PATHS.size():
|
||
var src: ArrayMesh = load(MERGED_MESH_PATHS[i])
|
||
var xform: Transform3D = MERGED_MESH_TRANSFORMS[i]
|
||
for surf in src.get_surface_count():
|
||
var st := SurfaceTool.new()
|
||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||
st.append_from(src, surf, xform)
|
||
st.commit(mesh)
|
||
mesh.surface_set_material(dest_idx, src.surface_get_material(surf))
|
||
dest_idx += 1
|
||
var instance := MeshInstance3D.new()
|
||
instance.name = "MergedHull"
|
||
instance.mesh = mesh
|
||
visual.add_child(instance)
|
||
|
||
|
||
# Attach the node that drives this ship (player, AI, or network). Replaces
|
||
# any existing controller; parents the new one under the ship if needed.
|
||
func set_controller(new_controller: ShipController) -> void:
|
||
if is_instance_valid(controller) and controller.get_parent() == self:
|
||
controller.queue_free()
|
||
controller = new_controller
|
||
if new_controller and new_controller.get_parent() == null:
|
||
add_child(new_controller)
|
||
|
||
|
||
func _physics_process(_delta):
|
||
_update_movement_vfx()
|
||
if _has_telemetry_listeners():
|
||
_emit_telemetry_data()
|
||
|
||
|
||
func _build_movement_vfx() -> void:
|
||
for x in [-0.42, 0.42]:
|
||
var engine_pos := Vector3(x, -0.05, 1.42)
|
||
|
||
var core_mat := _vfx_material(Color(1.0, 0.48, 0.1, 1.0), 1.0)
|
||
var core_mesh := SphereMesh.new()
|
||
core_mesh.radius = 0.11
|
||
core_mesh.height = 0.22
|
||
core_mesh.material = core_mat
|
||
var core := MeshInstance3D.new()
|
||
core.name = "EngineCoreL" if x < 0.0 else "EngineCoreR"
|
||
core.position = engine_pos
|
||
core.mesh = core_mesh
|
||
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||
visual.add_child(core)
|
||
_engine_cores.append(core)
|
||
|
||
# A single conventional orange flame replaces the layered particle plume
|
||
# and separate purple turbo effect. Turbo only lengthens and brightens the
|
||
# same flame, keeping the engine silhouette simple and readable.
|
||
var flame_mat := StandardMaterial3D.new()
|
||
flame_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||
flame_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||
flame_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||
flame_mat.albedo_color = Color(1.0, 0.34, 0.04, 0.92)
|
||
flame_mat.emission_enabled = true
|
||
flame_mat.emission = Color(1.0, 0.16, 0.015)
|
||
flame_mat.emission_energy_multiplier = 2.0
|
||
var flame_mesh := CylinderMesh.new()
|
||
flame_mesh.top_radius = 0.015
|
||
flame_mesh.bottom_radius = 0.14
|
||
flame_mesh.height = 1.0
|
||
flame_mesh.radial_segments = 12
|
||
flame_mesh.material = flame_mat
|
||
var flame := MeshInstance3D.new()
|
||
flame.name = "EngineFlameL" if x < 0.0 else "EngineFlameR"
|
||
flame.position = engine_pos + Vector3(0, 0, 0.3)
|
||
flame.rotation_degrees.x = 90.0
|
||
flame.mesh = flame_mesh
|
||
flame.visible = false
|
||
flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||
visual.add_child(flame)
|
||
_engine_flames.append(flame)
|
||
|
||
var light := OmniLight3D.new()
|
||
light.name = "EngineLightL" if x < 0.0 else "EngineLightR"
|
||
light.position = engine_pos
|
||
light.light_color = Color(1.0, 0.32, 0.08)
|
||
light.omni_range = 3.5
|
||
light.omni_attenuation = 2.0
|
||
light.shadow_enabled = false
|
||
visual.add_child(light)
|
||
_engine_lights.append(light)
|
||
|
||
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
|
||
var mat := StandardMaterial3D.new()
|
||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
||
mat.albedo_color = color
|
||
mat.emission_enabled = true
|
||
mat.emission = Color(color.r, color.g, color.b)
|
||
mat.emission_energy_multiplier = energy
|
||
return mat
|
||
|
||
|
||
func _update_movement_vfx() -> void:
|
||
if _engine_flames.is_empty():
|
||
return
|
||
# These are the two rear main engines, so lateral/vertical maneuvering jets
|
||
# must not make them flare. Reverse thrust also comes from separate attitude
|
||
# jets conceptually; only positive Z drives this rear-facing flame.
|
||
var thrust := clampf(maxf(_current_action.thrust.z, 0.0), 0.0, 1.0)
|
||
var turbo := _current_action.turbo and thrust > 0.05
|
||
for i in _engine_flames.size():
|
||
var flame := _engine_flames[i]
|
||
var flame_length := 0.28 + thrust * 0.82 + (0.62 if turbo else 0.0)
|
||
var flame_width := 0.72 + thrust * 0.32 + (0.12 if turbo else 0.0)
|
||
flame.visible = thrust > 0.02
|
||
flame.scale = Vector3(flame_width, flame_length, flame_width)
|
||
# CylinderMesh is centred on local Y (rotated to ship +Z), so moving its
|
||
# centre by half the length keeps the flame root fixed at the engine bell.
|
||
flame.position.z = 1.48 + flame_length * 0.5
|
||
var flame_mat := flame.mesh.material as StandardMaterial3D
|
||
flame_mat.emission_energy_multiplier = 1.8 + thrust * 2.2 + (1.8 if turbo else 0.0)
|
||
_engine_lights[i].light_energy = 0.35 + thrust * 2.1 + (2.3 if turbo else 0.0)
|
||
var core_mat := _engine_cores[i].mesh.material as StandardMaterial3D
|
||
core_mat.emission_energy_multiplier = 0.65 + thrust * 2.0 + (2.0 if turbo else 0.0)
|
||
_engine_cores[i].scale = Vector3.ONE * (0.8 + thrust * 0.3 + (0.25 if turbo else 0.0))
|
||
|
||
|
||
func get_speed_ratio() -> float:
|
||
return clampf(linear_velocity.length() / maxf(max_speed, 0.001), 0.0, 1.0)
|
||
|
||
|
||
func is_turbo_active() -> bool:
|
||
return _current_action.turbo and _current_action.thrust.z > 0.05
|
||
|
||
|
||
func _on_body_entered(body: Node) -> void:
|
||
if not body is Ball:
|
||
return
|
||
var relative_speed := (linear_velocity - (body as Ball).linear_velocity).length()
|
||
var intensity := clampf(inverse_lerp(3.0, 24.0, relative_speed), 0.12, 1.0)
|
||
var contact_pos := (global_position + (body as Ball).global_position) * 0.5
|
||
ball_contact.emit(intensity, contact_pos)
|
||
|
||
|
||
# Covers a second/AI ship with no HUD watching it - headless mode is already
|
||
# handled by disabling _physics_process entirely in _ready.
|
||
func _has_telemetry_listeners() -> bool:
|
||
return speed_changed.get_connections().size() > 0 \
|
||
or altitude_changed.get_connections().size() > 0 \
|
||
or attitude_changed.get_connections().size() > 0 \
|
||
or heading_changed.get_connections().size() > 0 \
|
||
or thrust_changed.get_connections().size() > 0
|
||
|
||
|
||
func _integrate_forces(state):
|
||
# Reconciliation telemetry needs to distinguish genuine free flight from
|
||
# Jolt contact windows. This is read only by the locally predicted client;
|
||
# it never changes forces, actions, collision state, or server behavior.
|
||
if not multiplayer.is_server():
|
||
net_prediction_contact_window = state.get_contact_count() > 0
|
||
if _has_pending_teleport:
|
||
_has_pending_teleport = false
|
||
state.transform = _pending_teleport
|
||
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||
_pending_teleport_has_velocity = false
|
||
reset_physics_interpolation()
|
||
if is_instance_valid(visual):
|
||
visual.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 = net_visual_offset.limit_length(net_visual_offset_max)
|
||
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
|
||
if net_visual_rotation_offset != Quaternion.IDENTITY:
|
||
net_visual_rotation_offset = net_visual_rotation_offset.slerp(Quaternion.IDENTITY, 1.0 - _tick_scaled(net_visual_offset_decay, state.step))
|
||
if absf(net_visual_rotation_offset.angle_to(Quaternion.IDENTITY)) < 0.001:
|
||
net_visual_rotation_offset = Quaternion.IDENTITY
|
||
visual.basis = Basis(net_visual_rotation_offset)
|
||
|
||
# One action per physics tick, pulled from the controller (deterministic)
|
||
_current_action = controller.get_action() if controller else _inert_action
|
||
|
||
# === TRANSLATION (Movement) ===
|
||
apply_thruster_forces(state, _current_action)
|
||
apply_surface_pull(state)
|
||
|
||
# === ROTATION (Turning) ===
|
||
apply_rotation_forces(state, _current_action.rotation)
|
||
apply_righting_torque(state)
|
||
|
||
# === DRAG AND LIMITS ===
|
||
apply_drag_and_limits(state, _current_action.rotation)
|
||
|
||
|
||
func apply_thruster_forces(state: PhysicsDirectBodyState3D, action: ShipAction):
|
||
var thrust_input := action.thrust
|
||
if thrust_input.length() < 0.01:
|
||
return
|
||
|
||
# Convert thrust input to world space forces based on ship orientation
|
||
# Physics: F = m * a (Newton's Second Law: Force = mass × acceleration)
|
||
# World force = Local force × Rotation matrix (basis transformation)
|
||
var ship_basis = global_transform.basis
|
||
var world_thrust = Vector3.ZERO
|
||
|
||
# All thrusters should work relative to ship orientation
|
||
# Physics: Vector transformation from local to world coordinates
|
||
# F_world = R * F_local (where R is rotation matrix)
|
||
|
||
# Forward/backward thrust (main engines)
|
||
world_thrust += -ship_basis.z * thrust_input.z * thrust_power
|
||
|
||
# Strafe thrust (left/right maneuvering thrusters)
|
||
world_thrust += ship_basis.x * thrust_input.x * maneuvering_thrust
|
||
|
||
# Vertical thrust (up/down thrusters relative to ship orientation)
|
||
world_thrust += ship_basis.y * thrust_input.y * vertical_thrust
|
||
|
||
# Turbo only boosts forward thrust
|
||
if action.turbo and thrust_input.z > 0:
|
||
world_thrust *= turbo_multiplier
|
||
|
||
# Apply the force
|
||
# Physics: Δv = F * Δt / m (change in velocity = force × time / mass)
|
||
state.apply_central_force(world_thrust)
|
||
|
||
|
||
func apply_surface_pull(state: PhysicsDirectBodyState3D) -> void:
|
||
if _boundary == null:
|
||
return
|
||
var pull := _boundary.get_surface_pull(
|
||
global_position, wall_pull_strength, wall_pull_range,
|
||
ceiling_pull_strength, ceiling_pull_range
|
||
)
|
||
state.apply_central_force(pull * mass)
|
||
|
||
|
||
# Spring-damper torque toward belly-down (see righting_strength). The spring
|
||
# term basis.y x UP is a world-space axis whose magnitude is sin(tilt) and
|
||
# whose direction is the shortest rotation back to upright, so it is zero
|
||
# when already level, peaks on its side, and — being a cross product —
|
||
# vanishes again when perfectly inverted. The damping term is applied only
|
||
# about that same righting axis, so it bleeds off tumble without taxing
|
||
# deliberate yaw. Fades linearly to nothing by righting_range metres up,
|
||
# matching every other ground-handling term's altitude ramp (see
|
||
# ShipAIController.GROUND_HANDLING_HEIGHT).
|
||
func apply_righting_torque(state: PhysicsDirectBodyState3D) -> void:
|
||
if righting_strength <= 0.0:
|
||
return
|
||
var height_factor := 1.0 - clampf(global_position.y / righting_range, 0.0, 1.0)
|
||
if height_factor <= 0.0:
|
||
return
|
||
|
||
var righting_axis := global_transform.basis.y.cross(Vector3.UP)
|
||
var torque := righting_axis * righting_strength
|
||
# Damp only the component of spin around the righting axis.
|
||
if righting_axis.length_squared() > 0.0001:
|
||
var axis := righting_axis.normalized()
|
||
torque -= axis * state.angular_velocity.dot(axis) * righting_damping
|
||
state.apply_torque(torque * height_factor)
|
||
|
||
|
||
func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
||
if rotation_input.length() < 0.01:
|
||
return
|
||
|
||
# Apply torque for rotation - simple and effective
|
||
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
|
||
# Also: α = τ / I (angular acceleration = torque / moment of inertia)
|
||
# Lower inertia = higher angular acceleration for same torque
|
||
var torque = Vector3(
|
||
rotation_input.x * rotation_power, # Pitch (rotation around X-axis)
|
||
rotation_input.y * rotation_power, # Yaw (rotation around Y-axis)
|
||
rotation_input.z * rotation_power # Roll (rotation around Z-axis)
|
||
)
|
||
|
||
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
|
||
state.apply_torque(torque)
|
||
|
||
|
||
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)
|
||
# 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 * SimConstants.TICK_HZ)
|
||
|
||
|
||
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
||
# Linear drag (air resistance)
|
||
# Physics: F_drag = -½ * ρ * v² * C_d * A (drag force equation)
|
||
# Simplified: v_new = v_old * drag_coefficient (exponential decay)
|
||
# This simulates air resistance reducing velocity over time.
|
||
# _tick_scaled makes the decay rate invariant to the physics tick rate —
|
||
# drag_coefficient/angular_drag/idle_angular_drag are all defined as the
|
||
# per-tick multiplier at a 60 Hz reference rate.
|
||
state.linear_velocity *= _tick_scaled(drag_coefficient, state.step)
|
||
|
||
# Angular drag (rotational resistance)
|
||
# Physics: Similar to linear drag but for rotational motion
|
||
# τ_drag = -C_angular * ω² (angular drag torque)
|
||
# Simplified: ω_new = ω_old * angular_drag (exponential decay)
|
||
if rotation_input.length() < 0.01:
|
||
# More drag when not actively rotating to stop quicker
|
||
state.angular_velocity *= _tick_scaled(idle_angular_drag, state.step)
|
||
else:
|
||
# Normal drag when actively rotating
|
||
state.angular_velocity *= _tick_scaled(angular_drag, state.step)
|
||
|
||
# Limit maximum speeds
|
||
# Physics: Terminal velocity concept - maximum achievable speed
|
||
# When thrust force = drag force, acceleration = 0, velocity = constant
|
||
if state.linear_velocity.length() > max_speed:
|
||
# Normalize to unit vector, then scale to max speed
|
||
# Physics: v̂ = v / |v| (unit vector), v_limited = v̂ * v_max
|
||
state.linear_velocity = state.linear_velocity.normalized() * max_speed
|
||
|
||
if state.angular_velocity.length() > max_angular_speed:
|
||
# Same concept for angular velocity
|
||
# Physics: ω̂ = ω / |ω|, ω_limited = ω̂ * ω_max
|
||
state.angular_velocity = state.angular_velocity.normalized() * max_angular_speed
|
||
|
||
|
||
func _emit_telemetry_data():
|
||
# Ship only calculates and emits data - HUD handles display
|
||
# Performance optimization: only emit signals when values change significantly
|
||
|
||
# Speed telemetry
|
||
# Physics: |v| = √(vₓ² + vᵧ² + vᵤ²) (magnitude of velocity vector)
|
||
var current_speed = linear_velocity.length()
|
||
if abs(current_speed - _last_speed) > SPEED_THRESHOLD:
|
||
speed_changed.emit(current_speed)
|
||
_last_speed = current_speed
|
||
|
||
# Altitude telemetry
|
||
# Physics: Height measurement from reference point (y = 0)
|
||
var current_altitude = global_transform.origin.y
|
||
if abs(current_altitude - _last_altitude) > ALTITUDE_THRESHOLD:
|
||
altitude_changed.emit(current_altitude)
|
||
_last_altitude = current_altitude
|
||
|
||
# Angular velocity telemetry
|
||
# Physics: |ω| = √(ωₓ² + ωᵧ² + ωᵤ²) (magnitude of angular velocity vector)
|
||
var angular_speed = angular_velocity.length()
|
||
if abs(angular_speed - _last_angular_speed) > ANGULAR_THRESHOLD:
|
||
angular_velocity_changed.emit(angular_speed)
|
||
_last_angular_speed = angular_speed
|
||
|
||
# Attitude telemetry (pitch, roll, yaw from ship orientation)
|
||
# Physics: Euler angles from rotation matrix
|
||
# Aviation convention (yaw → pitch → roll, EULER_ORDER_YXZ): pitch stays in
|
||
# ±90° and yaw covers the full circle. XYZ order would instead constrain
|
||
# yaw to ±90°, so an upright ship facing "south" would be reported as
|
||
# pitch 180 + roll 180 — mathematically equivalent, but it reads as
|
||
# upside-down on the attitude indicator and breaks the heading readout.
|
||
var ship_rotation = global_transform.basis.get_euler(EULER_ORDER_YXZ)
|
||
var pitch_deg = rad_to_deg(ship_rotation.x)
|
||
var roll_deg = rad_to_deg(ship_rotation.z)
|
||
var yaw_deg = rad_to_deg(ship_rotation.y)
|
||
|
||
if abs(pitch_deg - _last_pitch) > ATTITUDE_THRESHOLD or \
|
||
abs(roll_deg - _last_roll) > ATTITUDE_THRESHOLD or \
|
||
abs(yaw_deg - _last_yaw) > ATTITUDE_THRESHOLD:
|
||
attitude_changed.emit(pitch_deg, roll_deg, yaw_deg)
|
||
_last_pitch = pitch_deg
|
||
_last_roll = roll_deg
|
||
_last_yaw = yaw_deg
|
||
|
||
# Heading telemetry (yaw - direction ship is facing)
|
||
# Physics: Yaw = rotation around Y-axis (compass heading)
|
||
# Convert to 0-360° range for traditional compass display
|
||
var heading = fmod(yaw_deg + 360.0, 360.0) # Normalize to 0-360°
|
||
if abs(heading - _last_heading) > ATTITUDE_THRESHOLD:
|
||
heading_changed.emit(heading)
|
||
_last_heading = heading
|
||
|
||
# Thrust telemetry
|
||
# Physics: Thrust output as percentage of maximum available thrust
|
||
var thrust_percent = _current_action.thrust.length() * 100.0
|
||
if abs(thrust_percent - _last_thrust) > THRUST_THRESHOLD:
|
||
thrust_changed.emit(thrust_percent)
|
||
_last_thrust = thrust_percent
|