Validate POC A and POC B foundations

This commit is contained in:
chindit committed 2026-08-07 13:31:21 +02:00
1 parent 44fd4b4b43
commit c38e09a663
47 files changed
+4990 -35

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
class_name AlarmPlayer
extends AudioStreamPlayer
var _last_severity := 0
var _repeat_timer := 0.0
func update_alerts(delta: float, alerts: Array[int]) -> void:
var severity := 0
for alert in alerts:
severity = maxi(severity, alert)
_repeat_timer = maxf(0.0, _repeat_timer - delta)
if severity > _last_severity:
_play_alarm(severity)
_repeat_timer = _repeat_delay(severity)
elif severity >= SurvivalModel.AlertLevel.RETURN and _repeat_timer <= 0.0:
_play_alarm(severity)
_repeat_timer = _repeat_delay(severity)
_last_severity = severity
func reset() -> void:
_last_severity = 0
_repeat_timer = 0.0
stop()
func _repeat_delay(severity: int) -> float:
match severity:
SurvivalModel.AlertLevel.EMPTY:
return 0.75
SurvivalModel.AlertLevel.CRITICAL:
return 1.25
SurvivalModel.AlertLevel.RETURN:
return 2.5
SurvivalModel.AlertLevel.CAUTION:
return 5.0
_:
return 999.0
func _play_alarm(severity: int) -> void:
if severity <= SurvivalModel.AlertLevel.NORMAL:
return
var generator := AudioStreamGenerator.new()
generator.mix_rate = 22050.0
generator.buffer_length = 0.45
stream = generator
play()
var playback := get_stream_playback() as AudioStreamGeneratorPlayback
if playback == null:
return
var frequency := 520.0 + float(severity) * 110.0
var duration := 0.35
var frame_count := int(generator.mix_rate * duration)
var pulse_count := 1 if severity == SurvivalModel.AlertLevel.CAUTION else 2
if severity >= SurvivalModel.AlertLevel.CRITICAL:
pulse_count = 3
for frame_index in frame_count:
var time := float(frame_index) / generator.mix_rate
var phase_in_pulse := fmod(time * float(pulse_count), duration) / duration
var gate := 1.0 if phase_in_pulse < 0.62 else 0.0
var envelope := minf(time * 30.0, 1.0) * minf((duration - time) * 30.0, 1.0)
var sample := sin(TAU * frequency * time) * 0.16 * gate * envelope
playback.push_frame(Vector2(sample, sample))
+1
View File
@@ -0,0 +1 @@
uid://ya1ugkxp82l5
+394
View File
@@ -0,0 +1,394 @@
extends Node3D
const MISSION_POINTS := [
{
"title": "Rejoignez la balise de navigation",
"position": Vector3(0.0, -22.0, -58.0),
"color": Color("5ddcff"),
"radius": 8.0,
},
{
"title": "Descendez au-delà de 60 m et inspectez la balise",
"position": Vector3(38.0, -66.0, -128.0),
"color": Color("ffbd59"),
"radius": 4.0,
},
{
"title": "Revenez dans la zone de récupération",
"position": Vector3(0.0, -12.0, 12.0),
"color": Color("7af0a5"),
"radius": 8.0,
},
]
var _submarine: SubmarineController
var _hud: PocHud
var _alarm_player: AlarmPlayer
var _mission_index := 0
var _mission_markers: Array[Node3D] = []
var _mission_guide_groups: Array[Node3D] = []
var _terminal := false
var _elapsed_time := 0.0
func _ready() -> void:
_ensure_input_actions()
_build_environment()
_build_course()
_hud = PocHud.new()
add_child(_hud)
_alarm_player = AlarmPlayer.new()
add_child(_alarm_player)
_submarine = SubmarineController.new()
_submarine.name = "Submarine"
_submarine.position = Vector3(0.0, -12.0, 12.0)
_submarine.mission_origin = _submarine.position
_submarine.telemetry_updated.connect(_on_telemetry_updated)
_submarine.loss_triggered.connect(_on_loss_triggered)
_submarine.abandon_progress_changed.connect(_hud.set_ejection_progress)
add_child(_submarine)
_update_mission_marker_visibility()
_hud.set_objective(MISSION_POINTS[0]["title"])
func _process(delta: float) -> void:
if _terminal:
return
_elapsed_time += delta
var target: Vector3 = MISSION_POINTS[_mission_index]["position"]
var distance := _submarine.global_position.distance_to(target)
_hud.set_objective(MISSION_POINTS[_mission_index]["title"], distance)
if distance <= float(MISSION_POINTS[_mission_index]["radius"]):
_mission_index += 1
if _mission_index >= MISSION_POINTS.size():
_complete_course()
else:
_update_mission_marker_visibility()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("debug_critical") and not _terminal:
_submarine.force_critical_state()
if _terminal and event.is_action_pressed("restart_poc"):
get_tree().reload_current_scene()
func _on_telemetry_updated(snapshot: Dictionary) -> void:
if _hud == null:
return
_hud.update_telemetry(snapshot)
_alarm_player.update_alerts(
get_process_delta_time(),
[
int(snapshot["oxygen_alert"]),
int(snapshot["battery_alert"]),
int(snapshot["hull_alert"]),
int(snapshot["depth_alert"]),
]
)
func _on_loss_triggered(reason: StringName) -> void:
_terminal = true
_alarm_player.reset()
var message: String = {
&"hull_failure": "La coque a cédé sous la pression ou après un impact.",
&"oxygen_depleted": "L’oxygène est épuisé. La capsule a été récupérée.",
&"manual_ejection": "Vous avez abandonné volontairement le véhicule.",
}.get(reason, "Le véhicule a été perdu.")
_hud.show_terminal("SOUS-MARIN PERDU", message)
func _complete_course() -> void:
_terminal = true
_submarine.controls_enabled = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_alarm_player.reset()
_hud.show_terminal(
"PARCOURS TERMINÉ",
"Temps : %02d:%02d\nOxygène, propulsion, pression et retour ont été exercés."
% [int(_elapsed_time) / 60, int(_elapsed_time) % 60]
)
func _update_mission_marker_visibility() -> void:
for marker_index in _mission_markers.size():
_mission_markers[marker_index].visible = marker_index == _mission_index
for guide_index in _mission_guide_groups.size():
_mission_guide_groups[guide_index].visible = guide_index == _mission_index
func _build_environment() -> void:
var world_environment := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("031823")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("2b6575")
environment.ambient_light_energy = 0.46
environment.fog_enabled = true
environment.fog_light_color = Color("174553")
environment.fog_light_energy = 0.52
environment.fog_density = 0.013
environment.fog_height = -5.0
environment.fog_height_density = 0.03
world_environment.environment = environment
add_child(world_environment)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-55.0, -28.0, 0.0)
sun.light_color = Color("82bdc9")
sun.light_energy = 0.35
sun.shadow_enabled = true
add_child(sun)
var surface := MeshInstance3D.new()
var surface_mesh := PlaneMesh.new()
surface_mesh.size = Vector2(320.0, 420.0)
surface.mesh = surface_mesh
surface.position = Vector3(0.0, 0.0, -80.0)
var surface_material := StandardMaterial3D.new()
surface_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
surface_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
surface_material.cull_mode = BaseMaterial3D.CULL_DISABLED
surface_material.albedo_color = Color(0.13, 0.55, 0.65, 0.13)
surface.material_override = surface_material
add_child(surface)
_add_water_particles()
func _build_course() -> void:
_add_static_box(
"Seafloor",
Vector3(0.0, -96.0, -80.0),
Vector3(300.0, 10.0, 360.0),
Color("183a3c")
)
_add_static_box(
"LeftWall",
Vector3(-75.0, -50.0, -90.0),
Vector3(22.0, 95.0, 260.0),
Color("24494b")
)
# Nearby formations make speed, distance and heading readable from the
# cockpit before the player reaches the first mission beacon.
var navigation_rock_specs := [
[Vector3(-10.0, -24.0, -12.0), Vector3(10.0, 24.0, 10.0), -14.0],
[Vector3(12.0, -30.0, -28.0), Vector3(11.0, 34.0, 12.0), 18.0],
[Vector3(-12.0, -38.0, -42.0), Vector3(9.0, 46.0, 10.0), 8.0],
[Vector3(11.0, -44.0, -50.0), Vector3(8.0, 52.0, 10.0), -10.0],
[Vector3(7.0, -55.0, -73.0), Vector3(10.0, 38.0, 12.0), -16.0],
[Vector3(25.0, -69.0, -96.0), Vector3(12.0, 45.0, 14.0), 13.0],
[Vector3(45.0, -72.0, -112.0), Vector3(13.0, 50.0, 15.0), -9.0],
]
for rock_index in navigation_rock_specs.size():
var spec: Array = navigation_rock_specs[rock_index]
var navigation_rock := _add_static_box(
"NavigationRock%02d" % rock_index,
spec[0],
spec[1],
Color("315553") if rock_index % 2 == 0 else Color("294947")
)
navigation_rock.rotation_degrees.y = spec[2]
_add_static_box(
"RightWall",
Vector3(75.0, -50.0, -90.0),
Vector3(22.0, 95.0, 260.0),
Color("24494b")
)
var rock_specs := [
[Vector3(-25.0, -70.0, -78.0), Vector3(24.0, 44.0, 28.0), -18.0],
[Vector3(28.0, -76.0, -92.0), Vector3(18.0, 34.0, 20.0), 16.0],
[Vector3(-12.0, -83.0, -145.0), Vector3(42.0, 22.0, 30.0), 8.0],
[Vector3(54.0, -63.0, -150.0), Vector3(20.0, 58.0, 24.0), -12.0],
]
for rock_index in rock_specs.size():
var spec: Array = rock_specs[rock_index]
var rock := _add_static_box(
"Rock%02d" % rock_index,
spec[0],
spec[1],
Color("2d5452")
)
rock.rotation_degrees.y = spec[2]
for mission_data in MISSION_POINTS:
_mission_markers.append(
_add_beacon(mission_data["position"], mission_data["color"])
)
# Intermediate lights reveal the direction of the next leg without drawing
# a HUD arrow. Each chain only appears when its objective becomes active.
_mission_guide_groups.append(_add_guide_chain([], Color("5ddcff")))
_mission_guide_groups.append(
_add_guide_chain(
[
Vector3(8.0, -28.0, -75.0),
Vector3(18.0, -38.0, -94.0),
Vector3(29.0, -51.0, -112.0),
],
Color("ffbd59")
)
)
_mission_guide_groups.append(
_add_guide_chain(
[
Vector3(30.0, -55.0, -100.0),
Vector3(23.0, -44.0, -72.0),
Vector3(15.0, -34.0, -44.0),
Vector3(8.0, -23.0, -16.0),
],
Color("7af0a5")
)
)
func _add_water_particles() -> void:
var particles := GPUParticles3D.new()
particles.name = "SuspendedWaterParticles"
particles.position = Vector3(0.0, -45.0, -65.0)
particles.amount = 520
particles.lifetime = 14.0
particles.preprocess = 14.0
particles.fixed_fps = 30
particles.visibility_aabb = AABB(
Vector3(-85.0, -55.0, -125.0),
Vector3(170.0, 110.0, 250.0)
)
var particle_motion := ParticleProcessMaterial.new()
particle_motion.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
particle_motion.emission_box_extents = Vector3(78.0, 48.0, 118.0)
particle_motion.direction = Vector3(0.25, 0.12, 0.08)
particle_motion.spread = 35.0
particle_motion.initial_velocity_min = 0.025
particle_motion.initial_velocity_max = 0.11
particle_motion.gravity = Vector3.ZERO
particle_motion.scale_min = 0.035
particle_motion.scale_max = 0.11
particles.process_material = particle_motion
var mote_mesh := QuadMesh.new()
mote_mesh.size = Vector2(0.075, 0.075)
var mote_material := StandardMaterial3D.new()
mote_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mote_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mote_material.albedo_color = Color(0.62, 0.88, 0.92, 0.42)
mote_material.roughness = 0.15
mote_mesh.material = mote_material
particles.draw_pass_1 = mote_mesh
add_child(particles)
func _add_static_box(node_name: String, box_position: Vector3, box_size: Vector3, color: Color) -> StaticBody3D:
var body := StaticBody3D.new()
body.name = node_name
body.position = box_position
add_child(body)
var mesh_instance := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = box_size
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = color
material.roughness = 0.9
mesh_instance.material_override = material
body.add_child(mesh_instance)
var collision_shape := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = box_size
collision_shape.shape = shape
body.add_child(collision_shape)
return body
func _add_beacon(beacon_position: Vector3, color: Color) -> Node3D:
var marker := Node3D.new()
marker.position = beacon_position
add_child(marker)
var sphere_instance := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 2.2
sphere.height = 4.4
sphere_instance.mesh = sphere
var material := StandardMaterial3D.new()
material.albedo_color = color
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = 4.0
sphere_instance.material_override = material
marker.add_child(sphere_instance)
var light := OmniLight3D.new()
light.omni_range = 18.0
light.light_energy = 5.0
light.light_color = color
marker.add_child(light)
return marker
func _add_guide_chain(guide_positions: Array, color: Color) -> Node3D:
var group := Node3D.new()
group.name = "MissionGuideChain"
add_child(group)
for guide_position in guide_positions:
var guide := Node3D.new()
guide.position = guide_position
group.add_child(guide)
var guide_mesh_instance := MeshInstance3D.new()
var guide_mesh := SphereMesh.new()
guide_mesh.radius = 0.38
guide_mesh.height = 0.76
guide_mesh_instance.mesh = guide_mesh
var guide_material := StandardMaterial3D.new()
guide_material.albedo_color = color
guide_material.emission_enabled = true
guide_material.emission = color
guide_material.emission_energy_multiplier = 1.5
guide_mesh_instance.material_override = guide_material
guide.add_child(guide_mesh_instance)
var guide_light := OmniLight3D.new()
guide_light.omni_range = 7.0
guide_light.light_energy = 1.8
guide_light.light_color = color
guide.add_child(guide_light)
return group
func _ensure_input_actions() -> void:
_register_key_action("move_forward", KEY_W)
_register_key_action("move_backward", KEY_S)
_register_key_action("move_left", KEY_A)
_register_key_action("move_right", KEY_D)
_register_key_action("ascend", KEY_SPACE)
_register_key_action("descend", KEY_CTRL)
_register_key_action("boost", KEY_SHIFT)
_register_key_action("abandon_vehicle", KEY_X)
_register_key_action("debug_critical", KEY_F9)
_register_key_action("restart_poc", KEY_ENTER)
_register_key_action("toggle_mouse_capture", KEY_ESCAPE)
func _register_key_action(action_name: StringName, physical_keycode: Key) -> void:
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
if InputMap.action_get_events(action_name).is_empty():
var key_event := InputEventKey.new()
key_event.physical_keycode = physical_keycode
InputMap.action_add_event(action_name, key_event)
+1
View File
@@ -0,0 +1 @@
uid://hftqmbdbs7ch
+398
View File
@@ -0,0 +1,398 @@
extends Node3D
const SonarHudScript = preload("res://src/sonar_hud.gd")
const SonarModelScript = preload("res://src/sonar_model.gd")
const SonarSystemScript = preload("res://src/sonar_system.gd")
const START_POSITION := Vector3(0.0, -25.0, 22.0)
const METAL_CONTACT_POSITION := Vector3(0.0, -34.0, -45.0)
var _submarine: SubmarineController
var _sonar: Node
var _hud: CanvasLayer
var _ping_audio: AudioStreamPlayer
var _ping_audio_time_remaining := 0.0
func _ready() -> void:
_ensure_input_actions()
_build_environment()
_build_calibration_site()
_hud = SonarHudScript.new()
_hud.name = "SonarHud"
_hud.expanded_changed.connect(_on_sonar_expanded_changed)
add_child(_hud)
_submarine = SubmarineController.new()
_submarine.name = "Submarine"
_submarine.position = START_POSITION
_submarine.mission_origin = START_POSITION
_submarine.telemetry_updated.connect(_hud.update_telemetry)
add_child(_submarine)
_sonar = SonarSystemScript.new()
_sonar.name = "SonarSystem"
_sonar.ping_started.connect(_on_ping_started)
_sonar.ping_progress.connect(_on_ping_progress)
_sonar.ping_completed.connect(_on_ping_completed)
add_child(_sonar)
_sonar.setup(_submarine, self)
_ping_audio = AudioStreamPlayer.new()
_ping_audio.name = "SonarAudio"
add_child(_ping_audio)
func _process(delta: float) -> void:
_hud.update_sonar_pose(_submarine.global_transform)
if _ping_audio_time_remaining > 0.0:
_ping_audio_time_remaining = maxf(0.0, _ping_audio_time_remaining - delta)
if _ping_audio_time_remaining <= 0.0:
_ping_audio.stop()
_ping_audio.stream = null
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("sonar_ping"):
_sonar.request_ping()
elif event.is_action_pressed("toggle_sonar_map"):
_hud.toggle_expanded()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("toggle_mouse_capture") and _hud.is_expanded():
_hud.set_expanded(false)
get_viewport().set_input_as_handled()
func _on_ping_started() -> void:
_hud.show_ping_started(_sonar.max_range)
_play_sonar_ping()
func _on_ping_progress(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
progress: float
) -> void:
_hud.show_ping_progress(
current_cells,
previous_cells,
_sonar.max_range,
progress
)
func _on_ping_completed(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary]
) -> void:
_hud.show_ping_results(current_cells, previous_cells, _sonar.max_range)
func _on_sonar_expanded_changed(is_expanded: bool) -> void:
Input.mouse_mode = (
Input.MOUSE_MODE_VISIBLE
if is_expanded
else Input.MOUSE_MODE_CAPTURED
)
func _build_environment() -> void:
var world_environment := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("020d13")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("2b6870")
environment.ambient_light_energy = 0.46
environment.fog_enabled = true
environment.fog_light_color = Color("19434b")
environment.fog_light_energy = 0.46
environment.fog_density = 0.010
world_environment.environment = environment
add_child(world_environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-48.0, -22.0, 0.0)
light.light_color = Color("86c8cf")
light.light_energy = 0.34
add_child(light)
_add_water_particles()
func _build_calibration_site() -> void:
_add_bathymetric_terrain()
_add_metal_contact()
func _add_bathymetric_terrain() -> void:
var terrain := StaticBody3D.new()
terrain.name = "ContinuousCalibrationSeafloor"
terrain.set_meta("sonar_material", SonarModelScript.MaterialCategory.ROCK)
terrain.set_meta("sonar_reflectivity", 0.72)
add_child(terrain)
var mesh := _build_bathymetric_terrain_mesh()
var mesh_instance := MeshInstance3D.new()
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = Color("315f5c")
material.roughness = 0.96
mesh_instance.material_override = material
terrain.add_child(mesh_instance)
var collision := CollisionShape3D.new()
var shape := ConcavePolygonShape3D.new()
shape.set_faces(mesh.get_faces())
shape.backface_collision = true
collision.shape = shape
terrain.add_child(collision)
func _build_bathymetric_terrain_mesh() -> ArrayMesh:
var column_count := 61
var row_count := 69
var terrain_width := 150.0
var terrain_length := 170.0
var terrain_center_z := -28.0
var vertices: Array[Vector3] = []
var normals: Array[Vector3] = []
var indices: Array[int] = []
for row in row_count:
var z := terrain_center_z + lerpf(
terrain_length * 0.5,
-terrain_length * 0.5,
float(row) / float(row_count - 1)
)
for column in column_count:
var x := lerpf(
-terrain_width * 0.5,
terrain_width * 0.5,
float(column) / float(column_count - 1)
)
vertices.append(Vector3(x, _terrain_height_at(x, z), z))
normals.append(Vector3.ZERO)
for row in row_count - 1:
for column in column_count - 1:
var near_left := row * column_count + column
var near_right := near_left + 1
var far_left := (row + 1) * column_count + column
var far_right := far_left + 1
_append_terrain_triangle(
near_left,
near_right,
far_left,
vertices,
normals,
indices
)
_append_terrain_triangle(
near_right,
far_right,
far_left,
vertices,
normals,
indices
)
for normal_index in normals.size():
normals[normal_index] = normals[normal_index].normalized()
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array(vertices)
arrays[Mesh.ARRAY_NORMAL] = PackedVector3Array(normals)
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array(indices)
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _append_terrain_triangle(
first: int,
second: int,
third: int,
vertices: Array[Vector3],
normals: Array[Vector3],
indices: Array[int]
) -> void:
var normal := (vertices[second] - vertices[first]).cross(
vertices[third] - vertices[first]
).normalized()
if normal.y < 0.0:
var swapped := second
second = third
third = swapped
normal = -normal
indices.append_array([first, second, third])
normals[first] += normal
normals[second] += normal
normals[third] += normal
func _terrain_height_at(x: float, z: float) -> float:
var base := -39.4 + (z + 12.0) * 0.018
base += sin(x * 0.11) * 0.28 + cos(z * 0.085) * 0.24
var height := base
height += _gaussian_height(x, z, -12.0, -8.0, 9.2, 6.0, 8.0)
height += _gaussian_height(x, z, 14.0, -15.0, 8.2, 6.5, 8.5)
height += _gaussian_height(x, z, -10.0, -42.0, 16.0, 7.5, 14.0)
height += _gaussian_height(x, z, 10.0, -42.0, 16.0, 7.5, 14.0)
height += _gaussian_height(x, z, 0.0, -53.0, 12.0, 15.0, 7.5)
# This narrow negative ridge cuts a readable entrance into the mound. It is
# intentionally a bathymetric approximation rather than an impossible
# overhang in a height field.
height -= _gaussian_height(x, z, 0.0, -41.0, 19.0, 4.2, 16.0)
return maxf(height, base - 1.0)
func _gaussian_height(
x: float,
z: float,
center_x: float,
center_z: float,
amplitude: float,
width_x: float,
width_z: float
) -> float:
var normalized_x := (x - center_x) / maxf(width_x, 0.001)
var normalized_z := (z - center_z) / maxf(width_z, 0.001)
return amplitude * exp(-0.5 * (normalized_x * normalized_x + normalized_z * normalized_z))
func _add_rock_box(
node_name: String,
box_position: Vector3,
box_size: Vector3,
box_rotation_degrees: Vector3 = Vector3.ZERO
) -> StaticBody3D:
var body := StaticBody3D.new()
body.name = node_name
body.position = box_position
body.rotation_degrees = box_rotation_degrees
body.set_meta("sonar_material", SonarModelScript.MaterialCategory.ROCK)
body.set_meta("sonar_reflectivity", 0.68)
add_child(body)
var mesh_instance := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = box_size
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = Color("315b59")
material.roughness = 0.92
mesh_instance.material_override = material
body.add_child(mesh_instance)
var collision := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = box_size
collision.shape = shape
body.add_child(collision)
return body
func _add_metal_contact() -> void:
var contact := StaticBody3D.new()
contact.name = "MetalCalibrationObject"
contact.position = Vector3(
METAL_CONTACT_POSITION.x,
_terrain_height_at(METAL_CONTACT_POSITION.x, METAL_CONTACT_POSITION.z) + 2.5,
METAL_CONTACT_POSITION.z
)
contact.rotation_degrees.y = 16.0
contact.set_meta("sonar_material", SonarModelScript.MaterialCategory.METAL)
contact.set_meta("sonar_reflectivity", 1.0)
add_child(contact)
var mesh_instance := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = Vector3(8.0, 5.0, 7.0)
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = Color("855a35")
material.metallic = 0.88
material.roughness = 0.30
mesh_instance.material_override = material
contact.add_child(mesh_instance)
var collision := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = Vector3(8.0, 5.0, 7.0)
collision.shape = shape
contact.add_child(collision)
func _add_water_particles() -> void:
var particles := GPUParticles3D.new()
particles.name = "CalibrationWaterParticles"
particles.position = Vector3(0.0, -28.0, -15.0)
particles.amount = 280
particles.lifetime = 13.0
particles.preprocess = 13.0
particles.visibility_aabb = AABB(
Vector3(-55.0, -24.0, -65.0),
Vector3(110.0, 48.0, 130.0)
)
var motion := ParticleProcessMaterial.new()
motion.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
motion.emission_box_extents = Vector3(50.0, 20.0, 58.0)
motion.direction = Vector3(0.2, 0.08, 0.05)
motion.spread = 45.0
motion.initial_velocity_min = 0.02
motion.initial_velocity_max = 0.09
motion.gravity = Vector3.ZERO
motion.scale_min = 0.035
motion.scale_max = 0.10
particles.process_material = motion
var quad := QuadMesh.new()
quad.size = Vector2(0.07, 0.07)
var material := StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
material.albedo_color = Color(0.58, 0.86, 0.90, 0.35)
quad.material = material
particles.draw_pass_1 = quad
add_child(particles)
func _play_sonar_ping() -> void:
var generator := AudioStreamGenerator.new()
generator.mix_rate = 22050.0
generator.buffer_length = 0.5
_ping_audio.stream = generator
_ping_audio.play()
var playback := _ping_audio.get_stream_playback() as AudioStreamGeneratorPlayback
if playback == null:
return
var duration := 0.42
_ping_audio_time_remaining = duration + 0.08
var frame_count := int(generator.mix_rate * duration)
for frame_index in frame_count:
var time := float(frame_index) / generator.mix_rate
var frequency := lerpf(1180.0, 640.0, time / duration)
var envelope := exp(-time * 7.5) * minf(time * 80.0, 1.0)
var sample := sin(TAU * frequency * time) * envelope * 0.22
playback.push_frame(Vector2(sample, sample))
func _ensure_input_actions() -> void:
_register_key_action("move_forward", KEY_W)
_register_key_action("move_backward", KEY_S)
_register_key_action("move_left", KEY_A)
_register_key_action("move_right", KEY_D)
_register_key_action("ascend", KEY_SPACE)
_register_key_action("descend", KEY_CTRL)
_register_key_action("boost", KEY_SHIFT)
_register_key_action("abandon_vehicle", KEY_X)
_register_key_action("toggle_mouse_capture", KEY_ESCAPE)
_register_key_action("sonar_ping", KEY_F)
_register_key_action("toggle_sonar_map", KEY_TAB)
func _register_key_action(action_name: StringName, physical_keycode: Key) -> void:
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
if InputMap.action_get_events(action_name).is_empty():
var key_event := InputEventKey.new()
key_event.physical_keycode = physical_keycode
InputMap.action_add_event(action_name, key_event)
+1
View File
@@ -0,0 +1 @@
uid://cohfpdwraxtc0
+202
View File
@@ -0,0 +1,202 @@
class_name PocHud
extends CanvasLayer
var _oxygen_bar: ProgressBar
var _battery_bar: ProgressBar
var _hull_bar: ProgressBar
var _oxygen_label: Label
var _battery_label: Label
var _hull_label: Label
var _depth_label: Label
var _speed_label: Label
var _return_label: Label
var _objective_label: Label
var _warning_label: Label
var _ejection_label: Label
var _terminal_panel: PanelContainer
var _terminal_title: Label
var _terminal_body: Label
func _ready() -> void:
_build_hud()
func update_telemetry(snapshot: Dictionary) -> void:
_oxygen_bar.value = float(snapshot["oxygen_ratio"]) * 100.0
_battery_bar.value = float(snapshot["battery_ratio"]) * 100.0
_hull_bar.value = float(snapshot["hull_ratio"]) * 100.0
var oxygen_seconds := int(ceil(float(snapshot["oxygen"])))
_oxygen_label.text = "OXYGÈNE %02d:%02d" % [oxygen_seconds / 60, oxygen_seconds % 60]
_battery_label.text = "PROPULSION %d%%" % int(round(float(snapshot["battery"])))
_hull_label.text = "COQUE %d%%" % int(round(float(snapshot["hull"])))
_depth_label.text = "PROFONDEUR %.1f m / %.0f m" % [
float(snapshot["depth"]),
float(snapshot["nominal_depth"]),
]
_speed_label.text = "VITESSE %.1f m/s" % float(snapshot.get("speed", 0.0))
_return_label.text = "RETOUR %.0f m • coût estimé %.0f%%" % [
float(snapshot.get("return_distance", 0.0)),
float(snapshot.get("return_energy", 0.0)),
]
var highest_alert := maxi(
maxi(int(snapshot["oxygen_alert"]), int(snapshot["battery_alert"])),
maxi(int(snapshot["hull_alert"]), int(snapshot["depth_alert"]))
)
_apply_alert_style(highest_alert, snapshot)
func set_objective(text: String, distance: float = -1.0) -> void:
_objective_label.text = text if distance < 0.0 else "%s • %.0f m" % [text, distance]
func set_ejection_progress(progress: float) -> void:
if progress <= 0.01:
_ejection_label.text = ""
else:
_ejection_label.text = "ÉJECTION %d%% — maintenir X" % int(round(progress * 100.0))
func show_terminal(title: String, body: String) -> void:
_terminal_title.text = title
_terminal_body.text = body + "\n\nEntrée : recommencer le POC"
_terminal_panel.visible = true
func hide_terminal() -> void:
_terminal_panel.visible = false
func _apply_alert_style(severity: int, snapshot: Dictionary) -> void:
var color := Color("9be7ff")
var warning := ""
match severity:
SurvivalModel.AlertLevel.CAUTION:
color = Color("ffd166")
warning = "ATTENTION — vérifiez votre marge de retour"
SurvivalModel.AlertLevel.RETURN:
color = Color("ff9f43")
warning = "RETOUR RECOMMANDÉ"
SurvivalModel.AlertLevel.CRITICAL:
color = Color("ff4d5a")
warning = "DANGER CRITIQUE — remontez ou éjectez-vous"
SurvivalModel.AlertLevel.EMPTY:
color = Color("ff2340")
warning = "SYSTÈME EN DÉFAILLANCE"
if int(snapshot["depth_alert"]) >= SurvivalModel.AlertLevel.RETURN:
warning = "PRESSION EXCESSIVE — REMONTEZ"
_warning_label.text = warning
_warning_label.modulate = color
for bar in [_oxygen_bar, _battery_bar, _hull_bar]:
bar.modulate = color
func _build_hud() -> void:
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root)
var telemetry_panel := PanelContainer.new()
telemetry_panel.position = Vector2(22.0, 22.0)
telemetry_panel.size = Vector2(330.0, 260.0)
root.add_child(telemetry_panel)
var telemetry_box := VBoxContainer.new()
telemetry_box.add_theme_constant_override("separation", 7)
telemetry_panel.add_child(telemetry_box)
var title := Label.new()
title.text = "RANSOM’S WAKE • POC A"
title.add_theme_font_size_override("font_size", 19)
telemetry_box.add_child(title)
_oxygen_label = _add_metric(telemetry_box, "OXYGÈNE")
_oxygen_bar = _add_bar(telemetry_box)
_battery_label = _add_metric(telemetry_box, "PROPULSION")
_battery_bar = _add_bar(telemetry_box)
_hull_label = _add_metric(telemetry_box, "COQUE")
_hull_bar = _add_bar(telemetry_box)
_depth_label = _add_metric(telemetry_box, "PROFONDEUR")
_speed_label = _add_metric(telemetry_box, "VITESSE")
_return_label = _add_metric(telemetry_box, "RETOUR")
_objective_label = Label.new()
_objective_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
_objective_label.position = Vector2(-360.0, 24.0)
_objective_label.size = Vector2(720.0, 42.0)
_objective_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_objective_label.add_theme_font_size_override("font_size", 22)
root.add_child(_objective_label)
_warning_label = Label.new()
_warning_label.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_warning_label.position = Vector2(-400.0, -118.0)
_warning_label.size = Vector2(800.0, 38.0)
_warning_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_warning_label.add_theme_font_size_override("font_size", 21)
root.add_child(_warning_label)
_ejection_label = Label.new()
_ejection_label.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_ejection_label.position = Vector2(-300.0, -78.0)
_ejection_label.size = Vector2(600.0, 34.0)
_ejection_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_ejection_label.modulate = Color("ff6b6b")
root.add_child(_ejection_label)
var help := Label.new()
help.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
help.offset_left = 22.0
help.offset_top = -40.0
help.offset_right = -22.0
help.offset_bottom = -12.0
help.text = "W/S avancer-reculer • A/D tourner • Espace/Ctrl profondeur • Maj poussée • Souris orientation • X éjection • F9 critique • Échap souris"
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
help.modulate = Color(0.75, 0.88, 0.92, 0.88)
root.add_child(help)
_terminal_panel = PanelContainer.new()
_terminal_panel.set_anchors_preset(Control.PRESET_CENTER)
_terminal_panel.position = Vector2(-290.0, -150.0)
_terminal_panel.size = Vector2(580.0, 300.0)
_terminal_panel.visible = false
root.add_child(_terminal_panel)
var terminal_box := VBoxContainer.new()
terminal_box.alignment = BoxContainer.ALIGNMENT_CENTER
terminal_box.add_theme_constant_override("separation", 18)
_terminal_panel.add_child(terminal_box)
_terminal_title = Label.new()
_terminal_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_terminal_title.add_theme_font_size_override("font_size", 30)
terminal_box.add_child(_terminal_title)
_terminal_body = Label.new()
_terminal_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_terminal_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_terminal_body.add_theme_font_size_override("font_size", 17)
terminal_box.add_child(_terminal_body)
func _add_metric(parent: VBoxContainer, initial_text: String) -> Label:
var label := Label.new()
label.text = initial_text
label.add_theme_font_size_override("font_size", 14)
parent.add_child(label)
return label
func _add_bar(parent: VBoxContainer) -> ProgressBar:
var bar := ProgressBar.new()
bar.min_value = 0.0
bar.max_value = 100.0
bar.value = 100.0
bar.show_percentage = false
bar.custom_minimum_size = Vector2(300.0, 10.0)
parent.add_child(bar)
return bar
+1
View File
@@ -0,0 +1 @@
uid://cat260ck8g6bd
+251
View File
@@ -0,0 +1,251 @@
class_name SonarHud
extends CanvasLayer
const SonarModelScript = preload("res://src/sonar_model.gd")
const SonarReliefRendererScript = preload("res://src/sonar_relief_renderer.gd")
signal expanded_changed(is_expanded: bool)
var _renderer: SubViewport
var _telemetry_label: Label
var _compact_panel: PanelContainer
var _compact_status: Label
var _expanded_overlay: ColorRect
var _expanded_status: Label
var _help_label: Label
var _expanded := false
func _ready() -> void:
layer = 2
_renderer = SonarReliefRendererScript.new()
_renderer.name = "SonarReliefRenderer"
add_child(_renderer)
_build_hud()
func update_telemetry(snapshot: Dictionary) -> void:
_telemetry_label.text = "PROFONDEUR %.1f m\nVITESSE %.1f m/s\nPROPULSION %d%%" % [
float(snapshot.get("depth", 0.0)),
float(snapshot.get("speed", 0.0)),
int(round(float(snapshot.get("battery", 0.0)))),
]
func update_sonar_pose(new_transform: Transform3D) -> void:
_renderer.update_submarine_pose(new_transform)
func show_ping_started(_max_range: float) -> void:
_renderer.start_scan()
_set_status(
"SCAN 0% • PREMIERS RETOURS EN ATTENTE",
Color("9cf7f2"),
"SCAN 0%"
)
func show_ping_progress(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
_max_range: float,
progress: float
) -> void:
_renderer.update_scan(current_cells, previous_cells, progress)
var metal_detected := _metal_cell_count(current_cells) > 0
var color := Color("ffad52") if metal_detected else Color("9cf7f2")
var suffix := " • ANOMALIE" if metal_detected else ""
var status := "SCAN %d%%%s" % [int(round(progress * 100.0)), suffix]
_set_status(status, color, status)
func show_ping_results(
current_cells: Array[Dictionary],
_previous_cells: Array[Dictionary],
_max_range: float
) -> void:
_renderer.finish_scan(current_cells)
var metal_count := _metal_cell_count(current_cells)
var status := "SCAN TERMINÉ • RELIEF RECONSTRUIT"
var color := Color("9cf7f2")
if metal_count > 0:
status += " • ANOMALIE ORANGE"
color = Color("ffad52")
_set_status(
status,
color,
"SCAN TERMINÉ • ANOMALIE" if metal_count > 0 else "SCAN TERMINÉ"
)
func toggle_expanded() -> void:
set_expanded(not _expanded)
func set_expanded(is_expanded: bool) -> void:
if _expanded == is_expanded:
return
_expanded = is_expanded
_expanded_overlay.visible = is_expanded
_compact_panel.visible = not is_expanded
_help_label.visible = not is_expanded
expanded_changed.emit(is_expanded)
func is_expanded() -> bool:
return _expanded
func relief_renderer() -> SubViewport:
return _renderer
func _set_status(text: String, color: Color, compact_text: String = "") -> void:
_compact_status.text = text if compact_text.is_empty() else compact_text
_compact_status.modulate = color
_expanded_status.text = text
_expanded_status.modulate = color
func _metal_cell_count(cells: Array[Dictionary]) -> int:
var count := 0
for cell: Dictionary in cells:
if int(cell["material"]) == SonarModelScript.MaterialCategory.METAL:
count += 1
return count
func _build_hud() -> void:
var root := Control.new()
root.name = "SonarHudRoot"
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root)
var objective := Label.new()
objective.set_anchors_preset(Control.PRESET_CENTER_TOP)
objective.position = Vector2(-390.0, 24.0)
objective.size = Vector2(780.0, 38.0)
objective.text = "LISEZ LE RELIEF • FRANCHISSEZ L'OUVERTURE • IDENTIFIEZ L'ANOMALIE"
objective.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
objective.add_theme_font_size_override("font_size", 18)
root.add_child(objective)
var telemetry_panel := PanelContainer.new()
telemetry_panel.position = Vector2(22.0, 82.0)
telemetry_panel.size = Vector2(280.0, 118.0)
root.add_child(telemetry_panel)
_telemetry_label = Label.new()
_telemetry_label.text = "PROFONDEUR\nVITESSE\nPROPULSION"
_telemetry_label.add_theme_font_size_override("font_size", 16)
telemetry_panel.add_child(_telemetry_label)
var instruction_panel := PanelContainer.new()
instruction_panel.position = Vector2(22.0, 214.0)
instruction_panel.size = Vector2(294.0, 112.0)
root.add_child(instruction_panel)
var instruction := Label.new()
instruction.text = (
"F ÉMETTRE UN SCAN MANUEL\n"
+ "Le relief apparaît du proche au lointain.\n"
+ "L'orange signale un retour anormal."
)
instruction.modulate = Color(0.78, 0.94, 0.95, 0.94)
instruction_panel.add_child(instruction)
_compact_panel = PanelContainer.new()
_compact_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_compact_panel.position = Vector2(-342.0, 82.0)
_compact_panel.size = Vector2(320.0, 276.0)
root.add_child(_compact_panel)
var compact_box := VBoxContainer.new()
compact_box.add_theme_constant_override("separation", 6)
_compact_panel.add_child(compact_box)
var compact_title := Label.new()
compact_title.text = "SONAR BATHYMÉTRIQUE • AGRANDIR"
compact_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
compact_title.add_theme_font_size_override("font_size", 14)
compact_box.add_child(compact_title)
var compact_view := _make_sonar_texture_rect(Vector2(298.0, 190.0))
compact_view.tooltip_text = "Cliquez pour ouvrir le grand écran sonar"
compact_view.mouse_filter = Control.MOUSE_FILTER_STOP
compact_view.gui_input.connect(_on_compact_view_input)
compact_box.add_child(compact_view)
_compact_status = Label.new()
_compact_status.text = "F • LANCER LE PREMIER SCAN"
_compact_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
compact_box.add_child(_compact_status)
var compact_reference := Label.new()
compact_reference.text = "PORTÉE 95 M • SOUS-MARIN EN BAS • CAP VERS LE HAUT"
compact_reference.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
compact_reference.modulate = Color(0.62, 0.82, 0.84, 0.86)
compact_reference.add_theme_font_size_override("font_size", 11)
compact_box.add_child(compact_reference)
_expanded_overlay = ColorRect.new()
_expanded_overlay.name = "ExpandedSonarOverlay"
_expanded_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_expanded_overlay.color = Color(0.0, 0.015, 0.02, 0.96)
_expanded_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
_expanded_overlay.visible = false
root.add_child(_expanded_overlay)
var expanded_panel := PanelContainer.new()
expanded_panel.position = Vector2(110.0, 34.0)
expanded_panel.size = Vector2(1060.0, 652.0)
_expanded_overlay.add_child(expanded_panel)
var expanded_box := VBoxContainer.new()
expanded_box.add_theme_constant_override("separation", 7)
expanded_panel.add_child(expanded_box)
var expanded_title := Label.new()
expanded_title.text = "SONAR BATHYMÉTRIQUE • PORTÉE 95 M • CAP VERS LE HAUT"
expanded_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
expanded_title.add_theme_font_size_override("font_size", 19)
expanded_box.add_child(expanded_title)
var expanded_view := _make_sonar_texture_rect(Vector2(1038.0, 556.0))
expanded_box.add_child(expanded_view)
_expanded_status = Label.new()
_expanded_status.text = "F • LANCER LE PREMIER SCAN"
_expanded_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_expanded_status.add_theme_font_size_override("font_size", 16)
expanded_box.add_child(_expanded_status)
var legend := Label.new()
legend.text = "BLEU PROFOND • VERT/JAUNE HAUTEUR • ORANGE CAISSE/ANOMALIE • NOIR INCONNU"
legend.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
legend.modulate = Color(0.68, 0.88, 0.90, 0.90)
expanded_box.add_child(legend)
var close_button := Button.new()
close_button.position = Vector2(1044.0, 46.0)
close_button.size = Vector2(108.0, 36.0)
close_button.text = "FERMER ×"
close_button.pressed.connect(func() -> void: set_expanded(false))
_expanded_overlay.add_child(close_button)
_help_label = Label.new()
_help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_help_label.offset_left = 20.0
_help_label.offset_top = -42.0
_help_label.offset_right = -20.0
_help_label.offset_bottom = -12.0
_help_label.text = "F scanner • Tab agrandir/fermer • W/S propulsion • A/D gouvernail • Espace/Ctrl profondeur • Échap souris"
_help_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_help_label.modulate = Color(0.75, 0.90, 0.94, 0.9)
root.add_child(_help_label)
func _make_sonar_texture_rect(minimum_size: Vector2) -> TextureRect:
var view := TextureRect.new()
view.custom_minimum_size = minimum_size
view.texture = _renderer.get_texture()
view.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
view.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
return view
func _on_compact_view_input(event: InputEvent) -> void:
if (
event is InputEventMouseButton
and event.button_index == MOUSE_BUTTON_LEFT
and event.pressed
):
set_expanded(true)
+1
View File
@@ -0,0 +1 @@
uid://c3jt5jfbwtl2t
+72
View File
@@ -0,0 +1,72 @@
class_name SonarModel
extends RefCounted
enum MaterialCategory {
ROCK,
METAL,
VOID,
}
var cell_size := 0.75
var _observed_cells: Dictionary = {}
func record_echo(
world_position: Vector3,
intensity: float,
material_category: MaterialCategory
) -> bool:
var cell := _cell_for_position(world_position)
var clamped_intensity := clampf(intensity, 0.0, 1.0)
if _observed_cells.has(cell):
var existing: Dictionary = _observed_cells[cell]
if clamped_intensity <= float(existing["intensity"]):
return false
_observed_cells[cell] = {
"position": world_position,
"intensity": clamped_intensity,
"material": material_category,
}
return true
func observed_points() -> Array[Dictionary]:
var points: Array[Dictionary] = []
for point: Dictionary in _observed_cells.values():
points.append(point.duplicate())
return points
func observed_point_count() -> int:
return _observed_cells.size()
func clear() -> void:
_observed_cells.clear()
func calculate_intensity(
distance: float,
max_range: float,
incidence: float,
reflectivity: float
) -> float:
if max_range <= 0.0 or distance < 0.0 or distance > max_range:
return 0.0
var distance_falloff := 1.0 - distance / max_range
var incidence_response := lerpf(0.18, 1.0, clampf(incidence, 0.0, 1.0))
return clampf(
distance_falloff * incidence_response * clampf(reflectivity, 0.0, 1.0),
0.0,
1.0
)
func _cell_for_position(world_position: Vector3) -> Vector3i:
var safe_cell_size := maxf(cell_size, 0.001)
return Vector3i(
floori(world_position.x / safe_cell_size),
floori(world_position.y / safe_cell_size),
floori(world_position.z / safe_cell_size)
)
+1
View File
@@ -0,0 +1 @@
uid://rfiw5isteare
+430
View File
@@ -0,0 +1,430 @@
class_name SonarReliefRenderer
extends SubViewport
const SonarModelScript = preload("res://src/sonar_model.gd")
@export var terrain_skirt_depth := 4.5
var _camera: Camera3D
var _current_mesh: MeshInstance3D
var _previous_mesh: MeshInstance3D
var _current_anomaly: MeshInstance3D
var _previous_anomaly: MeshInstance3D
var _submarine_marker: Node3D
var _current_cells: Array[Dictionary] = []
var _previous_cells: Array[Dictionary] = []
var _last_built_cell_count := -1
func _ready() -> void:
size = Vector2i(960, 540)
disable_3d = false
render_target_update_mode = SubViewport.UPDATE_ALWAYS
world_3d = World3D.new()
_build_world()
func start_scan() -> void:
_previous_cells = _current_cells.duplicate(true)
_current_cells.clear()
_last_built_cell_count = -1
_rebuild_terrain(_previous_mesh, _previous_cells)
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_previous_anomaly, _previous_cells)
_update_anomaly(_current_anomaly, _current_cells)
_previous_mesh.transparency = 0.48 if not _previous_cells.is_empty() else 1.0
_previous_anomaly.transparency = _previous_mesh.transparency
func update_scan(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
progress: float
) -> void:
_current_cells = current_cells.duplicate(true)
_previous_cells = previous_cells.duplicate(true)
if _current_cells.size() != _last_built_cell_count:
_last_built_cell_count = _current_cells.size()
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_current_anomaly, _current_cells)
if _previous_mesh.mesh == null and not _previous_cells.is_empty():
_rebuild_terrain(_previous_mesh, _previous_cells)
_update_anomaly(_previous_anomaly, _previous_cells)
var previous_alpha := lerpf(0.48, 1.0, clampf(progress, 0.0, 1.0))
_previous_mesh.transparency = previous_alpha
_previous_anomaly.transparency = previous_alpha
func finish_scan(current_cells: Array[Dictionary]) -> void:
_current_cells = current_cells.duplicate(true)
_last_built_cell_count = _current_cells.size()
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_current_anomaly, _current_cells)
_previous_cells.clear()
_previous_mesh.mesh = null
_previous_mesh.transparency = 1.0
_previous_anomaly.visible = false
_previous_anomaly.transparency = 1.0
func update_submarine_pose(submarine_transform: Transform3D) -> void:
if _camera == null:
return
var forward := -submarine_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var camera_position := submarine_transform.origin - forward * 30.0 + Vector3.UP * 65.0
var focus := submarine_transform.origin + forward * 43.0 + Vector3.DOWN * 8.0
_camera.global_position = camera_position
_camera.look_at(focus, Vector3.UP)
_submarine_marker.global_position = submarine_transform.origin
_submarine_marker.rotation = Vector3(0.0, submarine_transform.basis.get_euler().y, 0.0)
func current_face_count() -> int:
if _current_mesh.mesh == null:
return 0
return int(_current_mesh.mesh.get_faces().size() / 3)
func _build_world() -> void:
var environment_node := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("020407")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("8fc9d1")
environment.ambient_light_energy = 0.42
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
environment_node.environment = environment
add_child(environment_node)
_previous_mesh = _make_terrain_instance("PreviousBathymetry")
_current_mesh = _make_terrain_instance("CurrentBathymetry")
_previous_anomaly = _make_anomaly_instance("PreviousAnomaly")
_current_anomaly = _make_anomaly_instance("CurrentAnomaly")
var key_light := DirectionalLight3D.new()
key_light.name = "ReliefKeyLight"
key_light.rotation_degrees = Vector3(-58.0, -34.0, 0.0)
key_light.light_color = Color("d8fbff")
key_light.light_energy = 1.25
key_light.shadow_enabled = true
add_child(key_light)
var fill_light := DirectionalLight3D.new()
fill_light.name = "ReliefFillLight"
fill_light.rotation_degrees = Vector3(-35.0, 145.0, 0.0)
fill_light.light_color = Color("3d86ad")
fill_light.light_energy = 0.38
add_child(fill_light)
_camera = Camera3D.new()
_camera.name = "BathymetryCamera"
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
_camera.size = 116.0
_camera.far = 240.0
_camera.current = true
add_child(_camera)
_build_submarine_marker()
func _make_terrain_instance(node_name: String) -> MeshInstance3D:
var instance := MeshInstance3D.new()
instance.name = node_name
var material := StandardMaterial3D.new()
material.vertex_color_use_as_albedo = true
material.roughness = 0.84
material.metallic = 0.0
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
# The scan may be inspected while the submarine pitches or passes above it.
# Rendering both sides keeps the reconstructed sheet readable from every
# permitted sonar-camera angle.
material.cull_mode = BaseMaterial3D.CULL_DISABLED
instance.material_override = material
add_child(instance)
return instance
func _make_anomaly_instance(node_name: String) -> MeshInstance3D:
var instance := MeshInstance3D.new()
instance.name = node_name
var crate := BoxMesh.new()
crate.size = Vector3(8.0, 4.5, 8.0)
instance.mesh = crate
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = Color("ff9d1f")
material.emission_enabled = true
material.emission = Color("ff6800")
material.emission_energy_multiplier = 4.0
material.roughness = 0.32
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
instance.material_override = material
instance.visible = false
add_child(instance)
return instance
func _build_submarine_marker() -> void:
_submarine_marker = Node3D.new()
_submarine_marker.name = "SubmarineReference"
add_child(_submarine_marker)
var body := MeshInstance3D.new()
var body_mesh := CapsuleMesh.new()
body_mesh.radius = 1.15
body_mesh.height = 5.8
body_mesh.radial_segments = 16
body_mesh.rings = 6
body.mesh = body_mesh
body.rotation_degrees.x = 90.0
var body_material := StandardMaterial3D.new()
body_material.albedo_color = Color("eaffff")
body_material.emission_enabled = true
body_material.emission = Color("a7f8ff")
body_material.emission_energy_multiplier = 1.45
body.material_override = body_material
_submarine_marker.add_child(body)
var nose := MeshInstance3D.new()
var nose_mesh := CylinderMesh.new()
nose_mesh.top_radius = 0.0
nose_mesh.bottom_radius = 0.72
nose_mesh.height = 1.6
nose_mesh.radial_segments = 16
nose.mesh = nose_mesh
nose.position.z = -3.5
nose.rotation_degrees.x = -90.0
nose.material_override = body_material
_submarine_marker.add_child(nose)
func _rebuild_terrain(instance: MeshInstance3D, cells: Array[Dictionary]) -> void:
var samples: Dictionary = {}
for cell: Dictionary in cells:
if String(cell.get("sample_type", "bathymetry")) != "bathymetry":
continue
samples[Vector2i(int(cell["column"]), int(cell["row"]))] = cell
if samples.size() < 4:
instance.mesh = null
return
var keys: Array[Vector2i] = []
for key: Vector2i in samples:
keys.append(key)
keys.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return a.y < b.y or (a.y == b.y and a.x < b.x)
)
var vertices: Array[Vector3] = []
var colors: Array[Color] = []
var normals: Array[Vector3] = []
var indices: Array[int] = []
var vertex_for_key: Dictionary = {}
for key: Vector2i in keys:
var cell: Dictionary = samples[key]
vertex_for_key[key] = vertices.size()
vertices.append(cell["position"])
colors.append(_bathymetry_color(cell))
normals.append(Vector3.ZERO)
for key: Vector2i in keys:
var right_key := key + Vector2i.RIGHT
var far_key := key + Vector2i.DOWN
var far_right_key := key + Vector2i(1, 1)
if not (
vertex_for_key.has(right_key)
and vertex_for_key.has(far_key)
and vertex_for_key.has(far_right_key)
):
continue
_append_top_triangle(
int(vertex_for_key[key]),
int(vertex_for_key[right_key]),
int(vertex_for_key[far_key]),
vertices,
normals,
indices
)
_append_top_triangle(
int(vertex_for_key[right_key]),
int(vertex_for_key[far_right_key]),
int(vertex_for_key[far_key]),
vertices,
normals,
indices
)
if indices.is_empty():
instance.mesh = null
return
for normal_index in normals.size():
normals[normal_index] = normals[normal_index].normalized()
_append_terrain_skirt(samples, vertices, normals, colors, indices)
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array(vertices)
arrays[Mesh.ARRAY_NORMAL] = PackedVector3Array(normals)
arrays[Mesh.ARRAY_COLOR] = PackedColorArray(colors)
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array(indices)
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
instance.mesh = mesh
func _append_top_triangle(
first: int,
second: int,
third: int,
vertices: Array[Vector3],
normals: Array[Vector3],
indices: Array[int]
) -> void:
var face_normal := (vertices[second] - vertices[first]).cross(
vertices[third] - vertices[first]
).normalized()
if face_normal.y < 0.0:
var swapped := second
second = third
third = swapped
face_normal = -face_normal
indices.append_array([first, second, third])
normals[first] += face_normal
normals[second] += face_normal
normals[third] += face_normal
func _append_terrain_skirt(
samples: Dictionary,
vertices: Array[Vector3],
normals: Array[Vector3],
colors: Array[Color],
indices: Array[int]
) -> void:
var row_bounds := _sample_bounds(samples)
if row_bounds.is_empty():
return
var minimum_row: int = row_bounds["min_row"]
var maximum_row: int = row_bounds["max_row"]
var minimum_column: int = row_bounds["min_column"]
var maximum_column: int = row_bounds["max_column"]
for column in range(minimum_column, maximum_column):
_append_skirt_between(
samples,
Vector2i(column, minimum_row),
Vector2i(column + 1, minimum_row),
vertices,
normals,
colors,
indices
)
_append_skirt_between(
samples,
Vector2i(column + 1, maximum_row),
Vector2i(column, maximum_row),
vertices,
normals,
colors,
indices
)
for row in range(minimum_row, maximum_row):
_append_skirt_between(
samples,
Vector2i(minimum_column, row + 1),
Vector2i(minimum_column, row),
vertices,
normals,
colors,
indices
)
_append_skirt_between(
samples,
Vector2i(maximum_column, row),
Vector2i(maximum_column, row + 1),
vertices,
normals,
colors,
indices
)
func _append_skirt_between(
samples: Dictionary,
first_key: Vector2i,
second_key: Vector2i,
vertices: Array[Vector3],
normals: Array[Vector3],
colors: Array[Color],
indices: Array[int]
) -> void:
if not samples.has(first_key) or not samples.has(second_key):
return
var first: Vector3 = samples[first_key]["position"]
var second: Vector3 = samples[second_key]["position"]
var first_bottom := first + Vector3.DOWN * terrain_skirt_depth
var second_bottom := second + Vector3.DOWN * terrain_skirt_depth
var side_normal := (second - first).cross(first_bottom - first).normalized()
var base_index := vertices.size()
vertices.append_array([first, second, first_bottom, second_bottom])
normals.append_array([side_normal, side_normal, side_normal, side_normal])
var side_color := Color("152a32")
colors.append_array([side_color, side_color, side_color, side_color])
indices.append_array([
base_index,
base_index + 1,
base_index + 2,
base_index + 1,
base_index + 3,
base_index + 2,
])
func _sample_bounds(samples: Dictionary) -> Dictionary:
var minimum_row := 1 << 30
var maximum_row := -(1 << 30)
var minimum_column := 1 << 30
var maximum_column := -(1 << 30)
for key: Vector2i in samples:
minimum_row = mini(minimum_row, key.y)
maximum_row = maxi(maximum_row, key.y)
minimum_column = mini(minimum_column, key.x)
maximum_column = maxi(maximum_column, key.x)
return {
"min_row": minimum_row,
"max_row": maximum_row,
"min_column": minimum_column,
"max_column": maximum_column,
}
func _bathymetry_color(cell: Dictionary) -> Color:
if int(cell["material"]) == SonarModelScript.MaterialCategory.METAL:
return Color("ff6b22")
var elevation := clampf(float(cell.get("elevation", 0.0)), 0.0, 1.0)
var color: Color
if elevation < 0.28:
color = Color("173f91").lerp(Color("168fc1"), elevation / 0.28)
elif elevation < 0.52:
color = Color("168fc1").lerp(Color("36b979"), (elevation - 0.28) / 0.24)
elif elevation < 0.76:
color = Color("36b979").lerp(Color("d7d93d"), (elevation - 0.52) / 0.24)
else:
color = Color("d7d93d").lerp(Color("ee5734"), (elevation - 0.76) / 0.24)
var return_strength := clampf(float(cell.get("intensity", 0.5)), 0.0, 1.0)
return color.lerp(Color.WHITE, return_strength * 0.08)
func _update_anomaly(instance: MeshInstance3D, cells: Array[Dictionary]) -> void:
var position_sum := Vector3.ZERO
var anomaly_count := 0
for cell: Dictionary in cells:
if int(cell.get("material", SonarModelScript.MaterialCategory.ROCK)) != SonarModelScript.MaterialCategory.METAL:
continue
position_sum += cell["position"]
anomaly_count += 1
instance.visible = anomaly_count > 0
if anomaly_count > 0:
instance.global_position = position_sum / float(anomaly_count) + Vector3.UP * 1.4
+1
View File
@@ -0,0 +1 @@
uid://dtorud30m7smk
+308
View File
@@ -0,0 +1,308 @@
class_name SonarSystem
extends Node
const SonarModelScript = preload("res://src/sonar_model.gd")
signal ping_started()
signal ping_progress(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
progress: float
)
signal ping_completed(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary]
)
@export var max_range := 95.0
@export var min_range := 5.0
@export var horizontal_fov_degrees := 76.0
@export var horizontal_ray_count := 49
@export var vertical_ray_count := 31
@export var scan_duration := 2.5
@export var collision_mask := 1
@export var bathymetry_probe_height := 48.0
@export var bathymetry_probe_depth := 92.0
@export var anomaly_vertical_fov_degrees := 64.0
@export var anomaly_down_angle_degrees := -18.0
var model := SonarModelScript.new()
var _submarine: SubmarineController
var _ping_requested := false
var _ping_active := false
var _cooldown := 0.0
var _ping_elapsed := 0.0
var _next_cell_index := 0
var _scheduled_cells: Array[Dictionary] = []
var _current_cells: Array[Dictionary] = []
var _previous_cells: Array[Dictionary] = []
func setup(submarine: SubmarineController, _world_parent: Node3D) -> void:
_submarine = submarine
model.cell_size = 0.65
func request_ping() -> bool:
if _submarine == null or _ping_requested or _ping_active or _cooldown > 0.0:
return false
_ping_requested = true
return true
func ping_duration() -> float:
return scan_duration
func current_cells() -> Array[Dictionary]:
return _current_cells.duplicate(true)
func previous_cells() -> Array[Dictionary]:
return _previous_cells.duplicate(true)
func _physics_process(delta: float) -> void:
_cooldown = maxf(0.0, _cooldown - delta)
if _ping_requested:
_ping_requested = false
_begin_ping()
if _ping_active:
_advance_ping(delta)
func _begin_ping() -> void:
_previous_cells = _current_cells.duplicate(true)
_current_cells.clear()
model.clear()
_ping_active = true
_ping_elapsed = 0.0
_next_cell_index = 0
_scheduled_cells = _collect_cells()
_scheduled_cells.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a["return_time"]) < float(b["return_time"])
)
ping_started.emit()
ping_progress.emit(_current_cells, _previous_cells, 0.0)
func _advance_ping(delta: float) -> void:
_ping_elapsed = minf(_ping_elapsed + delta, scan_duration)
while (
_next_cell_index < _scheduled_cells.size()
and float(_scheduled_cells[_next_cell_index]["return_time"]) <= _ping_elapsed
):
var cell: Dictionary = _scheduled_cells[_next_cell_index]
model.record_echo(cell["position"], cell["intensity"], cell["material"])
_current_cells.append(cell)
_next_cell_index += 1
var progress := clampf(_ping_elapsed / maxf(scan_duration, 0.001), 0.0, 1.0)
ping_progress.emit(_current_cells, _previous_cells, progress)
if _ping_elapsed >= scan_duration:
_ping_active = false
_cooldown = 0.35
ping_completed.emit(_current_cells, _previous_cells)
func _collect_cells() -> Array[Dictionary]:
var cells := _collect_bathymetry_cells()
cells.append_array(_collect_anomaly_cells())
_apply_elevation_scale(cells)
return cells
func _collect_bathymetry_cells() -> Array[Dictionary]:
var cells: Array[Dictionary] = []
var basis := _submarine.global_transform.basis
var origin := (
_submarine.global_position
+ basis * Vector3(0.0, -0.75, -2.2)
)
var forward := -basis.z
forward.y = 0.0
forward = forward.normalized()
var right := basis.x
right.y = 0.0
right = right.normalized()
var space_state := _submarine.get_world_3d().direct_space_state
var exclusions: Array[RID] = [_submarine.get_rid()]
var half_fov_tangent := tan(deg_to_rad(horizontal_fov_degrees * 0.5))
for row in vertical_ray_count:
var distance_ratio := (
0.5
if vertical_ray_count <= 1
else float(row) / float(vertical_ray_count - 1)
)
var sample_distance := lerpf(min_range, max_range, distance_ratio)
var half_width := maxf(2.5, sample_distance * half_fov_tangent)
for column in horizontal_ray_count:
var horizontal_ratio := (
0.5
if horizontal_ray_count <= 1
else float(column) / float(horizontal_ray_count - 1)
)
var lateral_offset := lerpf(-half_width, half_width, horizontal_ratio)
var sample_center := (
origin
+ forward * sample_distance
+ right * lateral_offset
)
var probe_start := Vector3(
sample_center.x,
origin.y + bathymetry_probe_height,
sample_center.z
)
var probe_end := Vector3(
sample_center.x,
origin.y - bathymetry_probe_depth,
sample_center.z
)
var query := PhysicsRayQueryParameters3D.create(
probe_start,
probe_end,
collision_mask,
exclusions
)
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var hit_position: Vector3 = hit["position"]
var hit_normal: Vector3 = hit["normal"]
var collider := hit.get("collider") as CollisionObject3D
var material_category := SonarModelScript.MaterialCategory.ROCK
var reflectivity := 0.66
if collider != null:
material_category = int(collider.get_meta(
"sonar_material",
SonarModelScript.MaterialCategory.ROCK
))
reflectivity = float(collider.get_meta("sonar_reflectivity", reflectivity))
var distance := origin.distance_to(hit_position)
var direction := origin.direction_to(hit_position)
var incidence := clampf(-direction.dot(hit_normal), 0.0, 1.0)
var intensity := model.calculate_intensity(
distance,
max_range * 1.4,
incidence,
reflectivity
)
if intensity <= 0.005:
continue
cells.append({
"position": hit_position,
"normal": hit_normal,
"distance": distance,
"column": column,
"row": row,
"intensity": intensity,
"material": material_category,
"sample_type": "bathymetry",
"return_time": scan_duration * lerpf(0.04, 1.0, distance_ratio),
})
return cells
func _collect_anomaly_cells() -> Array[Dictionary]:
var cells: Array[Dictionary] = []
var basis := _submarine.global_transform.basis
var origin := _submarine.global_position + basis * Vector3(0.0, -0.75, -2.2)
var space_state := _submarine.get_world_3d().direct_space_state
var exclusions: Array[RID] = [_submarine.get_rid()]
var anomaly_rows := 21
var anomaly_columns := 31
for row in anomaly_rows:
var vertical_ratio := float(row) / float(anomaly_rows - 1)
var elevation := deg_to_rad(
anomaly_down_angle_degrees
+ lerpf(
-anomaly_vertical_fov_degrees * 0.5,
anomaly_vertical_fov_degrees * 0.5,
vertical_ratio
)
)
for column in anomaly_columns:
var horizontal_ratio := float(column) / float(anomaly_columns - 1)
var azimuth := deg_to_rad(lerpf(
-horizontal_fov_degrees * 0.5,
horizontal_fov_degrees * 0.5,
horizontal_ratio
))
var local_direction := Vector3(
tan(azimuth),
tan(elevation),
-1.0
).normalized()
var direction := (basis * local_direction).normalized()
var query := PhysicsRayQueryParameters3D.create(
origin,
origin + direction * max_range,
collision_mask,
exclusions
)
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var collider := hit.get("collider") as CollisionObject3D
if collider == null:
continue
var material_category := int(collider.get_meta(
"sonar_material",
SonarModelScript.MaterialCategory.ROCK
))
if material_category != SonarModelScript.MaterialCategory.METAL:
continue
var hit_position: Vector3 = hit["position"]
var hit_normal: Vector3 = hit["normal"]
var distance := origin.distance_to(hit_position)
var incidence := clampf(-direction.dot(hit_normal), 0.0, 1.0)
var intensity := model.calculate_intensity(
distance,
max_range,
incidence,
float(collider.get_meta("sonar_reflectivity", 1.0))
)
cells.append({
"position": hit_position,
"normal": hit_normal,
"distance": distance,
"column": column,
"row": row,
"intensity": intensity,
"material": material_category,
"sample_type": "anomaly",
"return_time": scan_duration * distance / maxf(max_range, 0.001),
})
return cells
func _apply_elevation_scale(cells: Array[Dictionary]) -> void:
var minimum_elevation := INF
var maximum_elevation := -INF
for cell: Dictionary in cells:
if String(cell.get("sample_type", "bathymetry")) != "bathymetry":
continue
var elevation := float((cell["position"] as Vector3).y)
minimum_elevation = minf(minimum_elevation, elevation)
maximum_elevation = maxf(maximum_elevation, elevation)
if minimum_elevation == INF:
return
var elevation_span := maxf(maximum_elevation - minimum_elevation, 6.0)
for cell: Dictionary in cells:
if String(cell.get("sample_type", "bathymetry")) != "bathymetry":
continue
cell["elevation"] = clampf(
(float((cell["position"] as Vector3).y) - minimum_elevation) / elevation_span,
0.0,
1.0
)
+1
View File
@@ -0,0 +1 @@
uid://ruu8hrvkoln0
+550
View File
@@ -0,0 +1,550 @@
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
+1
View File
@@ -0,0 +1 @@
uid://02uauyco6kv5
+139
View File
@@ -0,0 +1,139 @@
class_name SurvivalModel
extends RefCounted
enum AlertLevel {
NORMAL,
CAUTION,
RETURN,
CRITICAL,
EMPTY,
}
const DEFAULT_OXYGEN_SECONDS := 600.0
const DEFAULT_BATTERY := 100.0
const DEFAULT_HULL := 100.0
const DEFAULT_NOMINAL_DEPTH := 60.0
const DEFAULT_CRITICAL_DEPTH := 72.0
const DEFAULT_CRUSH_DEPTH := 78.0
var oxygen_max := DEFAULT_OXYGEN_SECONDS
var battery_max := DEFAULT_BATTERY
var hull_max := DEFAULT_HULL
var oxygen := DEFAULT_OXYGEN_SECONDS
var battery := DEFAULT_BATTERY
var hull := DEFAULT_HULL
var nominal_depth := DEFAULT_NOMINAL_DEPTH
var critical_depth := DEFAULT_CRITICAL_DEPTH
var crush_depth := DEFAULT_CRUSH_DEPTH
var oxygen_drain_per_second := 1.0
var battery_drain_per_second_at_full_thrust := 0.60
func reset() -> void:
oxygen = oxygen_max
battery = battery_max
hull = hull_max
func tick(delta: float, thrust_ratio: float, depth: float, collision_damage: float = 0.0) -> Dictionary:
if delta <= 0.0:
return snapshot(depth)
oxygen = maxf(0.0, oxygen - oxygen_drain_per_second * delta)
battery = maxf(
0.0,
battery - battery_drain_per_second_at_full_thrust * clampf(thrust_ratio, 0.0, 1.0) * delta
)
if depth > nominal_depth:
if depth >= crush_depth:
hull -= 25.0 * delta
else:
var pressure_range := maxf(critical_depth - nominal_depth, 0.001)
var pressure_ratio := clampf((depth - nominal_depth) / pressure_range, 0.0, 1.0)
var damage_rate := lerpf(0.15, 5.0, pressure_ratio * pressure_ratio)
if depth > critical_depth:
var critical_ratio := (depth - critical_depth) / maxf(crush_depth - critical_depth, 0.001)
damage_rate = lerpf(5.0, 18.0, clampf(critical_ratio, 0.0, 1.0))
hull -= damage_rate * delta
hull = clampf(hull - maxf(collision_damage, 0.0), 0.0, hull_max)
return snapshot(depth)
func snapshot(depth: float) -> Dictionary:
return {
"oxygen": oxygen,
"oxygen_max": oxygen_max,
"oxygen_ratio": _ratio(oxygen, oxygen_max),
"battery": battery,
"battery_max": battery_max,
"battery_ratio": _ratio(battery, battery_max),
"hull": hull,
"hull_max": hull_max,
"hull_ratio": _ratio(hull, hull_max),
"depth": maxf(depth, 0.0),
"nominal_depth": nominal_depth,
"critical_depth": critical_depth,
"crush_depth": crush_depth,
"oxygen_alert": alert_level(oxygen, oxygen_max),
"battery_alert": alert_level(battery, battery_max),
"hull_alert": alert_level(hull, hull_max),
"depth_alert": depth_alert_level(depth),
"loss_reason": loss_reason(),
}
func loss_reason() -> StringName:
if hull <= 0.0:
return &"hull_failure"
if oxygen <= 0.0:
return &"oxygen_depleted"
return &""
func alert_level(value: float, maximum: float) -> AlertLevel:
var ratio := _ratio(value, maximum)
if ratio <= 0.0:
return AlertLevel.EMPTY
if ratio <= 0.05:
return AlertLevel.CRITICAL
if ratio <= 0.15:
return AlertLevel.RETURN
if ratio <= 0.30:
return AlertLevel.CAUTION
return AlertLevel.NORMAL
func depth_alert_level(depth: float) -> AlertLevel:
if depth >= crush_depth:
return AlertLevel.EMPTY
if depth >= critical_depth:
return AlertLevel.CRITICAL
if depth > nominal_depth:
return AlertLevel.RETURN
if depth >= nominal_depth * 0.85:
return AlertLevel.CAUTION
return AlertLevel.NORMAL
func estimated_return_energy(distance: float, vertical_gain: float = 0.0) -> float:
var seconds_at_cruise := maxf(distance, 0.0) / 6.0
var base_cost := seconds_at_cruise * battery_drain_per_second_at_full_thrust
var vertical_cost := maxf(vertical_gain, 0.0) * 0.035
return base_cost + vertical_cost
func force_critical_state() -> void:
oxygen = minf(oxygen, 30.0)
battery = minf(battery, 12.0)
hull = minf(hull, 28.0)
func _ratio(value: float, maximum: float) -> float:
if maximum <= 0.0:
return 0.0
return clampf(value / maximum, 0.0, 1.0)
+1
View File
@@ -0,0 +1 @@
uid://bp5rah23csyra