class_name Player extends CharacterBody3D ## The player character controller. ## ## Roblox-style third-person controller: ## ## * Classic camera (default): the mouse cursor is free; hold a mouse button ## and drag to orbit the camera 360 in yaw (pitch clamped). Right button ## also turns the character to face the camera while held. Scroll wheel ## zooms; zoom all the way in -> first person. ## * Shift Lock (toggle Left Shift): mouse locks to a centre crosshair, the ## camera moves over the right shoulder, and the character's facing is ## locked to the camera -- WASD strafes / backpedals. ## ## Movement is always camera-relative (W = away from the camera). Outside the ## camera-facing modes the body auto-rotates toward its movement direction. ## Spawn point comes from the current scene's `PlayerSpawn` Marker3D. # --------------------------------------------------------------------------- # Movement / camera 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 ## 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 ## Camera pitch limits (radians): near top-down to looking up from below. ## Yaw is deliberately unclamped -- full 360 orbit. const PITCH_MIN: float = -1.35 const PITCH_MAX: float = 1.2 ## Camera pivot height above the player origin, third-person vs first-person. const PIVOT_Y_TP: float = 1.2 const PIVOT_Y_FP: float = 0.65 ## Zoom (spring length) range, scroll step, and the distance at or below which ## we switch to first person. const ZOOM_MIN: float = 0.4 const ZOOM_MAX: float = 9.0 const ZOOM_STEP: float = 0.6 const FIRST_PERSON_ZOOM: float = 0.6 const ZOOM_DEFAULT: float = 3.5 ## How far right the camera sits in Shift Lock (metres). const SHOULDER_OFFSET: float = 0.85 ## How fast the body swings toward its target facing. const BODY_TURN_SPEED: float = 12.0 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var _input_direction := Vector2.ZERO var _camera_yaw: float = 0.0 # unclamped, wraps var _camera_pitch: float = -0.12 var _zoom: float = ZOOM_DEFAULT var _shift_lock: bool = false var _mouse_look_held: bool = false # LMB or RMB held (classic drag) var _rmb_held: bool = false # RMB specifically (turns the body) var _spawn_position: Vector3 = Vector3(0, 1.5, 0) @onready var _camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D @onready var _camera_pivot: Node3D = $CameraPivot @onready var _spring_arm: SpringArm3D = $CameraPivot/SpringArm3D @onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh var _avatar_view: Node3D = null var _limb_anim: Node = null var _crosshair: CanvasLayer = null # --------------------------------------------------------------------------- # _ready # --------------------------------------------------------------------------- func _ready() -> void: # Per ยง3 of pc2_test_level.md: look up PlayerSpawn from the current scene # root (recursive), guarding the null current_scene of headless runs. 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 rotation.y = (spawn as Node3D).rotation.y 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 (same # layer) or the camera gets yanked against the player's head. _spring_arm.add_excluded_object(self) # The camera pivot orbits the player freely -- it can't inherit the body's # yaw, so detach it and drive its world transform ourselves each frame. _camera_pivot.top_level = true _camera_yaw = rotation.y _build_crosshair() _refresh_mouse_mode() _update_camera() _apply_avatar() var avatars := get_node_or_null("/root/AvatarSystem") if avatars != null and avatars.has_signal("loadout_changed"): avatars.loadout_changed.connect(func(_lo): _apply_avatar()) func _build_crosshair() -> void: if DisplayServer.get_name() == "headless": return _crosshair = CanvasLayer.new() _crosshair.layer = 64 var l := Label.new() l.text = "+" l.add_theme_font_size_override("font_size", 22) l.add_theme_color_override("font_color", Color(1, 1, 1, 0.85)) l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8)) l.add_theme_constant_override("outline_size", 4) l.set_anchors_preset(Control.PRESET_CENTER) l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER l.offset_left = -12 l.offset_right = 12 l.offset_top = -16 l.offset_bottom = 16 l.mouse_filter = Control.MOUSE_FILTER_IGNORE _crosshair.add_child(l) add_child(_crosshair) _crosshair.visible = false ## Show the player's assembled avatar and hide the placeholder capsule. 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 if _avatar_view != null and is_instance_valid(_avatar_view): remove_child(_avatar_view) _avatar_view.queue_free() if _limb_anim != null and is_instance_valid(_limb_anim): remove_child(_limb_anim) _limb_anim.queue_free() _limb_anim = null 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 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) # --------------------------------------------------------------------------- # Input # --------------------------------------------------------------------------- func _is_first_person() -> bool: return _zoom <= FIRST_PERSON_ZOOM ## Mouse should be captured (hidden, locked) whenever the camera is in an ## aim-style mode or the player is mid-drag. func _want_mouse_captured() -> bool: return _shift_lock or _is_first_person() or _mouse_look_held ## Body facing is slaved to the camera in Shift Lock, first person, or while ## the right mouse button is held. func _want_face_camera() -> bool: return _shift_lock or _is_first_person() or _rmb_held func _refresh_mouse_mode() -> void: if DisplayServer.get_name() == "headless": return Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _want_mouse_captured() \ else Input.MOUSE_MODE_VISIBLE if _crosshair != null: _crosshair.visible = _shift_lock or _is_first_person() func _set_zoom(v: float) -> void: var was_fp := _is_first_person() _zoom = clampf(v, ZOOM_MIN, ZOOM_MAX) if _is_first_person() != was_fp: _refresh_mouse_mode() _update_camera() func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey: var key := event as InputEventKey if key.pressed and not key.echo: if key.keycode == KEY_SHIFT: _shift_lock = not _shift_lock _refresh_mouse_mode() _update_camera() else: _update_input_direction(key) elif not key.pressed: _clear_input_direction(key) # ESC (leave the world) is handled by game.gd, which owns the connection. elif event is InputEventMouseButton: var mb := event as InputEventMouseButton match mb.button_index: MOUSE_BUTTON_WHEEL_UP: if mb.pressed: _set_zoom(_zoom - ZOOM_STEP) MOUSE_BUTTON_WHEEL_DOWN: if mb.pressed: _set_zoom(_zoom + ZOOM_STEP) MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT: _mouse_look_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) \ or Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) _rmb_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) _refresh_mouse_mode() 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: _camera_yaw = wrapf(_camera_yaw - event.relative.x * MOUSE_SENSITIVITY, -PI, PI) _camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, PITCH_MIN, PITCH_MAX) _update_camera() ## Place the orbit pivot at the player's head and point it per yaw/pitch. ## Applies the first-person / shoulder framing. Called from _ready, on input, ## and every physics frame (the player moves under it). func _update_camera() -> void: if not is_instance_valid(_camera_pivot): return var fp := _is_first_person() _camera_pivot.global_position = global_position \ + Vector3(0.0, PIVOT_Y_FP if fp else PIVOT_Y_TP, 0.0) _camera_pivot.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0) _spring_arm.spring_length = 0.0 if fp else _zoom _spring_arm.position.x = SHOULDER_OFFSET if (_shift_lock and not fp) else 0.0 # Hide our own avatar in first person so the head doesn't fill the view. if _avatar_view != null and is_instance_valid(_avatar_view): _avatar_view.visible = not fp # --------------------------------------------------------------------------- # Physics # --------------------------------------------------------------------------- ## Input frame forwarded to the server by scripts/game.gd each physics tick: ## world-space move direction (yaw-transformed, normalized), jump edge, body yaw. var net_input: Dictionary = {"move_x": 0.0, "move_z": 0.0, "jump": false, "look_yaw": 0.0} func _physics_process(delta: float) -> void: if not is_on_floor(): velocity.y -= GRAVITY * delta var jumped := false if is_on_floor() and Input.is_action_just_pressed("ui_accept"): velocity.y = JUMP_VELOCITY jumped = true # Camera-relative movement. A node looks down its local -Z, so forward is # -cam_basis.z and W (input.y = +1) heads away from the camera. var cam_basis := Basis.from_euler(Vector3(0.0, _camera_yaw, 0.0)) var direction := (_input_direction.x * cam_basis.x - _input_direction.y * cam_basis.z).normalized() velocity.x = direction.x * WALK_SPEED velocity.z = direction.z * WALK_SPEED # Body facing: locked to the camera in aim modes, otherwise turns toward # the movement direction. if _want_face_camera(): rotation.y = _camera_yaw elif direction.length_squared() > 0.01: var target_yaw := atan2(-direction.x, -direction.z) rotation.y = lerp_angle(rotation.y, target_yaw, clampf(BODY_TURN_SPEED * delta, 0.0, 1.0)) move_and_slide() _update_camera() if _limb_anim != null: _limb_anim.set("speed", Vector2(velocity.x, velocity.z).length()) # Safety net -- never fall forever. 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 from scripts/game.gd when the local ## predicted position has drifted too far. func apply_server_correction(pos: Vector3) -> void: global_position = pos velocity = Vector3.ZERO