e59cf158e5
- api_client.gd: get_avatar_loadout() / save_avatar_loadout() - main_menu.gd: on successful auth (fresh or session-restore), load the catalog + fetch the saved loadout into AvatarSystem so the in-world player renders as the player's avatar. Best-effort: a slow/failed prime never gates getting in (UI goes authenticated first, prime runs after). New "Customize Avatar" button (enabled only when signed in). - avatar_system.gd: local-player convenience API -- ensure_catalog(), set_loadout_from_dict() (from the backend's GET shape), has_loadout(), build_current_avatar(). - player.gd: swaps the placeholder capsule for the assembled avatar (feet-aligned to the capsule bottom) when a loadout is primed; silent fallback to the capsule otherwise. Camera stays first-person for now (third-person is a noted follow-up). - avatar_visualizer.gd: rewrote it -- the old version referenced AvatarLoader.SlotResult / load_slot() which never existed, so it didn't compile and took avatar_customization_ui.gd down with it. Now a thin wrapper over AvatarLoader for the preview. - avatar_customization_ui.gd: seeds from the live loadout, saves through to AvatarSystem + PUT /api/avatar/loadout (local file kept as fallback), fixed a Camera3D.look_at() before-in-tree crash. - scenes/avatar_customization.tscn rebuilt (old one used the Godot-3 `ViewportContainer` node type) + avatar_customization_screen.gd wrapper that returns to the menu on close. - smoke_test_avatar_in_world.gd: primed loadout -> game.tscn -> player shows the assembled avatar, capsule hidden, rig + bone mounts present. Verified: cold-cache smokes green (player 15/15, avatar_system 14/14, avatar_in_world 10/10, main_menu_ui 13/13); against a live backend main_menu_full_flow 12/12, main_menu_back 20/20, world_list 4/4, enter_world 9/9, e2e 4/4; plus a manual register->default-loadout-> customize->save->re-fetch-persisted round trip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
176 lines
6.4 KiB
GDScript
176 lines
6.4 KiB
GDScript
class_name Player
|
|
extends CharacterBody3D
|
|
## The player character controller.
|
|
##
|
|
## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
|
|
## first-person Camera3D with mouse-look. Reads the spawn point from the
|
|
## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3).
|
|
##
|
|
## No networking, no chunk code, no avatar UI — PC2 is local-only.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
## Camera pitch is clamped so the player can't flip past looking straight
|
|
## up/down (which would otherwise let mouse-look invert on itself).
|
|
const PITCH_LIMIT: float = deg_to_rad(89.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Input state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _input_direction := Vector2.ZERO
|
|
var _camera_pitch: float = 0.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
|
|
|
|
@onready var _camera: Camera3D = $Camera3D
|
|
@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh
|
|
|
|
var _avatar_view: Node3D = 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)
|
|
|
|
# Capture the mouse for FPS-style 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()
|
|
|
|
|
|
## Replace the placeholder capsule with the player's assembled avatar, if the
|
|
## AvatarSystem has a loadout primed (set at login by main_menu.gd). Falls back
|
|
## to the capsule silently when there's no avatar to show -- e.g. running this
|
|
## scene directly in a test, or an offline/failed catalog load.
|
|
## TODO(PC3): a third-person camera so the local player actually sees this;
|
|
## for now it's mainly for other players (Phase 2) and looking down.
|
|
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:
|
|
return
|
|
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
|
|
add_child(avatar)
|
|
_avatar_view = avatar
|
|
if _capsule_mesh:
|
|
_capsule_mesh.visible = false
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _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 only tilts the camera, 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_LIMIT, PITCH_LIMIT)
|
|
_camera.rotation.x = _camera_pitch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Physics — gravity, jump, move_and_slide
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
# Apply gravity.
|
|
if not is_on_floor():
|
|
velocity.y -= GRAVITY * delta
|
|
|
|
# Jump.
|
|
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
|
velocity.y = JUMP_VELOCITY
|
|
|
|
# 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()
|