class_name Ship extends RigidBody3D # 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 # Accent colours per team, applied to the nose and tail fin meshes so the # two sides are tellable apart at a glance. const TEAM_COLORS := { 0: Color(0.25, 0.55, 1.0), 1: Color(1.0, 0.5, 0.15), } # 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() var controller: ShipController var _current_action: ShipAction = ShipAction.new() # 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) # 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 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 _apply_team_color() func _apply_team_color() -> void: if not is_inside_tree(): return var color: Color = TEAM_COLORS.get(team, 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 for mesh_name in ["Nose", "TailFin"]: var mesh := get_node_or_null(mesh_name) as MeshInstance3D if mesh: mesh.material_override = accent # 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): _emit_telemetry_data() func _integrate_forces(state): # One action per physics tick, pulled from the controller (deterministic) _current_action = controller.get_action() if controller else ShipAction.new() # === TRANSLATION (Movement) === apply_thruster_forces(state, _current_action) # === ROTATION (Turning) === apply_rotation_forces(state, _current_action.rotation) # === 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_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) 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 state.linear_velocity *= drag_coefficient # 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 *= 0.9 else: # Normal drag when actively rotating state.angular_velocity *= angular_drag # 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