Files
mercury/ship_controller.gd

262 lines
11 KiB
GDScript

extends CharacterBody3D
class_name ShipController
## FP4-style ship controller for Godot 4.6
##
## Full 3D flight — the ship flies wherever the mouse aims.
## Mouse X = yaw, Mouse Y = pitch. W accelerates along the aim direction,
## S brakes, A/D strafe laterally. No gravity, no hover, no ground.
## The visual model applies cosmetic roll/pitch on top of the actual orientation.
# ── Movement tuning ──────────────────────────────────────────────────────────
@export_group("Movement")
@export var max_speed: float = 30.0
@export var acceleration: float = 40.0
@export var brake_deceleration: float = 50.0
@export var strafe_speed: float = 20.0
@export var friction: float = 12.0 ## passive drag when no input
@export_group("Boost")
@export var boost_multiplier: float = 1.8
@export var boost_max: float = 100.0
@export var boost_drain_rate: float = 30.0
@export var boost_recharge_rate: float = 15.0
@export var boost_min_to_activate: float = 10.0
# ── Cosmetic tilt ────────────────────────────────────────────────────────────
@export_group("Visual Tilt")
@export var max_roll_deg: float = 25.0 ## extra bank on strafe / turning
@export var max_pitch_deg: float = 15.0 ## extra nose-down on accel
@export var tilt_lerp_speed: float = 6.0
# ── Mouse ────────────────────────────────────────────────────────────────────
@export_group("Mouse")
@export var mouse_sensitivity: float = 0.003
@export var pitch_limit_deg: float = 85.0 ## clamp so you can't fully invert
# ── Combat ───────────────────────────────────────────────────────────────────
@export_group("Combat")
@export var fire_rate: float = 10.0
@export var projectile_scene: PackedScene
# ── Node references ──────────────────────────────────────────────────────────
@export_group("Node References")
@export var ship_model: Node3D ## visual mesh that gets cosmetic tilt
@export var muzzle: Marker3D ## bullet spawn point
# ── Internal state ───────────────────────────────────────────────────────────
var _forward_speed: float = 0.0
var _strafe_speed: float = 0.0
# Mouse-driven aim targets (body smoothly rotates toward these)
var _target_yaw: float = 0.0
var _target_pitch: float = 0.0
var _current_yaw: float = 0.0
var _current_pitch: float = 0.0
var _boost_energy: float = 0.0
var _is_boosting: bool = false
var _fire_cooldown: float = 0.0
# Cosmetic tilt state (applied to ship_model only)
var _visual_roll: float = 0.0
var _visual_extra_pitch: float = 0.0
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_boost_energy = boost_max
_current_yaw = rotation.y
_current_pitch = rotation.x
_target_yaw = _current_yaw
_target_pitch = _current_pitch
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_target_yaw -= event.relative.x * mouse_sensitivity
_target_pitch -= event.relative.y * mouse_sensitivity
# Clamp pitch so the ship can't go fully vertical / inverted
var limit := deg_to_rad(pitch_limit_deg)
_target_pitch = clampf(_target_pitch, -limit, limit)
if event.is_action_pressed("ui_cancel"):
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
else:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _physics_process(delta: float) -> void:
_handle_turning(delta)
_handle_boost(delta)
_handle_movement(delta)
_apply_velocity(delta)
_handle_visual_tilt(delta)
_handle_shooting(delta)
# ── Turning (yaw + pitch) ───────────────────────────────────────────────────
## Instead of lerp_angle (which breaks on large deltas and can snap backward
## near ±π), we use move_toward on the angular difference. This guarantees:
## 1. The ship always turns toward the target, never away from it.
## 2. The turn rate is capped at max_turn_rate rad/s so fast mouse flicks
## produce a fast but controlled rotation, not a teleport or reversal.
@export var max_turn_rate: float = 12.0 ## radians/sec — max rotation speed
func _handle_turning(delta: float) -> void:
# Yaw — target is unbounded (no wraparound bugs), difference is always correct
var yaw_diff := _target_yaw - _current_yaw
var yaw_step := minf(absf(yaw_diff), max_turn_rate * delta)
_current_yaw += signf(yaw_diff) * yaw_step
# Pitch — same approach
var pitch_diff := _target_pitch - _current_pitch
var pitch_step := minf(absf(pitch_diff), max_turn_rate * delta)
_current_pitch += signf(pitch_diff) * pitch_step
rotation.y = _current_yaw
rotation.x = _current_pitch
# ── Movement along the ship's facing direction ──────────────────────────────
func _handle_movement(delta: float) -> void:
var forward_input: float = 0.0
var strafe_input: float = 0.0
if Input.is_action_pressed("accelerate"):
forward_input = 1.0
if Input.is_action_pressed("brake"):
forward_input -= 1.0
if Input.is_action_pressed("strafe_left"):
strafe_input -= 1.0
if Input.is_action_pressed("strafe_right"):
strafe_input += 1.0
var effective_max := max_speed * _boost_value()
var effective_accel := acceleration * _boost_value()
# Forward / brake
if forward_input > 0.0:
_forward_speed = move_toward(_forward_speed, effective_max, effective_accel * delta)
elif forward_input < 0.0:
_forward_speed = move_toward(_forward_speed, 0.0, brake_deceleration * delta)
else:
_forward_speed = move_toward(_forward_speed, 0.0, friction * delta)
# Strafe
if strafe_input != 0.0:
_strafe_speed = move_toward(_strafe_speed, strafe_speed * strafe_input, acceleration * delta)
else:
_strafe_speed = move_toward(_strafe_speed, 0.0, friction * delta)
# ── Boost ────────────────────────────────────────────────────────────────────
func _handle_boost(delta: float) -> void:
if Input.is_action_pressed("boost") and _boost_energy > boost_min_to_activate:
_is_boosting = true
_boost_energy = maxf(0.0, _boost_energy - boost_drain_rate * delta)
if _boost_energy <= 0.0:
_is_boosting = false
else:
_is_boosting = false
_boost_energy = minf(boost_max, _boost_energy + boost_recharge_rate * delta)
func _boost_value() -> float:
return boost_multiplier if _is_boosting else 1.0
# ── Apply velocity in 3D ────────────────────────────────────────────────────
func _apply_velocity(delta: float) -> void:
# Forward is wherever the ship is pointing (includes pitch + yaw)
var forward_dir := -transform.basis.z
var right_dir := transform.basis.x
var desired := forward_dir * _forward_speed + right_dir * _strafe_speed
# Clamp total speed
var max_total := max_speed * _boost_value()
if desired.length() > max_total:
desired = desired.normalized() * max_total
velocity = desired
move_and_slide()
# ── Cosmetic tilt on the visual model ────────────────────────────────────────
## The CharacterBody3D itself rotates to face the aim direction (yaw + pitch).
## On top of that, the child ship_model gets extra roll from strafing/turning
## and extra pitch from acceleration — purely visual seasoning.
func _handle_visual_tilt(delta: float) -> void:
if not ship_model:
return
# ── Roll from turning + strafing ──
# yaw is unbounded now, so the raw difference is the true angular lag
var yaw_delta: float = _target_yaw - _current_yaw
var strafe_input: float = 0.0
if Input.is_action_pressed("strafe_left"):
strafe_input -= 1.0
if Input.is_action_pressed("strafe_right"):
strafe_input += 1.0
var target_roll_deg: float = (-strafe_input * max_roll_deg * 0.6) + (yaw_delta * max_roll_deg * 2.0)
target_roll_deg = clampf(target_roll_deg, -max_roll_deg, max_roll_deg)
# ── Extra pitch from acceleration ──
var forward_input: float = 0.0
if Input.is_action_pressed("accelerate"):
forward_input = 1.0
if Input.is_action_pressed("brake"):
forward_input -= 1.0
var target_extra_pitch_deg: float = - forward_input * max_pitch_deg
if _is_boosting:
target_extra_pitch_deg *= 1.4
# Smooth interpolation
_visual_roll = lerp(_visual_roll, deg_to_rad(target_roll_deg), tilt_lerp_speed * delta)
_visual_extra_pitch = lerp(_visual_extra_pitch, deg_to_rad(target_extra_pitch_deg), tilt_lerp_speed * delta)
# Apply roll and extra pitch on top of the body's real orientation
ship_model.rotation = Vector3(_visual_extra_pitch, 0.0, _visual_roll)
# ── Shooting ─────────────────────────────────────────────────────────────────
func _handle_shooting(delta: float) -> void:
_fire_cooldown = maxf(0.0, _fire_cooldown - delta)
if Input.is_action_pressed("shoot") and _fire_cooldown <= 0.0 and projectile_scene:
_fire_cooldown = 1.0 / fire_rate
var bullet := projectile_scene.instantiate() as Node3D
get_tree().current_scene.add_child(bullet)
if muzzle:
bullet.global_position = muzzle.global_position
bullet.global_transform.basis = muzzle.global_transform.basis
else:
bullet.global_position = global_position + (-transform.basis.z * 2.0)
bullet.global_transform.basis = global_transform.basis
# Pass the full 3D forward direction to the bullet
if bullet.has_method("set_direction"):
bullet.set_direction(-global_transform.basis.z)
# ── Public API ───────────────────────────────────────────────────────────────
func get_boost_ratio() -> float:
return _boost_energy / boost_max
func get_speed_ratio() -> float:
return _forward_speed / (max_speed * boost_multiplier)
func is_boosting() -> bool:
return _is_boosting