Validate POC A and POC B foundations
This commit is contained in:
1 parent
44fd4b4b43
commit
c38e09a663
47 files changed
+4990
-35
No files matched your search
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://ya1ugkxp82l5
|
||||
+394
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hftqmbdbs7ch
|
||||
+398
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cohfpdwraxtc0
|
||||
+202
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cat260ck8g6bd
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3jt5jfbwtl2t
|
||||
@@ -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)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://rfiw5isteare
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtorud30m7smk
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ruu8hrvkoln0
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://02uauyco6kv5
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bp5rah23csyra
|
||||
Reference in new issue
Block a user