Files
chameleon/Assets/Scripts/ThirdPersonOrbitInput.cs
T
adminandClaude Opus 4.8 6fcada4631 Keep the pan limit constant across zoom and hand off cleanly to free cam
Two paint-mode pan refinements:
- The max-distance clamp is back to body-relative only (CurrentScale, no zoom
  factor), so the world-space pan limit is identical at every zoom level;
  only the sensitivity stays zoom-scaled (finer when zoomed in).
- Don't zero the pan offset in OnDisable. The only thing that disables this
  component is FreeCamMode taking over, and the freecam snaps to the live
  (still panned) view. Zeroing here jumped the orbit camera the brain blends
  FROM to center while the freecam sat at the panned pose - the visible
  "snap to 0, then translate to the offset" on the paint->free-cam switch.
  Leaving the offset frozen keeps blend-from == blend-to (both panned) for a
  seamless handoff; the pan glides home on its own once the orbit input next
  runs in a non-paint mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:53:35 +02:00

430 lines
19 KiB
C#

using UnityEngine;
using Unity.Cinemachine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <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()
{
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).
}
/// <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>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.</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 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.</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)
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());
}
/// <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;
}
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
}
/// <summary>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 <see cref="OrbitLookMode.Disabled"/> (pause / no-player).</summary>
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
}
}