Files
modbison/scripts/player.gd
T
daddy 1e917e196b
checkpoint / checkpoint (push) Successful in 1m45s
Free-orbit third-person camera
The mouse now orbits the camera around the player a full 360 in yaw
(unclamped) with a wide pitch range -- swing it around to look at the
avatar's face. The body no longer snaps to the camera yaw; instead it
turns smoothly to face its movement direction (lerp_angle at
BODY_TURN_SPEED), so controls feel the same: W is still "away from
camera", A/D still strafe relative to view.

Implementation: CameraPivot is set top_level so it doesn't inherit the
body's rotation; _update_camera() places it at the head each frame and
points it by _camera_yaw / _camera_pitch. Movement direction is built
from a yaw-only camera basis. net_input.look_yaw is now the body's
movement facing, so remote players render facing the way they walk.

Verified: headless boot clean, 2p multiplayer still connects/moves,
avatar smoke 14/14 (source + exported pack), fresh-clone PASS, and
eyeballed the orbit in rendered screenshots (behind -> side -> face).

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

252 lines
9.2 KiB
GDScript

class_name Player
extends CharacterBody3D
## The player character controller.
##
## A CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
## free-orbit third-person camera. The mouse orbits the camera around the
## player a full 360 (yaw) plus a wide pitch range -- swing it around to see
## the avatar's face if you want. Movement is camera-relative (W = away from
## camera) and the body turns to face the direction it's moving, independent
## of where the camera is pointed. Reads the spawn point from the current
## scene's `PlayerSpawn` Marker3D.
# ---------------------------------------------------------------------------
# Movement 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
# ---------------------------------------------------------------------------
# Input state
# ---------------------------------------------------------------------------
var _input_direction := Vector2.ZERO
var _camera_yaw: float = 0.0 # free orbit, unclamped (wraps 360)
var _camera_pitch: float = -0.12
var _spawn_position: Vector3 = Vector3(0, 1.5, 0)
## 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): from nearly top-down to looking up at the
## avatar from below. Yaw is deliberately unclamped -- full 360 orbit.
const PITCH_MIN: float = -1.35
const PITCH_MAX: float = 1.2
## Where the camera pivot sits relative to the player origin, and how quickly
## the body swings to face its movement direction.
const CAMERA_PIVOT_OFFSET: Vector3 = Vector3(0.0, 1.2, 0.0)
const BODY_TURN_SPEED: float = 12.0
@onready var _camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D
@onready var _camera_pivot: Node3D = $CameraPivot
@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh
var _avatar_view: Node3D = null
var _limb_anim: Node = null
# ---------------------------------------------------------------------------
# _ready — spawn at PlayerSpawn marker, capture the mouse for look
# ---------------------------------------------------------------------------
func _ready() -> void:
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current
# scene root. The lookup walks the full subtree (recursive=true) so
# it finds the marker whether it is a direct child of the level or
# nested deeper.
#
# current_scene is null in headless --script mode and in any scene
# before the main scene is fully swapped in, so guard it rather than
# blindly dereferencing.
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
# Align player forward with spawn yaw (optional polish).
var spawn_yaw := (spawn as Node3D).rotation.y
rotation.y = spawn_yaw
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 (they're
# on the same layer), or the camera gets yanked right up against the
# player's head.
var arm := $CameraPivot/SpringArm3D as SpringArm3D
arm.add_excluded_object(self)
# The camera pivot orbits the player freely, so it can't be a child that
# inherits the body's yaw -- detach it and drive its world transform
# ourselves each frame.
_camera_pivot.top_level = true
_camera_yaw = rotation.y
_update_camera()
# Capture the mouse for look. Headless/test runs have no real window to
# capture input for, so guard against that rather than error.
if DisplayServer.get_name() != "headless":
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_apply_avatar()
## Show the player's assembled avatar and hide the placeholder capsule.
## AvatarSystem.build_current_avatar() is self-sufficient now (loads the
## catalog + falls back to a default loadout), so this only no-ops if the
## avatar assets themselves are missing.
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
# ground 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)
# ---------------------------------------------------------------------------
# _unhandled_input — capture keyboard / mouse for movement
# ---------------------------------------------------------------------------
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo:
_update_input_direction(key)
elif not key.pressed:
_clear_input_direction(key)
# ESC (leave the world) is handled by game.gd, the scene root that
# owns the network connection, so the teardown is a single path.
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:
# Mouse orbits the camera only -- the body is left alone (it turns to face
# movement in _physics_process). Yaw wraps freely; pitch is clamped.
_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 free-orbit camera pivot at the player's head and point it per the
## current yaw/pitch. Called from _ready, on mouse-look, and every frame (the
## player moves under it).
func _update_camera() -> void:
if not is_instance_valid(_camera_pivot):
return
_camera_pivot.global_position = global_position + CAMERA_PIVOT_OFFSET
_camera_pivot.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
# ---------------------------------------------------------------------------
# Physics — gravity, jump, move_and_slide
# ---------------------------------------------------------------------------
## The input frame this player applied last physics tick, in the shape the
## network layer sends: world-space move direction (already yaw-transformed and
## normalized), jump edge, body yaw. scripts/game.gd reads this each frame in
## networked mode and forwards it to the server.
var net_input: Dictionary = {"move_x": 0.0, "move_z": 0.0, "jump": false, "look_yaw": 0.0}
func _physics_process(delta: float) -> void:
# Apply gravity.
if not is_on_floor():
velocity.y -= GRAVITY * delta
# Jump.
var jumped := false
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
velocity.y = JUMP_VELOCITY
jumped = true
# Movement is camera-relative: W (input.y = +1) heads away from the camera,
# A/D strafe. A node looks down its local -Z, so forward = -cam_basis.z.
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
# Turn the body to face where it's moving (independent of the camera).
if 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()
# Keep the orbit camera on the player as it moves.
_update_camera()
if _limb_anim != null:
_limb_anim.set("speed", Vector2(velocity.x, velocity.z).length())
# Safety net -- never fall forever if something goes wrong.
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: called by scripts/game.gd when the local
## predicted position has drifted too far from the server's.
func apply_server_correction(pos: Vector3) -> void:
global_position = pos
velocity.x = 0.0
velocity.z = 0.0