using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Cinemachine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Camera
{
///
/// 相机状态单例控制器。须放置在 Persistent 场景中。
///
/// 每个 均拥有自己专属的 DedicatedCamera,
/// 进入区域时调用 激活对应 VCam,
/// Cinemachine Brain 自动处理混合过渡。
///
[DefaultExecutionOrder(-100)]
public class CameraStateController : MonoBehaviour, ICameraService
{
[Header("引用")]
[SerializeField] private CinemachineBrain _brain;
[SerializeField] private CinemachineImpulseSource _impulseSource;
[SerializeField] private CameraLookSystem _lookSystem;
[Header("默认混合配置")]
[SerializeField] private CameraBlendProfileSO _defaultBlendProfile;
[Header("镜头配置")]
[Tooltip("相机镜头参数 SO,提供 FOV 与相机深度。\n" +
"与各 CameraArea 引用同一资产,确保 SetLensSize 换算结果与 VCam 配置一致。")]
[SerializeField] private CameraLensConfigSO _lensConfig;
[Header("玩家跟随")]
[Tooltip("PlayerController 生成时广播的事件频道(EVT_PlayerSpawned)。\n" +
"收到后自动查找 CameraFollowTarget 子节点并作为 VCam Follow 赋值。")]
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
// ── 状态 ──────────────────────────────────────────────────────────────
private CameraArea _roomBaselineArea; // SwitchArea(priority=0) 写入的房间基线,不被触发事件删除
private readonly List<(CameraArea area, int priority)> _activeZones = new(); // 玩家当前所在的触发区域集合(priority>0)
private CameraArea _currentArea;
private CinemachineCamera _activeDedicatedCam;
private CinemachineCamera _cutsceneCamera; // 过场模式专用高优先级 VCam
private const int CutscenePriority = 100; // 高于专有区域 VCam(默认 20)
private int _lastExternalFacing = 0; // 最近一次 SetPlayerFacing 的值,用于新激活 VCam 的初始化
private Transform _currentFollowTarget; // 最后一次 SetFollowTarget 设置的目标,激活 VCam 时自动同步
private readonly CompositeDisposable _subs = new();
// ── Lifecycle ────────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault() != null) { Destroy(gameObject); return; }
ServiceLocator.Register(this);
// 重置运行时状态,防止禁用 Domain Reload 时上一次 Play Mode 的数据残留。
// 非序列化字段在 Domain Reload 禁用时不会自动清零。
_roomBaselineArea = null;
_activeZones.Clear();
_currentArea = null;
_activeDedicatedCam = null;
_lastExternalFacing = 0;
_currentFollowTarget = null;
// 订阅 PlayerSpawned 事件,运行时自动为 VCam 赋值 Follow
_onPlayerSpawned?.Subscribe(OnPlayerSpawned).AddTo(_subs);
}
private void OnDestroy()
{
_subs.Dispose();
ServiceLocator.Unregister(this);
}
private void OnPlayerSpawned(Transform playerRoot)
{
const string followNodeName = "CameraFollowTarget";
Transform follow = playerRoot.Find(followNodeName) ?? playerRoot;
SetFollowTarget(follow);
}
private void Start()
{
// 场景启动时扫描全部 VCam,提前暴露组件顺序错误,
// 无需等待各区域被激活才触发检测。
var allVCams = FindObjectsByType(FindObjectsSortMode.None);
foreach (var vcam in allVCams)
{
var confiner = vcam.GetComponent();
if (confiner != null)
ValidateVCamExtensionOrder(vcam, confiner);
}
}
// ── 公开 API ──────────────────────────────────────────────────────────
///
/// 切换到目标相机区域。 < 当前激活优先级时忽略。
/// priority = 0:始终执行(适合 RoomController 入场初始化)。
///
public void SwitchArea(CameraArea area, int priority = 0, bool instantCut = false)
{
if (area == null) return;
if (priority == 0)
{
// 房间初始化 / 无条件切换:记录基线并清空触发集合
_roomBaselineArea = area;
_activeZones.Clear();
ActivateArea(area, instantCut);
return;
}
// 触发区域进入:更新集合(同一区域去重后重新加入,保证最新优先级)
_activeZones.RemoveAll(e => e.area == area);
_activeZones.Add((area, priority));
// 仅当此区域是当前最优且尚未激活时才切换
CameraArea best = GetEffectiveArea();
if (best == area && area != _currentArea)
ActivateArea(area, instantCut);
}
///
/// 释放 的权限。
/// 从优先级栈中移除该区域;若它是当前激活区域,则激活新栈顶(或 fallback)。
///
public void ReleaseArea(CameraArea releasedArea, CameraArea fallback)
{
if (releasedArea == null) return;
bool wasActive = releasedArea == _currentArea;
int removed = _activeZones.RemoveAll(e => e.area == releasedArea);
// 若区域本就不在栈中,且又不是当前激活区,则无需任何操作
if (removed == 0 && !wasActive) return;
if (!wasActive) return;
// 回退到当前最优区域(触发集合 → 房间基线 → fallback)
CameraArea next = GetEffectiveArea() ?? fallback;
if (next != null && next != _currentArea)
ActivateArea(next, instantCut: false);
}
///
/// 返回当前应激活的区域: 中优先级最高的
/// (同优先级取最近进入的),若触发集合为空则回退到 。
///
private CameraArea GetEffectiveArea()
{
CameraArea best = null;
int bestPriority = -1;
foreach (var (a, p) in _activeZones)
if (p >= bestPriority) { bestPriority = p; best = a; }
return best ?? _roomBaselineArea;
}
private void ActivateArea(CameraArea area, bool instantCut = false)
{
_currentArea = area;
if (instantCut)
{
// 房间入口硬切:相机立即跳到新房间位置,无混合动画
if (_brain != null)
_brain.DefaultBlend = new CinemachineBlendDefinition
{
Style = CinemachineBlendDefinition.Styles.Cut,
Time = 0f,
};
// 重置窥视偏移,避免旧房间的窥视状态残留
_lookSystem?.ResetOffsets(snap: true);
// 重置专属 VCam 扩展的内部状态,防止旧房间的速度/阻尼估算带入新房间
if (area.HasDedicated) ResetVCamExtensions(area.DedicatedCamera);
}
else
{
ApplyBlendProfile(area.BlendProfile ?? _defaultBlendProfile);
}
if (area.LensSize > 0f)
SetLensSize(area.LensSize, area.LensSizeDuration);
if (area.HasDedicated)
ActivateDedicated(area);
else
Debug.LogError($"[CameraStateController] {area.name} 缺少专属 VCam!请通过 Camera Area Setup 工具为此区域创建 DedicatedCamera。");
}
///
/// 运行时设置跟随目标。
/// 若存在 ,VCam 跟随系统输出的虚拟目标(含窗斥偏移)。
///
public void SetFollowTarget(Transform followTarget)
{
Transform actual = followTarget;
if (_lookSystem != null)
{
_lookSystem.SetBaseTarget(followTarget);
actual = _lookSystem.VirtualTarget;
}
_currentFollowTarget = actual;
SyncFollowToVCam(_activeDedicatedCam); // 立即同步到当前活跃专有 VCam
}
/// 触发屏幕抖动。
public void TriggerImpulse(Vector3 velocity)
{
if (_impulseSource != null) _impulseSource.GenerateImpulse(velocity);
}
/// 以默认强度触发屏幕抖动。
public void TriggerImpulse(float strength = 0.3f)
=> TriggerImpulse(Vector3.down * strength);
///
/// 平滑过渡视野尺寸(可视半高,世界单位)。 = 0 时瞬间切换。
/// 透视相机下自动换算为 FOV;语义等价于正交相机 OrthographicSize。
/// 区域进入时由 自动调用;游戏代码也可直接调用。
///
public void SetLensSize(float visibleHalfHeight, float duration = 0f)
{
if (_lensCoroutine != null) StopCoroutine(_lensCoroutine);
if (duration <= 0f) { ApplyLensSizeToAll(visibleHalfHeight); return; }
_lensCoroutine = StartCoroutine(LensSizeCo(visibleHalfHeight, duration));
}
///
/// 进入过场模式,将指定 VCam 提升至最高优先级,Brain 自动混合到它。
///
/// 过场 VCam 由设计者在 Inspector 中预先配置(位置、Follow、LookAt、Lens 等);
/// 此方法不强制覆写 Follow,保留 Inspector 配置不变。
///
/// 适用场景:Boss 登场固定镜头、对话拉近、剧情事件全景等。
///
public void EnterCutsceneMode(CinemachineCamera cutsceneCamera)
{
if (cutsceneCamera == null) return;
if (_cutsceneCamera != null && _cutsceneCamera != cutsceneCamera)
_cutsceneCamera.Priority = 0;
_cutsceneCamera = cutsceneCamera;
_cutsceneCamera.Priority = CutscenePriority;
}
///
/// 退出过场模式,撤销过场 VCam 的优先级,Brain 自动混合回当前区域相机。
///
public void ExitCutsceneMode()
{
if (_cutsceneCamera == null) return;
_cutsceneCamera.Priority = 0;
_cutsceneCamera = null;
}
///
/// 通知相机系统玩家的面朝方向,转发至所有活跃 VCam 的方向偏置扩展。
/// 由 PlayerController 在精灵翻转时调用:ICameraService.SetPlayerFacing(翻转方向 ? +1 : -1)。
///
public void SetPlayerFacing(int direction)
{
_lastExternalFacing = direction;
SetFacingOnVCam(_activeDedicatedCam, direction);
}
private static void SetFacingOnVCam(CinemachineCamera vcam, int direction)
=> vcam?.GetComponent()?.SetExternalFacing(direction);
private Coroutine _lensCoroutine;
private void ApplyLensSizeToAll(float size)
{
if (_activeDedicatedCam != null) SetVcamLens(_activeDedicatedCam, size);
}
// size = 可见半高(世界单位),透视相机下等效于正交 OrthographicSize。
// 换算公式:FOV = 2 * atan(size / depth)
private void SetVcamLens(CinemachineCamera vcam, float size)
{
if (vcam == null) return;
var lens = vcam.Lens;
float depth = _lensConfig != null ? _lensConfig.cameraDepth : 10f;
lens.FieldOfView = 2f * Mathf.Atan(size / depth) * Mathf.Rad2Deg;
vcam.Lens = lens;
}
private IEnumerator LensSizeCo(float target, float duration)
{
CinemachineCamera active = GetActiveVcam();
if (active == null) { _lensCoroutine = null; yield break; }
// 透视相机:从当前 FOV 反算等效可见半高,作为插值起点
float depth = _lensConfig != null ? _lensConfig.cameraDepth : 10f;
float start = depth * Mathf.Tan(active.Lens.FieldOfView * 0.5f * Mathf.Deg2Rad);
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
// 使用平滑过渡曲线(ease-in-out),视野缩放手感更自然。
// 线性插值会让镜头拉远感觉机械;平滑步多出平稳的起笔和收尾弹性。
float t = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(elapsed / duration));
ApplyLensSizeToAll(Mathf.Lerp(start, target, t));
yield return null;
}
ApplyLensSizeToAll(target);
_lensCoroutine = null;
}
private CinemachineCamera GetActiveVcam() => _activeDedicatedCam;
// ── 内部方法 ──────────────────────────────────────────────────────────
/// 激活区域的专有 VCam(高优先级)。
private void ActivateDedicated(CameraArea area)
{
// 降低前一个专有 VCam(若与新的不同)
if (_activeDedicatedCam != null && _activeDedicatedCam != area.DedicatedCamera)
_activeDedicatedCam.Priority = 0;
_activeDedicatedCam = area.DedicatedCamera;
_activeDedicatedCam.Priority = area.DedicatedPriority;
SyncFollowToVCam(_activeDedicatedCam); // 确保专有 VCam 的 Follow 指向当前跟随目标
SetFacingOnVCam(_activeDedicatedCam, _lastExternalFacing); // 应用当前玩家朝向
// 应用 CameraArea 参数(Confiner、Composer、扩展组件等)
var dedicatedConfiner = _activeDedicatedCam.GetComponent();
ValidateVCamExtensionOrder(_activeDedicatedCam, dedicatedConfiner);
ConfigureSlot(_activeDedicatedCam, dedicatedConfiner, area);
}
///
/// 检查 VCam 上各扩展组件的挂载顺序是否正确。
///
/// - FallBiasExtension / FacingBiasExtension 必须在 CinemachineConfiner3D 之前
/// - AxisLockExtension 必须在 CinemachineConfiner3D 之后
///
/// 顺序错误时相机会在应用偏置后逃出限位区域,或轴锁被 Confiner 覆盖失效。
///
private static void ValidateVCamExtensionOrder(CinemachineCamera vcam, CinemachineConfiner3D confiner)
{
if (vcam == null) return;
if (confiner == null)
{
Debug.LogWarning(
$"[CameraStateController] VCam {vcam.name} 缺少 CinemachineConfiner3D 组件!" +
"相机将不受任何限位约束,请通过 CameraAreaEditor 重新生成此 VCam 或手动添加。");
return;
}
Component[] comps = vcam.GetComponents();
int confinerIdx = -1;
int fallBiasIdx = -1;
int facingBiasIdx = -1;
int axisLockIdx = -1;
int asymDampIdx = -1;
int adaptiveLahIdx = -1;
for (int i = 0; i < comps.Length; i++)
{
switch (comps[i])
{
case CinemachineConfiner3D _: confinerIdx = i; break;
case CameraFallBiasExtension _: fallBiasIdx = i; break;
case CameraFacingBiasExtension _: facingBiasIdx = i; break;
case CameraAxisLockExtension _: axisLockIdx = i; break;
case CameraAsymmetricDampingExtension _: asymDampIdx = i; break;
case CameraAdaptiveLookaheadExtension _: adaptiveLahIdx = i; break;
}
}
if (asymDampIdx >= 0 && asymDampIdx > confinerIdx)
Debug.LogError(
$"[CameraStateController] VCam {vcam.name}:" +
"CameraAsymmetricDampingExtension 必须在 CinemachineConfiner3D 之前!" +
"当前顺序导致Y轴阻尼平滑值将相机推出限位区域而不被重新裁剪。" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
if (fallBiasIdx >= 0 && fallBiasIdx > confinerIdx)
Debug.LogError(
$"[CameraStateController] VCam {vcam.name}:" +
"CameraFallBiasExtension 必须在 CinemachineConfiner3D 之前!" +
"当前顺序导致下坠偏置将相机推出限位区域而不被重新裁剪。" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
if (facingBiasIdx >= 0 && facingBiasIdx > confinerIdx)
Debug.LogError(
$"[CameraStateController] VCam {vcam.name}:" +
"CameraFacingBiasExtension 必须在 CinemachineConfiner3D 之前!" +
"当前顺序导致朝向偏置将相机推出限位区域而不被重新裁剪。" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
if (adaptiveLahIdx >= 0 && adaptiveLahIdx > confinerIdx)
Debug.LogError(
$"[CameraStateController] VCam {vcam.name}:" +
"CameraAdaptiveLookaheadExtension 必须在 CinemachineConfiner3D 之前!" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
if (axisLockIdx >= 0 && axisLockIdx < confinerIdx)
Debug.LogError(
$"[CameraStateController] VCam {vcam.name}:" +
"CameraAxisLockExtension 必须在 CinemachineConfiner3D 之后!" +
"当前顺序导致轴向锁定被 Confiner 覆盖而失效。" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
}
///
/// 将最后已知的 Follow 目标同步到指定 VCam(若其 Follow 尚未设置或已过期)。
///
private void SyncFollowToVCam(CinemachineCamera vcam)
{
if (vcam == null || _currentFollowTarget == null) return;
if (vcam.Follow != _currentFollowTarget)
vcam.Follow = _currentFollowTarget;
}
private static void ConfigureSlot(
CinemachineCamera vcam, CinemachineConfiner3D confiner, CameraArea area)
{
// 1. Confiner
if (confiner != null && area.ConfinerCollider != null)
{
confiner.BoundingVolume = area.ConfinerCollider;
}
else if (confiner != null && area.ConfinerCollider == null)
{
Debug.LogError(
$"[CameraStateController] {area.name} 未绑定 ConfinerCollider!" +
"请将子节点 AreaBoundary 的 BoxCollider 拖入 CameraArea._confinerCollider 字段。");
}
// 2. 跟随行为覆盖
if (area.OverrideFollowBehaviour)
{
var composer = vcam.GetComponent();
if (composer != null)
{
// 屏幕位置(Y 偏下 → 玩家稍低于中心,上方更多视野)
var comp = composer.Composition;
comp.ScreenPosition = area.ScreenPosition;
comp.DeadZone.Enabled = true;
comp.DeadZone.Size = area.DeadZoneSize;
composer.Composition = comp;
// 阻尼
var d = composer.Damping;
d.x = area.Damping.x;
d.y = area.Damping.y;
composer.Damping = d;
// 非对称 Y 阻尼:若扩展存在,将其按区域配置并清零 Composer Y 阻尼防止叠加
var asymDamp = vcam.GetComponent();
if (asymDamp != null)
{
asymDamp.DampingDown = area.DampingDown;
asymDamp.DampingUp = area.DampingUp;
var yd = composer.Damping;
yd.y = 0f;
composer.Damping = yd;
}
// 引领预测
var lah = composer.Lookahead;
lah.Enabled = area.LookaheadTime > 0f;
lah.Time = area.LookaheadTime;
lah.Smoothing = area.LookaheadSmoothing;
lah.IgnoreY = true; // 平台跳跃中 Y 轴 Lookahead 会在起跳时猛拉镜头,应关闭
composer.Lookahead = lah;
// 自适应 Lookahead:通知扩展当前区域配置的最大 Lookahead 值
var adaptiveLah = vcam.GetComponent();
if (adaptiveLah != null)
adaptiveLah.SetConfiguredMax(area.LookaheadTime);
}
}
// 3. 轴向约束
var axisLock = vcam.GetComponent();
if (axisLock != null)
{
axisLock.LockX = area.LockHorizontal;
axisLock.LockY = area.LockVertical;
if (area.ConfinerCollider != null)
{
var center = area.ConfinerCollider.bounds.center;
axisLock.LockedX = center.x;
axisLock.LockedY = center.y;
}
}
// 4. 下坠视野偏置(无论是否覆写跟随行为,始终按区域配置)
var fallBias = vcam.GetComponent();
if (fallBias != null)
fallBias.SetConfiguredMax(area.DisableFallBias ? 0f : -1f);
// 5. 方向感知水平偏置
// X 轴已锁定时强制关闭偏置:两者均在 Body Stage 执行,若偏置后于锁定运行会破坏轴锁。
var facingBias = vcam.GetComponent();
if (facingBias != null)
{
if (area.LockHorizontal)
facingBias.FacingBias = 0f;
else if (area.OverrideFacingBias)
facingBias.FacingBias = area.FacingBiasOverride;
}
// 6. 相机噪音(区域氛围震动:洞穴、水下、机械等)
var noise = vcam.GetComponent();
if (noise != null)
{
noise.NoiseProfile = area.NoiseProfile;
noise.AmplitudeGain = area.NoiseProfile != null ? area.NoiseAmplitude : 0f;
noise.FrequencyGain = area.NoiseFrequency;
}
}
private static void ResetVCamExtensions(CinemachineCamera vcam)
{
if (vcam == null) return;
vcam.GetComponent()?.ResetState();
vcam.GetComponent()?.ResetState();
vcam.GetComponent()?.ResetState();
vcam.GetComponent()?.ResetState();
}
private void ApplyBlendProfile(CameraBlendProfileSO profile)
{
if (_brain != null && profile != null)
_brain.DefaultBlend = profile.ToBlendDefinition();
}
// ── 运行时调试覆盖层 ──────────────────────────────────────────────────
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[Header("调试")]
[Tooltip("运行时在屏幕左上角显示当前相机区域信息。\n仅在 Editor 和 Development Build 中有效。")]
[SerializeField] private bool _showDebugOverlay = false;
private GUIStyle _debugBoxStyle;
private GUIStyle _debugTitleStyle;
private GUIStyle _debugRowStyle;
private GUIStyle _debugWarnStyle;
private void InitDebugStyles()
{
if (_debugBoxStyle != null) return;
var bg = new Texture2D(1, 1);
bg.SetPixel(0, 0, new Color(0f, 0f, 0f, 0.72f));
bg.Apply();
_debugBoxStyle = new GUIStyle(GUI.skin.box)
{
normal = { background = bg, textColor = Color.white },
padding = new RectOffset(10, 10, 8, 8),
alignment = TextAnchor.UpperLeft,
fontSize = 12,
};
_debugTitleStyle = new GUIStyle(GUI.skin.label)
{
normal = { textColor = new Color(1f, 0.85f, 0.25f) },
fontStyle = FontStyle.Bold,
fontSize = 13,
};
_debugRowStyle = new GUIStyle(GUI.skin.label)
{
normal = { textColor = new Color(0.88f, 0.88f, 0.88f) },
fontSize = 12,
};
_debugWarnStyle = new GUIStyle(GUI.skin.label)
{
normal = { textColor = new Color(1f, 0.45f, 0.35f) },
fontSize = 12,
};
}
private void OnGUI()
{
if (!Application.isPlaying || !_showDebugOverlay) return;
InitDebugStyles();
float x = 12f, y = 12f, w = 320f;
// 计算高度(先收集内容)
string areaName = _currentArea != null ? _currentArea.name : "<无>";
string dedicatedLabel = _activeDedicatedCam != null
? $"{_activeDedicatedCam.name} (P={_activeDedicatedCam.Priority})"
: "<无激活 VCam>";
string followLabel = _currentFollowTarget != null
? _currentFollowTarget.name
: "<未设置>";
bool warnFollow = _currentFollowTarget == null;
bool warnNoVCam = _activeDedicatedCam == null;
bool warnNoBrain = _brain == null;
// 区域状态(基线 + 触发区域集合)
var zoneLines = new System.Collections.Generic.List();
string baselineName = _roomBaselineArea != null ? _roomBaselineArea.name : "<未设置>";
string baselineMarker = (_currentArea == _roomBaselineArea && _activeZones.Count == 0) ? " ◄ 激活" : "";
zoneLines.Add($" [基线] {baselineName}{baselineMarker}");
for (int i = _activeZones.Count - 1; i >= 0; i--)
{
var e = _activeZones[i];
string marker = (e.area == _currentArea) ? " ◄ 激活" : "";
zoneLines.Add($" [{e.priority}] {(e.area != null ? e.area.name : "null")}{marker}");
}
int lineCount = 4 + zoneLines.Count + (warnFollow ? 1 : 0) + (warnNoVCam ? 1 : 0) + (warnNoBrain ? 1 : 0);
float rowH = 19f;
float h = 28f + lineCount * rowH + 8f;
GUI.Box(new Rect(x, y, w, h), GUIContent.none, _debugBoxStyle);
float cy = y + 8f;
GUI.Label(new Rect(x + 8f, cy, w - 16f, 22f), "[ Camera State Controller ]", _debugTitleStyle);
cy += 22f;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"当前区域:{areaName}", _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"专有 VCam:{dedicatedLabel}", warnNoVCam ? _debugWarnStyle : _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"Follow 目标:{followLabel}", warnFollow ? _debugWarnStyle : _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "区域状态(基线 + 触发区域):", _debugRowStyle); cy += rowH;
foreach (var line in zoneLines)
{
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), line, _debugRowStyle);
cy += rowH;
}
if (warnFollow)
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ Follow 目标未设置(检查 _onPlayerSpawned)", _debugWarnStyle); cy += rowH; }
if (warnNoVCam)
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ 当前区域无激活专有 VCam(检查 DedicatedCamera 绑定)", _debugWarnStyle); cy += rowH; }
if (warnNoBrain)
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ CinemachineBrain 未绑定", _debugWarnStyle); cy += rowH; }
}
#endif
}
}