feat(dolphin): swim controller + third-person camera + HUD stats
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
261
scripts/dolphin/DolphinController.gd
Normal file
261
scripts/dolphin/DolphinController.gd
Normal 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
|
||||
Reference in New Issue
Block a user