Files
modbison/scripts/player.gd
T
daddy dba865ae4b PC3 Phase 2.3: networked play path in the game
- Session autoload: carries {world_id, host, port} from the world browser
  to the game scene.
- world_list.gd "Enter World": POST /api/worlds/{id}/join -> Session.begin
  -> load game.tscn. On 503 (backend has no orchestration) falls back to
  local single-player; other errors surface on the card.
- api_client.gd: join_world().
- game.gd (new, on game.tscn root): local single-player by default; when
  Session.active, connects a NetworkClient to the world's dedicated server
  and runs networked -- forwards the local Player's input each physics
  frame, snaps to the server position past a generous drift threshold
  (the Player keeps predicting locally with real collision), renders other
  players from snapshots (labelled capsules, lerped -- avatar appearance
  sync is a follow-up, the server doesn't replicate loadouts yet), and
  returns to the world list on disconnect.
- player.gd: exposes net_input (the input frame it applied) and
  apply_server_correction().
- network_client.gd: send_input() + get_own_server_state() for callers that
  do their own collision-aware prediction.
- scripts/mp_game_flow_client.gd + run_game_multiplayer_test.sh: two real
  clients each register -> join via the API -> game.tscn -> networked;
  A sees B and moves. PASS.

Verified end to end against an orchestration-enabled backend. Non-networked
smokes green (enter_world 9/9, world_list 5/5, player 15/15,
avatar_in_world 10/10, main_menu_full_flow 12/12).

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

198 lines
7.1 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
# ---------------------------------------------------------------------------
## 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()
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