Reorganize scripts into subfolders by responsibility

Move scripts under Assets/Scripts/ into Components, GameMode, Helper,
Network, Enum, Struct and ScriptableObject folders. Pure file moves
(plus folder .meta files); no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-02 15:02:15 +02:00
1 parent 382bfb36df
commit e23fae0c50
126 files changed
+112

No files matched your search

@@ -0,0 +1,184 @@
using UnityEngine;
using Unity.Cinemachine;
/// <summary>
/// First-person camera. Drives its OWN dedicated <see cref="CinemachineCamera"/>: each frame it snaps
/// to the player's dedicated <c>FirstPersonTarget</c> (the eye anchor) for position and
/// applies free mouse-look (yaw/pitch) for rotation. While active the player's mesh is hidden so the
/// body isn't seen from the inside. Movement stays with the player controller, which reads the
/// brain-driven Main Camera forward, so WASD is automatically relative to this view.
///
/// Activation is driven by <see cref="FirstPersonMode"/> via <see cref="SetActive"/>: it raises the
/// vcam priority so the CinemachineBrain blends to it, snaps look to the current view (no jump), hides
/// the mesh and locks the cursor. While inactive the vcam rests at a low priority and this component
/// does nothing.
/// </summary>
[RequireComponent(typeof(CinemachineCamera))]
[DisallowMultipleComponent]
public class FirstPersonController : MonoBehaviour
{
[Header("Target")]
[Tooltip("Eye position the camera sits at (the player's FirstPersonTarget). Empty = auto-resolved from the player's \"FirstPersonTarget\" child.")]
public Transform cameraTarget;
[Tooltip("Renderers hidden while first-person is active (so you don't see your own body). Empty = the player's SkinnedMeshRenderers, auto-resolved.")]
public Renderer[] hiddenRenderers;
[Header("Look")]
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
public float verticalSensitivity = 0.15f;
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
public bool invertY = false;
[Tooltip("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")]
[Range(1f, 89.9f)]
public float maxPitch = 89f;
[Header("Smoothing")]
[Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")]
[Range(0f, 0.3f)]
public float lookSmoothTime = 0.04f;
[Header("Camera")]
[Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")]
public int activePriority = 100;
[Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")]
public int inactivePriority = -100;
CinemachineCamera _vcam;
PlayerCharacterController _player; // body whose yaw we pin to the camera while active
PlayerPoseController _poseController; // supplies the active pose's per-pose first-person eye offset
SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with the freecam
bool _resolved;
void Awake()
{
_vcam = GetComponent<CinemachineCamera>();
if (!_vcam)
Debug.LogError("[FirstPersonController] No CinemachineCamera on this GameObject; first-person cannot go live.", this);
else
_vcam.Priority = inactivePriority;
ResolveReferences();
// Dormant until FirstPersonMode enters; nothing reads input or moves the rig meanwhile.
enabled = false;
}
/// <summary>Enter/leave first-person. Raises/lowers the vcam priority, hides the player mesh, snaps
/// look to the live view and manages the cursor. Called by <see cref="FirstPersonMode"/>.</summary>
public void SetActive(bool active)
{
if (_vcam)
_vcam.Priority = active ? activePriority : inactivePriority;
SetMeshHidden(active);
if (active)
{
SyncToCurrentView();
InputUtil.SetCursorLocked(true);
ApplyTransform(); // place at the eye on the same frame so the blend starts from there
if (_player) _player.FaceYaw = _look.DisplayYaw; // body faces the camera from frame one
}
else
{
InputUtil.SetCursorLocked(false);
if (_player) _player.FaceYaw = null; // restore movement-facing
}
enabled = active;
}
void LateUpdate()
{
// LateUpdate so the player has already moved the CameraTarget this frame.
LookTick();
ApplyTransform();
if (_player) _player.FaceYaw = _look.DisplayYaw; // keep the body's yaw locked to the camera
}
void LookTick()
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
_look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
}
void ApplyTransform()
{
Quaternion rot = _look.Rotation;
if (cameraTarget)
{
// Sit at the first-person eye anchor, then raise/lower the eye by the active pose's first-person
// offset. TransformVector keeps it in the target's local up and scales with the player (so it
// tracks PlayerScaler).
Vector3 pos = cameraTarget.position;
float offset = _poseController ? _poseController.FirstPersonCameraYOffset : 0f;
if (offset != 0f)
pos += cameraTarget.TransformVector(Vector3.up * offset);
transform.SetPositionAndRotation(pos, rot);
}
else
transform.rotation = rot;
}
/// <summary>Seed look from the currently rendered view so going live produces no visible jump.</summary>
void SyncToCurrentView()
{
var cam = Camera.main;
if (cam)
_look.SyncTo(cam.transform.rotation, maxPitch);
else
_look.Snap(); // no camera: keep the current angles but still snap the smoothed state
}
void SetMeshHidden(bool hidden)
{
if (hiddenRenderers == null) return;
foreach (var r in hiddenRenderers)
if (r) r.enabled = !hidden;
}
/// <summary>
/// Explicitly binds this rig to the locally-owned networked player (overriding the scene-wide
/// auto-resolve, which could otherwise latch onto a remote avatar). Called by the GameModeManager
/// when the local player spawns.
/// </summary>
public void SetPlayer(PlayerCharacterController p)
{
if (!p) return;
_player = p;
_poseController = p.GetComponent<PlayerPoseController>();
Transform t = p.transform.Find("FirstPersonTarget");
if (t) cameraTarget = t;
hiddenRenderers = p.GetComponentsInChildren<SkinnedMeshRenderer>(true);
_resolved = true;
}
void ResolveReferences()
{
if (_resolved) return;
_resolved = true;
var player = FindFirstObjectByType<PlayerCharacterController>(FindObjectsInactive.Include);
if (!player) return;
_player = player;
if (!_poseController)
_poseController = player.GetComponent<PlayerPoseController>();
if (!cameraTarget)
{
Transform t = player.transform.Find("FirstPersonTarget");
if (t) cameraTarget = t;
}
if (hiddenRenderers == null || hiddenRenderers.Length == 0)
hiddenRenderers = player.GetComponentsInChildren<SkinnedMeshRenderer>(true);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43b8f1dc43642294f99fab0aaabf6197
@@ -0,0 +1,193 @@
using UnityEngine;
using Unity.Cinemachine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Free-flying spectator camera. Drives its OWN dedicated <see cref="CinemachineCamera"/> by moving
/// this transform with WASD (relative to where it looks), Q/E (or Space/Left&#160;Ctrl) for down/up
/// and the mouse for look. Collision is a <see cref="CharacterController"/> capsule sized like the
/// player's, so the freecam slides along world geometry instead of clipping through walls/floors.
///
/// Activation is driven by <see cref="FreeCamMode"/> via <see cref="SetActive"/>: it raises the vcam
/// priority so the CinemachineBrain blends to it, snaps the rig to the current view (no jump) and
/// locks the cursor. While inactive the vcam rests at a low priority and this component does nothing.
/// </summary>
[RequireComponent(typeof(CharacterController))]
[DisallowMultipleComponent]
public class FreeCamController : MonoBehaviour
{
[Header("Flight")]
[Tooltip("Base flight speed (units/second).")]
public float moveSpeed = 6f;
[Tooltip("Speed multiplier while the sprint key (Left Shift) is held.")]
public float sprintMultiplier = 3f;
[Header("Look")]
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
public float verticalSensitivity = 0.15f;
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
public bool invertY = false;
[Tooltip("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")]
[Range(1f, 89.9f)]
public float maxPitch = 89f;
[Header("Smoothing")]
[Tooltip("Acceleration/deceleration smoothing for flight, in seconds. 0 = instant (snappy starts/stops); higher = floatier ramp-up and coast-down.")]
[Range(0f, 1f)]
public float moveSmoothTime = 0.12f;
[Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")]
[Range(0f, 0.3f)]
public float lookSmoothTime = 0.04f;
[Header("Camera")]
[Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")]
public int activePriority = 100;
[Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")]
public int inactivePriority = -100;
CharacterController _controller;
CinemachineCamera _vcam;
SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with first person
Vector3 _moveVelocity; // smoothed world-space flight velocity
Vector3 _moveVelocityRef;
bool _ignoredPlayer;
void Awake()
{
_controller = GetComponent<CharacterController>();
_vcam = GetComponent<CinemachineCamera>();
if (!_vcam)
Debug.LogError("[FreeCamController] No CinemachineCamera on this GameObject; freecam cannot go live.", this);
else
_vcam.Priority = inactivePriority;
// Dormant until FreeCamMode enters; nothing reads input or moves the rig meanwhile.
enabled = false;
}
/// <summary>Enter/leave freecam. Raises/lowers the vcam priority, snaps to the live view and
/// manages the cursor. Called by <see cref="FreeCamMode"/>.</summary>
public void SetActive(bool active)
{
if (_vcam)
_vcam.Priority = active ? activePriority : inactivePriority;
if (active)
{
IgnorePlayerCollision();
SyncToCurrentView();
InputUtil.SetCursorLocked(true);
}
else
{
InputUtil.SetCursorLocked(false);
}
enabled = active;
}
void Update()
{
LookTick();
MoveTick();
}
void LookTick()
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
_look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
// Rotation is unconstrained by the CharacterController; only position is.
transform.rotation = _look.Rotation;
}
void MoveTick()
{
Vector3 dir = ReadMoveDirection();
if (dir.sqrMagnitude > 1f)
dir.Normalize();
float speed = moveSpeed * (SprintHeld() ? sprintMultiplier : 1f);
Vector3 targetVelocity = dir * speed;
// SmoothDamp eases accel/decel and is already dt-correct; 0 collapses to instant.
_moveVelocity = moveSmoothTime > 0f
? Vector3.SmoothDamp(_moveVelocity, targetVelocity, ref _moveVelocityRef, moveSmoothTime)
: targetVelocity;
_controller.Move(_moveVelocity * Time.deltaTime);
}
/// <summary>Place the rig at the currently rendered view so going live produces no visible jump.</summary>
void SyncToCurrentView()
{
var cam = Camera.main;
if (!cam) return;
CharacterTeleport.Move(_controller, transform, cam.transform.position, cam.transform.rotation);
_look.SyncTo(transform.rotation, maxPitch);
_moveVelocity = Vector3.zero;
_moveVelocityRef = Vector3.zero;
}
/// <summary>Stop the freecam capsule from being blocked by the (frozen) player's capsule.</summary>
void IgnorePlayerCollision()
{
if (_ignoredPlayer) return;
_ignoredPlayer = true;
var player = FindFirstObjectByType<PlayerCharacterController>();
if (player)
{
var playerCC = player.GetComponent<CharacterController>();
// Only an enabled capsule can take part in a collision-ignore (a spectator's body has its CC
// disabled, and there is nothing to collide with there anyway).
if (playerCC && playerCC.enabled)
Physics.IgnoreCollision(_controller, playerCC, true);
}
}
Vector3 ReadMoveDirection()
{
Vector3 dir = Vector3.zero;
#if ENABLE_INPUT_SYSTEM
var kb = Keyboard.current;
if (kb != null)
{
if (kb.wKey.isPressed) dir += transform.forward;
if (kb.sKey.isPressed) dir -= transform.forward;
if (kb.dKey.isPressed) dir += transform.right;
if (kb.aKey.isPressed) dir -= transform.right;
if (kb.eKey.isPressed || kb.spaceKey.isPressed) dir += Vector3.up;
if (kb.qKey.isPressed || kb.leftCtrlKey.isPressed) dir -= Vector3.up;
}
#else
dir += transform.forward * Input.GetAxisRaw("Vertical");
dir += transform.right * Input.GetAxisRaw("Horizontal");
if (Input.GetKey(KeyCode.E) || Input.GetKey(KeyCode.Space)) dir += Vector3.up;
if (Input.GetKey(KeyCode.Q) || Input.GetKey(KeyCode.LeftControl)) dir -= Vector3.up;
#endif
return dir;
}
bool SprintHeld()
{
#if ENABLE_INPUT_SYSTEM
return Keyboard.current != null && Keyboard.current.leftShiftKey.isPressed;
#else
return Input.GetKey(KeyCode.LeftShift);
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ca7cff7542b53b498f0a9834786b9e0
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 463b6be548abf464287096e1e92fcd01
@@ -0,0 +1,525 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
using UnityEngine.Serialization;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Holds a list of <see cref="PlayerPose"/>s plus the active index. The pose system owns ALL character
/// animation (the Animator is left empty): standing / airborne / wall / ceiling holds the active pose's
/// idle clip, walking/sprinting on the ground plays its optional walk/run clip (unset = idle held).
///
/// One key cycles poses (wrapping); sprinting in any state snaps back to the default pose, held
/// continuously so the reset shows up immediately. Clips play through a tiny <see cref="PlayableGraph"/>
/// (two-input mixer on an <see cref="AnimationPlayableOutput"/>) that crossfades every clip switch over
/// <see cref="poseBlendDuration"/>. Each pose also carries its own CharacterController capsule (eased,
/// so a low pose can crawl under furniture) and per-pose camera y-offsets (third-person framing and
/// first-person eye).
/// </summary>
[RequireComponent(typeof(PlayerCharacterController))]
[DisallowMultipleComponent]
// Run after PlayerCharacterController (default order 0) so the sprint / locomotion state polled below is
// this frame's, and the per-pose capsule / camera offset resolve without a one-frame seam.
[DefaultExecutionOrder(100)]
public class PlayerPoseController : MonoBehaviour
{
[Header("Poses")]
[Tooltip("Selectable poses. Index 0 is the default pose that sprinting resets to.")]
[SerializeField] private List<PlayerPose> poses = new();
[Tooltip("Index the selection resets to while sprinting in any state (the default/standard pose).")]
[Min(0)]
[SerializeField] private int defaultPoseIndex = 0;
[Header("Input")]
#if ENABLE_INPUT_SYSTEM
[Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
[SerializeField] private Key cycleKey = Key.C;
#else
[Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
[SerializeField] private KeyCode cycleKey = KeyCode.C;
#endif
[Header("Transitions")]
[Tooltip("Seconds to crossfade from the previous pose to a newly selected one. 0 = instant snap.")]
[Min(0f)]
[SerializeField] private float poseBlendDuration = 0.2f;
[Tooltip("Approach speed (local units/sec) for easing the per-pose camera-target y-offset toward its " +
"target while cycling poses. 0 = instant.")]
[Min(0f)]
[FormerlySerializedAs("ceilingOffsetSpeed")]
[SerializeField] private float cameraOffsetSpeed = 4f;
[Tooltip("Blend speed (0..1 per second) for resizing the CharacterController capsule toward the active " +
"pose's dimensions when the pose changes. 0 = instant snap; higher = snappier resize.")]
[Min(0f)]
[SerializeField] private float capsuleBlendSpeed = 8f;
[Tooltip("Apply Mecanim foot IK to the played humanoid clips. Off (default) plays each pose exactly as " +
"authored. On bends the knees to plant the feet on the IK goal, which makes a straight-knee idle " +
"render visibly bent even when the clip's Lower Leg Stretch muscle is fully straight.")]
[SerializeField] private bool applyFootIK = false;
private PlayerCharacterController _player;
private Animator _animator;
private string _graphName; // cached so building the graph allocates no string from the Update path
private int _currentIndex;
/// <summary>Locomotion category that selects which clip the active pose plays.</summary>
public enum Locomotion { Idle, Walk, Run, Fall }
// Network drive: a remote avatar's controllers are disabled (NetworkTransform owns the transform),
// so its movement flags are dead - pose index + locomotion come from the owner via SetNetworkState.
// _currentLocomotion is the category resolved this frame, published by the owner for replication.
private bool _networkControlled;
private Locomotion _networkLoco;
private Locomotion _currentLocomotion;
// Playables: a 2-input AnimationMixerPlayable that overrides the Animator Controller. Input 1 holds
// the current ("to") pose; input 0 holds the outgoing ("from") pose during a crossfade and is empty
// once the blend completes. Blending both inputs over a per-frame rest baseline (see below) makes
// poses interpolate instead of snapping; an empty/partial "to" clip still resolves to rest.
private PlayableGraph _graph;
private AnimationPlayableOutput _output;
private AnimationMixerPlayable _mixer;
private AnimationClipPlayable _fromPlayable;
private AnimationClipPlayable _toPlayable;
private AnimationClip _toClip; // the clip currently targeted (held on input 1)
private bool _posePlaying;
private float _blend; // 0..1 weight of the "to" input; 1 = fully on the target pose
private bool _blending;
private float _cameraPoseOffset; // eased per-pose camera-target offset routed to the controller's camera target
private float _firstPersonCameraOffset; // eased per-pose first-person eye offset, read by the FirstPersonController (never written to the shared camera target)
// Capsule blend: the CharacterController dimensions are eased toward the active pose's via a single
// normalized blend (so the capsule stays a valid shape throughout) and written through the controller.
// Snapped on the first frame so the player spawns at its pose size with no resize blend.
private bool _capsuleStarted;
private float _capFromCenterY, _capFromRadius, _capFromHeight;
private float _capToCenterY, _capToRadius, _capToHeight;
private float _capCenterY, _capRadius, _capHeight;
private float _capBlend;
// Rest-pose snapshot of the Animator subtree, taken before the Animator first evaluates. A clip only
// writes the bones its curves cover, so un-keyed bones would stay frozen on the previous animation;
// restoring rest first makes every pose define the full body. The controller's meshOffsetTarget is
// dropped from the set (see CaptureRestPose) so the wall-lean / ground raise is never wiped.
private Transform[] _restBones;
private Vector3[] _restPos;
private Quaternion[] _restRot;
private Vector3[] _restScale;
/// <summary>Index of the currently selected pose.</summary>
public int CurrentIndex => _currentIndex;
/// <summary>Locomotion category resolved this frame (which clip the active pose is playing). The owner publishes it for network sync.</summary>
public Locomotion CurrentLocomotion => _currentLocomotion;
/// <summary>
/// Switches this avatar to network-driven animation: local input, the sprint-reset, the per-pose
/// collision capsule and camera framing are all skipped; pose index and locomotion are supplied by
/// <see cref="SetNetworkState"/>. Called on a remote avatar by <see cref="PlayerAnimationSync"/>.
/// </summary>
public void EnableNetworkDrive() => _networkControlled = true;
/// <summary>Pushes the replicated pose index and locomotion category onto a network-driven avatar.</summary>
public void SetNetworkState(int poseIndex, Locomotion loco)
{
_currentIndex = WrapIndex(poseIndex);
_networkLoco = loco;
}
/// <summary>Eased per-pose y-offset for the first-person eye, applied on top of the camera target by the FirstPersonController.</summary>
public float FirstPersonCameraYOffset => _firstPersonCameraOffset;
/// <summary>Holds the avatar on its current pose frame: the Playable graph stops advancing (its bones stay
/// exactly where they are) and all per-frame locomotion / rest-reset / capsule / camera work is skipped.
/// Set per-client for every still-in-play hider during the resolution reveal so nobody can wiggle in place.
/// Clearing it resumes normal playback. Independent of network drive — works on the owner and on remotes.</summary>
public bool Frozen { get; set; }
void Awake()
{
_player = GetComponent<PlayerCharacterController>();
_animator = _player ? _player.GetComponentInChildren<Animator>() : null;
_graphName = $"{name}-Pose";
if (_animator)
CaptureRestPose();
else
Debug.LogWarning("[PlayerPoseController] No Animator on the PlayerCharacterController; poses cannot play.", this);
}
void Update()
{
// Resolution freeze: keep the graph playing (it re-asserts the frozen frame every render, even
// under Animator culling) but zero the MIXER's speed so clip time stops advancing; skip everything
// below so no locomotion/rest-reset moves a bone. The per-input clip speeds are untouched; the
// mixer multiplier is restored on the first un-frozen frame just below.
if (Frozen)
{
if (_graph.IsValid())
{
if (!_graph.IsPlaying()) _graph.Play();
if (_mixer.IsValid()) PlayableExtensions.SetSpeed(_mixer, 0d);
}
return;
}
if (_mixer.IsValid() && PlayableExtensions.GetSpeed(_mixer) == 0d)
PlayableExtensions.SetSpeed(_mixer, 1d);
// Local control only - a remote's pose index is pushed in via SetNetworkState.
if (!_networkControlled)
{
if (InputUtil.GameplayKeyPressed(cycleKey))
NextPose();
// Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default
// pose. Held continuously, so the reset shows up immediately, not only once back on the floor.
if (_player.IsSprinting)
_currentIndex = WrapIndex(defaultPoseIndex);
}
PlayerPose pose = ActivePose();
// Resolve the locomotion category and its clip: locally from the controller's movement state, on
// a remote the replicated value. ClipFor falls back to idle for unset optional clips, so a remote
// (same pose asset) resolves to exactly the clip the owner shows.
_currentLocomotion = _networkControlled ? _networkLoco : ResolveLocalLocomotion();
AnimationClip clip = ClipFor(pose, _currentLocomotion, out float speed);
PlayPose(clip, speed);
// Collision capsule and camera framing are local-owner concerns: a remote avatar's controller and
// CharacterController are disabled and no camera tracks it, so a network-driven avatar stops here.
if (_networkControlled)
return;
// Per-pose collision capsule so a low pose can crawl where the standing capsule is too tall;
// unset dimensions (<= 0) keep the default capsule.
bool hasCapsule = pose && pose.ControllerHeight > 0f && pose.ControllerRadius > 0f;
float capCenterY = hasCapsule ? pose.ControllerCenterY : _player.BaseCapsuleCenterY;
float capRadius = hasCapsule ? pose.ControllerRadius : _player.BaseCapsuleRadius;
float capHeight = hasCapsule ? pose.ControllerHeight : _player.BaseCapsuleHeight;
ApplyCapsule(capCenterY, capRadius, capHeight);
// Camera target: a per-pose framing offset (applied in any state), eased so cycling poses glides
// the camera, routed to the ThirdPersonCamera's follow target only (never the body).
float cameraPoseTarget = pose ? pose.CameraTargetYOffset : 0f;
_cameraPoseOffset = EaseCameraOffset(_cameraPoseOffset, cameraPoseTarget);
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
// Separate per-pose first-person eye offset - NOT routed to the shared camera target (that would
// also move the ThirdPersonCamera); the FirstPersonController reads it directly.
float firstPersonTarget = pose ? pose.FirstPersonCameraTargetYOffset : 0f;
_firstPersonCameraOffset = EaseCameraOffset(_firstPersonCameraOffset, firstPersonTarget);
}
/// <summary>Eases a camera y-offset toward its target at <see cref="cameraOffsetSpeed"/>; 0 snaps.</summary>
private float EaseCameraOffset(float current, float target)
{
return cameraOffsetSpeed > 0f
? Mathf.MoveTowards(current, target, cameraOffsetSpeed * Time.deltaTime)
: target;
}
void OnDisable()
{
// Hand the rig back to the Animator Controller and never leak a graph; rebuilt lazily on the
// next idle frame. Also clear the camera offset and restore the default capsule we were holding.
ReleasePose();
_cameraPoseOffset = 0f;
_firstPersonCameraOffset = 0f;
if (_player)
{
_player.SetCameraTargetLocalYOffset(0f);
_player.SetCapsule(_player.BaseCapsuleCenterY, _player.BaseCapsuleRadius, _player.BaseCapsuleHeight);
}
_capsuleStarted = false; // re-snap to the active pose's capsule if this is re-enabled
}
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
public void NextPose() => SetPoseIndex(_currentIndex + 1);
/// <summary>Selects a pose by index, wrapping into range so cycling never goes out of bounds.</summary>
public void SetPoseIndex(int index) => _currentIndex = WrapIndex(index);
private int WrapIndex(int index)
{
int count = poses.Count;
if (count == 0) return 0;
return ((index % count) + count) % count;
}
private PlayerPose ActivePose()
{
if (poses.Count == 0) return null;
return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)];
}
/// <summary>Maps the controller's current movement state to a locomotion category for clip selection.</summary>
private Locomotion ResolveLocalLocomotion()
{
if (_player.IsMovingOnGround)
return _player.IsSprinting ? Locomotion.Run : Locomotion.Walk;
if (_player.IsFalling)
return Locomotion.Fall;
return Locomotion.Idle;
}
/// <summary>
/// Returns the clip (and its <paramref name="speed"/>) for <paramref name="pose"/> in the given
/// locomotion category. The walk/run/fall clips are optional: an unset one falls back to the pose's
/// idle clip, so an unauthored category keeps the held-pose behaviour identically on the owner and on
/// a network-driven remote avatar. A null pose hands the rig back (no clip).
/// </summary>
private AnimationClip ClipFor(PlayerPose pose, Locomotion loco, out float speed)
{
if (pose)
{
switch (loco)
{
case Locomotion.Walk when pose.WalkClip:
speed = pose.WalkClipSpeed;
return pose.WalkClip;
case Locomotion.Run when pose.RunClip:
speed = pose.RunClipSpeed;
return pose.RunClip;
case Locomotion.Fall when pose.FallClip:
speed = pose.FallClipSpeed;
return pose.FallClip;
}
speed = pose.ClipSpeed;
return pose.Clip;
}
speed = 1f;
return null;
}
/// <summary>
/// Eases the CharacterController capsule toward the given target dimensions and writes them through
/// the controller. A single normalized blend drives all three values together, so the capsule stays a
/// valid shape (and its floor contact moves linearly) throughout the resize. The first call snaps, so
/// the player spawns at its pose size without a resize blend; <see cref="capsuleBlendSpeed"/> 0 also snaps.
/// </summary>
private void ApplyCapsule(float centerY, float radius, float height)
{
if (!_capsuleStarted)
{
_capsuleStarted = true;
_capFromCenterY = _capToCenterY = _capCenterY = centerY;
_capFromRadius = _capToRadius = _capRadius = radius;
_capFromHeight = _capToHeight = _capHeight = height;
_capBlend = 1f;
}
else if (centerY != _capToCenterY || radius != _capToRadius || height != _capToHeight)
{
// New target (pose changed): ease from wherever the capsule currently is.
_capFromCenterY = _capCenterY;
_capFromRadius = _capRadius;
_capFromHeight = _capHeight;
_capToCenterY = centerY;
_capToRadius = radius;
_capToHeight = height;
_capBlend = 0f;
}
_capBlend = capsuleBlendSpeed > 0f
? Mathf.MoveTowards(_capBlend, 1f, capsuleBlendSpeed * Time.deltaTime)
: 1f;
_capCenterY = Mathf.Lerp(_capFromCenterY, _capToCenterY, _capBlend);
_capRadius = Mathf.Lerp(_capFromRadius, _capToRadius, _capBlend);
_capHeight = Mathf.Lerp(_capFromHeight, _capToHeight, _capBlend);
_player.SetCapsule(_capCenterY, _capRadius, _capHeight);
}
/// <summary>
/// Plays and holds the given clip, overriding the Animator Controller. Switching to a different
/// clip crossfades from the current pose over <see cref="poseBlendDuration"/> (the first pose, or a
/// zero duration, appears instantly). Called every frame: it starts a transition on a clip change,
/// advances any in-flight blend, applies <paramref name="speed"/> to the target playback, and
/// re-snaps un-keyed bones to rest before the graph evaluates. A null clip (or no animator) releases
/// back to the controller.
/// </summary>
private void PlayPose(AnimationClip clip, float speed)
{
if (!clip || !_animator)
{
ReleasePose();
return;
}
EnsureGraph();
if (!(_posePlaying && _toClip == clip))
BeginTransition(clip);
AdvanceBlend(Time.deltaTime);
// Set every frame so live speed-slider tuning takes effect immediately; the outgoing pose on
// input 0 keeps the speed it carried, fading out at its own pace.
if (_toPlayable.IsValid())
PlayableExtensions.SetSpeed(_toPlayable, speed);
// Snap un-keyed bones to rest every frame so each mixer input blends relative to the same
// baseline; the graph then overrides the keyed bones when it evaluates later this frame.
ResetToRestPose();
if (!_graph.IsPlaying())
_graph.Play();
_posePlaying = true;
}
/// <summary>Builds the graph (a 2-input mixer feeding the Animator output) lazily if it isn't alive yet.</summary>
private void EnsureGraph()
{
if (_graph.IsValid())
return;
_graph = PlayableGraph.Create(_graphName);
_graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
_mixer = AnimationMixerPlayable.Create(_graph, 2);
_output = AnimationPlayableOutput.Create(_graph, "Pose", _animator);
_output.SetSourcePlayable(_mixer);
}
/// <summary>
/// Starts a crossfade to <paramref name="clip"/>: the in-flight target becomes the outgoing pose on
/// input 0 (carrying its playback time), the new clip goes on input 1, and the blend ramps up from
/// 0. The first pose (or a zero duration) snaps in at full weight with no fade-from-nothing.
/// </summary>
private void BeginTransition(AnimationClip clip)
{
bool hadPose = _posePlaying && _toPlayable.IsValid();
// Drop the previous outgoing pose (already faded out, or none) before reusing input 0.
if (_fromPlayable.IsValid())
{
_mixer.DisconnectInput(0);
_graph.DestroyPlayable(_fromPlayable);
_fromPlayable = default;
}
if (hadPose)
{
// Move the current target from input 1 to input 0 so it becomes the outgoing pose without a
// recreate (its animation time carries over, no jump).
_mixer.DisconnectInput(1);
_fromPlayable = _toPlayable;
_mixer.ConnectInput(0, _fromPlayable, 0);
}
_toPlayable = AnimationClipPlayable.Create(_graph, clip);
// Off by default: foot IK plants the foot on its goal and bends the knee to reach it, so a straight-knee
// pose renders bent (SampleAnimation, which the authoring tools check against, never applies it). The
// outgoing pose on input 0 is a former _toPlayable, so it already carries this setting.
_toPlayable.SetApplyFootIK(applyFootIK);
_mixer.ConnectInput(1, _toPlayable, 0);
_toClip = clip;
_blend = (hadPose && poseBlendDuration > 0f) ? 0f : 1f;
_blending = _blend < 1f;
ApplyBlendWeights();
}
/// <summary>Advances an in-flight crossfade and drops the outgoing pose once it has fully faded.</summary>
private void AdvanceBlend(float dt)
{
if (!_blending)
return;
_blend = poseBlendDuration > 0f ? Mathf.MoveTowards(_blend, 1f, dt / poseBlendDuration) : 1f;
if (_blend >= 1f)
{
_blend = 1f;
_blending = false;
if (_fromPlayable.IsValid())
{
_mixer.DisconnectInput(0);
_graph.DestroyPlayable(_fromPlayable);
_fromPlayable = default;
}
}
ApplyBlendWeights();
}
/// <summary>Pushes the current blend onto the mixer inputs (smoothstepped while a "from" pose exists).</summary>
private void ApplyBlendWeights()
{
if (!_mixer.IsValid())
return;
bool hasFrom = _fromPlayable.IsValid();
float w = hasFrom ? Mathf.SmoothStep(0f, 1f, _blend) : 1f;
_mixer.SetInputWeight(0, hasFrom ? 1f - w : 0f);
_mixer.SetInputWeight(1, _toPlayable.IsValid() ? w : 0f);
}
/// <summary>
/// Snapshots every transform a pose clip can address (the Animator's whole subtree) in its
/// authored rest pose. Taken in Awake before the Animator first evaluates, so it captures the rig
/// at rest (the paintman rig's rest pose is its T-pose).
/// </summary>
private void CaptureRestPose()
{
_restBones = _animator.transform.GetComponentsInChildren<Transform>(true);
int n = _restBones.Length;
_restPos = new Vector3[n];
_restRot = new Quaternion[n];
_restScale = new Vector3[n];
for (int i = 0; i < n; i++)
{
Transform t = _restBones[i];
_restPos[i] = t.localPosition;
_restRot[i] = t.localRotation;
_restScale[i] = t.localScale;
}
// Drop the model-offset transform from the reset set: the wall-lean / ground raise (SetMeshLean)
// is its sole writer, and with the humanoid rig the Animator shares this node - resetting it each
// frame was exactly what wiped the offsets. Nulling the slot makes ResetToRestPose skip it.
Transform offsetTarget = _player ? _player.MeshOffsetTarget : null;
if (offsetTarget)
{
int idx = System.Array.IndexOf(_restBones, offsetTarget);
if (idx >= 0)
_restBones[idx] = null;
}
}
/// <summary>Restores the captured rest pose so an empty or partial pose clip fully defines the body.</summary>
private void ResetToRestPose()
{
if (_restBones == null)
return;
for (int i = 0; i < _restBones.Length; i++)
{
Transform t = _restBones[i];
if (!t)
continue;
t.localPosition = _restPos[i];
t.localRotation = _restRot[i];
t.localScale = _restScale[i];
}
}
/// <summary>Destroys the graph so the Animator Controller (run/idle) drives the rig again.</summary>
private void ReleasePose()
{
if (!_posePlaying)
return;
if (_graph.IsValid())
_graph.Destroy();
_graph = default;
_output = default;
_mixer = default;
_fromPlayable = default;
_toPlayable = default;
_toClip = null;
_blend = 0f;
_blending = false;
_posePlaying = false;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 324ce9a23e6a5144096a195460dd567f
@@ -0,0 +1,346 @@
using UnityEngine;
using Unity.Cinemachine;
/// <summary>
/// Feeds mouse input into a <see cref="CinemachineOrbitalFollow"/> so the camera
/// orbits the follow target. Mouse movement drives yaw/pitch, the wheel zooms.
/// The orbit behaviour depends on <see cref="LookMode"/> (usually set by the
/// GameModeManager). Attach to the camera rig (the CinemachineOrbitalFollow).
/// </summary>
[RequireComponent(typeof(CinemachineOrbitalFollow))]
[DisallowMultipleComponent]
public class ThirdPersonOrbitInput : MonoBehaviour
{
public enum OrbitLookMode
{
/// <summary>Rotate with every mouse move; cursor locked/hidden.</summary>
AlwaysActive,
/// <summary>Rotate only while the right mouse button is held; cursor free otherwise.</summary>
HoldRightMouseButton,
/// <summary>No rotation; cursor free.</summary>
Disabled
}
[Header("Look Mode")]
[Tooltip("How the camera orbit is driven (usually set by GameModeManager).")]
[SerializeField] private OrbitLookMode lookMode = OrbitLookMode.AlwaysActive;
[Header("Rotation")]
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
public float verticalSensitivity = 0.15f;
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
public bool invertY = false;
[Tooltip("Eases orbit rotation so quick mouse moves ramp in/out (higher = snappier, 0 = raw/instant).")]
public float rotationSmoothing = 20f;
[Header("Zoom")]
[Tooltip("Mouse wheel zooms the camera in and out (changes orbit radius).")]
public bool enableZoom = true;
[Tooltip("Scale the zoom range, step and default radius by the follow target's size (PlayerScaler), " +
"so a small (Hider) or large (Hunter) player is framed at the same body-relative distance. " +
"The radius fields below are then size-1 design values, multiplied by the target's scale at " +
"runtime. Off = absolute world units regardless of player size.")]
public bool scaleWithTarget = true;
[Tooltip("World units the orbit radius changes per mouse wheel notch (size-1 design value; scaled by " +
"the target when Scale With Target is on).")]
public float zoomSpeed = 0.5f;
[Tooltip("Eases the orbit radius toward the zoom target (higher = snappier, 0 = instant).")]
public float zoomSmoothing = 10f;
[Tooltip("Closest orbit radius / zoom in (size-1 design value; scaled by the target).")]
public float minRadius = 1.5f;
[Tooltip("Farthest orbit radius / zoom out (size-1 design value; scaled by the target).")]
public float maxRadius = 8f;
[Tooltip("Orbit radius applied when the camera (re)binds to a player - the starting zoom level " +
"(size-1 design value; scaled by the target). Set so the bound player is comfortably framed.")]
public float defaultRadius = 4.5f;
[Header("Pan (middle mouse)")]
[Tooltip("Hold the middle mouse button and drag to pan the camera framing - a temporary offset to bring an " +
"awkward spot of the model in front of the lens for painting. The pan stays put on release and only " +
"glides back to center when paint mode is left. Enabled only while PaintMode is active.")]
public bool enablePan = true;
[Tooltip("World units the pan offset moves per pixel of mouse drag, measured at the default zoom. It then " +
"scales with the live zoom distance so the model slides the same amount on screen per pixel at any " +
"zoom (closer in = finer). Body scale is folded in automatically via the orbit radius.")]
public float panSensitivity = 0.004f;
[Tooltip("Farthest the framing can pan from center, in world units (body scale folded in). This is NOT scaled " +
"by zoom, so the maximum pan distance stays the same at every zoom level - only the sensitivity changes.")]
public float panMaxDistance = 1f;
[Tooltip("How snappily the pan glides back to center when paint mode is left (1/second; 0 = snaps home " +
"instantly). The pan stays put on middle-button release - it only returns on leaving paint mode.")]
public float panReturnSpeed = 14f;
[Header("Cursor")]
[Tooltip("Lock and hide the cursor while the camera is actively rotating.")]
public bool manageCursor = true;
CinemachineOrbitalFollow _orbit;
Vector2 _smoothedLook; // low-pass-filtered look delta (deg/frame) for eased rotation
float _targetRadius; // zoom radius the actual radius eases toward
bool _zoomInit; // _targetRadius seeded from the live radius yet?
Vector2 _pan; // accumulated pan in camera-space units (x=right, y=up); stays put on release, eases to 0 on leaving paint mode
CinemachineCameraOffset _camOffset; // post-aim extension that slides the whole framing (true pan, not an orbit arc)
/// <summary>Current orbit mode. Setting it also refreshes the cursor state.</summary>
public OrbitLookMode LookMode
{
get => lookMode;
set { lookMode = value; ApplyCursorState(); }
}
/// <summary>
/// While true, mouse-look is paused (orbit frozen) without changing the cursor
/// state. The player controller sets this while dragging the wall-climb offset so
/// the same mouse motion is not also spent orbiting the camera.
/// </summary>
public bool LookSuppressed { get; set; }
/// <summary>While true, holding the middle mouse button and dragging pans the camera framing - a temporary
/// look-point offset that stays put on release. Set by PaintMode so the pan is only available while painting;
/// clearing it (on leaving paint mode) is what glides the framing back home.</summary>
public bool PanEnabled { get; set; }
void Awake()
{
_orbit = GetComponent<CinemachineOrbitalFollow>();
if (!_orbit)
Debug.LogError("[ThirdPersonOrbitInput] No CinemachineOrbitalFollow found on this GameObject.", this);
// The pan rides a CinemachineCameraOffset extension applied AFTER aim with PreserveComposition off: it
// shifts the camera position AND the look-at point by the same world vector, so the framing translates
// (the model slides across the screen) instead of re-aiming back to center like a body TargetOffset
// would. Offset is camera-local (x=right, y=up). Added at runtime so no scene/prefab authoring is needed.
if (enablePan)
{
_camOffset = GetComponent<CinemachineCameraOffset>();
if (!_camOffset) _camOffset = gameObject.AddComponent<CinemachineCameraOffset>();
_camOffset.ApplyAfter = CinemachineCore.Stage.Aim;
_camOffset.PreserveComposition = false;
_camOffset.Offset = Vector3.zero;
}
}
void OnEnable() => ApplyCursorState();
void OnDisable()
{
InputUtil.SetCursorLocked(false);
// Deliberately DON'T zero the pan: only FreeCamMode disables this component, and the freecam
// snaps to the live (still panned) view - resetting now would jump the blend-FROM camera to
// center while the freecam sits at the panned pose (the visible snap on the paint->freecam
// switch). Frozen offset = blend-from == blend-to; the pan glides home once the orbit runs again.
}
/// <summary>Uniform scale of the current follow target (1 when none, or when Scale With Target is off).
/// The zoom range, step and default radius are multiplied by this so the framing is body-relative across
/// player sizes. PlayerScaler keeps the player uniformly scaled, so a single lossyScale component is exact.</summary>
float CurrentScale()
{
if (!scaleWithTarget || !_orbit) return 1f;
Transform t = _orbit.FollowTarget;
return t ? Mathf.Max(0.0001f, t.lossyScale.x) : 1f;
}
/// <summary>Pan-SENSITIVITY scale with the live zoom: radius / defaultRadius = 1 at the default zoom,
/// finer zoomed in, coarser zoomed out - the model slides the same amount ON SCREEN per pixel at any
/// zoom. Body scale cancels out (the radius already embeds it). The clamp deliberately does NOT use
/// this: panMaxDistance stays body-relative only, constant across zoom levels.</summary>
float PanZoomScale() => _orbit ? _orbit.Radius / Mathf.Max(0.0001f, defaultRadius) : 1f;
/// <summary>Snaps the zoom to the scaled default radius. Called when a new avatar binds (see
/// GameModeManager.BindLocalPlayer) so each player starts at its body-relative default zoom instead of
/// inheriting the previous avatar's radius.</summary>
public void ResetZoom()
{
if (!_orbit) return;
_targetRadius = defaultRadius * CurrentScale();
_orbit.Radius = _targetRadius;
_zoomInit = true;
}
/// <summary>Snaps the orbit axes to Camera.main's current view direction so the next orbit mode keeps
/// that facing instead of the orbit's last-used angle. Called when switching out of a non-orbit rig:
/// Camera.main still shows that rig's view this frame (the brain hasn't blended yet), so the read is
/// exact. With WorldSpace binding + Sphere orbit + a centered composer the look rotation is exactly
/// Euler(VerticalAxis, HorizontalAxis, 0).</summary>
public void SyncToView()
{
if (!_orbit) return;
var cam = Camera.main;
if (!cam) return;
Vector3 e = cam.transform.rotation.eulerAngles;
SetAxisValue(ref _orbit.HorizontalAxis, e.y > 180f ? e.y - 360f : e.y);
SetAxisValue(ref _orbit.VerticalAxis, e.x > 180f ? e.x - 360f : e.x);
// Drop any eased look carry-over so the snapped angle isn't immediately dragged back.
_smoothedLook = Vector2.zero;
}
void Update()
{
if (!_orbit)
return;
bool modeWantsLook = ShouldRotate();
if (manageCursor)
InputUtil.SetCursorLocked(modeWantsLook);
if (modeWantsLook && !LookSuppressed)
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
Vector2 raw = new Vector2(
delta.x * horizontalSensitivity,
delta.y * verticalSensitivity * (invertY ? 1f : -1f));
// Ease the per-frame look toward the raw delta so fast flicks ramp in/out
// instead of snapping; steady motion still converges to full sensitivity.
Vector2 applied = rotationSmoothing > 0f
? (_smoothedLook = Vector2.Lerp(_smoothedLook, raw, 1f - Mathf.Exp(-rotationSmoothing * Time.deltaTime)))
: raw;
if (applied.sqrMagnitude > 0f)
{
ApplyAxisDelta(ref _orbit.HorizontalAxis, applied.x);
ApplyAxisDelta(ref _orbit.VerticalAxis, applied.y);
}
}
else
{
_smoothedLook = Vector2.zero;
}
if (enableZoom)
{
if (!_zoomInit)
{
// Seed the starting zoom at the scaled default (not the live radius) so a fresh bind frames
// the player body-relative; ResetZoom() does the same on an explicit (re)bind.
_targetRadius = defaultRadius * CurrentScale();
_orbit.Radius = _targetRadius;
_zoomInit = true;
}
float scale = CurrentScale();
// Shift/Ctrl+wheel is reserved for the paint brush resize (MeshPainter), so the orbit camera
// ignores the wheel while either is held - otherwise it would zoom and resize at once.
float scroll = (InputUtil.ShiftHeld() || InputUtil.CtrlHeld()) ? 0f : InputUtil.ReadScroll();
if (!Mathf.Approximately(scroll, 0f))
{
// One wheel notch = one zoom step (sign only, platform-independent). The step scales with
// the player so the notch count to cross the range is the same at every size.
_targetRadius -= Mathf.Sign(scroll) * zoomSpeed * scale;
}
// Clamp every frame against the scaled range so a runtime size change (PlayerScaler grow/shrink)
// keeps the target in-range instead of sticking at a stale absolute bound.
_targetRadius = Mathf.Clamp(_targetRadius, minRadius * scale, maxRadius * scale);
// Ease the actual radius toward the target (frame-rate independent).
_orbit.Radius = zoomSmoothing > 0f
? Mathf.Lerp(_orbit.Radius, _targetRadius, 1f - Mathf.Exp(-zoomSmoothing * Time.deltaTime))
: _targetRadius;
}
UpdatePan(Time.deltaTime);
}
bool ShouldRotate()
{
switch (lookMode)
{
case OrbitLookMode.AlwaysActive: return true;
case OrbitLookMode.HoldRightMouseButton: return InputUtil.RightMouseHeld();
default: return false;
}
}
void ApplyCursorState()
{
if (manageCursor)
InputUtil.SetCursorLocked(ShouldRotate());
}
/// <summary>Adds to an input axis; wrapping axes wrap, others clamp to their range.</summary>
static void ApplyAxisDelta(ref InputAxis axis, float amount)
=> SetAxisValue(ref axis, axis.Value + amount);
/// <summary>Sets an input axis to an absolute value; wrapping axes wrap, others clamp to their range.</summary>
static void SetAxisValue(ref InputAxis axis, float value)
{
float min = axis.Range.x;
float max = axis.Range.y;
if (axis.Wrap && max > min)
{
float span = max - min;
value = ((value - min) % span + span) % span + min;
}
else
{
value = Mathf.Clamp(value, min, max);
}
axis.Value = value;
}
/// <summary>Middle-mouse pan (while PaintMode enables it): slides the whole framing along the screen
/// right/up axes via the camera-offset extension - a temporary offset to line up an awkward spot of
/// the model for painting. Eases home outside paint mode / while the orbit is Disabled.</summary>
void UpdatePan(float dt)
{
if (!enablePan || !_camOffset)
return;
bool active = PanEnabled && lookMode != OrbitLookMode.Disabled;
float zoom = PanZoomScale();
if (active && InputUtil.MiddleMouseHeld())
{
Vector2 d = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
// Subtracting gives a hand-tool feel (the model follows the cursor). The step scales with the
// zoom (same on-screen feel, see PanZoomScale); the clamp stays body-relative only.
_pan -= d * (panSensitivity * zoom);
_pan = Vector2.ClampMagnitude(_pan, panMaxDistance * CurrentScale());
}
else if (active)
{
return; // still painting, middle button released - hold the pan where it was left
}
else if (_pan != Vector2.zero)
{
// Left paint mode (or look disabled) - glide the framing back to center.
if (panReturnSpeed > 0f)
_pan = Vector2.Lerp(_pan, Vector2.zero, 1f - Mathf.Exp(-panReturnSpeed * dt));
else
_pan = Vector2.zero;
// Snap the last sliver so the offset settles exactly on center and this branch stops running.
if (_pan.sqrMagnitude < 1e-8f)
_pan = Vector2.zero;
}
else
{
return; // at rest and not panning - nothing to write
}
// Offset is camera-local (x=right, y=up); the extension translates eye + look-at together -> true pan.
_camOffset.Offset = new Vector3(_pan.x, _pan.y, 0f);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: df129be8fadab79498bd429a59bb98e4