mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fbb783b947
Ship._emit_telemetry_data() ran get_euler()+trig every physics tick for every ship regardless of whether a HUD was watching, wasting work on AI ships and every headless training instance. Disable _physics_process outright when headless, and skip emission the rest of the time unless a signal actually has a listener.
302 lines
12 KiB
GDScript
302 lines
12 KiB
GDScript
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
|
||
|
||
@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
|
||
|
||
# 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()
|
||
var _boundary: ArenaBoundary
|
||
|
||
# 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()
|
||
|
||
_boundary = get_tree().get_first_node_in_group("arena_boundary")
|
||
|
||
# Telemetry emission is HUD-only work; headless (RL/CI) instances never
|
||
# have one, so skip the per-tick cost entirely rather than relying on
|
||
# the listener check below to no-op every frame.
|
||
if DisplayServer.get_name() == "headless":
|
||
set_physics_process(false)
|
||
|
||
|
||
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):
|
||
if _has_telemetry_listeners():
|
||
_emit_telemetry_data()
|
||
|
||
|
||
# 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):
|
||
# 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)
|
||
apply_surface_pull(state)
|
||
|
||
# === 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_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)
|
||
|
||
|
||
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
|