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