Add first-person camera mode with pose-aware eye offset and yaw facing
Introduce a FirstPersonController / FirstPersonMode pair (mirroring the free-cam mode) wired into the GameModeManager Tab cycle. In first person the mesh is hidden, the eye sits on CameraTarget plus a per-pose firstPersonCameraTargetYOffset, and the character body yaw is driven to match the camera via a new single-writer FaceYaw hook on PlayerCharacterController. Also includes user content edits (poses, prefabs, scene) carried along. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
381b21cd02
commit
cfb767f6cb
17 files changed
+681
-91
No files matched your search
@@ -0,0 +1,214 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// First-person camera. Drives its OWN dedicated <see cref="CinemachineCamera"/>: each frame it snaps
|
||||
/// to the player's <c>CameraTarget</c> (the same point the ThirdPersonCamera tracks) for position and
|
||||
/// applies free mouse-look (yaw/pitch) for rotation. While active the player's mesh is hidden so the
|
||||
/// body isn't seen from the inside. Movement stays with the player controller, which reads the
|
||||
/// brain-driven Main Camera forward, so WASD is automatically relative to this view.
|
||||
///
|
||||
/// Activation is driven by <see cref="FirstPersonMode"/> via <see cref="SetActive"/>: it raises the
|
||||
/// vcam priority so the CinemachineBrain blends to it, snaps look to the current view (no jump), hides
|
||||
/// the mesh and locks the cursor. While inactive the vcam rests at a low priority and this component
|
||||
/// does nothing.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CinemachineCamera))]
|
||||
[DisallowMultipleComponent]
|
||||
public class FirstPersonController : MonoBehaviour
|
||||
{
|
||||
[Header("Target")]
|
||||
[Tooltip("Eye position the camera sits at (the player's CameraTarget). Empty = auto-resolved from the player's \"CameraTarget\" child.")]
|
||||
public Transform cameraTarget;
|
||||
|
||||
[Tooltip("Renderers hidden while first-person is active (so you don't see your own body). Empty = the player's SkinnedMeshRenderers, auto-resolved.")]
|
||||
public Renderer[] hiddenRenderers;
|
||||
|
||||
[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("Smoothing")]
|
||||
[Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")]
|
||||
[Range(0f, 0.3f)]
|
||||
public float lookSmoothTime = 0.04f;
|
||||
|
||||
[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;
|
||||
|
||||
CinemachineCamera _vcam;
|
||||
PlayerCharacterController _player; // body whose yaw we pin to the camera while active
|
||||
PlayerPoseController _poseController; // supplies the active pose's per-pose first-person eye offset
|
||||
float _yaw; // look target (raw input accumulates here)
|
||||
float _pitch;
|
||||
float _displayYaw; // smoothed value actually applied to the transform
|
||||
float _displayPitch;
|
||||
float _yawSmoothRef;
|
||||
float _pitchSmoothRef;
|
||||
bool _resolved;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_vcam = GetComponent<CinemachineCamera>();
|
||||
if (!_vcam)
|
||||
Debug.LogError("[FirstPersonController] No CinemachineCamera on this GameObject; first-person cannot go live.", this);
|
||||
else
|
||||
_vcam.Priority = inactivePriority;
|
||||
|
||||
ResolveReferences();
|
||||
|
||||
// Dormant until FirstPersonMode enters; nothing reads input or moves the rig meanwhile.
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>Enter/leave first-person. Raises/lowers the vcam priority, hides the player mesh, snaps
|
||||
/// look to the live view and manages the cursor. Called by <see cref="FirstPersonMode"/>.</summary>
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (_vcam)
|
||||
_vcam.Priority = active ? activePriority : inactivePriority;
|
||||
|
||||
SetMeshHidden(active);
|
||||
|
||||
if (active)
|
||||
{
|
||||
SyncToCurrentView();
|
||||
SetCursorLocked(true);
|
||||
ApplyTransform(); // place at the eye on the same frame so the blend starts from there
|
||||
if (_player) _player.FaceYaw = _displayYaw; // body faces the camera from frame one
|
||||
}
|
||||
else
|
||||
{
|
||||
SetCursorLocked(false);
|
||||
if (_player) _player.FaceYaw = null; // restore movement-facing
|
||||
}
|
||||
|
||||
enabled = active;
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
// LateUpdate so the player has already moved the CameraTarget this frame.
|
||||
LookTick();
|
||||
ApplyTransform();
|
||||
if (_player) _player.FaceYaw = _displayYaw; // keep the body's yaw locked to the camera
|
||||
}
|
||||
|
||||
void LookTick()
|
||||
{
|
||||
Vector2 delta = ReadMouseDelta();
|
||||
_yaw += delta.x * horizontalSensitivity;
|
||||
float vs = verticalSensitivity * (invertY ? 1f : -1f);
|
||||
_pitch = Mathf.Clamp(_pitch + delta.y * vs, -maxPitch, maxPitch);
|
||||
|
||||
if (lookSmoothTime > 0f)
|
||||
{
|
||||
// SmoothDampAngle is frame-rate-independent and wraps yaw correctly.
|
||||
_displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, lookSmoothTime);
|
||||
_displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, lookSmoothTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
_displayYaw = _yaw;
|
||||
_displayPitch = _pitch;
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyTransform()
|
||||
{
|
||||
Quaternion rot = Quaternion.Euler(_displayPitch, _displayYaw, 0f);
|
||||
if (cameraTarget)
|
||||
{
|
||||
// Sit at the shared camera target, then raise/lower the eye by the active pose's first-person
|
||||
// offset. TransformVector keeps it in the target's local up and scales with the player (so it
|
||||
// tracks PlayerScaler), without disturbing the target the ThirdPersonCamera shares.
|
||||
Vector3 pos = cameraTarget.position;
|
||||
float offset = _poseController ? _poseController.FirstPersonCameraYOffset : 0f;
|
||||
if (offset != 0f)
|
||||
pos += cameraTarget.TransformVector(Vector3.up * offset);
|
||||
transform.SetPositionAndRotation(pos, rot);
|
||||
}
|
||||
else
|
||||
transform.rotation = rot;
|
||||
}
|
||||
|
||||
/// <summary>Seed look from the currently rendered view so going live produces no visible jump.</summary>
|
||||
void SyncToCurrentView()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam)
|
||||
{
|
||||
Vector3 e = cam.transform.rotation.eulerAngles;
|
||||
_yaw = e.y;
|
||||
float p = e.x > 180f ? e.x - 360f : e.x;
|
||||
_pitch = Mathf.Clamp(p, -maxPitch, maxPitch);
|
||||
}
|
||||
|
||||
// Snap the smoothed state to the fresh pose so entering never lags/jumps to catch up.
|
||||
_displayYaw = _yaw;
|
||||
_displayPitch = _pitch;
|
||||
_yawSmoothRef = 0f;
|
||||
_pitchSmoothRef = 0f;
|
||||
}
|
||||
|
||||
void SetMeshHidden(bool hidden)
|
||||
{
|
||||
if (hiddenRenderers == null) return;
|
||||
foreach (var r in hiddenRenderers)
|
||||
if (r) r.enabled = !hidden;
|
||||
}
|
||||
|
||||
void ResolveReferences()
|
||||
{
|
||||
if (_resolved) return;
|
||||
_resolved = true;
|
||||
|
||||
var player = FindFirstObjectByType<PlayerCharacterController>(FindObjectsInactive.Include);
|
||||
if (!player) return;
|
||||
_player = player;
|
||||
|
||||
if (!_poseController)
|
||||
_poseController = player.GetComponent<PlayerPoseController>();
|
||||
|
||||
if (!cameraTarget)
|
||||
{
|
||||
Transform t = player.transform.Find("CameraTarget");
|
||||
if (t) cameraTarget = t;
|
||||
}
|
||||
|
||||
if (hiddenRenderers == null || hiddenRenderers.Length == 0)
|
||||
hiddenRenderers = player.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 43b8f1dc43642294f99fab0aaabf6197
|
||||
@@ -0,0 +1,41 @@
|
||||
/// <summary>
|
||||
/// First-person mode. The player keeps walking (WASD, camera-relative) but the camera moves to the
|
||||
/// player's eye (the CameraTarget) with free mouse-look, and the player's mesh is hidden so the body
|
||||
/// isn't seen from the inside. The orbit camera input is disabled while here so it doesn't fight for
|
||||
/// the cursor; the <see cref="FirstPersonController"/> drives its own dedicated CinemachineCamera.
|
||||
/// </summary>
|
||||
public class FirstPersonMode : GameMode
|
||||
{
|
||||
public override string DisplayName => "First Person";
|
||||
|
||||
public override void Enter()
|
||||
{
|
||||
// Player stays fully controllable; only the view changes.
|
||||
if (Manager.Player)
|
||||
{
|
||||
Manager.Player.enabled = true;
|
||||
Manager.Player.ControlEnabled = true;
|
||||
}
|
||||
|
||||
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.FirstPerson)
|
||||
Manager.FirstPerson.SetActive(true);
|
||||
}
|
||||
|
||||
public override void Exit()
|
||||
{
|
||||
// Modes are Enter-only, so first-person must undo its own camera takeover (and unhide the mesh)
|
||||
// here; the next mode's Enter restores the player/painter/look-mode state.
|
||||
if (Manager.FirstPerson)
|
||||
Manager.FirstPerson.SetActive(false);
|
||||
|
||||
if (Manager.CameraInput)
|
||||
Manager.CameraInput.enabled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c514f1116e18b6c42907a5cb19aa228d
|
||||
@@ -25,6 +25,9 @@ public class GameModeManager : MonoBehaviour
|
||||
[Tooltip("Free-fly spectator camera. Empty = found automatically. Absent = Free Cam is left out of the cycle.")]
|
||||
public FreeCamController freeCam;
|
||||
|
||||
[Tooltip("First-person camera. Empty = found automatically. Absent = First Person is left out of the cycle.")]
|
||||
public FirstPersonController firstPerson;
|
||||
|
||||
//[Tooltip("Insect / surface-walk controller. Empty = found automatically.")]
|
||||
//public InsectController insect;
|
||||
|
||||
@@ -43,6 +46,7 @@ public class GameModeManager : MonoBehaviour
|
||||
public ThirdPersonOrbitInput CameraInput => cameraInput;
|
||||
public MeshPainter Painter => painter;
|
||||
public FreeCamController FreeCam => freeCam;
|
||||
public FirstPersonController FirstPerson => firstPerson;
|
||||
//public InsectController Insect => insect;
|
||||
|
||||
/// <summary>Raised after a mode switch finishes (the new mode's Enter has already run).
|
||||
@@ -63,6 +67,7 @@ public class GameModeManager : MonoBehaviour
|
||||
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 (!firstPerson) firstPerson = FindFirstObjectByType<FirstPersonController>(FindObjectsInactive.Include);
|
||||
//if (!insect) insect = FindFirstObjectByType<InsectController>();
|
||||
|
||||
BuildModes();
|
||||
@@ -90,6 +95,10 @@ public class GameModeManager : MonoBehaviour
|
||||
if (freeCam)
|
||||
_modes.Add(new FreeCamMode());
|
||||
|
||||
// First Person only joins the cycle when a first-person rig is present, so scenes without one are unchanged.
|
||||
if (firstPerson)
|
||||
_modes.Add(new FirstPersonMode());
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -147,6 +147,13 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
/// </summary>
|
||||
public bool ControlEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When set, the body faces this world-space yaw (degrees) every frame instead of turning toward its
|
||||
/// movement direction. First-person sets it so the character always faces where the camera looks;
|
||||
/// null restores movement-facing. Only affects ground/air locomotion — wall-climb owns its own facing.
|
||||
/// </summary>
|
||||
public float? FaceYaw { get; set; }
|
||||
|
||||
/// <summary>True while in wall-climb mode.</summary>
|
||||
public bool IsWallClimbing => _isWallClimbing;
|
||||
|
||||
@@ -425,8 +432,13 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
if (!_groundNormalUpdated)
|
||||
_groundNormal = Vector3.up;
|
||||
|
||||
// Face the movement direction, unless the right-button turn is actively steering the body.
|
||||
if (rotateTowardMovement && !groundYawDrag && moveDir.sqrMagnitude > 0.0001f)
|
||||
// Facing: first-person pins the body to the camera yaw (even while idle); otherwise turn toward
|
||||
// the movement direction, unless the right-button turn is actively steering the body.
|
||||
if (FaceYaw.HasValue)
|
||||
{
|
||||
transform.rotation = Quaternion.Euler(0f, FaceYaw.Value, 0f);
|
||||
}
|
||||
else if (rotateTowardMovement && !groundYawDrag && moveDir.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up);
|
||||
transform.rotation = Quaternion.RotateTowards(
|
||||
|
||||
@@ -58,6 +58,12 @@ public class PlayerPose : ScriptableObject
|
||||
"tracks, without moving the body. Meters.")]
|
||||
[SerializeField] private float cameraTargetYOffset;
|
||||
|
||||
[Tooltip("Additional local-up offset applied to the first-person eye (the FirstPersonController's camera " +
|
||||
"position) while this pose is active, on top of the shared camera target. Only affects first " +
|
||||
"person — never the body or the ThirdPersonCamera. Lets a low pose (ball/star) keep its eye at a " +
|
||||
"sensible height even though the camera target sits where third-person frames it. Meters.")]
|
||||
[SerializeField] private float firstPersonCameraTargetYOffset;
|
||||
|
||||
/// <summary>Idle clip held while the player stands still in this pose (may be null).</summary>
|
||||
public AnimationClip Clip => clip;
|
||||
|
||||
@@ -87,4 +93,7 @@ public class PlayerPose : ScriptableObject
|
||||
|
||||
/// <summary>Local-up offset applied to the camera target while this pose is active (any state).</summary>
|
||||
public float CameraTargetYOffset => cameraTargetYOffset;
|
||||
|
||||
/// <summary>Additional local-up offset for the first-person eye, on top of the camera target (first person only).</summary>
|
||||
public float FirstPersonCameraTargetYOffset => firstPersonCameraTargetYOffset;
|
||||
}
|
||||
@@ -95,6 +95,7 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
private bool _blending;
|
||||
|
||||
private float _cameraPoseOffset; // eased per-pose camera-target offset routed to the controller's camera target
|
||||
private float _firstPersonCameraOffset; // eased per-pose first-person eye offset, read by the FirstPersonController (never written to the shared camera target)
|
||||
|
||||
// Capsule blend: the CharacterController dimensions are eased toward the active pose's via a single
|
||||
// normalized blend (so the capsule stays a valid shape throughout) and written through the controller.
|
||||
@@ -120,6 +121,9 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
/// <summary>Index of the currently selected pose.</summary>
|
||||
public int CurrentIndex => _currentIndex;
|
||||
|
||||
/// <summary>Eased per-pose y-offset for the first-person eye, applied on top of the camera target by the FirstPersonController.</summary>
|
||||
public float FirstPersonCameraYOffset => _firstPersonCameraOffset;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_player = GetComponent<PlayerCharacterController>();
|
||||
@@ -178,6 +182,14 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, cameraOffsetSpeed * Time.deltaTime)
|
||||
: cameraPoseTarget;
|
||||
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
|
||||
|
||||
// First-person eye: a separate per-pose offset, eased the same way but NOT routed to the shared
|
||||
// camera target (that would also move the ThirdPersonCamera). The FirstPersonController reads this
|
||||
// and applies it to its own eye position only.
|
||||
float firstPersonTarget = pose ? pose.FirstPersonCameraTargetYOffset : 0f;
|
||||
_firstPersonCameraOffset = cameraOffsetSpeed > 0f
|
||||
? Mathf.MoveTowards(_firstPersonCameraOffset, firstPersonTarget, cameraOffsetSpeed * Time.deltaTime)
|
||||
: firstPersonTarget;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
@@ -186,6 +198,7 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
// next idle frame. Also clear the camera offset and restore the default capsule we were holding.
|
||||
ReleasePose();
|
||||
_cameraPoseOffset = 0f;
|
||||
_firstPersonCameraOffset = 0f;
|
||||
if (_player)
|
||||
{
|
||||
_player.SetCameraTargetLocalYOffset(0f);
|
||||
|
||||
Reference in new issue
Block a user