Files
modbison/scripts/player.gd
T
daddy da4e1420ee
checkpoint / checkpoint (push) Successful in 1m40s
Fix: rejoining stacked players on top of each other (one fell through)
All players spawned at the exact same PlayerSpawn point and were on the
same collision layer, so two overlapping CharacterBody3Ds triggered a
physics depenetration shove that could launch one through the 1-unit-thick
floor.

- server.gd: golden-angle spawn spread -- each join lands ~1m+ from the
  others, growing outward, never exactly repeating.
- server_player.tscn + player.tscn: players on collision layer 2, mask 1
  (world only) -- players no longer collide with each other at all
  (fine for a social game; also removes the shove).
- player_spawner.gd + player.gd: hard safety net -- if y < -10 (any
  tunnel-through), snap back to the spawn instead of falling forever.

Verified: 3 server players spawn 1.15m+ apart, none fall, all rest on the
floor. 2p multiplayer + network_client smokes green.

Client-side -- rebuild the exe.

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

214 lines
7.7 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
# ---------------------------------------------------------------------------
# _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
# ---------------------------------------------------------------------------
# _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()
# 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