diff --git a/Assets/Scripts/PaintMode.cs b/Assets/Scripts/PaintMode.cs index 49f7cde..3d2be60 100644 --- a/Assets/Scripts/PaintMode.cs +++ b/Assets/Scripts/PaintMode.cs @@ -1,6 +1,7 @@ /// /// Player stands still, the mesh is painted with the left mouse button. The -/// camera orbits only while the right mouse button is held. +/// camera orbits only while the right mouse button is held, and the middle +/// mouse button pans the framing (a temporary offset to line up the model). /// public class PaintMode : GameMode { @@ -18,9 +19,19 @@ public class PaintMode : GameMode } if (Manager.CameraInput) + { Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.HoldRightMouseButton; + Manager.CameraInput.PanEnabled = true; + } if (Manager.Painter) Manager.Painter.PaintingEnabled = true; } + + public override void Exit() + { + // Leaving paint mode releases the middle-mouse pan; the orbit input then eases the framing home. + if (Manager.CameraInput) + Manager.CameraInput.PanEnabled = false; + } } diff --git a/Assets/Scripts/ThirdPersonOrbitInput.cs b/Assets/Scripts/ThirdPersonOrbitInput.cs index 774baec..341d502 100644 --- a/Assets/Scripts/ThirdPersonOrbitInput.cs +++ b/Assets/Scripts/ThirdPersonOrbitInput.cs @@ -68,6 +68,25 @@ public class ThirdPersonOrbitInput : MonoBehaviour "(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; @@ -76,6 +95,8 @@ public class ThirdPersonOrbitInput : MonoBehaviour 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 @@ -91,16 +112,40 @@ public class ThirdPersonOrbitInput : MonoBehaviour /// 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); + 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 @@ -112,6 +157,15 @@ public class ThirdPersonOrbitInput : MonoBehaviour 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. @@ -211,6 +265,8 @@ public class ThirdPersonOrbitInput : MonoBehaviour ? Mathf.Lerp(_orbit.Radius, _targetRadius, 1f - Mathf.Exp(-zoomSmoothing * Time.deltaTime)) : _targetRadius; } + + UpdatePan(Time.deltaTime); } bool ShouldRotate() @@ -267,6 +323,61 @@ public class ThirdPersonOrbitInput : MonoBehaviour #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