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