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() signal use_consumable_requested() # --- 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 @onready var bubble_trail: Node = $BubbleEmitterPoint/BubbleTrail var _turn_input: float = 0.0 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, "use_consumable": KEY_F, } 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() if event.is_action_pressed("use_consumable"): use_consumable_requested.emit() 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) # 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() var current_speed: float = velocity.length() var speed_factor: float = clamp(current_speed / swim_speed, 0.0, 1.5) if is_boosting: speed_factor *= 1.8 if bubble_trail: bubble_trail.set_intensity(speed_factor) 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 # Idle body bob _dolphin_body.position.y = sin(_time * 2.5) * 0.04 # Speed factor for animation intensity var spd: float = velocity.length() var speed_factor: float = clamp(spd / swim_speed, 0.0, 1.5) # Track turn input for body lean var strafe: float = 0.0 if Input.is_action_pressed("strafe_right"): strafe += 1.0 if Input.is_action_pressed("strafe_left"): strafe -= 1.0 _turn_input = lerp(_turn_input, strafe, 0.1) # Drive procedural mesh builder if present var builder = _dolphin_body.get_node_or_null("DolphinMesh") if builder != null and builder.has_method("animate"): builder.animate(_time, speed_factor, is_boosting, _turn_input) 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 heal(amount: float) -> void: health = min(max_health, health + amount) _emit_stats() func feed(amount: float) -> void: hunger = min(max_hunger, hunger + amount) _emit_stats() func refill_oxygen(amount: float) -> void: oxygen = min(max_oxygen, oxygen + amount) _emit_stats() 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