Files
dauphincraft/scripts/dolphin/DolphinController.gd

257 lines
7.0 KiB
GDScript

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)
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()
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 take_damage(amount: float) -> void:
health = max(0.0, health - amount)
stats_changed.emit(oxygen, health, hunger)
if health <= 0.0:
player_died.emit()
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