Files
modbison/scripts/player.gd
T
daddy 4200a8b358
checkpoint / checkpoint (push) Successful in 1m46s
Roblox-style camera: Classic + Shift Lock + scroll zoom + first person
Matches Roblox's default controls so the feel doesn't stray:

Classic (default): mouse cursor is free; hold LMB/RMB and drag to orbit
the camera (RMB also turns the body to face the camera while held). The
camera stays where you leave it -- no auto-recenter. Body auto-rotates
toward its movement direction otherwise.

Scroll wheel zooms between 0.4 and 9.0 m; at/below 0.6 m it switches to
first person (mouse locks, camera at eye height, own avatar hidden so the
head doesn't fill the view).

Shift Lock (toggle Left Shift): mouse locks to a centre crosshair, camera
shifts 0.85 m to the right shoulder, body facing is slaved to the camera,
and WASD strafes / backpedals.

Movement stays camera-relative throughout. net_input.look_yaw is still
the body yaw, so remote players face the right way in every mode.

Verified headless (boot, 2p multiplayer, avatar smoke source+pack,
fresh-clone) and eyeballed all three modes in rendered screenshots
(classic behind / shift-lock over-shoulder+crosshair / first person).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:52:21 +00:00

342 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()
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
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.x = 0.0
velocity.z = 0.0