using UnityEngine;
using Unity.Cinemachine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
///
/// Feeds mouse input into a so the camera
/// orbits the follow target. Mouse movement drives yaw/pitch, the wheel zooms.
/// The orbit behaviour depends on (usually set by the
/// GameModeManager). Attach to the camera rig (the CinemachineOrbitalFollow).
///
[RequireComponent(typeof(CinemachineOrbitalFollow))]
[DisallowMultipleComponent]
public class ThirdPersonOrbitInput : MonoBehaviour
{
public enum OrbitLookMode
{
/// Rotate with every mouse move; cursor locked/hidden.
AlwaysActive,
/// Rotate only while the right mouse button is held; cursor free otherwise.
HoldRightMouseButton,
/// No rotation; cursor free.
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)
/// Current orbit mode. Setting it also refreshes the cursor state.
public OrbitLookMode LookMode
{
get => lookMode;
set { lookMode = value; ApplyCursorState(); }
}
///
/// 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.
///
public bool LookSuppressed { get; set; }
/// 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.
public bool PanEnabled { get; set; }
void Awake()
{
_orbit = GetComponent();
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();
if (!_camOffset) _camOffset = gameObject.AddComponent();
_camOffset.ApplyAfter = CinemachineCore.Stage.Aim;
_camOffset.PreserveComposition = false;
_camOffset.Offset = Vector3.zero;
}
}
void OnEnable() => ApplyCursorState();
void OnDisable()
{
SetCursorLocked(false);
// Deliberately DON'T zero the pan here. The only thing that disables this component is FreeCamMode
// taking over, and the freecam snaps to the live (still panned) view. Resetting the offset now would
// jump the orbit camera the brain blends FROM to center while the freecam sits at the panned pose -
// the visible "snap to 0, then translate to the offset" on the paint->freecam switch. Leaving the
// offset frozen keeps blend-from == blend-to (both panned) -> a seamless handoff. The pan then glides
// home on its own once the orbit input runs again in a non-paint mode (PanEnabled false).
}
/// 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.
float CurrentScale()
{
if (!scaleWithTarget || !_orbit) return 1f;
Transform t = _orbit.FollowTarget;
return t ? Mathf.Max(0.0001f, t.lossyScale.x) : 1f;
}
/// How much the pan SENSITIVITY scales with the live zoom distance. Dividing the orbit radius by the
/// default radius makes this exactly 1 at the default zoom (so the authored panSensitivity keeps its feel
/// there), smaller when zoomed in (finer pan) and larger when zoomed out. The body scale cancels out because
/// the radius already embeds it - a bigger player orbits proportionally farther - so the pan stays
/// body-relative across player sizes at every zoom. The result: the model slides the same amount ON SCREEN
/// per pixel regardless of how far the wheel has zoomed. The clamp does NOT use this - panMaxDistance is
/// body-relative only (CurrentScale), so the world-space pan limit is constant across zoom levels.
float PanZoomScale() => _orbit ? _orbit.Radius / Mathf.Max(0.0001f, defaultRadius) : 1f;
/// 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.
public void ResetZoom()
{
if (!_orbit) return;
_targetRadius = defaultRadius * CurrentScale();
_orbit.Radius = _targetRadius;
_zoomInit = true;
}
/// Snaps the orbit axes so the camera faces along Camera.main's current view direction, so the
/// next orbit mode keeps that facing instead of restoring the orbit's last-used angle. Called when
/// switching out of a non-orbit rig (first person): 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 camera look rotation is exactly Euler(VerticalAxis, HorizontalAxis, 0), so
/// HorizontalAxis = world yaw and VerticalAxis = world pitch (positive = looking down). No-op without a
/// live camera/orbit.
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)
SetCursorLocked(modeWantsLook);
if (modeWantsLook && !LookSuppressed)
{
Vector2 delta = 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 = (ShiftHeld() || CtrlHeld()) ? 0f : ReadScroll();
if (!Mathf.Approximately(scroll, 0f))
{
// One wheel notch = one zoom step (sign only), independent of the platform's raw wheel
// magnitude. Scrolling up zooms in. Step scales with the player so the number of notches 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 IsRightMouseHeld();
default: return false;
}
}
void ApplyCursorState()
{
if (manageCursor)
SetCursorLocked(ShouldRotate());
}
/// Adds to an input axis; wrapping axes wrap, others clamp to their range.
static void ApplyAxisDelta(ref InputAxis axis, float amount)
=> SetAxisValue(ref axis, axis.Value + amount);
/// Sets an input axis to an absolute value; wrapping axes wrap, others clamp to their range.
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;
}
static void SetCursorLocked(bool locked)
{
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
Cursor.visible = !locked;
}
bool IsRightMouseHeld()
{
#if ENABLE_INPUT_SYSTEM
return Mouse.current != null && Mouse.current.rightButton.isPressed;
#else
return Input.GetMouseButton(1);
#endif
}
bool IsMiddleMouseHeld()
{
#if ENABLE_INPUT_SYSTEM
return Mouse.current != null && Mouse.current.middleButton.isPressed;
#else
return Input.GetMouseButton(2);
#endif
}
/// Middle-mouse pan: while PaintMode enables it, holding the middle button and dragging slides the
/// whole framing along the screen right/up axes (via the camera-offset extension), then eases the offset
/// back to center on release. A temporary framing offset to line up an awkward spot of the model for
/// painting - the player body and its paint collider are untouched. Disabled (eases home) outside paint mode
/// and while the orbit is parked in (pause / no-player).
void UpdatePan(float dt)
{
if (!enablePan || !_camOffset)
return;
bool active = PanEnabled && lookMode != OrbitLookMode.Disabled;
float zoom = PanZoomScale();
if (active && IsMiddleMouseHeld())
{
Vector2 d = ReadMouseDelta() * MouseSensitivity.Multiplier;
// Subtracting the drag gives a hand-tool feel (the model follows the cursor: drag right -> model
// slides right). The step scales with the orbit distance so it feels the same on screen at every
// zoom (finer when zoomed in). The clamp stays body-relative only (no zoom factor), so the maximum
// pan distance in world units is the same at every zoom level.
_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);
}
static bool ShiftHeld()
{
#if ENABLE_INPUT_SYSTEM
var kb = Keyboard.current;
if (kb != null) return kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed;
#endif
#if ENABLE_LEGACY_INPUT_MANAGER
return Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
#else
return false;
#endif
}
static bool CtrlHeld()
{
#if ENABLE_INPUT_SYSTEM
var kb = Keyboard.current;
if (kb != null) return kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed;
#endif
#if ENABLE_LEGACY_INPUT_MANAGER
return Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl);
#else
return false;
#endif
}
Vector2 ReadMouseDelta()
{
#if ENABLE_INPUT_SYSTEM
return Mouse.current != null ? Mouse.current.delta.ReadValue() : Vector2.zero;
#else
return new Vector2(Input.GetAxisRaw("Mouse X"), Input.GetAxisRaw("Mouse Y")) * 10f;
#endif
}
float ReadScroll()
{
#if ENABLE_INPUT_SYSTEM
// Raw wheel delta; only its sign is used (one notch = one zoom step).
return Mouse.current != null ? Mouse.current.scroll.ReadValue().y : 0f;
#else
return Input.GetAxis("Mouse ScrollWheel");
#endif
}
}