mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
076d27a564
Playing with a gamepad did not work: all six move_* actions had no joypad event at all, so a pad could yaw/pitch/roll/turbo but could not translate. Nothing caught it because every action existed and the game booted fine — no assertion checked that an action is reachable on *both* devices. Controller layout, on the 6DOF convention (left stick aims, right stick translates), using all six of the pad's analog axes for the ship's six degrees of freedom: left stick yaw + pitch right stick strafe + vertical LB / RB roll RT / LT forward / back L3 turbo R3 ball camera Input is now read with Input.get_axis instead of is_action_pressed, so triggers and sticks are proportional. Keyboard values are unchanged. Three rotation bugs found by measuring a real Ship rather than reading the code: - apply_torque() is world-space and the torque was never rotated into the hull's frame (unlike thrust, which uses -ship_basis.z). Roll input became pitch after a 90 degree turn and inverted at 180, so the controls were correct flying up-field and backwards flying back. - ship.tscn's inertia is Vector3(7, 1, 7) but a flat torque was applied to every axis, giving yaw 7x the angular acceleration of pitch and roll (172 deg/s vs 52). Torque is now scaled per-axis by inertia, so rotation_acceleration means rad/s^2 and all three axes match. Yaw is unchanged. - pitch_down pitched the nose UP: get_axis's arguments were reversed, so the I/K keys and the stick each did the opposite of their label. Menus were unusable on a pad for a separate reason: Godot 4.7 gives ui_up/down/left/right joypad events by default but leaves ui_accept and ui_cancel with none (verified against a pristine project), so a controller could move the highlight and never press anything. A confirms and B goes back. Gameplay exits on a new leave_gameplay action (Escape / Start) rather than ui_cancel, so carrying B for menus cannot abandon a live match. Bindings for both devices are rebindable in Settings -> Controls, persisted to user://input.cfg — a separate file from settings.cfg because VideoSettings.save() rewrites that file wholesale and would drop any section it does not know about. project.godot stays the source of truth for defaults; overrides are only ever a delta on top of a boot-time snapshot. Verified: 268 unit tests, the ENet integration gate, and a 16-sample before/after comparison of networked prediction residuals showing the physics change does not regress them (median 0.083m -> 0.065m). Note for follow-up: every policy in Game/bots/ was trained against the old sluggish, world-axis rotation and will over-rotate until retrained.
684 lines
30 KiB
GDScript
684 lines
30 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_acceleration = 20.0 # Angular acceleration, rad/s^2, equal on all three axes (see apply_rotation_forces)
|
||
@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.
|
||
# The queued-but-not-yet-applied teleport target, or null when none is
|
||
# pending. queue_teleport() defers the actual write to the next
|
||
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
|
||
# in between — anything that needs to broadcast where a body is ABOUT to be
|
||
# (networked_match.gd's kickoff) must read this instead, or it ships the
|
||
# pre-reset position and corrects it a tick later.
|
||
func get_pending_teleport():
|
||
return _pending_teleport if _has_pending_teleport else null
|
||
|
||
|
||
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_SPEC.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)
|
||
signal wall_contact(intensity: float)
|
||
|
||
# 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-next.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 body is StaticBody3D:
|
||
wall_contact.emit(clampf(linear_velocity.length() / maxf(max_speed, 0.001), 0.0, 1.0))
|
||
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
|
||
|
||
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
|
||
# Scaling each axis by its own inertia makes rotation_acceleration mean
|
||
# exactly that — α, in rad/s² — so all three axes respond identically.
|
||
# ship.tscn's inertia is Vector3(7, 1, 7): a flat torque across all three
|
||
# axes therefore used to give yaw 7x the angular acceleration of pitch and
|
||
# roll (172 deg/s vs 52 deg/s at steady state). That was an accident of the
|
||
# inertia tensor rather than a design decision, and it read as "rotation is
|
||
# sluggish except when turning".
|
||
var torque = Vector3(
|
||
rotation_input.x * rotation_acceleration * inertia.x, # Pitch (local X)
|
||
rotation_input.y * rotation_acceleration * inertia.y, # Yaw (local Y)
|
||
rotation_input.z * rotation_acceleration * inertia.z # Roll (local Z)
|
||
)
|
||
|
||
# apply_torque() is world-space, and the vector above is in the ship's own
|
||
# frame, so it MUST be rotated by the hull's basis — exactly as thrust is
|
||
# (see the -ship_basis.z term in apply_thrust_forces). Without this the
|
||
# ship rotated about the world axes: roll input became pitch once the ship
|
||
# had yawed 90 degrees, and both roll and pitch inverted at 180 degrees, so
|
||
# the controls were correct flying up-field and backwards flying back.
|
||
state.apply_torque(state.transform.basis * 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
|