using UnityEngine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif /// /// CharacterController-based player: camera-relative WASD movement, manual gravity and jumping, /// sliding off too-steep slopes/edges, and a wall/ceiling-climb mode. /// /// Wall-climb (entered by pushing the move input into a wall): W/S climb straight up/down (velocity /// ramps in for precise placement), A/D strafe camera-relative, the body faces the wall. Left-drag /// leans the visual mesh out of / into the wall (capsule stays put), right-drag turns the body; /// either pauses the camera orbit. Jumping detaches; reaching the top edge mantles over. Ceiling-climb /// shares the mode (entered by jumping up into a ceiling or climbing a wall into an overhang): the /// body stays upright and moves camera-relative in the horizontal plane; jump lets go. On the ground /// the same mouse scheme raises/lowers the mesh and turns the body without moving the capsule. /// /// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's "PlayerController". /// [RequireComponent(typeof(CharacterController))] [DisallowMultipleComponent] public class PlayerCharacterController : MonoBehaviour { [Header("Movement")] [Tooltip("Horizontal movement speed (units/second).")] public float moveSpeed = 2f; [Tooltip("Speed multiplier applied to ground movement and wall/ceiling climbing while the sprint key (Left Shift) is held.")] public float sprintSpeedMultiplier = 3.2f; [Tooltip("How quickly ground movement ramps up to the target speed (units/second²). Lower = more gradual start.")] public float acceleration = 30f; [Tooltip("How quickly ground movement slows to a stop when input eases off (units/second²). Lower = more glide.")] public float deceleration = 45f; [Tooltip("Interpret WASD relative to the camera (off = world X/Z axes).")] public bool cameraRelative = true; [Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")] public Transform cameraTransform; [Header("Rotation")] [Tooltip("Smoothly turn the character to face its movement direction.")] public bool rotateTowardMovement = true; [Tooltip("Turn speed in degrees per second.")] public float rotationSpeed = 540f; [Header("Jump & Gravity")] [Tooltip("Peak jump height in units.")] public float jumpHeight = 1.2f; [Tooltip("Gravity in units per second squared (negative = downward).")] public float gravity = -14.715f; [Tooltip("Small downward velocity kept while grounded for reliable ground contact.")] public float groundedStickVelocity = -2f; [Tooltip("Downward speed (units/second) past which the player counts as falling, for the fall " + "animation. Only while airborne (not grounded, not wall-climbing). Scales with player size.")] public float fallAnimationSpeed = 4f; [Header("Rescue")] [Tooltip("Kill-plane fail-safe: if the player falls below this world Y (e.g. off the map into the void), " + "they are teleported back to where this avatar spawned (spawn center + per-client ring offset) " + "with all momentum cleared. Owner-only; the authoritative NetworkTransform replicates the reset.")] public float rescueMinY = -100f; [Header("Slope Sliding")] [Tooltip("Slide down surfaces steeper than the CharacterController's Slope Limit instead of balancing on them.")] public bool slideDownSteepSlopes = true; [Tooltip("How quickly the slide accelerates (units/second squared).")] public float slideAcceleration = 20f; [Tooltip("Maximum downhill slide speed (units/second).")] public float maxSlideSpeed = 8f; [Tooltip("Steering control kept while sliding (0 = none, 1 = full).")] [Range(0f, 1f)] public float slideControl = 0.3f; [Header("Wall Climb")] [Tooltip("Enter wall-climb mode by pushing into a wall: stick to the surface, move at reduced speed, ignore gravity and jumping.")] public bool enableWallClimb = true; [Tooltip("Minimum surface angle from 'up' (degrees) that counts as a climbable wall/ceiling rather than a slope.")] [Range(50f, 180f)] public float wallMinAngle = 60f; [Tooltip("How directly the move input must point into a wall to grab it (0 = any inward nudge, 1 = head-on only). Stops mere contact (e.g. standing at a wall's base) from grabbing.")] [Range(0f, 1f)] public float wallGrabMinPush = 0.3f; [Tooltip("Speed multiplier for climbing up and strafing while wall-climbing (0.5 = half speed).")] [Range(0f, 1f)] public float wallClimbSpeedMultiplier = 0.5f; [Tooltip("How quickly wall/ceiling climb movement ramps up to speed (units/second²). Kept low so brief " + "taps nudge only a little for precise placement; scaled up while sprinting so sprint stays snappy.")] [Min(0.01f)] public float wallClimbAcceleration = 5f; [Tooltip("Speed multiplier for the mantle (pulling up over the top edge), independent of the climb speed. Multiplies moveSpeed; 1 = full walk speed, >1 = snappier pull-up.")] [Min(0f)] public float wallMantleSpeedMultiplier = 1.5f; [Tooltip("Farthest the mesh can lean out from the wall via the mouse-drag offset (units).")] public float wallMaxOffset = 0.1f; [Tooltip("Farthest the mesh can sink INTO the wall via the mouse-drag offset (units). Only the visual model + its paint collider move; the CharacterController capsule stays put, so this isn't blocked by the wall collider.")] public float wallMaxInset = 0.2f; [Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")] public float wallOffsetSensitivity = 0.001f; [Tooltip("Degrees the body turns per pixel of horizontal mouse drag (left button held) while climbing.")] public float wallRotateSensitivity = 0.2f; [Tooltip("How fast the lean-out offset eases back to the wall while moving (units/second).")] public float wallOffsetReturnSpeed = 2f; [Tooltip("How snappily the body follows the lean-out offset (1/second). Higher = tighter, lower = smoother.")] public float wallOffsetFollow = 12f; [Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")] public float wallExitGrace = 0.15f; [Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")] public float ceilingGrabDistance = 0.02f; [Tooltip("Upward hop when topping out over a wall/slope edge, so holding W carries you onto the ledge instead of dropping back onto the face and re-grabbing. In meters of jump height; 0 = no hop.")] public float wallTopOutBoost = 0.3f; [Tooltip("Colliders with this tag are invisible to wall-climb and ceiling-grab: they still block the capsule physically, but can never be grabbed, re-probed onto, or used as a ceiling. Leave empty to disable.")] public string noClimbTag = "NoClimb"; [Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")] [SerializeField] private ThirdPersonOrbitInput cameraOrbit; [Tooltip("Visual model root moved by the wall-climb lean offset (into/out of the wall) while the capsule stays put. Must parent both the skinned mesh and its bones. Auto-resolved from the SkinnedMeshRenderer if empty.")] [SerializeField] private Transform meshOffsetTarget; [Tooltip("Camera follow/look target (what the ThirdPersonCamera tracks). Moved vertically by the active pose's camera y-offset plus the ceiling offset, without moving the body. Auto-resolved from a child named \"CameraTarget\" if empty.")] [SerializeField] private Transform cameraTarget; /// /// When false, movement/jump input is ignored but gravity and ground contact /// keep running. Toggled by the GameModeManager. /// public bool ControlEnabled { get; set; } = true; /// /// When set, the body faces this world-space yaw (degrees) every frame instead of turning toward its /// movement direction. First-person sets it so the character always faces where the camera looks; /// null restores movement-facing. Only affects ground/air locomotion — wall-climb owns its own facing. /// public float? FaceYaw { get; set; } /// True while in wall-climb mode. public bool IsWallClimbing => _isWallClimbing; /// /// True while the player is idle (under control and essentially still): standing on the floor /// below the run threshold, or attached to a wall/ceiling with no climb input. Snapshotted at the /// end of Update so a sibling reads a stable, order-independent value. /// public bool IsIdle => _isIdle; /// True while the sprint key is held, in any state (ground, air, wall, or ceiling). public bool IsSprinting => _sprinting; /// True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal). public bool IsOnCeiling => _isWallClimbing && _onCeiling; /// True while grounded and moving above the run threshold (the ground-locomotion state); /// airborne / wall / standing all read false. The pose system picks its walk/run clips off this. public bool IsMovingOnGround => _movingOnGround; /// True while airborne (not wall-climbing) and descending faster than /// (scaled by player size). The pose system picks its fall clip off this. public bool IsFalling => _isFalling; /// /// Set by while a pose Playable graph owns the Animator output. /// While set, the run-bool is not poked: it would be moot (the graph overrides it) and Unity would /// log "Animator is not playing an AnimatorController" every frame. /// public bool AnimatorOverridden { get; set; } /// /// The visual-model transform moved by the wall-lean / ceiling offset; its localPosition is owned /// solely by . Exposed so can exclude it /// from its per-frame rest reset — with the humanoid rig the Animator sits on this same node, so it /// lands in the reset subtree and would otherwise be snapped back to base every frame, wiping the offset. /// public Transform MeshOffsetTarget => meshOffsetTarget; /// Authored CharacterController capsule center Y, captured in Awake. The per-pose capsule /// (driven by ) eases back to this when no pose overrides it. public float BaseCapsuleCenterY { get; private set; } /// Authored CharacterController capsule radius, captured in Awake (see ). public float BaseCapsuleRadius { get; private set; } /// Authored CharacterController capsule height, captured in Awake (see ). public float BaseCapsuleHeight { get; private set; } // Below this horizontal speed (m/s, squared) the character counts as idle for the run animation. private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2 private CharacterController _controller; private NetworkPlayerSetup _setup; // sibling role source; null on non-networked test instances private float _verticalVelocity; private Vector3 _horizontalVelocity; // smoothed horizontal move velocity (accel/decel ramp) private Vector3 _climbVelocity; // smoothed along-surface climb velocity (accel ramp for precise placement) private bool _sprinting; // exposed via IsSprinting private bool _isIdle; // exposed via IsIdle private bool _movingOnGround; // exposed via IsMovingOnGround private bool _isFalling; // exposed via IsFalling private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust (+ up / - down); folded into SetMeshLean private Vector3 _spawnPosition; // world position this avatar spawned at; the rescue (kill-plane) teleport target private bool _spawnCaptured; // false until the first Update captures the spawn position (after OnNetworkSpawn's ring offset has landed) // Ground contact normal from the latest Move (for slope sliding). private Vector3 _groundNormal = Vector3.up; private bool _groundNormalUpdated; private float _slideSpeed; // Wall-climb state. A climb surface counts as a ceiling (horizontal-crawl model) rather // than a wall (up/down model) once its normal points down past this threshold (~>120 deg // from up); between that and wallMinAngle it is treated as a wall. private const float CeilingNormalThreshold = -0.5f; // Hysteresis band around the threshold: in the inner wall/ceiling corner the capsule touches // both surfaces, so the per-frame contact normal mixes horizontal (wall) and downward (ceiling) // and its y crosses -0.5 back and forth -- flipping the climb mode and snapping the body's yaw // between "face the wall" and "face the move direction". Latch the class and only flip when the // normal is unambiguously past one edge of the band. private const float CeilingEnterThreshold = -0.6f; // wall -> ceiling only when clearly down-facing private const float CeilingExitThreshold = -0.4f; // ceiling -> wall only when clearly not down-facing private bool _onCeiling; // latched climb-surface class (hysteretic; see thresholds above) private bool _isWallClimbing; private Vector3 _wallNormal; private Vector3 _wallNormalAccum; // summed wall normals during the latest Move private bool _wallContactThisFrame; private float _lostContactTimer; private float _wallOffset; // signed mesh lean (mouse drag): + = out from the wall, - = into it; moves only the model, not the capsule private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving private int _wallStrafeSign; // latched sideways direction (camera picks it once per strafe); 0 = unlatched private int _wallClimbSign; // latched up/down direction: whichever W/S key pushes into the wall climbs UP (camera picks it once per climb); 0 = unlatched private bool _offsetDragging; // true while the offset/rotation is being set (pauses camera orbit) private bool _climbLeftGround; // true once the capsule has left the ground while climbing (enables ground-detach) private Vector3 _meshBaseLocalPos; // meshOffsetTarget's authored local position; the lean offset is applied relative to this private Vector3 _cameraTargetBaseLocalPos; // cameraTarget's authored local position; the pose/ceiling camera offset is applied relative to this private float _cameraYOffset; // last camera-target local-up offset written (cached so an unchanged value skips the transform write) private float _meshOffsetResidual; // leftover mesh lean still easing back to 0 after leaving the wall (no longer climbing) private Vector3 _meshOffsetExitNormal; // wall normal latched at exit; the residual eases along it (live _wallNormal may shift on stray contact) private readonly RaycastHit[] _wallProbe = new RaycastHit[16]; private bool _wallProbeSaturated; // log-once: a full buffer can hide the nearest valid wall/ground // Mantle (top-out) state. private const float TopOutRegrabCooldown = 0.3f; // blocks re-grab right after any top-out so holding W tops out cleanly instead of detach/re-grab buzzing private bool _mantling; private bool _mantleClearedFeet; // phase 1 done: capsule fully above the edge private float _mantleOverDist; // phase 2 forward distance over the top private float _mantleTimer; // safety cap private float _reentryCooldown; // blocks re-grabbing right after topping out void Awake() { _controller = GetComponent(); _setup = GetComponent(); BaseCapsuleCenterY = _controller.center.y; BaseCapsuleRadius = _controller.radius; BaseCapsuleHeight = _controller.height; if (!cameraTransform && Camera.main) cameraTransform = Camera.main.transform; if (!cameraOrbit) cameraOrbit = FindFirstObjectByType(); // Resolve the model root to offset: walk up from the skinned mesh to the topmost node still // under this transform, so it parents BOTH the renderer and its bones. Offsetting only the // renderer would move just the baked paint collider, not the bone-driven visible mesh. if (!meshOffsetTarget) { var smr = GetComponentInChildren(); if (smr) { Transform t = smr.transform; while (t.parent && t.parent != transform) t = t.parent; meshOffsetTarget = t; } } if (meshOffsetTarget) _meshBaseLocalPos = meshOffsetTarget.localPosition; if (!cameraTarget) { Transform t = transform.Find("CameraTarget"); if (t) cameraTarget = t; } if (cameraTarget) _cameraTargetBaseLocalPos = cameraTarget.localPosition; } void Update() { // Kill-plane fail-safe (owner-only: the controller is disabled on remotes). Capture the spawn // point on the first frame -- by now OnNetworkSpawn's ring offset has landed -- then snap the // player back if they fall through the world. Runs ahead of the control gate so it rescues in // any mode (paint/free-cam included). if (!_spawnCaptured) { _spawnPosition = transform.position; _spawnCaptured = true; } else if (transform.position.y < rescueMinY) { RescueToSpawn(); return; } float h = 0f, v = 0f; bool jumpPressed = false; bool sprinting = false; // Skip input when control is disabled (e.g. paint mode); gravity and // grounding below still run so the player stays planted. if (ControlEnabled) { #if ENABLE_INPUT_SYSTEM var keyboard = Keyboard.current; if (keyboard != null) { if (keyboard.aKey.isPressed) h -= 1f; if (keyboard.dKey.isPressed) h += 1f; if (keyboard.sKey.isPressed) v -= 1f; if (keyboard.wKey.isPressed) v += 1f; jumpPressed = keyboard.spaceKey.wasPressedThisFrame; sprinting = keyboard.leftShiftKey.isPressed; } #else h = Input.GetAxisRaw("Horizontal"); v = Input.GetAxisRaw("Vertical"); jumpPressed = Input.GetButtonDown("Jump"); sprinting = Input.GetKey(KeyCode.LeftShift); #endif } _sprinting = sprinting; Vector3 inputDir = new Vector3(h, 0f, v); if (inputDir.sqrMagnitude > 1f) inputDir.Normalize(); Vector3 moveDir; if (cameraRelative && cameraTransform) { // Project the camera axes onto the ground plane so movement stays horizontal. Vector3 forward = cameraTransform.forward; forward.y = 0f; forward.Normalize(); Vector3 right = cameraTransform.right; right.y = 0f; right.Normalize(); moveDir = forward * inputDir.z + right * inputDir.x; } else { moveDir = inputDir; } UpdateWallClimbState(moveDir); // Latch the wall normal from contacts ONLY while not yet climbing (the grab needs it). // During a climb the probes own _wallNormal: the per-frame contact blend mixes ceiling // and wall normals in an inner corner (e.g. ~(0,-0.97,-0.26)), which would fight the // probed surface and flip the wall/ceiling classification frame to frame. Reset the // accumulators regardless (OnControllerColliderHit refills them during Move). if (!_isWallClimbing && _wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f) _wallNormal = _wallNormalAccum.normalized; _wallContactThisFrame = false; _wallNormalAccum = Vector3.zero; if (_isWallClimbing) { // Frozen on the wall while control is off (e.g. paint mode): keep the // grip, no movement or gravity, so switching modes never drops us. if (ControlEnabled) ApplyWallClimbMovement(h, v, moveDir, jumpPressed, sprinting, Time.deltaTime); else _offsetDragging = false; if (cameraOrbit) cameraOrbit.LookSuppressed = _offsetDragging; _horizontalVelocity = Vector3.zero; // drop ground momentum so resuming starts from rest _isIdle = ControlEnabled && (h * h + v * v) <= 1e-4f; // attached & no climb input = holding a pose _movingOnGround = false; // on a wall/ceiling, never ground locomotion _isFalling = false; // attached to a surface, not falling return; } // Ground pose-adjust (mouse): left button + vertical drag raises/lowers the mesh, right // button + horizontal drag turns the body -- the wall scheme mirrored to the ground. Either // pauses the camera orbit so the same drag isn't also spent orbiting. Gated to player control // so it never fights painting (paint mode owns the left button). bool groundYawDrag = false; if (ControlEnabled) groundYawDrag = HandleGroundPoseAdjust(h, v, Time.deltaTime); else _offsetDragging = false; if (cameraOrbit) cameraOrbit.LookSuppressed = _offsetDragging; // Glide the leftover wall lean home and re-apply the composed mesh offset (residual lean + // ground raise + pose y-offset) in this frame's single write of meshOffsetTarget. EaseMeshOffsetToRest(Time.deltaTime); // A ground normal steeper than the slope limit is too steep to stand on // (also covers balancing on a sharp box edge). float slopeAngle = Vector3.Angle(_groundNormal, Vector3.up); bool onSteepSlope = slideDownSteepSlopes && _controller.isGrounded && slopeAngle > _controller.slopeLimit + 0.01f; if (_controller.isGrounded) { if (_verticalVelocity < 0f) _verticalVelocity = groundedStickVelocity; // No jumping off a slope too steep to stand on. if (jumpPressed && !onSteepSlope) _verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity); } _verticalVelocity += gravity * Time.deltaTime; Vector3 slideVelocity = Vector3.zero; if (onSteepSlope) { Vector3 slideDir = Vector3.ProjectOnPlane(Vector3.down, _groundNormal).normalized; _slideSpeed = Mathf.Min(_slideSpeed + slideAcceleration * Time.deltaTime, maxSlideSpeed); slideVelocity = slideDir * _slideSpeed; moveDir *= slideControl; // reduced steering while sliding } else { _slideSpeed = 0f; } float currentSpeed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed; // Ease horizontal velocity toward the target so movement accelerates/decelerates // instead of snapping. Accel while there is input, decel when it eases off. Vector3 targetHorizontal = moveDir * currentSpeed; float accelRate = moveDir.sqrMagnitude > 0.0001f ? acceleration : deceleration; _horizontalVelocity = Vector3.MoveTowards(_horizontalVelocity, targetHorizontal, accelRate * Time.deltaTime); Vector3 velocity = _horizontalVelocity + slideVelocity + Vector3.up * _verticalVelocity; // Move fires OnControllerColliderHit, which refills the ground normal; // fall back to "up" if nothing was hit. _groundNormalUpdated = false; _controller.Move(velocity * Time.deltaTime); if (!_groundNormalUpdated) _groundNormal = Vector3.up; // Facing: first-person pins the body to the camera yaw (even while idle); otherwise turn toward // the movement direction, unless the right-button turn is actively steering the body. if (FaceYaw.HasValue) { transform.rotation = Quaternion.Euler(0f, FaceYaw.Value, 0f); } else if (rotateTowardMovement && !groundYawDrag && moveDir.sqrMagnitude > 0.0001f) { Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, target, rotationSpeed * Time.deltaTime); } // Run animation: only while grounded and actually moving (airborne/sliding-blocked = idle). // Real horizontal speed, so pushing into a wall (blocked) reads as idle, not foot-sliding. Vector3 horizontalVel = _controller.velocity; horizontalVel.y = 0f; bool movingOnGround = _controller.isGrounded && horizontalVel.sqrMagnitude > RunningSpeedThresholdSqr; _movingOnGround = movingOnGround; _isIdle = ControlEnabled && _controller.isGrounded && !movingOnGround; // standing still on the floor // Falling: airborne and descending past the threshold (wall-climb already returned above, so // not-grounded here means free in the air). Scaled by player size like gravity/jump. _isFalling = !_controller.isGrounded && _verticalVelocity < -fallAnimationSpeed * CapsuleScale; } /// /// Kill-plane rescue: snap the avatar back to its captured spawn point and clear all momentum. /// Owner-only; the authoritative NetworkTransform replicates the new position to remotes. /// private void RescueToSpawn() { CharacterTeleport.Move(_controller, transform, _spawnPosition); _verticalVelocity = 0f; _horizontalVelocity = Vector3.zero; _climbVelocity = Vector3.zero; _slideSpeed = 0f; } // Hunters may not grab a wall during the hide phase (they wait it out); every other role/phase climbs // normally. The controller only runs for the local owner, so _setup is this avatar's own role. private bool WallClimbBlockedNow() { var match = MatchManager.Instance; return match && match.LocalSubPhase == MatchManager.MatchSubPhase.Hide && _setup && _setup.Role == PlayerRole.Hunter; } /// /// Enters wall-climb mode only when the move input actively pushes into a wall (not on mere /// contact). Leaving while climbing is handled in . /// private void UpdateWallClimbState(Vector3 moveDir) { if (_reentryCooldown > 0f) _reentryCooldown -= Time.deltaTime; if (!enableWallClimb || WallClimbBlockedNow()) { if (_isWallClimbing) ExitWallClimb(); return; } // Decide whether the contacted surface is being pushed into. A wall is grabbed by // pushing the move input horizontally into it while not rising (mere contact, e.g. // standing at a wall's base, must not grab). A ceiling can't be pushed into // horizontally, so it is grabbed by rising up into it (jumping up against it). bool pushingIn = false; if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f) { Vector3 surfaceNormal = _wallNormalAccum.normalized; if (surfaceNormal.y < CeilingNormalThreshold) pushingIn = _verticalVelocity > 0f; else pushingIn = moveDir.sqrMagnitude > 1e-4f && Vector3.Dot(moveDir.normalized, surfaceNormal) <= -wallGrabMinPush && _verticalVelocity <= 0f; } // Grab only under player control; once climbing we stay attached across control // toggles (e.g. paint mode) so switching modes never drops us off the surface. // The cooldown prevents instantly re-grabbing the edge we just mantled over. if (!_isWallClimbing && ControlEnabled && pushingIn && _reentryCooldown <= 0f) { _isWallClimbing = true; _wallNormal = _wallNormalAccum.normalized; _onCeiling = _wallNormal.y < CeilingNormalThreshold; _lostContactTimer = 0f; _verticalVelocity = 0f; _slideSpeed = 0f; _wallOffset = 0f; _groundOffset = 0f; // drop any ground pose-adjust raise so it doesn't ride onto the wall _meshOffsetResidual = 0f; // fresh grab: the climb path owns the mesh, drop any leftover ease _wallYaw = 0f; _wallStrafeSign = 0; _wallClimbSign = 0; _climbLeftGround = false; _climbVelocity = Vector3.zero; // start the climb ramp from rest } } /// /// Movement while climbing, walls and ceilings (distinguished by the surface normal). WALL: W/S /// climb straight up/down, A/D strafe camera-relative, the body faces the wall; left-drag leans the /// mesh via (capsule stays put), right-drag turns via . /// CEILING: upright, WASD camera-relative in the horizontal plane like on the ground. The surface is /// re-probed each frame; a wall's top edge mantles, climbing into an overhang hands off to the ceiling. /// private void ApplyWallClimbMovement(float h, float v, Vector3 moveDir, bool jumpPressed, bool sprinting, float dt) { _offsetDragging = false; if (_mantling) { MantleOverEdge(dt); return; } UpdateCeilingClass(); bool onCeiling = _onCeiling; // Map W/S to UP/DOWN by which key pushes INTO the wall (like A/D's strafe sign): facing the wall // that's W; with the camera on the wall's side (walking backward into it) it's S. Without the // remap the grab (camera-relative) and the base-release (raw S) disagree and the climb flickers. // Latched once per press; only walls remap (the ceiling crawl is already camera-relative). // vEff is the effective climb axis used for every up/down test below. bool climbing = Mathf.Abs(v) > 1e-4f; if (!climbing) _wallClimbSign = 0; else if (_wallClimbSign == 0 && !onCeiling) { Vector3 camFwd = cameraTransform ? cameraTransform.forward : Vector3.zero; camFwd.y = 0f; // camFwd aligned with the outward wall normal = camera faces away from the wall, so // forward (W) moves away from it and S is the into-wall, climb-up key. _wallClimbSign = (cameraTransform && Vector3.Dot(camFwd, _wallNormal) > 0f) ? -1 : 1; } float vEff = _wallClimbSign != 0 ? v * _wallClimbSign : v; // Jump detaches. Off a wall it pushes straight up; off a ceiling it just lets go and // drops. Re-grab stays blocked appropriately by UpdateWallClimbState's push-in test. if (jumpPressed) { ExitWallClimb(); _verticalVelocity = onCeiling ? 0f : Mathf.Sqrt(jumpHeight * -2f * gravity); return; } // Wall->ceiling hand-off: while climbing up a wall into an overhang, switch to the // ceiling right above the head instead of trying to mantle over a (non-existent) top. if (!onCeiling && vEff > 0f && ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap) && ceilHeadGap <= ceilingGrabDistance) { _wallNormal = ceilNormal; _onCeiling = true; onCeiling = true; _wallOffset = 0f; } // Re-measure the surface each frame, probing the kind we're committed to. On a ceiling probe // straight UP, NOT along -_wallNormal: in an inner corner that ray would clip the wall and // demote us to "wall", the hand-off re-promotes next frame, and the body's facing flips ~9 deg // every frame (the corner twitch). Stay latched until the ceiling is genuinely gone overhead. bool found; float gap; if (onCeiling) { found = ProbeCeiling(out Vector3 ceilNormal2, out float ceilGap); if (found) { _wallNormal = ceilNormal2; gap = ceilGap; _lostContactTimer = 0f; } else { found = ProbeWall(out Vector3 wallNormal, out gap); if (found) { _wallNormal = wallNormal; _lostContactTimer = 0f; } UpdateCeilingClass(); onCeiling = _onCeiling; } } else { found = ProbeWall(out Vector3 probedNormal, out gap); if (found) { _wallNormal = probedNormal; _lostContactTimer = 0f; UpdateCeilingClass(); onCeiling = _onCeiling; } } // Topped out onto a slope: the climbed face has flattened into something walkable (no // longer steep enough to be a wall). Hand back to normal grounded movement instead of // clinging to a "wall" that is really just ground now. if (found && !onCeiling && _climbLeftGround && Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle) { // Hop over the lip only when actually moving up onto the flattened face (a real // top-out); if we reached it going down, just step off cleanly with no hop. if (vEff > 0f) TopOutDetach(); else ExitWallClimb(); return; } Vector3 alongSurface; if (onCeiling) { // Crawl across the ceiling in the horizontal plane, camera-relative (moveDir is // already the ground-projected camera input); project onto the ceiling for safety. alongSurface = Vector3.ProjectOnPlane(moveDir, _wallNormal); } else { // W/S climb straight up/down (camera pitch never tilts them). Strafe along the wall's own // horizontal tangent, not live camera-right: that coupled movement to the orbit camera's // per-frame re-aim and jittered sideways motion. The camera only picks the sign. Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal); if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize(); Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp); if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize(); // Pick the strafe sign ONCE from the camera when a strafe begins, then hold it: re-deciding // per frame flips wallRight while rounding a curved wall (at 90 deg to the camera the dot // crosses zero and the input inverts frame-to-frame, locking you there). Resets when the // strafe input releases so the next one re-syncs to the camera. bool strafing = Mathf.Abs(h) > 1e-4f; if (!strafing) _wallStrafeSign = 0; else if (_wallStrafeSign == 0) _wallStrafeSign = (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f) ? -1 : 1; if (_wallStrafeSign < 0) wallRight = -wallRight; alongSurface = wallUp * vEff + wallRight * h; } if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize(); bool hasMoveInput = (h * h + v * v) > 1e-4f; if (!found) { // Wall only: losing the center ray while climbing up the face = top edge → mantle. if (!onCeiling && alongSurface.y > 0f && WallInFront(WorldHalfHeight)) { _mantling = true; _mantleClearedFeet = false; _mantleOverDist = 0f; _mantleTimer = 0f; MantleOverEdge(dt); return; } // Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace, // dropping into normal gravity. _lostContactTimer += dt; if (_lostContactTimer >= wallExitGrace) { ExitWallClimb(); return; } } // Left drag = signed lean (moves only the visual mesh, so it can sink into the wall where the // capsule can't); right drag = body turn (WALL: held yaw offset from the wall normal; CEILING: // rotates the transform directly). The lean eases home while moving; either drag pauses the orbit. bool offsetDrag = found && InputUtil.LeftMouseHeld(); bool yawDrag = found && InputUtil.RightMouseHeld(); if (offsetDrag || yawDrag) { _offsetDragging = true; Vector2 drag = InputUtil.ReadMouseDelta(); if (offsetDrag) _wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset); if (yawDrag) { if (onCeiling) transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World); else _wallYaw += drag.x * wallRotateSensitivity; } } if (!offsetDrag && hasMoveInput) { _wallOffset = Mathf.MoveTowards(_wallOffset, 0f, wallOffsetReturnSpeed * dt); } ApplyMeshOffset(_wallOffset); float climbSpeed = moveSpeed * wallClimbSpeedMultiplier; if (sprinting) climbSpeed *= sprintSpeedMultiplier; // Ramp the along-surface velocity instead of snapping: a brief tap moves only a little (precise // placement). Sprint scales the accel too, so it reaches its higher speed just as quickly; // releasing decelerates at the ground rate for a prompt, controllable stop. Vector3 targetAlong = alongSurface.sqrMagnitude > 1e-6f ? alongSurface.normalized * climbSpeed : Vector3.zero; float climbAccel = targetAlong.sqrMagnitude > 1e-6f ? wallClimbAcceleration * (sprinting ? sprintSpeedMultiplier : 1f) : deceleration; _climbVelocity = Vector3.MoveTowards(_climbVelocity, targetAlong, climbAccel * dt); Vector3 velocity = _climbVelocity; if (found) { // Smoothly hold the capsule one skin-width off the surface (flush): the lean offset no // longer moves the capsule (only the visual mesh), so it always rests against the wall. // The controller's depenetration never fights back, so there's no buzz and no quantizing // deadzone is needed. Exponential follow keeps it framerate-stable; clamp guards spikes. float maxPerpSpeed = 8f * CapsuleScale; float gapError = _controller.skinWidth * CapsuleScale - gap; float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt); velocity += _wallNormal * Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed); } _controller.Move(velocity * dt); // Reaching walkable ground detaches. The top-out hop fires ONLY when topping out upward // (vEff > 0); descending to the floor, or pressing away (vEff < 0) at the base while grounded, // lets go cleanly with no hop - the latter is the only detach that doesn't require having left // the ground first, so a wall grabbed at its base can be released by pressing away. Walkable // ground is probed under the feet rather than trusting isGrounded (the standoff correction can // lift the capsule just off the surface, so at a slope's top isGrounded stays false). bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow(); if (!onWalkableGround) { _climbLeftGround = true; } else if (_climbLeftGround && vEff > 0f) { TopOutDetach(); return; } else if (_climbLeftGround || vEff < 0f) { ExitWallClimb(); return; } if (rotateTowardMovement) { if (onCeiling) { // Stay upright and face the horizontal move direction, like on the ground -- unless // the right-button turn is actively steering the body (then the manual yaw holds). if (!yawDrag) { Vector3 dir = alongSurface; dir.y = 0f; if (dir.sqrMagnitude > 1e-4f) { Quaternion target = Quaternion.LookRotation(dir.normalized, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, target, rotationSpeed * dt); } } } else { // Face the wall (forward = into the wall), turned by the held mouse-drag yaw. // On leaving, the ground rotation eases the body back to its movement direction. Vector3 faceWall = -_wallNormal; faceWall.y = 0f; if (faceWall.sqrMagnitude > 0.0001f) { Vector3 dir = Quaternion.AngleAxis(_wallYaw, Vector3.up) * faceWall.normalized; transform.rotation = Quaternion.LookRotation(dir, Vector3.up); } } } } /// /// Updates the latched wall/ceiling classification with hysteresis on the surface normal's /// downward tilt. In the inner corner the live normal jitters across the single threshold; the /// band keeps the climb mode (and the body yaw it drives) from flipping frame to frame. /// private void UpdateCeilingClass() { if (_onCeiling) { if (_wallNormal.y > CeilingExitThreshold) _onCeiling = false; } else if (_wallNormal.y < CeilingEnterThreshold) { _onCeiling = true; } } /// /// Offsets only the visual model (mesh + its baked paint collider) along the wall normal by the /// given signed distance (+ = out, - = into the wall), relative to its authored local position. /// The capsule is untouched, so the lean can sink into the wall where the capsule would be blocked. /// private void ApplyMeshOffset(float offset) => SetMeshLean(_wallNormal, offset); /// /// Places the visual model at the given signed lean along (world space), /// relative to its authored local pose. Local-space conversion tracks the live body yaw. /// private void SetMeshLean(Vector3 dir, float offset) { if (!meshOffsetTarget) return; Vector3 worldOffset = dir * offset; meshOffsetTarget.localPosition = _meshBaseLocalPos + transform.InverseTransformVector(worldOffset) + Vector3.up * _groundOffset; // ground mouse raise (0 unless in use) } /// /// Re-applies the resting mesh offset every non-climbing frame in a single write: eases the /// leftover wall lean home (along the latched exit normal) while composing in the ground raise. /// Writing unconditionally keeps this the sole owner of the offset transform. /// private void EaseMeshOffsetToRest(float dt) { if (_meshOffsetResidual != 0f) _meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt); SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual); } /// /// Ground pose-adjust, mirroring the wall-climb mouse scheme: LEFT drag raises/lowers the visual /// mesh (, capsule untouched), RIGHT drag turns the body. The raise /// eases back while moving and holds while standing; the turn persists until movement re-aims the /// body. Returns true while a right-button turn is active so the caller skips /// rotate-toward-movement that frame. The call site gates on ControlEnabled (paint mode owns the left button). /// private bool HandleGroundPoseAdjust(float h, float v, float dt) { _offsetDragging = false; bool offsetDrag = InputUtil.LeftMouseHeld(); bool yawDrag = InputUtil.RightMouseHeld(); bool hasMoveInput = (h * h + v * v) > 1e-4f; if (offsetDrag || yawDrag) { _offsetDragging = true; Vector2 drag = InputUtil.ReadMouseDelta(); if (offsetDrag) _groundOffset = Mathf.Clamp( _groundOffset + drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset); if (yawDrag) transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World); } // The raise eases home only while actually moving; standing still holds it for posing. if (!offsetDrag && hasMoveInput) _groundOffset = Mathf.MoveTowards(_groundOffset, 0f, wallOffsetReturnSpeed * dt); return yawDrag; } /// /// Resizes the CharacterController capsule (center Y, radius, height) to the active pose's dimensions, /// driven by so a low pose can fit where the default standing capsule /// is too tall. Only the collision capsule changes — the visual model and camera target are untouched. /// Cached so an unchanged value skips the write (a resize re-bakes the capsule internally). /// public void SetCapsule(float centerY, float radius, float height) { Vector3 c = _controller.center; if (c.y == centerY && _controller.radius == radius && _controller.height == height) return; c.y = centerY; _controller.center = c; _controller.radius = radius; _controller.height = height; } /// /// Places the camera target (the ThirdPersonCamera's follow/look target) at the given local-up /// offset relative to its authored local position, without moving the body. Driven by /// with the active pose's camera y-offset plus the ceiling offset; /// 0 restores the authored target height. Cached so an unchanged value skips the write. /// public void SetCameraTargetLocalYOffset(float y) { if (!cameraTarget || _cameraYOffset == y) return; _cameraYOffset = y; cameraTarget.localPosition = _cameraTargetBaseLocalPos + Vector3.up * y; } // The CharacterController's serialized center/radius/height are authored at the player's base // (scale-1) size; transform.localScale resizes the ACTUAL collision capsule via lossyScale but // leaves those serialized numbers untouched. Every climb probe below raycasts in WORLD space, so // it must use the scaled (world) capsule dimensions and scale its world-distance paddings too -- // otherwise the wall grab range, head-to-ceiling gap and mantle distance are all measured at the // authored size on a shrunk/grown player (PlayerScaler scales the tunables but not these probes). // Uniform player scale is enforced by PlayerScaler, so a single lossyScale component suffices. private float CapsuleScale => transform.lossyScale.y; private Vector3 WorldCapsuleCenter => transform.TransformPoint(_controller.center); private float WorldRadius => _controller.radius * CapsuleScale; private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale; private const float ProbeReachSlack = 0.5f; // extra ray length beyond the capsule surface (scale-1 units) /// /// True if the collider belongs to ANY player avatar (self or a networked remote): avatars still /// collide physically, but another chameleon's body must never register as climbable. Finds the /// controller even on remotes, where the component is disabled (GetComponentInParent only checks /// the GameObject's active state, not the component's enabled flag). /// private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent(); /// /// True if the collider is tagged as non-climbable (): every climb probe /// and the grab detector skip it, so such a wall/ceiling blocks the capsule but is never grabbed, /// re-probed onto, or treated as a ceiling. Empty/unset tag disables the check (never throws). /// private bool IsNoClimb(Collider c) => !string.IsNullOrEmpty(noClimbTag) && c && c.CompareTag(noClimbTag); /// /// Raycasts from the character center toward the surface (along -_wallNormal) to find the /// current normal and the gap (surface clearance) to it. The capsule's support distance /// along the probe direction is the radius sideways but up to the half-height vertically, /// so the gap is correct for ceilings too. Our own colliders are skipped. False = no hit. /// private bool ProbeWall(out Vector3 normal, out float gap) { Vector3 origin = WorldCapsuleCenter; Vector3 dir = -_wallNormal; float extent = CapsuleExtent(dir); float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale; if (NearestClimbHit(origin, dir, maxDist, extent, ceilingOnly: false, out normal, out gap)) return true; normal = _wallNormal; return false; } /// /// Capsule support distance from the center along a unit direction: the radius sideways, /// the half-height straight up/down, interpolated by the vertical component in between. /// private float CapsuleExtent(Vector3 dir) { float radius = WorldRadius; return radius + Mathf.Abs(dir.y) * (WorldHalfHeight - radius); } /// /// Raycasts straight up from the center to find a ceiling overhead and its head clearance /// (top of the capsule to the surface). Only downward-facing (ceiling) hits count. /// private bool ProbeCeiling(out Vector3 normal, out float gap) { Vector3 origin = WorldCapsuleCenter; float half = WorldHalfHeight; float maxDist = half + wallMaxOffset + ProbeReachSlack * CapsuleScale; if (NearestClimbHit(origin, Vector3.up, maxDist, half, ceilingOnly: true, out normal, out gap)) return true; normal = Vector3.down; return false; } /// /// Raycasts along and returns the nearest hit that a climb probe accepts /// (player avatars and no-climb surfaces never count; additionally /// rejects non-ceiling normals). is the clearance beyond the capsule's /// support along the probe direction. False = no valid hit /// (/ are then zero; callers supply their fallback). /// private bool NearestClimbHit(Vector3 origin, Vector3 dir, float maxDist, float extent, bool ceilingOnly, out Vector3 normal, out float gap) { normal = Vector3.zero; gap = 0f; int count = Physics.RaycastNonAlloc( origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); WarnIfProbeSaturated(count); float best = float.MaxValue; bool found = false; for (int i = 0; i < count; i++) { if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider)) continue; if (ceilingOnly && _wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling continue; if (_wallProbe[i].distance < best) { best = _wallProbe[i].distance; normal = _wallProbe[i].normal; found = true; } } if (found) gap = Mathf.Max(0f, best - extent); return found; } private void WarnIfProbeSaturated(int hitCount) { if (hitCount != _wallProbe.Length || _wallProbeSaturated) return; _wallProbeSaturated = true; Debug.LogWarning($"PlayerCharacterController: climb probe buffer saturated ({hitCount}); the nearest surface may be missed.", this); } /// /// Pulls the character up and over the wall's top edge: phase 1 rises until the /// capsule clears the edge, phase 2 moves forward onto the surface, then exits. /// A short re-grab cooldown stops the passed edge from re-triggering a climb. /// private void MantleOverEdge(float dt) { _mantleTimer += dt; float speed = moveSpeed * wallMantleSpeedMultiplier; Vector3 forward = -_wallNormal; forward.y = 0f; forward = forward.sqrMagnitude > 1e-6f ? forward.normalized : transform.forward; if (!_mantleClearedFeet) { // Phase 1: climb straight up until no wall is left in front of the feet. _controller.Move(Vector3.up * (speed * dt)); if (!WallInFront(WorldHalfHeight)) _mantleClearedFeet = true; } else { // Phase 2: move forward onto the top surface, then finish. _controller.Move(forward * (speed * dt)); _mantleOverDist += speed * dt; if (_mantleOverDist >= WorldRadius * 2f + 0.15f * CapsuleScale || _controller.isGrounded) { _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); return; } } // Safety: never mantle indefinitely (e.g. blocked by other geometry). if (_mantleTimer >= 2f) { _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); } } /// /// Horizontal ray toward the wall from a point below the center. Distinguishes a /// top edge (wall still present lower down) from the wall being truly gone. /// private bool WallInFront(float downOffset) { Vector3 origin = WorldCapsuleCenter - Vector3.up * downOffset; float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale; int count = Physics.RaycastNonAlloc( origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); WarnIfProbeSaturated(count); for (int i = 0; i < count; i++) { if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider)) continue; return true; } return false; } /// /// Short downward ray from the capsule center: true when walkable ground (angle within the /// controller's slope limit) sits within step range below the feet. Used to top out of a /// climb back onto solid ground without trusting isGrounded, which the standoff correction /// can falsify by lifting the capsule slightly off the surface. /// private bool WalkableGroundBelow() { Vector3 origin = WorldCapsuleCenter; float maxDist = (_controller.center.y + _controller.stepOffset) * CapsuleScale; int count = Physics.RaycastNonAlloc( origin, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); WarnIfProbeSaturated(count); float best = float.MaxValue; Vector3 normal = Vector3.up; bool found = false; for (int i = 0; i < count; i++) { if (IsPlayerCollider(_wallProbe[i].collider)) continue; if (_wallProbe[i].distance < best) { best = _wallProbe[i].distance; normal = _wallProbe[i].normal; found = true; } } return found && Vector3.Angle(normal, Vector3.up) <= _controller.slopeLimit; } /// /// Detaches at the top of a climbed wall/slope with an upward hop, so holding W carries the /// player over the lip onto the ledge instead of dropping back onto the face (which would /// re-grab and stall). The re-grab cooldown spans the hop, and the grab test only fires when /// not rising (), so the hop is never cut short. /// private void TopOutDetach() { _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); if (wallTopOutBoost > 0f) _verticalVelocity = Mathf.Sqrt(wallTopOutBoost * -2f * gravity); } /// Leaves wall-climb mode and resets vertical velocity so the player falls cleanly. private void ExitWallClimb() { _isWallClimbing = false; _onCeiling = false; _lostContactTimer = 0f; _verticalVelocity = 0f; _climbVelocity = Vector3.zero; // Hand the current lean off to the residual so it eases home (EaseMeshOffsetToRest) // instead of snapping; latch the normal since the live one may shift after detaching. _meshOffsetResidual = _wallOffset; _meshOffsetExitNormal = _wallNormal; _wallOffset = 0f; _wallYaw = 0f; _wallStrafeSign = 0; _wallClimbSign = 0; _offsetDragging = false; _climbLeftGround = false; _mantling = false; _mantleClearedFeet = false; _mantleOverDist = 0f; _mantleTimer = 0f; } /// /// Collects contact normals during Move: wall/ceiling contacts feed the /// wall-climb detector, the flattest upward contact becomes the ground normal. /// private void OnControllerColliderHit(ControllerColliderHit hit) { if (enableWallClimb && !IsNoClimb(hit.collider)) { float angle = Vector3.Angle(hit.normal, Vector3.up); if (angle >= wallMinAngle) { _wallContactThisFrame = true; _wallNormalAccum += hit.normal; } } // Ignore near-vertical walls and ceilings for ground-normal collection. if (hit.normal.y <= 0.05f) return; // Keep the flattest (most supportive) contact seen this frame. if (!_groundNormalUpdated || hit.normal.y > _groundNormal.y) { _groundNormal = hit.normal; _groundNormalUpdated = true; } } }