Add free-camera mode with player-equivalent collision

Introduce a free-fly camera as the next entry in the Tab mode cycle
(Player Control -> Paint -> Free Cam):

- FreeCamController: dedicated CinemachineCamera plus a CharacterController
  mirroring the player capsule (radius 0.1, height 1.3, slope 59, step 0.15)
  so the free camera collides with walls/ceiling/floor exactly like the
  player. WASD/Q-E (and Space/Ctrl) fly relative to view; mouse looks.
- FreeCamMode: plain-C# GameMode mirroring PaintMode, with an Exit() that
  restores camera priority and re-enables player control + camera input.
- GameModeManager: register freeCam in the Tab cycle (gated like player).
- Scene: FreeCamera object wired, Main Camera custom blends (0.4s) for the
  vcam priority hand-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-26 23:01:14 +02:00
1 parent 0edc59555a
commit a3ef7055b4
10 files changed
+472 -19

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
using UnityEngine;
using Unity.Cinemachine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Free-flying spectator camera. Drives its OWN dedicated <see cref="CinemachineCamera"/> by moving
/// this transform with WASD (relative to where it looks), Q/E (or Space/Left&#160;Ctrl) for down/up
/// and the mouse for look. Collision is a <see cref="CharacterController"/> capsule sized like the
/// player's, so the freecam slides along world geometry instead of clipping through walls/floors.
///
/// Activation is driven by <see cref="FreeCamMode"/> via <see cref="SetActive"/>: it raises the vcam
/// priority so the CinemachineBrain blends to it, snaps the rig to the current view (no jump) and
/// locks the cursor. While inactive the vcam rests at a low priority and this component does nothing.
/// </summary>
[RequireComponent(typeof(CharacterController))]
[DisallowMultipleComponent]
public class FreeCamController : MonoBehaviour
{
[Header("Flight")]
[Tooltip("Base flight speed (units/second).")]
public float moveSpeed = 6f;
[Tooltip("Speed multiplier while the sprint key (Left Shift) is held.")]
public float sprintMultiplier = 3f;
[Header("Look")]
[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("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")]
[Range(1f, 89.9f)]
public float maxPitch = 89f;
[Header("Camera")]
[Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")]
public int activePriority = 100;
[Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")]
public int inactivePriority = -100;
CharacterController _controller;
CinemachineCamera _vcam;
float _yaw;
float _pitch;
bool _ignoredPlayer;
void Awake()
{
_controller = GetComponent<CharacterController>();
_vcam = GetComponent<CinemachineCamera>();
if (!_vcam)
Debug.LogError("[FreeCamController] No CinemachineCamera on this GameObject; freecam cannot go live.", this);
else
_vcam.Priority = inactivePriority;
// Dormant until FreeCamMode enters; nothing reads input or moves the rig meanwhile.
enabled = false;
}
/// <summary>Enter/leave freecam. Raises/lowers the vcam priority, snaps to the live view and
/// manages the cursor. Called by <see cref="FreeCamMode"/>.</summary>
public void SetActive(bool active)
{
if (_vcam)
_vcam.Priority = active ? activePriority : inactivePriority;
if (active)
{
IgnorePlayerCollision();
SyncToCurrentView();
SetCursorLocked(true);
}
else
{
SetCursorLocked(false);
}
enabled = active;
}
void Update()
{
LookTick();
MoveTick();
}
void LookTick()
{
Vector2 delta = ReadMouseDelta();
_yaw += delta.x * horizontalSensitivity;
float vs = verticalSensitivity * (invertY ? 1f : -1f);
_pitch = Mathf.Clamp(_pitch + delta.y * vs, -maxPitch, maxPitch);
// Rotation is unconstrained by the CharacterController; only position is.
transform.rotation = Quaternion.Euler(_pitch, _yaw, 0f);
}
void MoveTick()
{
Vector3 dir = ReadMoveDirection();
if (dir.sqrMagnitude > 1f)
dir.Normalize();
float speed = moveSpeed * (SprintHeld() ? sprintMultiplier : 1f);
_controller.Move(dir * (speed * Time.deltaTime));
}
/// <summary>Place the rig at the currently rendered view so going live produces no visible jump.</summary>
void SyncToCurrentView()
{
var cam = Camera.main;
if (!cam) return;
// CharacterController forbids direct transform writes while enabled, so toggle it for the teleport.
bool wasEnabled = _controller.enabled;
_controller.enabled = false;
transform.SetPositionAndRotation(cam.transform.position, cam.transform.rotation);
_controller.enabled = wasEnabled;
Vector3 e = transform.rotation.eulerAngles;
_yaw = e.y;
float p = e.x > 180f ? e.x - 360f : e.x;
_pitch = Mathf.Clamp(p, -maxPitch, maxPitch);
}
/// <summary>Stop the freecam capsule from being blocked by the (frozen) player's capsule.</summary>
void IgnorePlayerCollision()
{
if (_ignoredPlayer) return;
_ignoredPlayer = true;
var player = FindFirstObjectByType<PlayerCharacterController>();
if (player)
{
var playerCC = player.GetComponent<CharacterController>();
if (playerCC)
Physics.IgnoreCollision(_controller, playerCC, true);
}
}
Vector3 ReadMoveDirection()
{
Vector3 dir = Vector3.zero;
#if ENABLE_INPUT_SYSTEM
var kb = Keyboard.current;
if (kb != null)
{
if (kb.wKey.isPressed) dir += transform.forward;
if (kb.sKey.isPressed) dir -= transform.forward;
if (kb.dKey.isPressed) dir += transform.right;
if (kb.aKey.isPressed) dir -= transform.right;
if (kb.eKey.isPressed || kb.spaceKey.isPressed) dir += Vector3.up;
if (kb.qKey.isPressed || kb.leftCtrlKey.isPressed) dir -= Vector3.up;
}
#else
dir += transform.forward * Input.GetAxisRaw("Vertical");
dir += transform.right * Input.GetAxisRaw("Horizontal");
if (Input.GetKey(KeyCode.E) || Input.GetKey(KeyCode.Space)) dir += Vector3.up;
if (Input.GetKey(KeyCode.Q) || Input.GetKey(KeyCode.LeftControl)) dir -= Vector3.up;
#endif
return dir;
}
bool SprintHeld()
{
#if ENABLE_INPUT_SYSTEM
return Keyboard.current != null && Keyboard.current.leftShiftKey.isPressed;
#else
return Input.GetKey(KeyCode.LeftShift);
#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
}
static void SetCursorLocked(bool locked)
{
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
Cursor.visible = !locked;
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ca7cff7542b53b498f0a9834786b9e0
+41
View File
@@ -0,0 +1,41 @@
/// <summary>
/// Free-fly camera mode. The player is frozen in place, painting is off, and a dedicated
/// freecam (<see cref="FreeCamController"/> + its own CinemachineCamera) takes over: fly the
/// camera through the map with WASD/Q/E and the mouse, colliding with world geometry like the
/// player. The orbit camera input is disabled while here so it doesn't fight for the cursor.
/// </summary>
public class FreeCamMode : GameMode
{
public override string DisplayName => "Free Cam";
public override void Enter()
{
// Keep the player component alive (gravity/grounding) but reject input, like Paint mode.
if (Manager.Player)
{
Manager.Player.enabled = true;
Manager.Player.ControlEnabled = false;
}
if (Manager.Painter)
Manager.Painter.PaintingEnabled = false;
// The orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame.
if (Manager.CameraInput)
Manager.CameraInput.enabled = false;
if (Manager.FreeCam)
Manager.FreeCam.SetActive(true);
}
public override void Exit()
{
// Modes are Enter-only, so freecam must undo its own camera takeover here; the next mode's
// Enter restores the player/painter/look-mode state.
if (Manager.FreeCam)
Manager.FreeCam.SetActive(false);
if (Manager.CameraInput)
Manager.CameraInput.enabled = true;
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a221ef0b363824540993c714c0fc7385
+11 -1
View File
@@ -5,7 +5,7 @@ using UnityEngine.InputSystem;
#endif
/// <summary>
/// Switches the game between high-level modes (Player Control, Paint). The switch
/// Switches the game between high-level modes (Player Control, Paint, Free Cam). The switch
/// key (Tab) cycles to the next registered mode. Register additional modes in
/// <see cref="BuildModes"/>. Attach to a single, persistent scene GameObject.
/// </summary>
@@ -22,6 +22,9 @@ public class GameModeManager : MonoBehaviour
[Tooltip("Mesh painter. Empty = found automatically.")]
public MeshPainter painter;
[Tooltip("Free-fly spectator camera. Empty = found automatically. Absent = Free Cam is left out of the cycle.")]
public FreeCamController freeCam;
//[Tooltip("Insect / surface-walk controller. Empty = found automatically.")]
//public InsectController insect;
@@ -39,6 +42,7 @@ public class GameModeManager : MonoBehaviour
public PlayerCharacterController Player => player;
public ThirdPersonOrbitInput CameraInput => cameraInput;
public MeshPainter Painter => painter;
public FreeCamController FreeCam => freeCam;
//public InsectController Insect => insect;
/// <summary>Raised after a mode switch finishes (the new mode's Enter has already run).
@@ -57,6 +61,8 @@ public class GameModeManager : MonoBehaviour
if (!player) player = FindFirstObjectByType<PlayerCharacterController>();
if (!cameraInput) cameraInput = FindFirstObjectByType<ThirdPersonOrbitInput>();
if (!painter) painter = FindFirstObjectByType<MeshPainter>();
// Include inactive so a freecam rig that starts disabled is still found and wired.
if (!freeCam) freeCam = FindFirstObjectByType<FreeCamController>(FindObjectsInactive.Include);
//if (!insect) insect = FindFirstObjectByType<InsectController>();
BuildModes();
@@ -80,6 +86,10 @@ public class GameModeManager : MonoBehaviour
_modes.Add(new PaintMode());
// Free Cam only joins the cycle when a freecam rig is present, so scenes without one are unchanged.
if (freeCam)
_modes.Add(new FreeCamMode());
// Insect mode only joins the Tab cycle when a surface walker is present, so a scene
// without one behaves exactly as before (no dead, player-freezing mode in the cycle).
//if (insect)