feat(dolphin): swim controller + third-person camera + HUD stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Floppyrj45
2026-04-19 17:07:08 +02:00
parent 1c1ff67d88
commit b004a65e96
5 changed files with 594 additions and 1 deletions

View File

@@ -14,7 +14,78 @@ window/size/viewport_height=720
window/size/mode=2
[input]
; Bindings custom — à compléter plus tard par le module dauphin
move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_backward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
strafe_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
strafe_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
ascend={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
descend={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":16777237,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
boost={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":16777238,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
echolocate={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
break_block={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"button_index":1,"factor":1.0,"script":null)
]
}
place_block={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"button_index":2,"factor":1.0,"script":null)
]
}
toggle_inventory={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":16777217,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
escape={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":16777217,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[autoload]
BlockDatabase="*res://scripts/world/BlockDatabase.gd"

176
scenes/Dolphin.tscn Normal file
View File

@@ -0,0 +1,176 @@
[gd_scene load_steps=22 format=3 uid="uid://dolphin_main"]
[ext_resource type="Script" path="res://scripts/dolphin/DolphinController.gd" id="1_controller"]
[ext_resource type="Script" path="res://scripts/dolphin/HUD.gd" id="2_hud"]
[ext_resource type="Script" path="res://scripts/dolphin/EcholocationPulse.gd" id="3_echo"]
[sub_resource type="CapsuleShape3D" id="1_colshape"]
radius = 0.4
height = 2.0
[sub_resource type="CapsuleMesh" id="2_body_mesh"]
radius = 0.4
height = 2.0
[sub_resource type="StandardMaterial3D" id="3_body_mat"]
albedo_color = Color(0.29, 0.478, 0.584, 1)
[sub_resource type="CapsuleMesh" id="4_belly_mesh"]
radius = 0.35
height = 1.6
[sub_resource type="StandardMaterial3D" id="5_belly_mat"]
albedo_color = Color(0.91, 0.933, 0.949, 1)
[sub_resource type="CapsuleMesh" id="6_tail_mesh"]
radius = 0.2
height = 0.9
[sub_resource type="StandardMaterial3D" id="7_tail_mat"]
albedo_color = Color(0.29, 0.478, 0.584, 1)
[sub_resource type="PrismMesh" id="8_fin_mesh"]
size = Vector3(0.15, 0.4, 0.08)
[sub_resource type="StandardMaterial3D" id="9_fin_mat"]
albedo_color = Color(0.29, 0.478, 0.584, 1)
[sub_resource type="CapsuleMesh" id="10_pec_mesh"]
radius = 0.1
height = 0.5
[sub_resource type="StandardMaterial3D" id="11_pec_mat"]
albedo_color = Color(0.29, 0.478, 0.584, 1)
[sub_resource type="StyleBoxFlat" id="12_panel_style"]
bg_color = Color(0.05, 0.05, 0.05, 0.7)
corner_radius_top_left = 10
corner_radius_top_right = 10
corner_radius_bottom_left = 10
corner_radius_bottom_right = 10
[node name="Dolphin" type="CharacterBody3D"]
script = ExtResource("1_controller")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("1_colshape")
[node name="DolphinBody" type="Node3D" parent="."]
rotation = Vector3(0, 0, 0)
[node name="Body" type="MeshInstance3D" parent="DolphinBody"]
rotation = Vector3(1.5708, 0, 0)
mesh = SubResource("2_body_mesh")
surface_material_override/0 = SubResource("3_body_mat")
[node name="Belly" type="MeshInstance3D" parent="DolphinBody"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0.15)
rotation = Vector3(1.5708, 0, 0)
mesh = SubResource("4_belly_mesh")
surface_material_override/0 = SubResource("5_belly_mat")
[node name="Tail" type="MeshInstance3D" parent="DolphinBody"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -1.1)
rotation = Vector3(1.5708, 0, 0)
mesh = SubResource("6_tail_mesh")
surface_material_override/0 = SubResource("7_tail_mat")
[node name="DorsalFin" type="MeshInstance3D" parent="DolphinBody"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.45, -0.1)
mesh = SubResource("8_fin_mesh")
surface_material_override/0 = SubResource("9_fin_mat")
[node name="PecFinLeft" type="MeshInstance3D" parent="DolphinBody"]
transform = Transform3D(1, 0, 0, 0, 0.866, -0.5, 0, 0.5, 0.866, -0.45, 0.0, 0.1)
mesh = SubResource("10_pec_mesh")
surface_material_override/0 = SubResource("11_pec_mat")
[node name="PecFinRight" type="MeshInstance3D" parent="DolphinBody"]
transform = Transform3D(1, 0, 0, 0, 0.866, 0.5, 0, -0.5, 0.866, 0.45, 0.0, 0.1)
mesh = SubResource("10_pec_mesh")
surface_material_override/0 = SubResource("11_pec_mat")
[node name="CameraPivot" type="Node3D" parent="."]
[node name="SpringArm" type="SpringArm3D" parent="CameraPivot"]
spring_length = 3.5
rotation = Vector3(0, 3.14159, 0)
[node name="Camera" type="Camera3D" parent="CameraPivot/SpringArm"]
[node name="EcholocationPulse" type="Node3D" parent="."]
script = ExtResource("3_echo")
[node name="HUD" type="CanvasLayer" parent="."]
script = ExtResource("2_hud")
[node name="Panel" type="PanelContainer" parent="HUD"]
anchors_preset = 0
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 0.0
anchor_bottom = 1.0
offset_left = 16.0
offset_top = -160.0
offset_right = 220.0
offset_bottom = -16.0
theme_override_styles/panel = SubResource("12_panel_style")
[node name="VBox" type="VBoxContainer" parent="HUD/Panel"]
layout_mode = 2
theme_override_constants/separation = 8
offset_left = 10.0
offset_top = 10.0
offset_right = -10.0
offset_bottom = -10.0
[node name="OxygenRow" type="HBoxContainer" parent="HUD/Panel/VBox"]
layout_mode = 2
[node name="OxygenLabel" type="Label" parent="HUD/Panel/VBox/OxygenRow"]
layout_mode = 2
text = "O2:"
custom_minimum_size = Vector2(36, 0)
[node name="OxygenBar" type="ProgressBar" parent="HUD/Panel/VBox/OxygenRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(130, 18)
max_value = 100.0
value = 100.0
show_percentage = false
[node name="HealthRow" type="HBoxContainer" parent="HUD/Panel/VBox"]
layout_mode = 2
[node name="HealthLabel" type="Label" parent="HUD/Panel/VBox/HealthRow"]
layout_mode = 2
text = "HP:"
custom_minimum_size = Vector2(36, 0)
[node name="HealthBar" type="ProgressBar" parent="HUD/Panel/VBox/HealthRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(130, 18)
max_value = 100.0
value = 100.0
show_percentage = false
[node name="HungerRow" type="HBoxContainer" parent="HUD/Panel/VBox"]
layout_mode = 2
[node name="HungerLabel" type="Label" parent="HUD/Panel/VBox/HungerRow"]
layout_mode = 2
text = "Food:"
custom_minimum_size = Vector2(36, 0)
[node name="HungerBar" type="ProgressBar" parent="HUD/Panel/VBox/HungerRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(130, 18)
max_value = 100.0
value = 100.0
show_percentage = false

View File

@@ -0,0 +1,261 @@
extends CharacterBody3D
# --- Exported Properties ---
@export var swim_speed: float = 8.0
@export var boost_multiplier: float = 2.5
@export var mouse_sensitivity: float = 0.002
@export var max_oxygen: float = 100.0
@export var oxygen_drain: float = 0.8
@export var max_health: float = 100.0
@export var max_hunger: float = 100.0
@export var hunger_drain: float = 0.3
@export var water_ceiling_y: float = 60.0
# --- Signals ---
signal block_break_requested(hit_position: Vector3, block_normal: Vector3)
signal block_place_requested(hit_position: Vector3, block_normal: Vector3)
signal hotbar_scroll(direction: int)
signal echolocation_triggered(position: Vector3, radius: float)
signal stats_changed(oxygen: float, health: float, hunger: float)
signal player_died()
# --- Internal State ---
var oxygen: float
var health: float
var hunger: float
var is_boosting: bool = false
var is_echolocating: bool = false
var _yaw: float = 0.0
var _pitch: float = 0.0
var _time: float = 0.0
var _is_dead: bool = false
@onready var _camera_pivot: Node3D = $CameraPivot
@onready var _spring_arm: SpringArm3D = $CameraPivot/SpringArm
@onready var _camera: Camera3D = $CameraPivot/SpringArm/Camera
@onready var _dolphin_body: Node3D = $DolphinBody
@onready var _echo_pulse: Node3D = $EcholocationPulse
func _ready() -> void:
oxygen = max_oxygen
health = max_health
hunger = max_hunger
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
_setup_input_actions()
_emit_stats()
func _setup_input_actions() -> void:
var actions: Dictionary = {
"move_forward": KEY_W,
"move_backward": KEY_S,
"strafe_left": KEY_A,
"strafe_right": KEY_D,
"ascend": KEY_SPACE,
"descend": KEY_SHIFT,
"boost": KEY_CTRL,
"echolocate": KEY_E,
"toggle_inventory": KEY_TAB,
}
for action_name: String in actions:
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
var ev := InputEventKey.new()
ev.keycode = actions[action_name]
InputMap.action_add_event(action_name, ev)
var mouse_actions: Dictionary = {
"break_block": MOUSE_BUTTON_LEFT,
"place_block": MOUSE_BUTTON_RIGHT,
}
for action_name: String in mouse_actions:
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
var ev := InputEventMouseButton.new()
ev.button_index = mouse_actions[action_name]
InputMap.action_add_event(action_name, ev)
if not InputMap.has_action("escape"):
InputMap.add_action("escape")
var ev := InputEventKey.new()
ev.keycode = KEY_ESCAPE
InputMap.action_add_event("escape", ev)
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
var motion := event as InputEventMouseMotion
_yaw -= motion.relative.x * mouse_sensitivity
_pitch -= motion.relative.y * mouse_sensitivity
_pitch = clamp(_pitch, -PI * 0.45, PI * 0.45)
rotation.y = _yaw
_camera_pivot.rotation.x = _pitch
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed:
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
hotbar_scroll.emit(1)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
hotbar_scroll.emit(-1)
if event.is_action_pressed("break_block"):
_do_raycast_break()
if event.is_action_pressed("place_block"):
_do_raycast_place()
if event.is_action_pressed("echolocate"):
_trigger_echolocation()
if event.is_action_pressed("escape"):
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _physics_process(delta: float) -> void:
if _is_dead:
return
_time += delta
_update_stats(delta)
_update_movement(delta)
_animate_body()
if position.y > water_ceiling_y:
position.y = water_ceiling_y
velocity.y = min(velocity.y, 0.0)
func _update_movement(delta: float) -> void:
is_boosting = Input.is_action_pressed("boost")
var speed: float = swim_speed * (boost_multiplier if is_boosting else 1.0)
var forward: Vector3 = -global_transform.basis.z
var right: Vector3 = global_transform.basis.x
# Recompute forward/right using camera pitch for intuitive swim direction
var cam_basis: Basis = _camera_pivot.global_transform.basis
var swim_forward: Vector3 = -cam_basis.z
var swim_right: Vector3 = cam_basis.x
var input_dir: Vector3 = Vector3.ZERO
if Input.is_action_pressed("move_forward"):
input_dir += swim_forward
if Input.is_action_pressed("move_backward"):
input_dir -= swim_forward
if Input.is_action_pressed("strafe_right"):
input_dir += swim_right
if Input.is_action_pressed("strafe_left"):
input_dir -= swim_right
if Input.is_action_pressed("ascend"):
input_dir += Vector3.UP
if Input.is_action_pressed("descend"):
input_dir += Vector3.DOWN
if input_dir.length_squared() > 0.0:
input_dir = input_dir.normalized()
var target_velocity: Vector3 = input_dir * speed
velocity = velocity.lerp(target_velocity, 4.0 * delta)
move_and_slide()
func _update_stats(delta: float) -> void:
var changed: bool = false
# Hunger drain
hunger = max(0.0, hunger - hunger_drain * delta)
changed = true
# Oxygen
if position.y < 55.0:
oxygen = max(0.0, oxygen - oxygen_drain * delta)
else:
oxygen = min(max_oxygen, oxygen + 20.0 * delta)
# Health drain from oxygen/hunger
if oxygen <= 0.0:
health = max(0.0, health - 5.0 * delta)
changed = true
if hunger <= 0.0:
health = max(0.0, health - 1.0 * delta)
changed = true
if changed:
_emit_stats()
if health <= 0.0 and not _is_dead:
_die()
func _emit_stats() -> void:
stats_changed.emit(oxygen, health, hunger)
func _die() -> void:
_is_dead = true
player_died.emit()
# Respawn after short delay
await get_tree().create_timer(2.0).timeout
_respawn()
func _respawn() -> void:
position = Vector3(0.0, 50.0, 0.0)
velocity = Vector3.ZERO
oxygen = max_oxygen
health = max_health
hunger = max_hunger
_is_dead = false
_emit_stats()
func _animate_body() -> void:
if not is_instance_valid(_dolphin_body):
return
# Body bob
_dolphin_body.position.y = sin(_time * 2.5) * 0.05
# Tail wag via last child if present
var child_count: int = _dolphin_body.get_child_count()
if child_count > 1:
var tail_node: Node3D = _dolphin_body.get_child(child_count - 1) as Node3D
if tail_node != null:
tail_node.rotation.y = sin(_time * 3.0) * 0.3
func _do_raycast_break() -> void:
var result: Dictionary = _raycast(5.0)
if not result.is_empty():
block_break_requested.emit(result["position"], result["normal"])
func _do_raycast_place() -> void:
var result: Dictionary = _raycast(5.0)
if not result.is_empty():
block_place_requested.emit(result["position"], result["normal"])
func _raycast(reach: float) -> Dictionary:
if not is_instance_valid(_camera):
return {}
var space_state: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
var cam_pos: Vector3 = _camera.global_position
var cam_fwd: Vector3 = -_camera.global_transform.basis.z
var query := PhysicsRayQueryParameters3D.create(cam_pos, cam_pos + cam_fwd * reach)
query.exclude = [self]
var result: Dictionary = space_state.intersect_ray(query)
return result
func _trigger_echolocation() -> void:
if is_echolocating:
return
is_echolocating = true
echolocation_triggered.emit(global_position, 20.0)
if is_instance_valid(_echo_pulse):
_echo_pulse.trigger()
await get_tree().create_timer(1.2).timeout
is_echolocating = false

View File

@@ -0,0 +1,46 @@
extends Node3D
const MAX_RADIUS: float = 20.0
const DURATION: float = 1.0
signal pulse_finished()
func trigger() -> void:
var mesh_instance := MeshInstance3D.new()
var sphere_mesh := SphereMesh.new()
sphere_mesh.radius = 0.1
sphere_mesh.height = 0.2
mesh_instance.mesh = sphere_mesh
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(0.2, 0.85, 1.0, 0.5)
mat.emission_enabled = true
mat.emission = Color(0.1, 0.6, 1.0)
mat.emission_energy_multiplier = 1.5
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mesh_instance.material_override = mat
add_child(mesh_instance)
var tween: Tween = create_tween()
tween.set_parallel(true)
tween.tween_method(
func(r: float) -> void:
mesh_instance.scale = Vector3(r, r, r),
0.1,
MAX_RADIUS,
DURATION
)
tween.tween_method(
func(a: float) -> void:
mat.albedo_color = Color(0.2, 0.85, 1.0, a),
0.5,
0.0,
DURATION
)
tween.chain().tween_callback(func() -> void:
mesh_instance.queue_free()
pulse_finished.emit()
)

39
scripts/dolphin/HUD.gd Normal file
View File

@@ -0,0 +1,39 @@
extends CanvasLayer
@onready var _oxygen_bar: ProgressBar = %OxygenBar
@onready var _health_bar: ProgressBar = %HealthBar
@onready var _hunger_bar: ProgressBar = %HungerBar
func _ready() -> void:
_style_bar(_oxygen_bar, Color(0.31, 0.76, 0.97))
_style_bar(_health_bar, Color(0.91, 0.30, 0.24))
_style_bar(_hunger_bar, Color(0.95, 0.61, 0.07))
func _style_bar(bar: ProgressBar, color: Color) -> void:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_left = 4
style.corner_radius_bottom_right = 4
bar.add_theme_stylebox_override("fill", style)
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = Color(0.1, 0.1, 0.1, 0.6)
bg_style.corner_radius_top_left = 4
bg_style.corner_radius_top_right = 4
bg_style.corner_radius_bottom_left = 4
bg_style.corner_radius_bottom_right = 4
bar.add_theme_stylebox_override("background", bg_style)
func connect_to_dolphin(dolphin: CharacterBody3D) -> void:
dolphin.stats_changed.connect(_on_stats_changed)
func _on_stats_changed(oxygen: float, hp: float, hunger: float) -> void:
_oxygen_bar.value = oxygen
_health_bar.value = hp
_hunger_bar.value = hunger