262 lines
11 KiB
GDScript
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 |