Camera: Cinemachine-based rig with an anchor, a state director, point-of- interest framing, pan input, and dialogue framing volumes. Player: click-to-move over the NavMesh, a pointer input layer, and a motion arbiter that reconciles direct stick input with navigation-driven movement. The pure math is isolated in NightclubArcadia.Locomotion.Math with an EditMode suite, so the arbitration rules are testable without a scene. Interaction: IClickInteractable plus a bridge that routes pointer clicks to it. Supporting project settings: adds the Cinemachine package, the Interactable and Ground tags the raycasts depend on, and NavMesh agent dimensions matched to the player capsule (radius 0.3, height 1.8, climb 0.25). Co-Authored-By: Claude Opus 5 <[email protected]>
136 lines
3.7 KiB
C#
136 lines
3.7 KiB
C#
using NightclubArcadia.UI;
|
|
using UnityEngine;
|
|
|
|
namespace NightclubArcadia.Cinematics
|
|
{
|
|
public enum CameraState
|
|
{
|
|
Follow,
|
|
ManualPan,
|
|
PointOfInterest,
|
|
Reveal,
|
|
Dialogue,
|
|
Menu
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves which camera subsystem may write each frame.
|
|
/// </summary>
|
|
public sealed class CameraStateDirector : MonoBehaviour
|
|
{
|
|
[SerializeField] CameraRigAnchor anchor;
|
|
[SerializeField] float dialogueRecenterTime = 0.5f;
|
|
|
|
int activeRevealCount;
|
|
float dialogueRecenterTimer;
|
|
bool wasDialogueActive;
|
|
bool wasMenuOpen;
|
|
|
|
public CameraState Current { get; private set; } = CameraState.Follow;
|
|
|
|
public bool AllowsManualPan =>
|
|
Current != CameraState.Dialogue
|
|
&& Current != CameraState.Menu
|
|
&& Current != CameraState.Reveal;
|
|
|
|
public bool AllowsPointOfInterest =>
|
|
Current == CameraState.Follow || Current == CameraState.PointOfInterest;
|
|
|
|
void Awake()
|
|
{
|
|
if (anchor == null)
|
|
{
|
|
anchor = GetComponent<CameraRigAnchor>();
|
|
}
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
if (UIStateController.Instance != null)
|
|
{
|
|
UIStateController.Instance.StateChanged += OnUiStateChanged;
|
|
}
|
|
|
|
ResolveState(false);
|
|
}
|
|
|
|
void OnDestroy()
|
|
{
|
|
if (UIStateController.Instance != null)
|
|
{
|
|
UIStateController.Instance.StateChanged -= OnUiStateChanged;
|
|
}
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
ResolveState(true);
|
|
|
|
if (Current == CameraState.Dialogue && anchor != null)
|
|
{
|
|
dialogueRecenterTimer += Time.deltaTime;
|
|
var t = Mathf.Clamp01(dialogueRecenterTimer / dialogueRecenterTime);
|
|
anchor.ManualPanOffset = Vector3.Lerp(anchor.ManualPanOffset, Vector3.zero, t);
|
|
}
|
|
}
|
|
|
|
void OnUiStateChanged() => ResolveState(true);
|
|
|
|
public void RegisterReveal() => activeRevealCount++;
|
|
|
|
public void UnregisterReveal() => activeRevealCount = Mathf.Max(0, activeRevealCount - 1);
|
|
|
|
void ResolveState(bool allowManualPanPromotion)
|
|
{
|
|
var ui = UIStateController.Instance;
|
|
var menuOpen = ui != null && ui.IsMenuOpen;
|
|
var dialogueActive = ui != null && ui.IsDialogueActive;
|
|
|
|
if (menuOpen)
|
|
{
|
|
Current = CameraState.Menu;
|
|
}
|
|
else if (dialogueActive)
|
|
{
|
|
if (!wasDialogueActive)
|
|
{
|
|
dialogueRecenterTimer = 0f;
|
|
}
|
|
|
|
Current = CameraState.Dialogue;
|
|
}
|
|
else if (activeRevealCount > 0)
|
|
{
|
|
Current = CameraState.Reveal;
|
|
}
|
|
else if (allowManualPanPromotion && IsManualPanActive())
|
|
{
|
|
Current = CameraState.ManualPan;
|
|
}
|
|
else if (HasActivePointOfInterest())
|
|
{
|
|
Current = CameraState.PointOfInterest;
|
|
}
|
|
else
|
|
{
|
|
Current = CameraState.Follow;
|
|
}
|
|
|
|
wasDialogueActive = dialogueActive;
|
|
wasMenuOpen = menuOpen;
|
|
}
|
|
|
|
bool IsManualPanActive()
|
|
{
|
|
var pan = GetComponent<CameraPanInput>();
|
|
return pan != null && pan.IsManualPanActive;
|
|
}
|
|
|
|
bool HasActivePointOfInterest()
|
|
{
|
|
var director = GetComponent<CameraPointOfInterestDirector>();
|
|
return director != null && director.HasActivePoi;
|
|
}
|
|
}
|
|
}
|