eb4da62f99
checkpoint / checkpoint (push) Successful in 1m47s
ESC: player.gd changed scene directly while game.gd._exit_tree closed the socket, which re-fired server_disconnected -> _on_server_lost ran mid- teardown (add_child on an exiting node, await on a null get_tree(), a second change_scene_to_file). Moved ESC to game.gd as one ordered path: _leave_world() sets a _leaving guard, disconnects the _on_server_lost handlers before closing the connection, then changes scene. _on_server_lost and _exit_tree now respect the guard. Verified headless: ESC -> world_list, no errors; 2p multiplayer still connects. Avatar faced +Z but a Godot node's forward is -Z, so walking forward showed the character its own face. Rebuilt the placeholder facing -Z (face plates, eyes, jaw, toes, and front-mounted attachments all moved to negative Z; backpack/ponytail to +Z). The procedural walk was already written for -Z forward, so it's unchanged. Build stamp was there but tiny/low-contrast bottom-left -- bumped to 20px opaque yellow with a heavy outline, moved to the top-left strip. limb_animator: guard _process with is_instance_valid(_skel) so a frame landing during avatar teardown can't touch a freed skeleton. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
223 lines
7.9 KiB
GDScript
223 lines
7.9 KiB
GDScript
class_name Player
|
|
extends CharacterBody3D
|
|
## The player character controller.
|
|
##
|
|
## A CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
|
|
## third-person camera (SpringArm3D behind the player) with mouse-look --
|
|
## yaw turns the body, pitch orbits the camera. 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_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
|
|
|
|
## Third-person pitch limits (radians): a bit up, a lot down (looking over
|
|
## the avatar's shoulder toward the ground).
|
|
const PITCH_MIN: float = -1.1
|
|
const PITCH_MAX: float = 0.45
|
|
|
|
@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)
|
|
|
|
# Start the camera arm tilted down a little so the avatar is framed.
|
|
_camera_pivot.rotation.x = _camera_pitch
|
|
|
|
# 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:
|
|
# Yaw rotates the whole body (so movement direction turns with the
|
|
# look direction); pitch orbits the camera arm around the player, so the
|
|
# capsule itself never tips over.
|
|
rotation.y -= event.relative.x * MOUSE_SENSITIVITY
|
|
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, PITCH_MIN, PITCH_MAX)
|
|
_camera_pivot.rotation.x = _camera_pitch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
# Build movement direction from input. Godot's local "forward" is -Z, not
|
|
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
|
|
# move the player backward when pressing forward (confirmed by reading
|
|
# Camera3D/Node3D's own convention: a node looks down its local -Z).
|
|
var direction := (_input_direction.x * transform.basis.x -
|
|
_input_direction.y * transform.basis.z).normalized()
|
|
velocity.x = direction.x * WALK_SPEED
|
|
velocity.z = direction.z * WALK_SPEED
|
|
|
|
move_and_slide()
|
|
|
|
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
|