352 lines
12 KiB
GDScript
352 lines
12 KiB
GDScript
class_name Player
|
|
extends CharacterBody3D
|
|
## The player character controller.
|
|
##
|
|
## Roblox-style third-person controller:
|
|
##
|
|
## * Classic camera (default): the mouse cursor is free; hold a mouse button
|
|
## and drag to orbit the camera 360 in yaw (pitch clamped). Right button
|
|
## also turns the character to face the camera while held. Scroll wheel
|
|
## zooms; zoom all the way in -> first person.
|
|
## * Shift Lock (toggle Left Shift): mouse locks to a centre crosshair, the
|
|
## camera moves over the right shoulder, and the character's facing is
|
|
## locked to the camera -- WASD strafes / backpedals.
|
|
##
|
|
## Movement is always camera-relative (W = away from the camera). Outside the
|
|
## camera-facing modes the body auto-rotates toward its movement direction.
|
|
## Spawn point comes from the current scene's `PlayerSpawn` Marker3D.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Movement / camera constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const WALK_SPEED: float = 5.0
|
|
const JUMP_VELOCITY: float = 4.5
|
|
const GRAVITY: float = 9.8
|
|
|
|
## Mouse-look sensitivity (radians per pixel of relative motion).
|
|
const MOUSE_SENSITIVITY: float = 0.003
|
|
|
|
## Vertical offset applied to the assembled avatar so its feet (origin y=0)
|
|
## line up with the bottom of the player's collision capsule.
|
|
const AVATAR_FEET_OFFSET: float = -0.8
|
|
|
|
## Camera pitch limits (radians): near top-down to looking up from below.
|
|
## Yaw is deliberately unclamped -- full 360 orbit.
|
|
const PITCH_MIN: float = -1.35
|
|
const PITCH_MAX: float = 1.2
|
|
|
|
## Camera pivot height above the player origin, third-person vs first-person.
|
|
const PIVOT_Y_TP: float = 1.2
|
|
const PIVOT_Y_FP: float = 0.65
|
|
|
|
## Zoom (spring length) range, scroll step, and the distance at or below which
|
|
## we switch to first person.
|
|
const ZOOM_MIN: float = 0.4
|
|
const ZOOM_MAX: float = 9.0
|
|
const ZOOM_STEP: float = 0.6
|
|
const FIRST_PERSON_ZOOM: float = 0.6
|
|
const ZOOM_DEFAULT: float = 3.5
|
|
|
|
## How far right the camera sits in Shift Lock (metres).
|
|
const SHOULDER_OFFSET: float = 0.85
|
|
|
|
## How fast the body swings toward its target facing.
|
|
const BODY_TURN_SPEED: float = 12.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _input_direction := Vector2.ZERO
|
|
var _camera_yaw: float = 0.0 # unclamped, wraps
|
|
var _camera_pitch: float = -0.12
|
|
var _zoom: float = ZOOM_DEFAULT
|
|
var _shift_lock: bool = false
|
|
var _mouse_look_held: bool = false # LMB or RMB held (classic drag)
|
|
var _rmb_held: bool = false # RMB specifically (turns the body)
|
|
var _spawn_position: Vector3 = Vector3(0, 1.5, 0)
|
|
|
|
@onready var _camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D
|
|
@onready var _camera_pivot: Node3D = $CameraPivot
|
|
@onready var _spring_arm: SpringArm3D = $CameraPivot/SpringArm3D
|
|
@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh
|
|
|
|
var _avatar_view: Node3D = null
|
|
var _limb_anim: Node = null
|
|
var _crosshair: CanvasLayer = null
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _ready
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current scene
|
|
# root (recursive), guarding the null current_scene of headless runs.
|
|
var current_scene: Node = get_tree().current_scene
|
|
var spawn: Node = null
|
|
if current_scene != null:
|
|
spawn = current_scene.find_child("PlayerSpawn", true, false)
|
|
|
|
if spawn is Node3D:
|
|
global_position = (spawn as Node3D).global_position
|
|
rotation.y = (spawn as Node3D).rotation.y
|
|
else:
|
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
|
global_position = Vector3(0, 1.0, 0)
|
|
_spawn_position = global_position
|
|
|
|
# The spring arm must not collide with the player's own capsule (same
|
|
# layer) or the camera gets yanked against the player's head.
|
|
_spring_arm.add_excluded_object(self)
|
|
|
|
# The camera pivot orbits the player freely -- it can't inherit the body's
|
|
# yaw, so detach it and drive its world transform ourselves each frame.
|
|
_camera_pivot.top_level = true
|
|
_camera_yaw = rotation.y
|
|
|
|
_build_crosshair()
|
|
_refresh_mouse_mode()
|
|
_update_camera()
|
|
_apply_avatar()
|
|
var avatars := get_node_or_null("/root/AvatarSystem")
|
|
if avatars != null and avatars.has_signal("loadout_changed"):
|
|
avatars.loadout_changed.connect(func(_lo): _apply_avatar())
|
|
|
|
|
|
func _build_crosshair() -> void:
|
|
if DisplayServer.get_name() == "headless":
|
|
return
|
|
_crosshair = CanvasLayer.new()
|
|
_crosshair.layer = 64
|
|
var l := Label.new()
|
|
l.text = "+"
|
|
l.add_theme_font_size_override("font_size", 22)
|
|
l.add_theme_color_override("font_color", Color(1, 1, 1, 0.85))
|
|
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
|
|
l.add_theme_constant_override("outline_size", 4)
|
|
l.set_anchors_preset(Control.PRESET_CENTER)
|
|
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
l.offset_left = -12
|
|
l.offset_right = 12
|
|
l.offset_top = -16
|
|
l.offset_bottom = 16
|
|
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_crosshair.add_child(l)
|
|
add_child(_crosshair)
|
|
_crosshair.visible = false
|
|
|
|
|
|
## Show the player's assembled avatar and hide the placeholder capsule.
|
|
func _apply_avatar() -> void:
|
|
var avatars := get_node_or_null("/root/AvatarSystem")
|
|
if avatars == null or not avatars.has_method("build_current_avatar"):
|
|
return
|
|
var avatar: Node3D = avatars.build_current_avatar()
|
|
if avatar == null:
|
|
push_warning("player.gd: no avatar to show; keeping the placeholder capsule")
|
|
return
|
|
if _avatar_view != null and is_instance_valid(_avatar_view):
|
|
remove_child(_avatar_view)
|
|
_avatar_view.queue_free()
|
|
if _limb_anim != null and is_instance_valid(_limb_anim):
|
|
remove_child(_limb_anim)
|
|
_limb_anim.queue_free()
|
|
_limb_anim = null
|
|
|
|
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
|
|
add_child(avatar)
|
|
_avatar_view = avatar
|
|
if _capsule_mesh:
|
|
_capsule_mesh.visible = false
|
|
|
|
# Procedural walk cycle -- no clips yet, just swing the limb bones with speed.
|
|
var skel := avatar.find_child("Skeleton3D", true, false)
|
|
if skel is Skeleton3D:
|
|
_limb_anim = preload("res://scripts/avatar/limb_animator.gd").new()
|
|
add_child(_limb_anim)
|
|
_limb_anim.call("setup", skel)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Input
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _is_first_person() -> bool:
|
|
return _zoom <= FIRST_PERSON_ZOOM
|
|
|
|
|
|
## Mouse should be captured (hidden, locked) whenever the camera is in an
|
|
## aim-style mode or the player is mid-drag.
|
|
func _want_mouse_captured() -> bool:
|
|
return _shift_lock or _is_first_person() or _mouse_look_held
|
|
|
|
|
|
## Body facing is slaved to the camera in Shift Lock, first person, or while
|
|
## the right mouse button is held.
|
|
func _want_face_camera() -> bool:
|
|
return _shift_lock or _is_first_person() or _rmb_held
|
|
|
|
|
|
func _refresh_mouse_mode() -> void:
|
|
if DisplayServer.get_name() == "headless":
|
|
return
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _want_mouse_captured() \
|
|
else Input.MOUSE_MODE_VISIBLE
|
|
if _crosshair != null:
|
|
_crosshair.visible = _shift_lock or _is_first_person()
|
|
|
|
|
|
func _set_zoom(v: float) -> void:
|
|
var was_fp := _is_first_person()
|
|
_zoom = clampf(v, ZOOM_MIN, ZOOM_MAX)
|
|
if _is_first_person() != was_fp:
|
|
_refresh_mouse_mode()
|
|
_update_camera()
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if event is InputEventKey:
|
|
var key := event as InputEventKey
|
|
if key.pressed and not key.echo:
|
|
if key.keycode == KEY_SHIFT:
|
|
_shift_lock = not _shift_lock
|
|
_refresh_mouse_mode()
|
|
_update_camera()
|
|
else:
|
|
_update_input_direction(key)
|
|
elif not key.pressed:
|
|
_clear_input_direction(key)
|
|
# ESC (leave the world) is handled by game.gd, which owns the connection.
|
|
|
|
elif event is InputEventMouseButton:
|
|
var mb := event as InputEventMouseButton
|
|
match mb.button_index:
|
|
MOUSE_BUTTON_WHEEL_UP:
|
|
if mb.pressed:
|
|
_set_zoom(_zoom - ZOOM_STEP)
|
|
MOUSE_BUTTON_WHEEL_DOWN:
|
|
if mb.pressed:
|
|
_set_zoom(_zoom + ZOOM_STEP)
|
|
MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT:
|
|
_mouse_look_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) \
|
|
or Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
|
|
_rmb_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
|
|
_refresh_mouse_mode()
|
|
|
|
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
|
_handle_mouse_look(event as InputEventMouseMotion)
|
|
|
|
|
|
func _update_input_direction(key: InputEventKey) -> void:
|
|
match key.keycode:
|
|
KEY_W, KEY_UP:
|
|
_input_direction.y += 1.0
|
|
KEY_S, KEY_DOWN:
|
|
_input_direction.y -= 1.0
|
|
KEY_A, KEY_LEFT:
|
|
_input_direction.x -= 1.0
|
|
KEY_D, KEY_RIGHT:
|
|
_input_direction.x += 1.0
|
|
|
|
|
|
func _clear_input_direction(key: InputEventKey) -> void:
|
|
match key.keycode:
|
|
KEY_W, KEY_UP:
|
|
_input_direction.y -= 1.0
|
|
KEY_S, KEY_DOWN:
|
|
_input_direction.y += 1.0
|
|
KEY_A, KEY_LEFT:
|
|
_input_direction.x += 1.0
|
|
KEY_D, KEY_RIGHT:
|
|
_input_direction.x -= 1.0
|
|
|
|
|
|
func _handle_mouse_look(event: InputEventMouseMotion) -> void:
|
|
_camera_yaw = wrapf(_camera_yaw - event.relative.x * MOUSE_SENSITIVITY, -PI, PI)
|
|
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY,
|
|
PITCH_MIN, PITCH_MAX)
|
|
_update_camera()
|
|
|
|
|
|
## Place the orbit pivot at the player's head and point it per yaw/pitch.
|
|
## Applies the first-person / shoulder framing. Called from _ready, on input,
|
|
## and every physics frame (the player moves under it).
|
|
func _update_camera() -> void:
|
|
if not is_instance_valid(_camera_pivot):
|
|
return
|
|
var fp := _is_first_person()
|
|
_camera_pivot.global_position = global_position \
|
|
+ Vector3(0.0, PIVOT_Y_FP if fp else PIVOT_Y_TP, 0.0)
|
|
_camera_pivot.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
|
|
|
|
_spring_arm.spring_length = 0.0 if fp else _zoom
|
|
_spring_arm.position.x = SHOULDER_OFFSET if (_shift_lock and not fp) else 0.0
|
|
|
|
# Hide our own avatar in first person so the head doesn't fill the view.
|
|
if _avatar_view != null and is_instance_valid(_avatar_view):
|
|
_avatar_view.visible = not fp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Physics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Input frame forwarded to the server by scripts/game.gd each physics tick:
|
|
## world-space move direction (yaw-transformed, normalized), jump edge, body yaw.
|
|
var net_input: Dictionary = {"move_x": 0.0, "move_z": 0.0, "jump": false, "look_yaw": 0.0}
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
if not is_on_floor():
|
|
velocity.y -= GRAVITY * delta
|
|
|
|
var jumped := false
|
|
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
|
velocity.y = JUMP_VELOCITY
|
|
jumped = true
|
|
|
|
# Camera-relative movement. A node looks down its local -Z, so forward is
|
|
# -cam_basis.z and W (input.y = +1) heads away from the camera.
|
|
var cam_basis := Basis.from_euler(Vector3(0.0, _camera_yaw, 0.0))
|
|
var direction := (_input_direction.x * cam_basis.x
|
|
- _input_direction.y * cam_basis.z).normalized()
|
|
velocity.x = direction.x * WALK_SPEED
|
|
velocity.z = direction.z * WALK_SPEED
|
|
|
|
# Body facing: locked to the camera in aim modes, otherwise turns toward
|
|
# the movement direction.
|
|
if _want_face_camera():
|
|
rotation.y = _camera_yaw
|
|
elif direction.length_squared() > 0.01:
|
|
var target_yaw := atan2(-direction.x, -direction.z)
|
|
rotation.y = lerp_angle(rotation.y, target_yaw,
|
|
clampf(BODY_TURN_SPEED * delta, 0.0, 1.0))
|
|
|
|
move_and_slide()
|
|
_update_camera()
|
|
|
|
if _limb_anim != null:
|
|
_limb_anim.set("speed", Vector2(velocity.x, velocity.z).length())
|
|
|
|
# Safety net -- never fall forever.
|
|
if global_position.y < -10.0:
|
|
global_position = _spawn_position
|
|
velocity = Vector3.ZERO
|
|
|
|
net_input = {
|
|
"move_x": direction.x, "move_z": direction.z,
|
|
"jump": jumped, "look_yaw": rotation.y,
|
|
}
|
|
|
|
|
|
## Server-authoritative correction from scripts/game.gd when the local
|
|
## predicted position has drifted too far.
|
|
func apply_server_correction(pos: Vector3) -> void:
|
|
global_position = pos
|
|
velocity = Vector3.ZERO
|