Files
zeling_v2/Assets/_Game/Scripts/Camera/CameraStateController.cs
2026-05-17 07:56:12 +08:00

609 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Cinemachine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Camera
{
/// <summary>
/// 相机状态单例控制器。须放置在 Persistent 场景中。
///
/// 支持两种相机切换模式:
/// 1. 全局双 VCam 模式(推荐):<see cref="SwitchArea"/>
/// 两台全局 CinemachineCamera<c>_vcamA</c> / <c>_vcamB</c>)交替承接各区域,
/// 通过优先级 ping-pong 触发 Cinemachine 混合过渡。场景内无需每个区域都放置 VCam。
///
/// 2. 专有 VCam 模式:<see cref="SwitchArea"/>(区域含 dedicatedCamera 时自动使用)
/// 激活该区域专属的 CinemachineCamera优先级 > 全局 VCam
/// 适用于需要独特相机参数的特殊区域。
/// </summary>
[DefaultExecutionOrder(-100)]
public class CameraStateController : MonoBehaviour, ICameraService
{
[Header("引用")]
[SerializeField] private CinemachineBrain _brain;
[SerializeField] private CinemachineImpulseSource _impulseSource;
[SerializeField] private CameraLookSystem _lookSystem;
[Header("全局双 VCamPersistent 场景中放置两台通用虚拟相机)")]
[Tooltip("两台 VCam 交替承接各相机区域,通过优先级 ping-pong 触发混合过渡。\n" +
"须各自挂载 CinemachineCamera + CinemachineConfiner2D\n" +
"Follow 指向 Player/CameraFollowTarget或运行时调用 SetFollowTarget 赋值)。")]
[SerializeField] private CinemachineCamera _vcamA;
[SerializeField] private CinemachineCamera _vcamB;
[Tooltip("全局 VCam 激活时的优先级。专有 VCam 的 _dedicatedPriority 须高于此值。")]
[SerializeField] private int _globalActivePriority = 10;
[Tooltip("待机 VCam 的优先级。\n" +
"Cinemachine 3.x 中 Priority = 0 的 VCam 不会被 Brain 选中,导致主相机停止跟随。\n" +
"必须 > 0 且 < _globalActivePriority确保 Brain 始终有可用 VCam\n" +
"同时切换时两台 VCam 均在 Brain 视野内以完成正确的混合过渡。")]
[SerializeField] private int _standbyPriority = 1;
[Header("默认混合配置")]
[SerializeField] private CameraBlendProfileSO _defaultBlendProfile;
[Header("镜头配置")]
[Tooltip("全局镜头参数 SO。Awake 时将 fieldOfView 应用到 _vcamA / _vcamB。\n" +
"与各 CameraArea 引用同一资产,确保 FOV 参数一致。")]
[SerializeField] private CameraLensConfigSO _lensConfig;
[Header("玩家跟随")]
[Tooltip("PlayerController 生成时广播的事件频道EVT_PlayerSpawned。\n" +
"收到后自动查找 CameraFollowTarget 子节点并赋值给两台全局 VCam 的 Follow。")]
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
// ── 状态 ──────────────────────────────────────────────────────────────
private int _activeSlot = -1; // -1 = 未初始化0 = A1 = B
private CameraArea _roomBaselineArea; // SwitchArea(priority=0) 写入的房间基线,不被触发事件删除
private readonly List<(CameraArea area, int priority)> _activeZones = new(); // 玩家当前所在的触发区域集合priority>0
private CameraArea _currentArea;
private CinemachineCamera _activeDedicatedCam;
private CinemachineConfiner2D _confinerA;
private CinemachineConfiner2D _confinerB;
private Transform _currentFollowTarget; // 最后一次 SetFollowTarget 设置的目标,激活 VCam 时自动同步
private readonly CompositeDisposable _subs = new();
// ── Lifecycle ────────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<ICameraService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<ICameraService>(this);
// 缓存 Confiner 引用
if (_vcamA != null) _confinerA = _vcamA.GetComponent<CinemachineConfiner2D>();
if (_vcamB != null) _confinerB = _vcamB.GetComponent<CinemachineConfiner2D>();
// 初始两台 VCam 均处于待机优先级(> 0
// Cinemachine 3.x 中 Priority = 0 的 VCam 不被 Brain 选中,主相机会停止运动
if (_vcamA != null) _vcamA.Priority = _standbyPriority;
if (_vcamB != null) _vcamB.Priority = _standbyPriority;
// 将 SO 中的 FOV 应用到两台全局 VCam
ApplyLensConfig();
// 订阅 PlayerSpawned 事件,运行时自动为 VCam 赋值 Follow
_onPlayerSpawned?.Subscribe(OnPlayerSpawned).AddTo(_subs);
}
private void OnDestroy()
{
_subs.Dispose();
ServiceLocator.Unregister<ICameraService>(this);
}
private void OnPlayerSpawned(Transform playerRoot)
{
const string followNodeName = "CameraFollowTarget";
Transform follow = playerRoot.Find(followNodeName) ?? playerRoot;
SetFollowTarget(follow);
}
private void ApplyLensConfig()
{
if (_lensConfig == null) return;
float fov = _lensConfig.fieldOfView;
float depth = _lensConfig.cameraDepth;
ApplyLensToVcam(_vcamA, fov, depth);
ApplyLensToVcam(_vcamB, fov, depth);
}
private static void ApplyLensToVcam(CinemachineCamera vcam, float fov, float depth)
{
if (vcam == null) return;
var lens = vcam.Lens;
lens.FieldOfView = fov;
vcam.Lens = lens;
// CinemachinePositionComposer.CameraDistance 是运行时真正控制 Z 距离的属性,
// 必须同步,否则 Transform Z 被 Cinemachine Pipeline 覆盖
var composer = vcam.GetComponent<CinemachinePositionComposer>();
if (composer != null)
composer.CameraDistance = depth;
// 同步 Transform Z保证编辑器预览与运行时一致
var pos = vcam.transform.position;
pos.z = -depth;
vcam.transform.position = pos;
}
#if UNITY_EDITOR
private void OnValidate() => ApplyLensConfig();
#endif
// ── 公开 API ──────────────────────────────────────────────────────────
/// <summary>
/// 切换到目标相机区域。<paramref name="priority"/> &lt; 当前激活优先级时忽略。
/// <para>priority = 0始终执行适合 RoomController 入场初始化)。</para>
/// </summary>
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));
// 仅当此区域是当前最优且尚未激活时才切换,避免不必要的 ping-pong
CameraArea best = GetEffectiveArea();
if (best == area && area != _currentArea)
ActivateArea(area, instantCut);
}
/// <summary>
/// 释放 <paramref name="releasedArea"/> 的权限。
/// 从优先级栈中移除该区域;若它是当前激活区域,则激活新栈顶(或 fallback
/// </summary>
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) return;
if (!wasActive) return;
// 回退到当前最优区域(触发集合 → 房间基线 → fallback
CameraArea next = GetEffectiveArea() ?? fallback;
if (next != null && next != _currentArea)
ActivateArea(next, instantCut: false);
}
/// <summary>
/// 返回当前应激活的区域:<see cref="_activeZones"/> 中优先级最高的
/// (同优先级取最近进入的),若触发集合为空则回退到 <see cref="_roomBaselineArea"/>。
/// </summary>
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 扩展的内部状态,防止旧房间的速度/阻尼估算带入新房间
ResetVCamExtensions(_vcamA);
ResetVCamExtensions(_vcamB);
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
ActivateGlobalSlot(area);
}
/// <summary>
/// 运行时为全局双 VCam 设置跟随目标。
/// 若存在 <see cref="CameraLookSystem"/>VCam 跟随系统输出的虚拟目标(含窥视偏移)。
/// </summary>
public void SetFollowTarget(Transform followTarget)
{
Transform actual = followTarget;
if (_lookSystem != null)
{
_lookSystem.SetBaseTarget(followTarget);
actual = _lookSystem.VirtualTarget;
}
_currentFollowTarget = actual; // 缓存供后续激活 VCam 时同步
if (_vcamA != null) _vcamA.Follow = actual;
if (_vcamB != null) _vcamB.Follow = actual;
}
/// <summary>触发屏幕抖动。</summary>
public void TriggerImpulse(Vector3 velocity)
{
if (_impulseSource != null) _impulseSource.GenerateImpulse(velocity);
}
/// <summary>以默认强度触发屏幕抖动。</summary>
public void TriggerImpulse(float strength = 0.3f)
=> TriggerImpulse(Vector3.down * strength);
/// <summary>
/// 平滑过渡正交相机尺寸。<paramref name="duration"/> = 0 时瞬间切换。
/// 区域进入时由 <see cref="CameraArea"/> 自动调用;游戏代码也可直接调用。
/// </summary>
public void SetLensSize(float orthographicSize, float duration = 0f)
{
if (_lensCoroutine != null) StopCoroutine(_lensCoroutine);
if (duration <= 0f) { ApplyLensSizeToAll(orthographicSize); return; }
_lensCoroutine = StartCoroutine(LensSizeCo(orthographicSize, duration));
}
private Coroutine _lensCoroutine;
private void ApplyLensSizeToAll(float size)
{
SetVcamLens(_vcamA, size);
SetVcamLens(_vcamB, size);
if (_activeDedicatedCam != null) SetVcamLens(_activeDedicatedCam, size);
}
private static void SetVcamLens(CinemachineCamera vcam, float size)
{
if (vcam == null) return;
var lens = vcam.Lens;
lens.OrthographicSize = size;
vcam.Lens = lens;
}
private IEnumerator LensSizeCo(float target, float duration)
{
CinemachineCamera active = GetActiveVcam();
if (active == null) { _lensCoroutine = null; yield break; }
float start = active.Lens.OrthographicSize;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
ApplyLensSizeToAll(Mathf.Lerp(start, target, elapsed / duration));
yield return null;
}
ApplyLensSizeToAll(target);
_lensCoroutine = null;
}
private CinemachineCamera GetActiveVcam()
{
if (_activeDedicatedCam != null) return _activeDedicatedCam;
return _activeSlot == 0 ? _vcamA : (_vcamB != null ? _vcamB : _vcamA);
}
// ── 内部方法 ──────────────────────────────────────────────────────────
/// <summary>激活区域的专有 VCam高优先级。</summary>
private void ActivateDedicated(CameraArea area)
{
// 降低前一个专有 VCam若与新的不同
if (_activeDedicatedCam != null && _activeDedicatedCam != area.DedicatedCamera)
_activeDedicatedCam.Priority = 0;
_activeDedicatedCam = area.DedicatedCamera;
_activeDedicatedCam.Priority = area.DedicatedPriority;
}
/// <summary>
/// 使用全局 VCam ping-pong 切换到新区域。
/// 配置非活跃 VCam 的 Confiner → 提升其优先级 → 降低旧 VCam 优先级。
/// Cinemachine Brain 检测到优先级变化后自动触发混合。
/// </summary>
private void ActivateGlobalSlot(CameraArea area)
{
// 收回专有 VCam
if (_activeDedicatedCam != null)
{
_activeDedicatedCam.Priority = 0;
_activeDedicatedCam = null;
}
bool noVCams = _vcamA == null && _vcamB == null;
if (noVCams)
{
Debug.LogWarning("[CameraStateController] 全局 VCam A / B 均未绑定,无法切换相机区域。");
return;
}
// 首次调用:直接激活 VCamA场景淡入阶段无需混合动画
if (_activeSlot < 0)
{
var cam = _vcamA ?? _vcamB;
var confiner = _vcamA != null ? _confinerA : _confinerB;
ConfigureSlot(cam, confiner, area);
SyncFollowToVCam(cam);
cam.Priority = _globalActivePriority;
_activeSlot = _vcamA != null ? 0 : 1;
return;
}
// 只有一台 VCam 时:直接重新配置,不做优先级 ping-pong
// (之前的 null 保护令 inactiveCam == activeCam导致先升后降为 0 自毁)
if (_vcamA == null || _vcamB == null)
{
var cam = _vcamA ?? _vcamB;
var confiner = _vcamA != null ? _confinerA : _confinerB;
ConfigureSlot(cam, confiner, area);
SyncFollowToVCam(cam);
cam.Priority = _globalActivePriority; // 保持激活,不改变 _activeSlot
return;
}
// 双 VCam ping-pong配置非活跃槽 → 升级其优先级 → 降低活跃槽优先级
bool nextIsA = _activeSlot != 0;
var inactiveCam = nextIsA ? _vcamA : _vcamB;
var activeCam = nextIsA ? _vcamB : _vcamA;
var inactiveConfiner = nextIsA ? _confinerA : _confinerB;
ConfigureSlot(inactiveCam, inactiveConfiner, area);
SyncFollowToVCam(inactiveCam); // 确保 Follow 正确(防止 SetFollowTarget 未被调用)
inactiveCam.Priority = _globalActivePriority;
activeCam.Priority = _standbyPriority; // 降到待机但仍 > 0Brain 可在混合期间读取其状态
_activeSlot = nextIsA ? 0 : 1;
}
/// <summary>
/// 将最后已知的 Follow 目标同步到指定 VCam若其 Follow 尚未设置或已过期)。
/// </summary>
private void SyncFollowToVCam(CinemachineCamera vcam)
{
if (vcam == null || _currentFollowTarget == null) return;
if (vcam.Follow != _currentFollowTarget)
vcam.Follow = _currentFollowTarget;
}
private static void ConfigureSlot(
CinemachineCamera vcam, CinemachineConfiner2D confiner, CameraArea area)
{
// 1. Confiner
if (confiner != null && area.ConfinerCollider != null)
{
confiner.BoundingShape2D = area.ConfinerCollider;
// 限位多边形已在编辑器中预收缩(可视区域 - 视口半尺寸 = 相机中心运动范围)。
// OversizeWindow.MaxWindowSize = 0.001f(极小正值):
// 使 Cinemachine 将实际视口高度裁剪至 0.001,几乎不再对多边形额外收缩,
// 从而以预收缩后的多边形直接作为相机中心约束边界。
// 对于小于视口的房间(预收缩后多边形退化为点),仍正确固定相机于中心。
confiner.OversizeWindow = new CinemachineConfiner2D.OversizeWindowSettings
{
Enabled = true,
MaxWindowSize = 0.001f,
Padding = 0.1f,
};
// BoundingShape2D 变更后必须刷新内部缓存路径,否则限位仍使用旧边界
confiner.InvalidateLensCache();
}
else if (confiner != null && area.ConfinerCollider == null)
{
Debug.LogError(
$"[CameraStateController] {area.name} 未绑定 ConfinerCollider" +
"请将子节点 AreaBoundary 的 PolygonCollider2D 拖入 CameraArea._confinerCollider 字段。");
}
// 2. 跟随行为覆盖
if (area.OverrideFollowBehaviour)
{
var composer = vcam.GetComponent<CinemachinePositionComposer>();
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<CameraAsymmetricDampingExtension>();
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<CameraAdaptiveLookaheadExtension>();
if (adaptiveLah != null)
adaptiveLah.SetConfiguredMax(area.LookaheadTime);
}
}
// 3. 轴向约束
var axisLock = vcam.GetComponent<CameraAxisLockExtension>();
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<CameraFallBiasExtension>();
if (fallBias != null)
fallBias.SetConfiguredMax(area.DisableFallBias ? 0f : -1f);
}
private static void ResetVCamExtensions(CinemachineCamera vcam)
{
if (vcam == null) return;
vcam.GetComponent<CameraAsymmetricDampingExtension>()?.ResetState();
vcam.GetComponent<CameraFallBiasExtension>()?.ResetState();
vcam.GetComponent<CameraAdaptiveLookaheadExtension>()?.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 slotLabel = _activeSlot < 0 ? "未初始化"
: _activeSlot == 0 ? "VCam A"
: "VCam B";
string followLabel = _currentFollowTarget != null
? _currentFollowTarget.name
: "<未设置>";
bool warnFollow = _currentFollowTarget == null;
bool warnNoVCam = _vcamA == null && _vcamB == null;
bool warnNoBrain = _brain == null;
// 区域状态(基线 + 触发区域集合)
var zoneLines = new System.Collections.Generic.List<string>();
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 = 5 + 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 槽:{slotLabel}", _debugRowStyle); cy += rowH;
string vcamALabel = _vcamA != null ? $"{_vcamA.name} (P={_vcamA.Priority})" : "<未绑定>";
string vcamBLabel = _vcamB != null ? $"{_vcamB.name} (P={_vcamB.Priority})" : "<未绑定>";
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"VCam A{vcamALabel}", _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"VCam B{vcamBLabel}", _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 A/B 均未绑定", _debugWarnStyle); cy += rowH; }
if (warnNoBrain)
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ CinemachineBrain 未绑定", _debugWarnStyle); cy += rowH; }
}
#endif
}
}