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