1b1260f371
checkpoint / checkpoint (push) Successful in 1m52s
The blocky body rendered fully black from every angle: _append_box wound its faces clockwise-from-outside, so every computed normal pointed inward -- an inside-out shell, lit from behind on all sides, and the level had ambient light disabled so nothing lifted it. Fixed the winding (normals now verify outward 234:36) and switched test_level's environment to a soft colour ambient so shaded sides read grey, not pure black. Limbs were static because the rig had no animation at all. Added scripts/avatar/limb_animator.gd: a clip-free walk cycle that swings the upper arm/leg bones with ground speed, folds the knees on the rear stride, and eases back to rest when idle. Wired into the local player (speed from velocity) and remote players in game.gd (speed from the per-snapshot authoritative position delta). Verified: avatar smoke 14/14 from source and exported pack; fresh-clone boot PASS; game + menu boot headless clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
226 lines
8.1 KiB
GDScript
226 lines
8.1 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)
|
|
# Escape exits the level back to the world browse list, mirroring
|
|
# the Back button pattern already used in world_list.gd/main_menu.gd.
|
|
if key.pressed and key.keycode == KEY_ESCAPE:
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
|
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
|