摄像机区域的优化
This commit is contained in:
105
Assets/_Game/Scripts/Camera/CameraAdaptiveLookaheadExtension.cs
Normal file
105
Assets/_Game/Scripts/Camera/CameraAdaptiveLookaheadExtension.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 速度自适应 Lookahead 扩展。
|
||||
///
|
||||
/// 玩家水平速度越快,Lookahead.Time 越接近 CameraArea 配置的最大值;
|
||||
/// 静止时衰减至最大值的 <see cref="_restScale"/> 倍,避免静止时镜头无谓偏移。
|
||||
///
|
||||
/// 挂载位置:Persistent 场景中的 VCamA / VCamB GameObject。
|
||||
/// <see cref="CameraStateController.ConfigureSlot"/> 在每次切换区域时调用
|
||||
/// <see cref="SetConfiguredMax"/> 传入该区域的 LookaheadTime。
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Adaptive Lookahead")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraAdaptiveLookaheadExtension : CinemachineExtension
|
||||
{
|
||||
[Tooltip("静止时 Lookahead 缩减比例(0~1)。\n" +
|
||||
"0 = 静止时完全无 Lookahead;0.25 = 静止时使用配置值的 25%。\n" +
|
||||
"推荐 0.2~0.3。")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _restScale = 0.25f;
|
||||
|
||||
[Tooltip("达到最大 Lookahead 所需的水平速度(世界单位/秒)。\n" +
|
||||
"玩家以此速度奔跑时 Lookahead.Time = 100% 配置值。推荐 10~15。")]
|
||||
[SerializeField] private float _speedAtFullLookahead = 12f;
|
||||
|
||||
[Tooltip("水平速度估算的平滑强度。越大响应越快。推荐 4~6。")]
|
||||
[SerializeField] private float _speedSmoothing = 5f;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
|
||||
private float _configuredMaxTime = -1f; // -1 = ConfigureSlot 尚未调用
|
||||
private float _estimatedSpeedX;
|
||||
private float _lastFollowX;
|
||||
private bool _trackingInitialized;
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 由 <see cref="CameraStateController.ConfigureSlot"/> 调用,
|
||||
/// 传入当前 CameraArea 配置的最大 Lookahead 时长。
|
||||
/// </summary>
|
||||
public void SetConfiguredMax(float maxTime) => _configuredMaxTime = maxTime;
|
||||
|
||||
// ── Extension ─────────────────────────────────────────────────────────
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
// 编辑器预览时不运行
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
_trackingInitialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ConfigureSlot 尚未调用时跳过(避免覆盖默认值)
|
||||
if (_configuredMaxTime < 0f) return;
|
||||
|
||||
// ── 估算玩家水平速度 ──────────────────────────────────────────────
|
||||
Transform follow = vcam.Follow;
|
||||
if (follow != null)
|
||||
{
|
||||
if (!_trackingInitialized)
|
||||
{
|
||||
_lastFollowX = follow.position.x;
|
||||
_trackingInitialized = true;
|
||||
}
|
||||
|
||||
float rawSpeedX = Mathf.Abs(follow.position.x - _lastFollowX) / deltaTime;
|
||||
_lastFollowX = follow.position.x;
|
||||
_estimatedSpeedX = Mathf.Lerp(_estimatedSpeedX, rawSpeedX, deltaTime * _speedSmoothing);
|
||||
}
|
||||
|
||||
// ── 速度映射 → Lookahead 时长 ─────────────────────────────────────
|
||||
float fraction = Mathf.Clamp01(_estimatedSpeedX / _speedAtFullLookahead);
|
||||
float scaledTime = Mathf.Lerp(_configuredMaxTime * _restScale, _configuredMaxTime, fraction);
|
||||
|
||||
var composer = vcam.GetComponent<CinemachinePositionComposer>();
|
||||
if (composer == null) return;
|
||||
|
||||
var lah = composer.Lookahead;
|
||||
lah.Time = scaledTime;
|
||||
composer.Lookahead = lah;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置速度估算状态。在相机硬切时由 CameraStateController 调用,
|
||||
/// 避免上一区域的奔跑速度影响新区域的初始 Lookahead 量。
|
||||
/// </summary>
|
||||
public void ResetState()
|
||||
{
|
||||
_estimatedSpeedX = 0f;
|
||||
_trackingInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38af2eabab7039c4a919181e4c507d12
|
||||
guid: a12cbb2380ff137459b7ba80d492733f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -21,15 +21,81 @@ namespace BaseGames.Camera
|
||||
[SerializeField] private PolygonCollider2D _confinerCollider;
|
||||
|
||||
[Header("可视区域(透视相机)")]
|
||||
[Tooltip("摄像机应显示的最大可视矩形(世界坐标)。\n" +
|
||||
[Tooltip("摄像机应显示的最大可视矩形(本地坐标,相对于此 GameObject 的 Transform 位置)。\n" +
|
||||
"Scene 视图中可直接拖拽四条边编辑,然后点击 Inspector 中的\n" +
|
||||
"「从可视区域更新限位区域(透视)」按钮将其换算为限位多边形。")]
|
||||
[SerializeField] private Rect _visibleBounds = new Rect(-12f, -6f, 24f, 12f);
|
||||
|
||||
[Tooltip("摄像机到场景平面(Z = 0)的垂直距离,用于透视视口尺寸计算。\n" +
|
||||
"留 0 时自动取 transform.position.z 的绝对值(推荐)。")]
|
||||
[HideInInspector]
|
||||
[SerializeField] private float _cameraDepth = 0f;
|
||||
|
||||
[Header("镜头配置")]
|
||||
[Tooltip("全局相机镜头参数 SO。与 CameraStateController 引用同一资产,\n" +
|
||||
"保证 FOV 等参数在 Room 场景中也能正确读取。\n" +
|
||||
"SO 中的 fieldOfView 发生变化时,编辑器会自动重新同步限位多边形。")]
|
||||
[SerializeField] private CameraLensConfigSO _lensConfig;
|
||||
|
||||
// 编辑器通过它检测限位多边形是否需要重新同步(不展示在 Inspector 中)
|
||||
[HideInInspector]
|
||||
[SerializeField] private float _lastSyncFOV = 0f;
|
||||
// ── 跟随行为 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("跟随行为(覆盖全局 VCam 参数)")]
|
||||
[Tooltip("启用后,进入此区域时将把以下参数写入全局 VCam;\n" +
|
||||
"关闭则 VCam 保持上一区域或 Inspector 中的默认值。")]
|
||||
[SerializeField] private bool _overrideFollowBehaviour = true;
|
||||
|
||||
[Tooltip("玩家跟踪点在屏幕上的位置(0 = 中心,±0.5 = 边缘)。\n" +
|
||||
"推荐 (0, -0.1):玩家稍低于中心,上方有更多视野(对横版山洞类游戏第三视角推荐)。")]
|
||||
[SerializeField] private Vector2 _screenPosition = new Vector2(0f, -0.15f);
|
||||
|
||||
[Tooltip("水平(X)/ 垂直(Y)跟随阻尼(秒)。越大越滞后。\n" +
|
||||
"推荐:X=0.5 Y=0.2(水平稍慢、垂直快速响应)。\n" +
|
||||
"同时挂载 CameraAsymmetricDampingExtension 时,Y 分量自动被清零,改由下方两个字段控制。")]
|
||||
[SerializeField] private Vector2 _damping = new Vector2(0.5f, 0.2f);
|
||||
|
||||
[Tooltip("非对称 Y 阻尼 —— 相机向下(下落)时的 Y 轴阻尼(秒)。越小相机越快跟随,玩家能提前看到地面。")]
|
||||
[SerializeField] private float _dampingDown = 0.06f;
|
||||
|
||||
[Tooltip("非对称 Y 阻尼 —— 相机向上(起跳)时的 Y 轴阻尼(秒)。越大越慢,保留地面视野不被立刻拉高。")]
|
||||
[SerializeField] private float _dampingUp = 0.65f;
|
||||
|
||||
[Tooltip("死区范围(全屏 = 1)。玩家在死区内相机不移动,产生松散跟随感。\n" +
|
||||
"推荐:X=0.1 Y=0.05。")]
|
||||
[SerializeField] private Vector2 _deadZoneSize = new Vector2(0.15f, 0.05f);
|
||||
|
||||
[Tooltip("引领预测时长(秒,0 = 不引领)。相机超前于玩家移动方向,令玩家更早看到前方地形。")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _lookaheadTime = 0.28f;
|
||||
|
||||
[Tooltip("引领算法平滑度(0~30)。越大越平滑但预测延迟更大。")]
|
||||
[Range(0f, 30f)]
|
||||
[SerializeField] private float _lookaheadSmoothing = 5f;
|
||||
|
||||
[Header("下坠视野偏置(需配合 CameraFallBiasExtension)")]
|
||||
[Tooltip("禁用此区域的下坠视野偏置效果。\n"
|
||||
+ "在垂直高度较小的房间(短走廊 / 矬间)中建议开启,\n"
|
||||
+ "防止相机因偏置超出 Confiner 边界。")]
|
||||
[SerializeField] private bool _disableFallBias = false;
|
||||
|
||||
// ── 轴向约束 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("轴向约束")]
|
||||
[Tooltip("锁定相机 X 轴(垂直竖井:相机仅上下移动,X 固定在限位区域中心)。")]
|
||||
[SerializeField] private bool _lockHorizontal = false;
|
||||
|
||||
[Tooltip("锁定相机 Y 轴(水平走廊:相机仅左右移动,Y 固定在限位区域中心)。")]
|
||||
[SerializeField] private bool _lockVertical = false;
|
||||
|
||||
[Header("镜头尺寸(正交相机)")]
|
||||
[Tooltip("进入此区域时的目标正交尺寸(0 = 不覆盖当前尺寸)。\n" +
|
||||
"适用于 Boss 战拉远或精密解谜区域拉近。")]
|
||||
[SerializeField] private float _lensSize = 0f;
|
||||
|
||||
[Tooltip("镜头尺寸过渡时长(秒)。0 = 瞬间切换。")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float _lensSizeDuration = 0.5f;
|
||||
|
||||
[Header("混合配置")]
|
||||
[SerializeField] private CameraBlendProfileSO _blendProfile;
|
||||
|
||||
@@ -44,24 +110,43 @@ namespace BaseGames.Camera
|
||||
|
||||
// ── 公开属性 ──────────────────────────────────────────────────────────
|
||||
|
||||
public PolygonCollider2D ConfinerCollider => _confinerCollider;
|
||||
public CameraBlendProfileSO BlendProfile => _blendProfile;
|
||||
public Rect VisibleBounds => _visibleBounds;
|
||||
public bool HasDedicated => _dedicatedCamera != null;
|
||||
public CinemachineCamera DedicatedCamera => _dedicatedCamera;
|
||||
public int DedicatedPriority => _dedicatedPriority;
|
||||
public PolygonCollider2D ConfinerCollider => _confinerCollider;
|
||||
public CameraLensConfigSO LensConfig => _lensConfig;
|
||||
public float LastSyncFOV => _lastSyncFOV;
|
||||
public CameraBlendProfileSO BlendProfile => _blendProfile;
|
||||
/// <summary>世界坐标可视区域(本地 _visibleBounds + transform.position)。</summary>
|
||||
public Rect VisibleBounds => new Rect(
|
||||
_visibleBounds.x + transform.position.x,
|
||||
_visibleBounds.y + transform.position.y,
|
||||
_visibleBounds.width, _visibleBounds.height);
|
||||
public bool HasDedicated => _dedicatedCamera != null;
|
||||
public CinemachineCamera DedicatedCamera => _dedicatedCamera;
|
||||
public int DedicatedPriority => _dedicatedPriority;
|
||||
public bool OverrideFollowBehaviour => _overrideFollowBehaviour;
|
||||
public Vector2 ScreenPosition => _screenPosition;
|
||||
public Vector2 Damping => _damping;
|
||||
public Vector2 DeadZoneSize => _deadZoneSize;
|
||||
public float LookaheadTime => _lookaheadTime;
|
||||
public float LookaheadSmoothing => _lookaheadSmoothing;
|
||||
public bool DisableFallBias => _disableFallBias;
|
||||
public bool LockHorizontal => _lockHorizontal;
|
||||
public bool LockVertical => _lockVertical;
|
||||
public float DampingDown => _dampingDown;
|
||||
public float DampingUp => _dampingUp;
|
||||
public float LensSize => _lensSize;
|
||||
public float LensSizeDuration => _lensSizeDuration;
|
||||
|
||||
/// <summary>
|
||||
/// 摄像机到场景平面的有效深度(用于透视视口换算)。
|
||||
/// _cameraDepth > 0 时使用配置值,否则自动读取 |transform.position.z|,再兜底 10。
|
||||
/// 来源:区域专有 _cameraDepth(>0 时) → LensConfig SO。
|
||||
/// 未绑定 SO 时返回 0,限位同步工具会在 Inspector 中给出警告。
|
||||
/// </summary>
|
||||
public float CameraDepth
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cameraDepth > 0f) return _cameraDepth;
|
||||
float z = Mathf.Abs(transform.position.z);
|
||||
return z > 0.01f ? z : 10f;
|
||||
return _lensConfig != null ? _lensConfig.cameraDepth : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,16 +154,33 @@ namespace BaseGames.Camera
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// 黄色:可视区域
|
||||
Vector3 center = new Vector3(_visibleBounds.center.x, _visibleBounds.center.y, 0f);
|
||||
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
|
||||
// 黄色:可视区域(本地坐标 + transform.position = 世界坐标)
|
||||
Vector3 center = new Vector3(
|
||||
_visibleBounds.center.x + transform.position.x,
|
||||
_visibleBounds.center.y + transform.position.y, 0f);
|
||||
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
|
||||
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.10f);
|
||||
Gizmos.DrawCube(center, size);
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.90f);
|
||||
Gizmos.DrawWireCube(center, size);
|
||||
|
||||
// 青色:专有 VCam 指示线
|
||||
// 青色:轴向锁定指示
|
||||
if ((_lockHorizontal || _lockVertical) && _confinerCollider != null)
|
||||
{
|
||||
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.9f);
|
||||
var bounds = _confinerCollider.bounds;
|
||||
if (_lockHorizontal)
|
||||
Gizmos.DrawLine(
|
||||
new Vector3(bounds.center.x, bounds.min.y, 0f),
|
||||
new Vector3(bounds.center.x, bounds.max.y, 0f));
|
||||
if (_lockVertical)
|
||||
Gizmos.DrawLine(
|
||||
new Vector3(bounds.min.x, bounds.center.y, 0f),
|
||||
new Vector3(bounds.max.x, bounds.center.y, 0f));
|
||||
}
|
||||
|
||||
// 青绿:专有 VCam 指示线
|
||||
if (_dedicatedCamera != null)
|
||||
{
|
||||
Gizmos.color = new Color(0.2f, 1f, 0.8f, 0.8f);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// Y 轴非对称阻尼扩展。实现下落快、起跳缓的非对称相机追随手感:
|
||||
/// - <b>下落时快速跟随</b>(低阻尼):玩家落下时相机迅速移动,提前呈现地面地形;
|
||||
/// - <b>起跳时缓慢上移</b>(高阻尼):相机不会在跳跃峰值前立刻拉高,保留地面视野。
|
||||
///
|
||||
/// <para>使用须知:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>挂载在 VCamA / VCamB 上(<see cref="CameraStateController"/> 初始化时自动识别)。</item>
|
||||
/// <item>此扩展存在时,<see cref="CameraStateController.ConfigureSlot"/> 会自动将
|
||||
/// <see cref="CinemachinePositionComposer.Damping"/> 的 Y 分量清零,避免双重阻尼。</item>
|
||||
/// <item>阻尼值可由 <see cref="CameraArea"/> 通过 <c>DampingDown</c> / <c>DampingUp</c>
|
||||
/// 属性 per-area 覆写,无需手动修改此组件。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Asymmetric Damping")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraAsymmetricDampingExtension : CinemachineExtension
|
||||
{
|
||||
[Tooltip("相机向下(下落)时的 Y 轴阻尼(秒)。\n" +
|
||||
"越小跟随越快,玩家能提前看到地面。推荐 0.05 ~ 0.15。")]
|
||||
[SerializeField] private float _dampingDown = 0.08f;
|
||||
|
||||
[Tooltip("相机向上(起跳)时的 Y 轴阻尼(秒)。\n" +
|
||||
"越大跟随越慢,保留地面视野、不会在起跳瞬间拉高。推荐 0.5 ~ 0.8。")]
|
||||
[SerializeField] private float _dampingUp = 0.65f;
|
||||
|
||||
/// <summary>供 <see cref="CameraStateController"/> 运行时按区域写入阻尼值。</summary>
|
||||
public float DampingDown { get => _dampingDown; set => _dampingDown = value; }
|
||||
/// <summary>供 <see cref="CameraStateController"/> 运行时按区域写入阻尼值。</summary>
|
||||
public float DampingUp { get => _dampingUp; set => _dampingUp = value; }
|
||||
|
||||
private float _smoothedY;
|
||||
private bool _initialized;
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
// deltaTime <= 0:编辑器预览 / 初始帧,重置平滑器避免脏状态
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
_initialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 此时 state.RawPosition 是 CinemachinePositionComposer(Damping.y = 0)输出的"理想"位置
|
||||
float idealY = state.RawPosition.y;
|
||||
|
||||
if (!_initialized)
|
||||
{
|
||||
_smoothedY = idealY;
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// idealY < _smoothedY → 相机目标在当前位置下方(玩家下落) → 低阻尼快速跟随
|
||||
// idealY > _smoothedY → 相机目标在当前位置上方(玩家起跳) → 高阻尼缓慢跟随
|
||||
float damping = idealY < _smoothedY ? _dampingDown : _dampingUp;
|
||||
float t = damping > 0f
|
||||
? 1f - Mathf.Exp(-deltaTime / damping)
|
||||
: 1f;
|
||||
|
||||
_smoothedY = Mathf.LerpUnclamped(_smoothedY, idealY, t);
|
||||
|
||||
var pos = state.RawPosition;
|
||||
pos.y = _smoothedY;
|
||||
state.RawPosition = pos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置内部平滑状态。在相机硬切(instantCut)时由 CameraStateController 调用,
|
||||
/// 确保新房间的 Y 坐标从目标位置开始,不受旧房间阻尼状态影响。
|
||||
/// </summary>
|
||||
public void ResetState() => _initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b358a30ac16c6a34fb673ede0a288e48
|
||||
guid: cb5a7225ab133e74b81d1f0ae22ccc77
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
43
Assets/_Game/Scripts/Camera/CameraAxisLockExtension.cs
Normal file
43
Assets/_Game/Scripts/Camera/CameraAxisLockExtension.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// Cinemachine 扩展:在 Body 阶段之后硬锁定相机某一轴向。
|
||||
///
|
||||
/// 用途:
|
||||
/// - <see cref="LockX"/> = true → 垂直竖井 / 电梯:相机仅上下移动,X 固定于限位区域中心。
|
||||
/// - <see cref="LockY"/> = true → 水平走廊:相机仅左右移动,Y 固定于限位区域中心。
|
||||
///
|
||||
/// 由 <see cref="CameraStateController"/> 在切换区域时自动写入 <see cref="LockedX"/> /
|
||||
/// <see cref="LockedY"/>(从 ConfinerCollider.bounds.center 取值)并切换锁定开关。
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Axis Lock")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraAxisLockExtension : CinemachineExtension
|
||||
{
|
||||
/// <summary>锁定 X 轴(垂直竖井)。</summary>
|
||||
[HideInInspector] public bool LockX = false;
|
||||
/// <summary>锁定 Y 轴(水平走廊)。</summary>
|
||||
[HideInInspector] public bool LockY = false;
|
||||
/// <summary>X 轴锁定到的世界坐标(由 CameraStateController 写入)。</summary>
|
||||
[HideInInspector] public float LockedX = 0f;
|
||||
/// <summary>Y 轴锁定到的世界坐标(由 CameraStateController 写入)。</summary>
|
||||
[HideInInspector] public float LockedY = 0f;
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
var pos = state.RawPosition;
|
||||
if (LockX) pos.x = LockedX;
|
||||
if (LockY) pos.y = LockedY;
|
||||
state.RawPosition = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af7e12583264b8c4da8dcd69df274793
|
||||
guid: 7e2e7849ca8d76f438c4b2899c9fb421
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -1,22 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Camera/CameraConfig")]
|
||||
public class CameraConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("跟随")]
|
||||
public float FollowDamping = 0.15f;
|
||||
public float LookAheadTime = 0.3f;
|
||||
public float LookAheadSmoothing = 0.1f;
|
||||
public Vector2 DeadZoneSize = new Vector2(1f, 0.5f);
|
||||
public Vector2 SoftZoneSize = new Vector2(2.5f, 2f);
|
||||
|
||||
[Header("偏移")]
|
||||
public float LookDownOffset = -1.5f;
|
||||
public float LookUpOffset = 1.5f;
|
||||
|
||||
[Header("画面抖动默认强度")]
|
||||
public float DefaultImpulseStrength = 0.3f;
|
||||
}
|
||||
}
|
||||
136
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs
Normal file
136
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 下坠视野偏置扩展。
|
||||
///
|
||||
/// 玩家持续高速下落超过 <see cref="_activationDelay"/> 秒后,
|
||||
/// 将相机 Y 坐标平滑下移最多 <see cref="_maxShift"/> 世界单位,
|
||||
/// 使玩家出现在画面上方区域,提前暴露落点地形。
|
||||
/// 玩家减速或着地后快速复位。
|
||||
///
|
||||
/// <para>挂载顺序(三者顺序必须严格遵守):</para>
|
||||
/// <list type="number">
|
||||
/// <item><see cref="CameraAsymmetricDampingExtension"/> — 先对 Y 轴做非对称阻尼平滑;</item>
|
||||
/// <item><b>本扩展(CameraFallBiasExtension)</b> — 将偏置叠加到平滑后的 Y 上;</item>
|
||||
/// <item><c>CinemachineConfiner2D</c> — 最后将偏置后的位置裁剪回限位边界内。</item>
|
||||
/// </list>
|
||||
/// <para>如果顺序错误(本扩展在 Confiner 之后),偏置会导致相机超出限位边界且不被修正。</para>
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Fall Bias")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraFallBiasExtension : CinemachineExtension
|
||||
{
|
||||
[Tooltip("触发下坠偏置所需的最小下落速度(世界单位/秒,绝对值)。\n" +
|
||||
"低于此值不激活(短跳不触发)。推荐 6~10。")]
|
||||
[Range(1f, 20f)]
|
||||
[SerializeField] private float _fallSpeedThreshold = 7f;
|
||||
|
||||
[Tooltip("持续下落超过此时长后开始偏置(秒)。\n" +
|
||||
"避免短暂跳跃/下落触发偏移。推荐 0.25~0.40。")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _activationDelay = 0.30f;
|
||||
|
||||
[Tooltip("最大相机 Y 偏移量(世界单位,相机向下移动 = 玩家在画面上方 = 视野暴露下方地形)。\n" +
|
||||
"推荐 1.5~2.5 单位。")]
|
||||
[Range(0f, 5f)]
|
||||
[SerializeField] private float _maxShift = 2f;
|
||||
|
||||
[Tooltip("偏置增加速度(Lerp 系数)。越大偏移越快达到最大值。推荐 3~5。")]
|
||||
[SerializeField] private float _shiftSpeed = 3f;
|
||||
|
||||
[Tooltip("偏置复位速度。着陆后应快速恢复以避免画面跳变。推荐 8~12。")]
|
||||
[SerializeField] private float _resetSpeed = 10f;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
private float _configuredMaxShift = -1f; // -1 = 不覆盖,使用检查器默认属性值
|
||||
private float _lastFollowY;
|
||||
private float _smoothedVY;
|
||||
private bool _initialized;
|
||||
private float _fallTimer;
|
||||
private float _currentShift;
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 由 <see cref="CameraStateController.ConfigureSlot"/> 调用:
|
||||
/// 传入 0 表示禁用此区域的下坠偏置;-1 表示使用检查器默认属性值。
|
||||
/// </summary>
|
||||
public void SetConfiguredMax(float maxShift) => _configuredMaxShift = maxShift;
|
||||
|
||||
/// <summary>
|
||||
/// 重置内部状态。在相机硬切(instantCut)时由 CameraStateController 调用,
|
||||
/// 避免旧房间的下坠计时 / 偷移量带入新房间。
|
||||
/// </summary>
|
||||
public void ResetState()
|
||||
{
|
||||
_initialized = false;
|
||||
_fallTimer = 0f;
|
||||
_currentShift = 0f;
|
||||
_smoothedVY = 0f;
|
||||
}
|
||||
// ── Extension ─────────────────────────────────────────────────────────
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
_initialized = false;
|
||||
_fallTimer = 0f;
|
||||
_currentShift = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
Transform follow = vcam.Follow;
|
||||
if (follow == null) return;
|
||||
|
||||
if (!_initialized)
|
||||
{
|
||||
_lastFollowY = follow.position.y;
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 估算玩家 Y 轴速度(负值 = 下落)────────────────────────────
|
||||
float rawVY = (follow.position.y - _lastFollowY) / deltaTime;
|
||||
_lastFollowY = follow.position.y;
|
||||
_smoothedVY = Mathf.Lerp(_smoothedVY, rawVY, deltaTime * 10f);
|
||||
|
||||
bool isFalling = _smoothedVY < -_fallSpeedThreshold;
|
||||
|
||||
// ── 下落计时器 ────────────────────────────────────────────────
|
||||
if (isFalling)
|
||||
_fallTimer = Mathf.Min(_fallTimer + deltaTime, _activationDelay + 1f);
|
||||
else
|
||||
_fallTimer = Mathf.Max(_fallTimer - deltaTime * 3f, 0f); // 快速衰减
|
||||
|
||||
// ── 目标偏置 ──────────────────────────────────────────────────
|
||||
// 超过 activationDelay 后线性增加偏置;0.4s 达到最大
|
||||
float effectiveMax = _configuredMaxShift >= 0f ? _configuredMaxShift : _maxShift;
|
||||
float ratio = Mathf.Clamp01((_fallTimer - _activationDelay) / 0.4f);
|
||||
float targetShift = -effectiveMax * ratio; // 负就:相机向下
|
||||
|
||||
// 使用指数衰减公式(帧率无关)替代 Lerp*deltaTime
|
||||
float dampingTime = targetShift < _currentShift
|
||||
? 1f / Mathf.Max(_shiftSpeed, 0.001f)
|
||||
: 1f / Mathf.Max(_resetSpeed, 0.001f);
|
||||
float t = 1f - Mathf.Exp(-deltaTime / dampingTime);
|
||||
_currentShift = Mathf.Lerp(_currentShift, targetShift, t);
|
||||
|
||||
// ── 写入相机 Y 偏置 ───────────────────────────────────────────
|
||||
if (Mathf.Abs(_currentShift) > 0.001f)
|
||||
{
|
||||
var pos = state.RawPosition;
|
||||
pos.y += _currentShift;
|
||||
state.RawPosition = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs.meta
Normal file
11
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67a1710d47e3d4c4b9ac15dcb4a17036
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
38
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs
Normal file
38
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局相机镜头配置。
|
||||
///
|
||||
/// 作为 <see cref="CameraStateController"/> 和各 <see cref="CameraArea"/> 之间的
|
||||
/// 单一参数来源:
|
||||
/// - Persistent 场景的 <see cref="CameraStateController"/> 在 Awake 时将
|
||||
/// <see cref="fieldOfView"/> 写入两台全局 VCam 的 Lens。
|
||||
/// - Room 场景的 <see cref="CameraArea"/> 引用同一 SO,编辑器工具在计算限位多边形
|
||||
/// 时直接读取,无需依赖 Persistent 场景是否已加载。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. Project 窗口右键 → BaseGames/Camera/Lens Config 新建一个 SO 资产。
|
||||
/// 2. 将该资产同时赋给 CameraStateController._lensConfig 和所有 CameraArea._lensConfig。
|
||||
/// 3. 修改 <see cref="fieldOfView"/> 后,编辑器会自动重新同步所有已打开场景中的
|
||||
/// CameraArea 限位多边形。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Camera/Lens Config", fileName = "CameraLensConfig")]
|
||||
public class CameraLensConfigSO : ScriptableObject
|
||||
{
|
||||
[Tooltip("全局虚拟相机的垂直 FOV(度)。\n" +
|
||||
"修改此值后,编辑器会自动对所有已打开场景中的 CameraArea 重新同步限位多边形。\n" +
|
||||
"运行时由 CameraStateController 在 Awake 时应用到全局 VCam。")]
|
||||
[Range(1f, 179f)]
|
||||
public float fieldOfView = 60f;
|
||||
|
||||
[Tooltip("摄像机到场景平面(Z = 0)的垂直距离(世界单位)。\n" +
|
||||
"与 fieldOfView 共同决定透视相机的视口尺寸,\n" +
|
||||
"用于将可视区域(VisibleBounds)换算为 CinemachineConfiner2D 限位多边形。\n" +
|
||||
"推荐与 Persistent 场景中相机 Transform 的 |Z| 保持一致(通常为 10)。\n" +
|
||||
"CameraArea._cameraDepth > 0 时以区域专有值优先覆盖此全局值。")]
|
||||
[Min(0.1f)]
|
||||
public float cameraDepth = 10f;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e825a1ad33662d41819655575a49941
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
196
Assets/_Game/Scripts/Camera/CameraLookSystem.cs
Normal file
196
Assets/_Game/Scripts/Camera/CameraLookSystem.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 双轴窥视系统(Look Up / Down / Left / Right)。
|
||||
///
|
||||
/// - <b>垂直窥视</b>:玩家按住垂直方向超过 _holdDelayV 秒后,
|
||||
/// 相机在 Y 轴漂移最多 _lookDistanceV 个世界单位,松开后平滑回弹。
|
||||
/// - <b>水平窥视</b>:玩家静止时按住左/右超过 _holdDelayH 秒后,
|
||||
/// 相机在 X 轴漂移最多 _lookDistanceH 个单位。
|
||||
/// - <b>速度门控</b>:玩家速度超过 _speedGateThreshold 时窥视不再激活。
|
||||
///
|
||||
/// 挂载位置:Persistent 场景 [Camera] 节点下。
|
||||
/// CameraStateController 持有此组件的引用(_lookSystem 字段),
|
||||
/// 在 SetFollowTarget 时自动注册玩家目标,VCam.Follow 指向 VirtualTarget。
|
||||
///
|
||||
/// 玩家输入接入:由 PlayerController 调用 SetLookInput(float, float)。
|
||||
/// </summary>
|
||||
public class CameraLookSystem : MonoBehaviour
|
||||
{
|
||||
[Header("窥视参数 —— 垂直")]
|
||||
[Tooltip("持续按住垂直方向键多少秒后触发垂直窥视。0 = 立即触发。推荐 0.8。")]
|
||||
[SerializeField] private float _holdDelayV = 0.8f;
|
||||
|
||||
[Tooltip("最大垂直偏移量(世界单位)。推荐 3~4 单位。")]
|
||||
[SerializeField] private float _lookDistanceV = 3.5f;
|
||||
|
||||
[Tooltip("垂直偏移过渡速度(越大收敛越快)。")]
|
||||
[SerializeField] private float _lookSpeedV = 2.5f;
|
||||
|
||||
[Tooltip("垂直回弹速度(建议稍快于 _lookSpeedV,避免回弹拖沓)。")]
|
||||
[SerializeField] private float _resetSpeedV = 5f;
|
||||
|
||||
[Header("窥视参数 —— 水平")]
|
||||
[Tooltip("静止后持续按住水平方向键多少秒后触发水平窥视。推荐 0.5。")]
|
||||
[SerializeField] private float _holdDelayH = 0.5f;
|
||||
|
||||
[Tooltip("最大水平偏移量(世界单位)。水平面比垂直面小,避免与 Lookahead 叠加过度。推荐 2.5。")]
|
||||
[SerializeField] private float _lookDistanceH = 2.5f;
|
||||
|
||||
[Tooltip("水平偏移过渡速度。")]
|
||||
[SerializeField] private float _lookSpeedH = 2.0f;
|
||||
|
||||
[Tooltip("水平回弹速度。")]
|
||||
[SerializeField] private float _resetSpeedH = 5f;
|
||||
|
||||
[Header("速度门控")]
|
||||
[Tooltip("玩家移动速度超过此值时窥视系统不再新增偏移,已有偏移平滑回弹。推荐 2.5。")]
|
||||
[SerializeField] private float _speedGateThreshold = 2.5f;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
|
||||
private Transform _baseTarget;
|
||||
private Transform _virtualTargetTransform;
|
||||
private Vector3 _lastBasePosition;
|
||||
private float _estimatedSpeed;
|
||||
|
||||
// 垂直窥视
|
||||
private float _holdTimerV;
|
||||
private float _inputY;
|
||||
private float _currentOffsetY;
|
||||
private float _targetOffsetY;
|
||||
|
||||
// 水平窥视
|
||||
private float _holdTimerH;
|
||||
private float _inputX;
|
||||
private float _currentOffsetX;
|
||||
private float _targetOffsetX;
|
||||
|
||||
/// <summary>
|
||||
/// VCam 应跟随此 Transform(玩家位置 + 窥视偏移)。
|
||||
/// 由 <see cref="CameraStateController"/> 赋值给 VCam.Follow。
|
||||
/// </summary>
|
||||
public Transform VirtualTarget => _virtualTargetTransform;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
var go = new GameObject("[CameraLookTarget]")
|
||||
{
|
||||
hideFlags = HideFlags.HideInHierarchy
|
||||
};
|
||||
DontDestroyOnLoad(go);
|
||||
_virtualTargetTransform = go.transform;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_virtualTargetTransform != null)
|
||||
Destroy(_virtualTargetTransform.gameObject);
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 注册玩家的 CameraFollowTarget。通常由 <see cref="CameraStateController.SetFollowTarget"/> 调用。
|
||||
/// </summary>
|
||||
public void SetBaseTarget(Transform target)
|
||||
{
|
||||
_baseTarget = target;
|
||||
_lastBasePosition = target != null ? target.position : Vector3.zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传入归一化输入(x = 水平,y = 垂直)。
|
||||
/// 由 PlayerController / InputReader 在 Update 中调用。
|
||||
/// </summary>
|
||||
public void SetLookInput(float horizontal, float vertical)
|
||||
{
|
||||
_inputX = Mathf.Clamp(horizontal, -1f, 1f);
|
||||
_inputY = Mathf.Clamp(vertical, -1f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>仅设置垂直窥视输入(向后兼容旧调用方式)。</summary>
|
||||
public void SetLookInput(float vertical) => SetLookInput(0f, vertical);
|
||||
|
||||
/// <summary>
|
||||
/// 重置窥视状态。房间切换(即时硬切)时调用,
|
||||
/// 避免旧房间的窥视偏移残留影响新房间的相机初始位置。
|
||||
/// </summary>
|
||||
/// <param name="snap">
|
||||
/// true = 立即将当前偏移归零(硬切时推荐);
|
||||
/// false = 仅清除目标偏移,让当前偏移通过正常 Update 平滑回弹。
|
||||
/// </param>
|
||||
public void ResetOffsets(bool snap = false)
|
||||
{
|
||||
_holdTimerV = 0f;
|
||||
_holdTimerH = 0f;
|
||||
_targetOffsetY = 0f;
|
||||
_targetOffsetX = 0f;
|
||||
if (snap)
|
||||
{
|
||||
_currentOffsetY = 0f;
|
||||
_currentOffsetX = 0f;
|
||||
if (_baseTarget != null && _virtualTargetTransform != null)
|
||||
_virtualTargetTransform.position = _baseTarget.position;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_baseTarget == null || _virtualTargetTransform == null) return;
|
||||
|
||||
// ── 速度估算(速度门控基准)────────────────────────────────────────
|
||||
float dt = Time.deltaTime;
|
||||
if (dt > 0f)
|
||||
{
|
||||
float rawSpeed = (_baseTarget.position - _lastBasePosition).magnitude / dt;
|
||||
_estimatedSpeed = Mathf.Lerp(_estimatedSpeed, rawSpeed, dt * 8f);
|
||||
}
|
||||
_lastBasePosition = _baseTarget.position;
|
||||
|
||||
bool withinGate = _estimatedSpeed < _speedGateThreshold;
|
||||
|
||||
// ── 垂直窥视 ──────────────────────────────────────────────────────
|
||||
if (withinGate && Mathf.Abs(_inputY) > 0.5f)
|
||||
{
|
||||
_holdTimerV += dt;
|
||||
if (_holdTimerV >= _holdDelayV)
|
||||
_targetOffsetY = _inputY * _lookDistanceV;
|
||||
}
|
||||
else
|
||||
{
|
||||
_holdTimerV = 0f;
|
||||
_targetOffsetY = 0f;
|
||||
}
|
||||
|
||||
float speedV = Mathf.Abs(_targetOffsetY) < 0.01f ? _resetSpeedV : _lookSpeedV;
|
||||
_currentOffsetY = Mathf.Lerp(_currentOffsetY, _targetOffsetY, dt * speedV);
|
||||
|
||||
// ── 水平窥视 ──────────────────────────────────────────────────────
|
||||
if (withinGate && Mathf.Abs(_inputX) > 0.5f)
|
||||
{
|
||||
_holdTimerH += dt;
|
||||
if (_holdTimerH >= _holdDelayH)
|
||||
_targetOffsetX = _inputX * _lookDistanceH;
|
||||
}
|
||||
else
|
||||
{
|
||||
_holdTimerH = 0f;
|
||||
_targetOffsetX = 0f;
|
||||
}
|
||||
|
||||
float speedH = Mathf.Abs(_targetOffsetX) < 0.01f ? _resetSpeedH : _lookSpeedH;
|
||||
_currentOffsetX = Mathf.Lerp(_currentOffsetX, _targetOffsetX, dt * speedH);
|
||||
|
||||
// ── 虚拟目标 = 玩家位置 + 双轴偏移 ────────────────────────────────
|
||||
_virtualTargetTransform.position = _baseTarget.position
|
||||
+ new Vector3(_currentOffsetX, _currentOffsetY, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Camera/CameraLookSystem.cs.meta
Normal file
11
Assets/_Game/Scripts/Camera/CameraLookSystem.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af0ce1cbe43451741ae32ee518e7bc2a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
@@ -22,6 +25,7 @@ namespace BaseGames.Camera
|
||||
[Header("引用")]
|
||||
[SerializeField] private CinemachineBrain _brain;
|
||||
[SerializeField] private CinemachineImpulseSource _impulseSource;
|
||||
[SerializeField] private CameraLookSystem _lookSystem;
|
||||
|
||||
[Header("全局双 VCam(Persistent 场景中放置两台通用虚拟相机)")]
|
||||
[Tooltip("两台 VCam 交替承接各相机区域,通过优先级 ping-pong 触发混合过渡。\n" +
|
||||
@@ -30,17 +34,38 @@ namespace BaseGames.Camera
|
||||
[SerializeField] private CinemachineCamera _vcamA;
|
||||
[SerializeField] private CinemachineCamera _vcamB;
|
||||
|
||||
[Tooltip("全局 VCam 激活时的优先级(非活跃时为 0)。专有 VCam 的 _dedicatedPriority 须高于此值。")]
|
||||
[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 = A;1 = B
|
||||
private int _activeSlot = -1; // -1 = 未初始化;0 = A;1 = 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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,45 +78,170 @@ namespace BaseGames.Camera
|
||||
if (_vcamA != null) _confinerA = _vcamA.GetComponent<CinemachineConfiner2D>();
|
||||
if (_vcamB != null) _confinerB = _vcamB.GetComponent<CinemachineConfiner2D>();
|
||||
|
||||
// 初始两台 VCam 均处于非活跃优先级
|
||||
if (_vcamA != null) _vcamA.Priority = 0;
|
||||
if (_vcamB != null) _vcamB.Priority = 0;
|
||||
// 初始两台 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>
|
||||
/// 切换到目标相机区域。
|
||||
/// <list type="bullet">
|
||||
/// <item>区域有专有 VCam → 激活它(高优先级),全局 VCam 保持当前状态。</item>
|
||||
/// <item>区域无专有 VCam → 配置非活跃全局 VCam,ping-pong 切换优先级触发混合。</item>
|
||||
/// </list>
|
||||
/// 切换到目标相机区域。<paramref name="priority"/> < 当前激活优先级时忽略。
|
||||
/// <para>priority = 0:始终执行(适合 RoomController 入场初始化)。</para>
|
||||
/// </summary>
|
||||
public void SwitchArea(CameraArea targetArea)
|
||||
public void SwitchArea(CameraArea area, int priority = 0, bool instantCut = false)
|
||||
{
|
||||
if (targetArea == null) return;
|
||||
if (area == null) return;
|
||||
|
||||
ApplyBlendProfile(targetArea.BlendProfile ?? _defaultBlendProfile);
|
||||
if (priority == 0)
|
||||
{
|
||||
// 房间初始化 / 无条件切换:记录基线并清空触发集合
|
||||
_roomBaselineArea = area;
|
||||
_activeZones.Clear();
|
||||
ActivateArea(area, instantCut);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetArea.HasDedicated)
|
||||
ActivateDedicated(targetArea);
|
||||
else
|
||||
ActivateGlobalSlot(targetArea);
|
||||
// 触发区域进入:更新集合(同一区域去重后重新加入,保证最新优先级)
|
||||
_activeZones.RemoveAll(e => e.area == area);
|
||||
_activeZones.Add((area, priority));
|
||||
|
||||
// 仅当此区域是当前最优且尚未激活时才切换,避免不必要的 ping-pong
|
||||
CameraArea best = GetEffectiveArea();
|
||||
if (best == area && area != _currentArea)
|
||||
ActivateArea(area, instantCut);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 运行时为两台全局 VCam 统一设置跟随目标(如 Player/CameraFollowTarget)。
|
||||
/// 可在 Player 生成后由任意系统调用。
|
||||
/// 释放 <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)
|
||||
{
|
||||
if (_vcamA != null) _vcamA.Follow = followTarget;
|
||||
if (_vcamB != null) _vcamB.Follow = 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>
|
||||
@@ -104,6 +254,56 @@ namespace BaseGames.Camera
|
||||
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>
|
||||
@@ -141,34 +341,149 @@ namespace BaseGames.Camera
|
||||
// 首次调用:直接激活 VCamA(场景淡入阶段,无需混合动画)
|
||||
if (_activeSlot < 0)
|
||||
{
|
||||
var cam = _vcamA ?? _vcamB;
|
||||
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;
|
||||
}
|
||||
|
||||
// Ping-pong:配置非活跃槽 → 升级其优先级 → 降低活跃槽优先级
|
||||
bool nextIsA = _activeSlot != 0;
|
||||
var inactiveCam = nextIsA ? _vcamA : _vcamB;
|
||||
var activeCam = nextIsA ? _vcamB : _vcamA;
|
||||
// 只有一台 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;
|
||||
|
||||
// 只有一台 VCam 时降级处理(仍能工作,但无混合动画)
|
||||
if (inactiveCam == null) inactiveCam = activeCam;
|
||||
|
||||
ConfigureSlot(inactiveCam, inactiveConfiner, area);
|
||||
SyncFollowToVCam(inactiveCam); // 确保 Follow 正确(防止 SetFollowTarget 未被调用)
|
||||
inactiveCam.Priority = _globalActivePriority;
|
||||
activeCam.Priority = 0;
|
||||
activeCam.Priority = _standbyPriority; // 降到待机但仍 > 0,Brain 可在混合期间读取其状态
|
||||
_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)
|
||||
@@ -176,5 +491,118 @@ namespace BaseGames.Camera
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,173 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 相机区域切换触发器。玩家进入时通知 <see cref="CameraStateController"/> 切换到目标 <see cref="CameraArea"/>。
|
||||
/// 相机区域切换触发器。
|
||||
/// 当触发区域重叠时,玩家必须先离开当前所在的触发区域,才会切换到下一个区域,
|
||||
/// 而不是进入重叠区域时立即切换。
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[RequireComponent(typeof(BoxCollider2D))]
|
||||
[RequireComponent(typeof(PolygonCollider2D))]
|
||||
public class CameraTriggerZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private CameraArea _targetArea;
|
||||
|
||||
[Tooltip("玩家离开此触发区域时回退到的区域(留空则退出时不做处理)。\n" +
|
||||
"通常设为上级/相邻的包含区域,使玩家返回时相机自然过渡。")]
|
||||
[SerializeField] private CameraArea _exitFallbackArea;
|
||||
|
||||
[Tooltip("触发区域优先级。同时在多个触发区域内时,高优先级区域胜出。\n" +
|
||||
"相同优先级则后进入的胜出(推荐默认值 1)。")]
|
||||
[SerializeField] private int _priority = 1;
|
||||
|
||||
[SerializeField] private string _playerTag = "Player";
|
||||
|
||||
private BoxCollider2D _collider;
|
||||
private PolygonCollider2D _collider;
|
||||
private bool _isPlayerInside;
|
||||
|
||||
// ── 静态:跨实例共享触发状态 ──────────────────────────────────────────
|
||||
// 玩家当前物理上所在的所有触发区域(按进入顺序排列)
|
||||
private static readonly List<CameraTriggerZone> s_InsideZones = new();
|
||||
// 当前已向 ICameraService 发出 SwitchArea 请求的触发区域
|
||||
private static CameraTriggerZone s_ActiveZone;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_collider = GetComponent<BoxCollider2D>();
|
||||
_collider = GetComponent<PolygonCollider2D>();
|
||||
_collider.isTrigger = true;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
if (!_isPlayerInside) return;
|
||||
|
||||
_isPlayerInside = false;
|
||||
s_InsideZones.Remove(this);
|
||||
if (s_ActiveZone == this)
|
||||
Deactivate(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 若玩家出生时已在触发区域内,OnTriggerEnter2D 不会触发。
|
||||
/// 延迟一帧(确保 RoomController.Start 先完成基准区域设置)后主动检测。
|
||||
/// </summary>
|
||||
private IEnumerator Start()
|
||||
{
|
||||
if (!Application.isPlaying) yield break;
|
||||
|
||||
// 等一帧:让 RoomController.Start(priority=0)先建立基准区域,
|
||||
// 再以 _priority 叠加子区域,保证栈顺序正确。
|
||||
yield return null;
|
||||
|
||||
if (_targetArea == null) yield break;
|
||||
|
||||
GameObject player = GameObject.FindWithTag(_playerTag);
|
||||
if (player == null || !_collider.OverlapPoint(player.transform.position)) yield break;
|
||||
|
||||
// OnTriggerEnter2D 可能已先一步处理,避免重复加入
|
||||
if (!_isPlayerInside)
|
||||
{
|
||||
_isPlayerInside = true;
|
||||
s_InsideZones.Add(this);
|
||||
}
|
||||
|
||||
if (s_ActiveZone == null)
|
||||
Activate(this);
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
if (!other.CompareTag(_playerTag)) return;
|
||||
if (_targetArea == null || _isPlayerInside) return;
|
||||
|
||||
var service = ServiceLocator.GetOrDefault<ICameraService>();
|
||||
if (service == null) return;
|
||||
_isPlayerInside = true;
|
||||
s_InsideZones.Add(this);
|
||||
|
||||
if (_targetArea != null)
|
||||
service.SwitchArea(_targetArea);
|
||||
// 没有激活的触发区域 → 立即切换
|
||||
// 已有激活的触发区域 → 等玩家离开后再接管(避免重叠区域间提前切换)
|
||||
if (s_ActiveZone == null)
|
||||
Activate(this);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
if (!other.CompareTag(_playerTag)) return;
|
||||
if (!_isPlayerInside) return;
|
||||
|
||||
_isPlayerInside = false;
|
||||
s_InsideZones.Remove(this);
|
||||
|
||||
if (s_ActiveZone == this)
|
||||
Deactivate(this);
|
||||
}
|
||||
|
||||
// ── 静态辅助方法 ────────────────────────────────────────────────────────
|
||||
|
||||
private static void Activate(CameraTriggerZone zone)
|
||||
{
|
||||
s_ActiveZone = zone;
|
||||
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(zone._targetArea, zone._priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 离开 <paramref name="leaving"/> 时的处理:
|
||||
/// 若还有其他触发区域,先激活最优者再释放 leaving(避免短暂回退到房间基线);
|
||||
/// 否则直接释放并使用 <see cref="_exitFallbackArea"/>。
|
||||
/// </summary>
|
||||
private static void Deactivate(CameraTriggerZone leaving)
|
||||
{
|
||||
ICameraService svc = ServiceLocator.GetOrDefault<ICameraService>();
|
||||
if (s_InsideZones.Count > 0)
|
||||
{
|
||||
// 先激活下一个,再释放 leaving —— 此时 _currentArea 已更新为 next,
|
||||
// ReleaseArea(leaving) 中 wasActive=false,仅从 _activeZones 移除,不触发额外跳转
|
||||
CameraTriggerZone next = SelectBest();
|
||||
s_ActiveZone = next;
|
||||
svc?.SwitchArea(next._targetArea, next._priority);
|
||||
svc?.ReleaseArea(leaving._targetArea, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
s_ActiveZone = null;
|
||||
svc?.ReleaseArea(leaving._targetArea, leaving._exitFallbackArea);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>从 <see cref="s_InsideZones"/> 中选出优先级最高的区域。</summary>
|
||||
private static CameraTriggerZone SelectBest()
|
||||
{
|
||||
CameraTriggerZone best = s_InsideZones[0];
|
||||
for (int i = 1; i < s_InsideZones.Count; i++)
|
||||
if (s_InsideZones[i]._priority > best._priority)
|
||||
best = s_InsideZones[i];
|
||||
return best;
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (_collider == null) _collider = GetComponent<BoxCollider2D>();
|
||||
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.25f);
|
||||
if (_collider == null) _collider = GetComponent<PolygonCollider2D>();
|
||||
if (_collider == null || _collider.pathCount == 0) return;
|
||||
|
||||
var pts = new System.Collections.Generic.List<Vector2>();
|
||||
_collider.GetPath(0, pts);
|
||||
if (pts.Count < 2) return;
|
||||
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.DrawCube(_collider.offset, _collider.size);
|
||||
Vector2 off = _collider.offset;
|
||||
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.8f);
|
||||
Gizmos.DrawWireCube(_collider.offset, _collider.size);
|
||||
for (int i = 0; i < pts.Count; i++)
|
||||
{
|
||||
Vector2 a = pts[i] + off;
|
||||
Vector2 b = pts[(i + 1) % pts.Count] + off;
|
||||
Gizmos.DrawLine(new Vector3(a.x, a.y), new Vector3(b.x, b.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 相机服务接口。供 CameraTriggerZone 等调用,
|
||||
/// 通过 ServiceLocator.Get<ICameraService>() 访问,无需直接依赖 CameraStateController。
|
||||
/// 相机服务接口。通过 <c>ServiceLocator.GetOrDefault<ICameraService>()</c> 访问。
|
||||
/// </summary>
|
||||
public interface ICameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// 切换到目标相机区域。
|
||||
/// 区域有专有 VCam 时激活它(高优先级);无专有 VCam 时由全局双 VCam 交替承接。
|
||||
/// <paramref name="priority"/> 用于触发区域优先级仲裁:
|
||||
/// 仅当 priority ≥ 当前激活优先级时才执行切换。
|
||||
/// 传 0(默认)时始终切换(适合 RoomController 初始化或场景加载)。
|
||||
/// <para>
|
||||
/// <paramref name="instantCut"/> = true 时使用即时切断混合(房间入口硬切:相机直接跳到目标位置、无过渡动画),
|
||||
/// 同时重置窥视偏移,避免旧房间的窥视偏移残留到新房间。
|
||||
/// 适合通过门传送后的首次相机初始化;区域内触发区域切换应保持默认 false。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
void SwitchArea(CameraArea targetArea);
|
||||
void SwitchArea(CameraArea area, int priority = 0, bool instantCut = false);
|
||||
|
||||
/// <summary>
|
||||
/// 释放 <paramref name="releasedArea"/>(通常由 CameraTriggerZone.OnTriggerExit 调用)。
|
||||
/// 若 <paramref name="releasedArea"/> 正是当前激活区域,切换到 <paramref name="fallback"/>;
|
||||
/// 否则无操作。
|
||||
/// </summary>
|
||||
void ReleaseArea(CameraArea releasedArea, CameraArea fallback);
|
||||
|
||||
/// <summary>为全局双 VCam 设置跟随目标(Player/CameraFollowTarget)。</summary>
|
||||
void SetFollowTarget(Transform followTarget);
|
||||
|
||||
/// <summary>触发屏幕抖动(指定速度矢量)。</summary>
|
||||
void TriggerImpulse(Vector3 velocity);
|
||||
|
||||
/// <summary>触发屏幕抖动(向下方向)。</summary>
|
||||
void TriggerImpulse(float strength = 0.3f);
|
||||
|
||||
/// <summary>
|
||||
/// 平滑过渡正交相机尺寸。<paramref name="duration"/> = 0 时瞬间切换。
|
||||
/// 适用于 Boss 战拉远、特殊演出室拉近等场景。
|
||||
/// </summary>
|
||||
void SetLensSize(float orthographicSize, float duration = 0f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 单房间虚拟相机。激活时提升优先级,停用时降为 0。
|
||||
/// 挂载在每个房间的 CinemachineCamera GameObject 上。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CinemachineCamera))]
|
||||
public class RoomCamera : MonoBehaviour
|
||||
{
|
||||
[Header("房间设置")]
|
||||
[SerializeField] private RoomVisibleArea _visibleArea;
|
||||
[SerializeField] private Vector2 _cameraOffset = Vector2.zero;
|
||||
[SerializeField] private CameraBlendProfileSO _blendProfile;
|
||||
[SerializeField] private int _activePriority = 15;
|
||||
|
||||
[Header("可视区域(透视相机)")]
|
||||
[Tooltip("摄像机应显示的最大可视矩形(世界坐标)。\n" +
|
||||
"在 Scene 视图中可直接拖拽四条边编辑,然后点击 Inspector 中的\n" +
|
||||
"「从可视区域更新限位区域」按钮将其换算为 CinemachineConfiner2D 所需的限位多边形。")]
|
||||
[SerializeField] private Rect _visibleBounds = new Rect(-12f, -6f, 24f, 12f);
|
||||
|
||||
[Tooltip("摄像机到场景平面(Z = 0)的垂直距离,用于透视视口尺寸计算。\n" +
|
||||
"留 0 时自动取 transform.position.z 的绝对值(推荐)。")]
|
||||
[SerializeField] private float _cameraDepth = 0f;
|
||||
|
||||
private CinemachineCamera _vcam;
|
||||
|
||||
private void Awake() => _vcam = GetComponent<CinemachineCamera>();
|
||||
private void OnEnable() => _vcam.Priority = _activePriority;
|
||||
private void OnDisable() => _vcam.Priority = 0;
|
||||
|
||||
public PolygonCollider2D ConfinerCollider => _visibleArea?.Collider;
|
||||
public Vector2 CameraOffset => _cameraOffset;
|
||||
public CameraBlendProfileSO BlendProfile => _blendProfile;
|
||||
public Rect VisibleBounds => _visibleBounds;
|
||||
|
||||
/// <summary>
|
||||
/// 摄像机到场景平面的有效深度。
|
||||
/// _cameraDepth > 0 时使用配置值,否则自动读取 |transform.position.z|,再兜底 10。
|
||||
/// </summary>
|
||||
public float CameraDepth
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cameraDepth > 0f) return _cameraDepth;
|
||||
float z = Mathf.Abs(transform.position.z);
|
||||
return z > 0.01f ? z : 10f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>在 CameraStateController 管理的激活流程中调用。</summary>
|
||||
public void Activate() => gameObject.SetActive(true);
|
||||
public void Deactivate() => gameObject.SetActive(false);
|
||||
|
||||
// ── Gizmo ──────────────────────────────────────────────────────────────
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// 黄色:可视区域(设计意图——玩家在此房间内的最大可见范围)
|
||||
Vector3 center = new Vector3(_visibleBounds.center.x, _visibleBounds.center.y, 0f);
|
||||
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
|
||||
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.10f);
|
||||
Gizmos.DrawCube(center, size);
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.90f);
|
||||
Gizmos.DrawWireCube(center, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记房间的可见区域(多边形)。供 CinemachineConfiner2D 使用。
|
||||
/// [ExecuteAlways] 确保编辑器中碰撞体立即更新。
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[RequireComponent(typeof(PolygonCollider2D))]
|
||||
public class RoomVisibleArea : MonoBehaviour
|
||||
{
|
||||
private PolygonCollider2D _collider;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_collider = GetComponent<PolygonCollider2D>();
|
||||
_collider.isTrigger = true;
|
||||
}
|
||||
|
||||
public PolygonCollider2D Collider
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_collider == null) _collider = GetComponent<PolygonCollider2D>();
|
||||
return _collider;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user