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