Files
Ransom-s-wake/src/submarine_controller.gd
T

551 lines
18 KiB
GDScript

class_name SubmarineController
extends CharacterBody3D
signal telemetry_updated(snapshot: Dictionary)
signal loss_triggered(reason: StringName)
signal abandon_progress_changed(progress: float)
@export var cruise_speed := 6.0
@export var boost_speed := 9.5
@export var acceleration := 2.0
@export var braking := 1.35
@export var mouse_sensitivity := 0.0025
@export var keyboard_turn_speed_degrees := 55.0
@export var max_turn_speed_degrees := 45.0
@export var turn_acceleration_degrees := 30.0
@export var turn_braking_degrees := 24.0
@export var lateral_grip := 2.2
@export var camera_position_response := 5.0
@export var camera_rotation_response := 2.8
@export var camera_max_lag := 2.5
@export var visual_tilt_response := 4.5
@export var max_visual_pitch_degrees := 18.0
@export var max_visual_roll_degrees := 16.0
@export var headlight_range := 58.0
@export var headlight_energy := 38.0
@export var headlight_angle_degrees := 24.0
@export var first_person_view := true
@export var first_person_fov := 82.0
@export var cockpit_camera_offset := Vector3(0.0, 0.52, -0.48)
var survival := SurvivalModel.new()
var controls_enabled := true
var mission_origin := Vector3.ZERO
var _yaw := 0.0
var _target_yaw := 0.0
var _camera_yaw := 0.0
var _yaw_velocity := 0.0
var _pitch := -0.22
var _abandon_progress := 0.0
var _collision_cooldown := 0.0
var _last_snapshot: Dictionary = {}
var _camera_pivot: Node3D
var _vehicle_visual: Node3D
func _ready() -> void:
if first_person_view:
_pitch = -0.04
_build_vehicle()
_yaw = rotation.y
_target_yaw = _yaw
_camera_yaw = _yaw
_reset_camera_transform()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_last_snapshot = survival.snapshot(current_depth())
telemetry_updated.emit(_last_snapshot)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and controls_enabled:
_target_yaw = wrapf(
_target_yaw - event.relative.x * mouse_sensitivity,
-PI,
PI
)
var min_pitch := -0.30 if first_person_view else -0.75
var max_pitch := 0.20 if first_person_view else 0.45
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, min_pitch, max_pitch)
elif event.is_action_pressed("toggle_mouse_capture"):
Input.mouse_mode = (
Input.MOUSE_MODE_VISIBLE
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
else Input.MOUSE_MODE_CAPTURED
)
func _physics_process(delta: float) -> void:
if not controls_enabled:
return
_collision_cooldown = maxf(0.0, _collision_cooldown - delta)
var steering_input := Input.get_axis("move_left", "move_right")
_target_yaw = wrapf(
_target_yaw - steering_input * deg_to_rad(keyboard_turn_speed_degrees) * delta,
-PI,
PI
)
_update_yaw(delta)
var forward_input := Input.get_axis("move_forward", "move_backward")
var vertical_input := Input.get_axis("descend", "ascend")
var local_input := Vector3(0.0, vertical_input, forward_input)
if local_input.length() > 1.0:
local_input = local_input.normalized()
var has_power := survival.battery > 0.0
var boosting := Input.is_action_pressed("boost") and has_power
var requested_speed := boost_speed if boosting else cruise_speed
var world_input := (global_transform.basis * local_input).normalized() if local_input.length() > 0.0 else Vector3.ZERO
var target_velocity := world_input * requested_speed if has_power else Vector3.ZERO
var change_rate := acceleration if target_velocity.length() > 0.0 else braking
velocity = velocity.move_toward(target_velocity, change_rate * delta)
_apply_lateral_grip(delta)
var velocity_before_move := velocity
move_and_slide()
if global_position.y > -2.0:
global_position.y = -2.0
velocity.y = minf(velocity.y, 0.0)
_update_vehicle_visual(delta)
_update_camera(delta)
var collision_damage := _calculate_collision_damage(velocity_before_move)
var thrust_ratio := maxf(local_input.length(), absf(steering_input) * 0.35)
if boosting:
thrust_ratio = minf(1.0, thrust_ratio * 1.8)
_last_snapshot = survival.tick(delta, thrust_ratio, current_depth(), collision_damage)
_last_snapshot["speed"] = velocity.length()
_last_snapshot["return_distance"] = global_position.distance_to(mission_origin)
_last_snapshot["return_energy"] = survival.estimated_return_energy(
_last_snapshot["return_distance"],
maxf(current_depth() - 12.0, 0.0)
)
telemetry_updated.emit(_last_snapshot)
_update_abandon(delta)
var reason: StringName = _last_snapshot["loss_reason"]
if not reason.is_empty():
_trigger_loss(reason)
func current_depth() -> float:
return maxf(-global_position.y, 0.0)
func force_critical_state() -> void:
survival.force_critical_state()
func _update_abandon(delta: float) -> void:
if Input.is_action_pressed("abandon_vehicle"):
_abandon_progress = minf(1.0, _abandon_progress + delta / 3.0)
else:
_abandon_progress = maxf(0.0, _abandon_progress - delta * 1.5)
abandon_progress_changed.emit(_abandon_progress)
if _abandon_progress >= 1.0:
_trigger_loss(&"manual_ejection")
func _trigger_loss(reason: StringName) -> void:
if not controls_enabled:
return
controls_enabled = false
velocity = Vector3.ZERO
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
loss_triggered.emit(reason)
func _calculate_collision_damage(previous_velocity: Vector3) -> float:
if _collision_cooldown > 0.0:
return 0.0
var strongest_impact := 0.0
for collision_index in get_slide_collision_count():
var collision := get_slide_collision(collision_index)
var impact := absf(previous_velocity.dot(collision.get_normal()))
strongest_impact = maxf(strongest_impact, impact)
if strongest_impact <= 3.5:
return 0.0
_collision_cooldown = 0.45
return (strongest_impact - 3.5) * 4.0
func _build_vehicle() -> void:
var collision_shape := CollisionShape3D.new()
var capsule := CapsuleShape3D.new()
capsule.radius = 0.72
capsule.height = 3.8
collision_shape.shape = capsule
collision_shape.rotation.x = PI / 2.0
add_child(collision_shape)
_vehicle_visual = Node3D.new()
_vehicle_visual.name = "VehicleVisual"
add_child(_vehicle_visual)
var hull_mesh := MeshInstance3D.new()
var hull := CapsuleMesh.new()
hull.radius = 0.72
hull.height = 3.8
hull_mesh.mesh = hull
hull_mesh.rotation.x = PI / 2.0
hull_mesh.material_override = _material(Color("d8b24c"), 0.18)
_vehicle_visual.add_child(hull_mesh)
var canopy := MeshInstance3D.new()
var canopy_mesh := SphereMesh.new()
canopy_mesh.radius = 0.58
canopy_mesh.height = 1.05
canopy.mesh = canopy_mesh
canopy.position = Vector3(0.0, 0.36, -0.45)
canopy.scale = Vector3(0.85, 0.55, 1.0)
canopy.material_override = _material(Color("163c54"), 0.6)
_vehicle_visual.add_child(canopy)
_add_fin(Vector3(0.0, 0.68, 1.15), Vector3(0.12, 0.7, 1.0))
_add_fin(Vector3(-0.92, 0.0, 0.85), Vector3(0.9, 0.10, 0.8))
_add_fin(Vector3(0.92, 0.0, 0.85), Vector3(0.9, 0.10, 0.8))
for light_x in [-0.42, 0.42]:
_add_headlight(light_x)
_camera_pivot = Node3D.new()
_camera_pivot.name = "CameraPivot"
add_child(_camera_pivot)
if not first_person_view:
_camera_pivot.set_as_top_level(true)
var camera := Camera3D.new()
camera.name = "CockpitCamera" if first_person_view else "ChaseCamera"
camera.current = true
if first_person_view:
camera.fov = first_person_fov
camera.near = 0.05
_camera_pivot.add_child(camera)
_build_cockpit_overlay()
else:
var spring_arm := SpringArm3D.new()
spring_arm.spring_length = 7.0
spring_arm.margin = 0.25
_camera_pivot.add_child(spring_arm)
camera.fov = 72.0
spring_arm.add_child(camera)
func _add_fin(fin_position: Vector3, fin_size: Vector3) -> void:
var fin := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = fin_size
fin.mesh = box
fin.position = fin_position
fin.material_override = _material(Color("bb9134"), 0.05)
_vehicle_visual.add_child(fin)
func _add_headlight(light_x: float) -> void:
var outward_yaw := -signf(light_x) * deg_to_rad(6.0)
var downward_pitch := deg_to_rad(-3.0)
var housing := MeshInstance3D.new()
var housing_mesh := CylinderMesh.new()
housing_mesh.top_radius = 0.22
housing_mesh.bottom_radius = 0.28
housing_mesh.height = 0.48
housing.mesh = housing_mesh
housing.position = Vector3(light_x, 0.05, -1.72)
housing.rotation.x = PI / 2.0
housing.material_override = _material(Color("273238"), 0.72)
housing.visible = not first_person_view
_vehicle_visual.add_child(housing)
var lens := MeshInstance3D.new()
var lens_mesh := SphereMesh.new()
lens_mesh.radius = 0.22
lens_mesh.height = 0.44
lens.mesh = lens_mesh
lens.position = Vector3(light_x, 0.05, -1.98)
lens.scale = Vector3(1.0, 1.0, 0.16)
lens.material_override = _emissive_material(Color("d7f3ff"), 8.0)
lens.visible = not first_person_view
_vehicle_visual.add_child(lens)
# Keep the light source beyond the hull so the submarine cannot cast a
# shadow over its own beams.
var headlight := SpotLight3D.new()
headlight.position = Vector3(light_x, 0.05, -2.03)
headlight.spot_range = headlight_range
headlight.spot_angle = headlight_angle_degrees
headlight.light_energy = headlight_energy
headlight.light_color = Color("d7f3ff")
headlight.rotation = Vector3(downward_pitch, outward_yaw, 0.0)
headlight.shadow_enabled = false
_vehicle_visual.add_child(headlight)
_add_light_beam(light_x, outward_yaw, downward_pitch)
func _add_light_beam(light_x: float, outward_yaw: float, downward_pitch: float) -> void:
var beam_length := headlight_range * 0.76
var beam_direction := (
Basis(Vector3.UP, outward_yaw)
* Basis(Vector3.RIGHT, downward_pitch)
)
var beam_origin := Vector3(light_x, 0.05, -2.03)
if first_person_view:
_add_first_person_beam_haze(beam_origin, beam_direction)
return
var beam_radius := tan(deg_to_rad(headlight_angle_degrees * 0.5)) * beam_length
var beam_mesh := CylinderMesh.new()
beam_mesh.top_radius = 0.10
beam_mesh.bottom_radius = beam_radius
beam_mesh.height = beam_length
beam_mesh.radial_segments = 32
beam_mesh.rings = 1
beam_mesh.cap_top = false
beam_mesh.cap_bottom = false
var shader := Shader.new()
shader.code = """
shader_type spatial;
render_mode unshaded, blend_add, cull_disabled, depth_draw_never;
uniform vec4 beam_color : source_color = vec4(0.42, 0.82, 1.0, 1.0);
uniform float beam_alpha = 0.13;
uniform float beam_length = 36.0;
varying float beam_depth;
void vertex() {
beam_depth = clamp(0.5 - VERTEX.y / beam_length, 0.0, 1.0);
}
void fragment() {
float near_fade = smoothstep(0.0, 0.10, beam_depth);
float distance_fade = 1.0 - smoothstep(0.32, 1.0, beam_depth);
ALBEDO = beam_color.rgb;
EMISSION = beam_color.rgb;
ALPHA = beam_alpha * near_fade * distance_fade;
}
"""
var beam_material := ShaderMaterial.new()
beam_material.shader = shader
beam_material.set_shader_parameter("beam_length", beam_length)
var beam := MeshInstance3D.new()
beam.name = "HeadlightBeam"
beam.mesh = beam_mesh
beam.position = beam_origin + beam_direction * Vector3(0.0, 0.0, -beam_length * 0.5)
beam.basis = beam_direction * Basis(Vector3.RIGHT, PI / 2.0)
beam.material_override = beam_material
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
beam.extra_cull_margin = beam_length
_vehicle_visual.add_child(beam)
func _add_first_person_beam_haze(beam_origin: Vector3, beam_direction: Basis) -> void:
var haze_shader := Shader.new()
haze_shader.code = """
shader_type spatial;
render_mode unshaded, blend_add, cull_disabled, depth_draw_never;
uniform vec4 beam_color : source_color = vec4(0.46, 0.86, 1.0, 1.0);
uniform float slice_alpha = 0.16;
void fragment() {
vec2 centered_uv = UV * 2.0 - 1.0;
float radius = length(centered_uv);
float soft_disc = 1.0 - smoothstep(0.18, 1.0, radius);
ALBEDO = beam_color.rgb;
EMISSION = beam_color.rgb * 2.6;
ALPHA = slice_alpha * soft_disc;
}
"""
var slice_distances := [4.0, 7.5, 12.0, 18.0, 25.0, 32.0]
for slice_index in slice_distances.size():
var distance: float = slice_distances[slice_index]
var radius := tan(deg_to_rad(headlight_angle_degrees * 0.5)) * distance
var slice_mesh := QuadMesh.new()
slice_mesh.size = Vector2(radius * 2.0, radius * 2.0)
var slice_material := ShaderMaterial.new()
slice_material.shader = haze_shader
slice_material.set_shader_parameter(
"slice_alpha",
lerpf(0.19, 0.055, float(slice_index) / float(slice_distances.size() - 1))
)
var haze_slice := MeshInstance3D.new()
haze_slice.name = "HeadlightHaze"
haze_slice.mesh = slice_mesh
haze_slice.position = beam_origin + beam_direction * Vector3(0.0, 0.0, -distance)
haze_slice.basis = beam_direction
haze_slice.material_override = slice_material
haze_slice.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
haze_slice.extra_cull_margin = headlight_range
_vehicle_visual.add_child(haze_slice)
func _build_cockpit_overlay() -> void:
var overlay := CanvasLayer.new()
overlay.name = "CockpitOverlay"
overlay.layer = 0
add_child(overlay)
var glass := ColorRect.new()
glass.name = "PanoramicPorthole"
glass.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
glass.mouse_filter = Control.MOUSE_FILTER_IGNORE
overlay.add_child(glass)
var shader := Shader.new()
shader.code = """
shader_type canvas_item;
void fragment() {
float aspect = SCREEN_PIXEL_SIZE.y / SCREEN_PIXEL_SIZE.x;
vec2 point = (UV - vec2(0.5)) * vec2(aspect, 1.0);
float distance_to_glass = length(point) - 0.435;
vec3 deep_frame = vec3(0.012, 0.025, 0.030);
vec3 frame_highlight = vec3(0.10, 0.16, 0.18);
if (distance_to_glass > 0.030) {
COLOR = vec4(deep_frame, 0.995);
} else if (distance_to_glass > -0.026) {
float bevel = smoothstep(-0.026, 0.002, distance_to_glass);
float upper_glint = 0.35 + 0.65 * (1.0 - UV.y);
vec3 frame_color = mix(deep_frame, frame_highlight, bevel * upper_glint);
COLOR = vec4(frame_color, 0.97);
} else {
float edge_vignette = smoothstep(-0.12, -0.026, distance_to_glass);
float glass_sheen = pow(max(0.0, 1.0 - length(UV - vec2(0.40, 0.30)) * 1.7), 5.0);
vec3 glass_color = vec3(0.08, 0.25, 0.30);
float glass_alpha = 0.012 + edge_vignette * 0.13 + glass_sheen * 0.025;
COLOR = vec4(glass_color, glass_alpha);
}
}
"""
var material := ShaderMaterial.new()
material.shader = shader
glass.material = material
func _update_vehicle_visual(delta: float) -> void:
var local_velocity := global_transform.basis.inverse() * velocity
var pitch_limit := deg_to_rad(max_visual_pitch_degrees)
var roll_limit := deg_to_rad(max_visual_roll_degrees)
var reference_speed := maxf(cruise_speed, 0.001)
# The hull points in the direction of vertical travel instead of remaining
# perfectly level while the world slides past it.
var target_pitch := clampf(local_velocity.y / reference_speed, -1.0, 1.0) * pitch_limit
var turn_ratio := clampf(angle_difference(_yaw, _target_yaw) / 0.55, -1.0, 1.0)
var strafe_ratio := clampf(local_velocity.x / reference_speed, -1.0, 1.0)
var target_roll := clampf(turn_ratio - strafe_ratio * 0.65, -1.0, 1.0) * roll_limit
var weight := _response_weight(visual_tilt_response, delta)
_vehicle_visual.rotation.x = lerp_angle(_vehicle_visual.rotation.x, target_pitch, weight)
_vehicle_visual.rotation.z = lerp_angle(_vehicle_visual.rotation.z, target_roll, weight)
func _update_yaw(delta: float) -> void:
var heading_error := angle_difference(_yaw, _target_yaw)
var max_turn_speed := deg_to_rad(max_turn_speed_degrees)
var desired_yaw_velocity := clampf(
heading_error * 1.8,
-max_turn_speed,
max_turn_speed
)
var change_rate := deg_to_rad(turn_acceleration_degrees)
if absf(desired_yaw_velocity) < absf(_yaw_velocity):
change_rate = deg_to_rad(turn_braking_degrees)
_yaw_velocity = move_toward(_yaw_velocity, desired_yaw_velocity, change_rate * delta)
var yaw_step := _yaw_velocity * delta
if absf(yaw_step) >= absf(heading_error) and signf(yaw_step) == signf(heading_error):
_yaw = _target_yaw
_yaw_velocity = 0.0
else:
_yaw = wrapf(_yaw + yaw_step, -PI, PI)
rotation.y = _yaw
func _apply_lateral_grip(delta: float) -> void:
# A submarine keeps some inertia, but its hull and fins resist a prolonged
# sideways slide. This makes the velocity follow the new heading in a turn.
var local_velocity := global_transform.basis.inverse() * velocity
local_velocity.x = move_toward(local_velocity.x, 0.0, lateral_grip * delta)
velocity = global_transform.basis * local_velocity
func _update_camera(delta: float) -> void:
if first_person_view:
# The POV remains a rigid child of the hull: it can rotate inside the
# porthole, but its position can never leave the submarine.
_camera_pivot.position = cockpit_camera_offset
_camera_yaw = _yaw
_camera_pivot.rotation = Vector3(
_pitch + _vehicle_visual.rotation.x,
0.0,
_vehicle_visual.rotation.z * 0.55
)
return
# This rig ignores the submarine transform and pursues it in world space.
# The small delay lets the vehicle visibly turn and move vertically on screen.
var desired_position := global_position + Vector3(0.0, 0.55, 0.0)
_camera_pivot.global_position = _camera_pivot.global_position.lerp(
desired_position,
_response_weight(camera_position_response, delta)
)
var lag := desired_position - _camera_pivot.global_position
if lag.length() > camera_max_lag:
_camera_pivot.global_position = desired_position - lag.normalized() * camera_max_lag
_camera_yaw = lerp_angle(
_camera_yaw,
_yaw,
_response_weight(camera_rotation_response, delta)
)
_camera_pivot.global_rotation = Vector3(_pitch, _camera_yaw, 0.0)
func _reset_camera_transform() -> void:
if first_person_view:
_camera_pivot.position = cockpit_camera_offset
_camera_pivot.rotation = Vector3(_pitch, 0.0, 0.0)
else:
_camera_pivot.global_position = global_position + Vector3(0.0, 0.55, 0.0)
_camera_pivot.global_rotation = Vector3(_pitch, _camera_yaw, 0.0)
func _response_weight(response: float, delta: float) -> float:
return 1.0 - exp(-maxf(response, 0.0) * delta)
func _material(color: Color, metallic: float) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.metallic = metallic
material.roughness = 0.55
return material
func _emissive_material(color: Color, energy: float) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = energy
material.roughness = 0.18
return material