diff --git a/scripts/player.gd b/scripts/player.gd index 46815d6..c6fea50 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -2,17 +2,23 @@ class_name Player extends CharacterBody3D ## The player character controller. ## -## A CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a -## free-orbit third-person camera. The mouse orbits the camera around the -## player a full 360 (yaw) plus a wide pitch range -- swing it around to see -## the avatar's face if you want. Movement is camera-relative (W = away from -## camera) and the body turns to face the direction it's moving, independent -## of where the camera is pointed. Reads the spawn point from the current -## scene's `PlayerSpawn` Marker3D. +## 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 constants +# Movement / camera constants # --------------------------------------------------------------------------- const WALK_SPEED: float = 5.0 @@ -22,51 +28,64 @@ 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_yaw: float = 0.0 # free orbit, unclamped (wraps 360) -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 -## Camera pitch limits (radians): from nearly top-down to looking up at the -## avatar from below. Yaw is deliberately unclamped -- full 360 orbit. +## 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 -## Where the camera pivot sits relative to the player origin, and how quickly -## the body swings to face its movement direction. -const CAMERA_PIVOT_OFFSET: Vector3 = Vector3(0.0, 1.2, 0.0) +## 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 — spawn at PlayerSpawn marker, capture the mouse for look +# _ready # --------------------------------------------------------------------------- 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. + # 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: @@ -74,39 +93,52 @@ func _ready() -> void: 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 + 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 (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) + # 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, so it can't be a child that - # inherits the body's yaw -- detach it and drive its world transform - # ourselves each frame. + # 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() - - # 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() +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. -## 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"): @@ -121,8 +153,7 @@ func _apply_avatar() -> void: if _capsule_mesh: _capsule_mesh.visible = false - # Procedural walk cycle -- no clips yet, just swing the limb bones with - # ground speed. + # 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() @@ -131,18 +162,71 @@ func _apply_avatar() -> void: # --------------------------------------------------------------------------- -# _unhandled_input — capture keyboard / mouse for movement +# 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: - _update_input_direction(key) + 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, the scene root that - # owns the network connection, so the teardown is a single path. + # 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) @@ -172,67 +256,73 @@ func _clear_input_direction(key: InputEventKey) -> void: func _handle_mouse_look(event: InputEventMouseMotion) -> void: - # Mouse orbits the camera only -- the body is left alone (it turns to face - # movement in _physics_process). Yaw wraps freely; pitch is clamped. _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) + _camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, + PITCH_MIN, PITCH_MAX) _update_camera() -## Place the free-orbit camera pivot at the player's head and point it per the -## current yaw/pitch. Called from _ready, on mouse-look, and every frame (the -## player moves under it). +## 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 - _camera_pivot.global_position = global_position + CAMERA_PIVOT_OFFSET + 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 — gravity, jump, move_and_slide +# Physics # --------------------------------------------------------------------------- -## 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. +## 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: - # 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 - # Movement is camera-relative: W (input.y = +1) heads away from the camera, - # A/D strafe. A node looks down its local -Z, so forward = -cam_basis.z. + # 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() + 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 - # Turn the body to face where it's moving (independent of the camera). - if direction.length_squared() > 0.01: + # 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)) + rotation.y = lerp_angle(rotation.y, target_yaw, + clampf(BODY_TURN_SPEED * delta, 0.0, 1.0)) move_and_slide() - - # Keep the orbit camera on the player as it moves. _update_camera() if _limb_anim != null: _limb_anim.set("speed", Vector2(velocity.x, velocity.z).length()) - # Safety net -- never fall forever if something goes wrong. + # Safety net -- never fall forever. if global_position.y < -10.0: global_position = _spawn_position velocity = Vector3.ZERO @@ -243,8 +333,8 @@ func _physics_process(delta: float) -> void: } -## Server-authoritative correction: called by scripts/game.gd when the local -## predicted position has drifted too far from the server's. +## 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.x = 0.0