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, measured at the default zoom. It scales with the zoom " + "distance too, so the maximum on-screen pan reach stays the same at every zoom level.")] 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); // Drop any live pan so a disabled rig doesn't leave a stale framing offset behind. _pan = Vector2.zero; if (_camOffset) _camOffset.Offset = Vector3.zero; } /// 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 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/panMaxDistance keep their /// feel there), smaller when zoomed in (finer pan, less reach) 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, just like CurrentScale() did at the default /// radius. The result: the model slides the same amount ON SCREEN per pixel, and the same maximum reach /// across the screen, regardless of how far the wheel has zoomed. 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). Both the step and the clamp scale with the orbit distance so the pan feels the same // on screen at every zoom - finer when zoomed in, and the same maximum reach across the screen. _pan -= d * (panSensitivity * zoom); _pan = Vector2.ClampMagnitude(_pan, panMaxDistance * zoom); } 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 } }