摄像机区域的架构改动

This commit is contained in:
2026-05-15 14:47:24 +08:00
parent 1b37297585
commit f264329751
3591 changed files with 1687228 additions and 446503 deletions

View File

@@ -0,0 +1,17 @@
{
"excludePlatforms": [],
"allowUnsafeCode": false,
"precompiledReferences": [],
"name": "BaseGames.Camera",
"defineConstraints": [],
"noEngineReferences": false,
"versionDefines": [],
"rootNamespace": "BaseGames.Camera",
"references": [
"BaseGames.Core.Events",
"Unity.Cinemachine"
],
"autoReferenced": true,
"overrideReferences": false,
"includePlatforms": []
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5b9cbc0f2e569d64a862f3b7f417c7b6
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,90 @@
using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
/// <summary>
/// 相机区域数据组件。一个房间场景内可放置任意数量的 CameraArea
/// 每个区域独立定义限位范围、可视边界与混合配置。
///
/// 运行时由 <see cref="CameraStateController"/> 管理:
/// - <c>_dedicatedCamera</c> 为空 → 使用 Persistent 场景中的两台全局 VCam 交替承接,
/// 减少场景内 VCam 数量,相机参数统一由全局 VCam 配置。
/// - <c>_dedicatedCamera</c> 不为空 → 激活该专有 VCam优先级高于全局 VCam
/// 适用于需要独特相机参数FOV / Offset / 阻尼)的特殊区域。
/// </summary>
public class CameraArea : MonoBehaviour
{
[Header("限位区域")]
[Tooltip("定义相机移动边界的 PolygonCollider2D通常挂载在子节点 AreaBoundary 上)。\n" +
"会被赋给全局 VCam 的 CinemachineConfiner2D.BoundingShape2D。")]
[SerializeField] private PolygonCollider2D _confinerCollider;
[Header("可视区域(透视相机)")]
[Tooltip("摄像机应显示的最大可视矩形(世界坐标)。\n" +
"Scene 视图中可直接拖拽四条边编辑,然后点击 Inspector 中的\n" +
"「从可视区域更新限位区域(透视)」按钮将其换算为限位多边形。")]
[SerializeField] private Rect _visibleBounds = new Rect(-12f, -6f, 24f, 12f);
[Tooltip("摄像机到场景平面Z = 0的垂直距离用于透视视口尺寸计算。\n" +
"留 0 时自动取 transform.position.z 的绝对值(推荐)。")]
[SerializeField] private float _cameraDepth = 0f;
[Header("混合配置")]
[SerializeField] private CameraBlendProfileSO _blendProfile;
[Header("专有虚拟相机(可选)")]
[Tooltip("为空时由全局双 VCam 交替过渡(推荐,节省 VCam 数量)。\n" +
"不为空时激活此专有 CinemachineCamera优先级高于全局 VCam。\n" +
"适用于需要独特 FOV / Noise / LookAt 等参数的特殊区域。")]
[SerializeField] private CinemachineCamera _dedicatedCamera;
[Tooltip("专有 VCam 激活时使用的优先级,须高于全局 VCam 的 _globalActivePriority默认 10。")]
[SerializeField] private int _dedicatedPriority = 20;
// ── 公开属性 ──────────────────────────────────────────────────────────
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;
/// <summary>
/// 摄像机到场景平面的有效深度(用于透视视口换算)。
/// _cameraDepth &gt; 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;
}
}
// ── 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);
// 青色:专有 VCam 指示线
if (_dedicatedCamera != null)
{
Gizmos.color = new Color(0.2f, 1f, 0.8f, 0.8f);
Gizmos.DrawLine(transform.position,
_dedicatedCamera.transform.position);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8ebf3921efccfad429f451251738375e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
[CreateAssetMenu(menuName = "BaseGames/Camera/BlendProfile")]
public class CameraBlendProfileSO : ScriptableObject
{
public CinemachineBlendDefinition.Styles Style = CinemachineBlendDefinition.Styles.EaseInOut;
public float BlendTime = 0.5f;
[Tooltip("Style = Custom 时使用")]
public AnimationCurve CustomCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);
/// <summary>转换为 Cinemachine 混合定义。</summary>
public CinemachineBlendDefinition ToBlendDefinition()
{
return new CinemachineBlendDefinition
{
Style = this.Style,
Time = this.BlendTime,
CustomCurve = this.Style == CinemachineBlendDefinition.Styles.Custom ? CustomCurve : null
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04f7b183b6d364d4ea85283d30339db7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,22 @@
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;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b358a30ac16c6a34fb673ede0a288e48
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,180 @@
using UnityEngine;
using Unity.Cinemachine;
using BaseGames.Core;
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;
[Header("全局双 VCamPersistent 场景中放置两台通用虚拟相机)")]
[Tooltip("两台 VCam 交替承接各相机区域,通过优先级 ping-pong 触发混合过渡。\n" +
"须各自挂载 CinemachineCamera + CinemachineConfiner2D\n" +
"Follow 指向 Player/CameraFollowTarget或运行时调用 SetFollowTarget 赋值)。")]
[SerializeField] private CinemachineCamera _vcamA;
[SerializeField] private CinemachineCamera _vcamB;
[Tooltip("全局 VCam 激活时的优先级(非活跃时为 0。专有 VCam 的 _dedicatedPriority 须高于此值。")]
[SerializeField] private int _globalActivePriority = 10;
[Header("默认混合配置")]
[SerializeField] private CameraBlendProfileSO _defaultBlendProfile;
// ── 状态 ──────────────────────────────────────────────────────────────
private int _activeSlot = -1; // -1 = 未初始化0 = A1 = B
private CinemachineCamera _activeDedicatedCam;
private CinemachineConfiner2D _confinerA;
private CinemachineConfiner2D _confinerB;
// ── 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 均处于非活跃优先级
if (_vcamA != null) _vcamA.Priority = 0;
if (_vcamB != null) _vcamB.Priority = 0;
}
private void OnDestroy()
{
ServiceLocator.Unregister<ICameraService>(this);
}
// ── 公开 API ──────────────────────────────────────────────────────────
/// <summary>
/// 切换到目标相机区域。
/// <list type="bullet">
/// <item>区域有专有 VCam → 激活它(高优先级),全局 VCam 保持当前状态。</item>
/// <item>区域无专有 VCam → 配置非活跃全局 VCamping-pong 切换优先级触发混合。</item>
/// </list>
/// </summary>
public void SwitchArea(CameraArea targetArea)
{
if (targetArea == null) return;
ApplyBlendProfile(targetArea.BlendProfile ?? _defaultBlendProfile);
if (targetArea.HasDedicated)
ActivateDedicated(targetArea);
else
ActivateGlobalSlot(targetArea);
}
/// <summary>
/// 运行时为两台全局 VCam 统一设置跟随目标(如 Player/CameraFollowTarget
/// 可在 Player 生成后由任意系统调用。
/// </summary>
public void SetFollowTarget(Transform followTarget)
{
if (_vcamA != null) _vcamA.Follow = followTarget;
if (_vcamB != null) _vcamB.Follow = followTarget;
}
/// <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>激活区域的专有 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);
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;
var inactiveConfiner = nextIsA ? _confinerA : _confinerB;
// 只有一台 VCam 时降级处理(仍能工作,但无混合动画)
if (inactiveCam == null) inactiveCam = activeCam;
ConfigureSlot(inactiveCam, inactiveConfiner, area);
inactiveCam.Priority = _globalActivePriority;
activeCam.Priority = 0;
_activeSlot = nextIsA ? 0 : 1;
}
private static void ConfigureSlot(
CinemachineCamera vcam, CinemachineConfiner2D confiner, CameraArea area)
{
if (confiner != null && area.ConfinerCollider != null)
confiner.BoundingShape2D = area.ConfinerCollider;
}
private void ApplyBlendProfile(CameraBlendProfileSO profile)
{
if (_brain != null && profile != null)
_brain.DefaultBlend = profile.ToBlendDefinition();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 49f718c655d71394ea13e312a2dd9eed
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,46 @@
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Camera
{
/// <summary>
/// 相机区域切换触发器。玩家进入时通知 <see cref="CameraStateController"/> 切换到目标 <see cref="CameraArea"/>。
/// </summary>
[ExecuteAlways]
[RequireComponent(typeof(BoxCollider2D))]
public class CameraTriggerZone : MonoBehaviour
{
[SerializeField] private CameraArea _targetArea;
[SerializeField] private string _playerTag = "Player";
private BoxCollider2D _collider;
private void Awake()
{
_collider = GetComponent<BoxCollider2D>();
_collider.isTrigger = true;
}
private void OnTriggerEnter2D(Collider2D other)
{
if (!Application.isPlaying) return;
if (!other.CompareTag(_playerTag)) return;
var service = ServiceLocator.GetOrDefault<ICameraService>();
if (service == null) return;
if (_targetArea != null)
service.SwitchArea(_targetArea);
}
private void OnDrawGizmos()
{
if (_collider == null) _collider = GetComponent<BoxCollider2D>();
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.25f);
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(_collider.offset, _collider.size);
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.8f);
Gizmos.DrawWireCube(_collider.offset, _collider.size);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 108d2b73047255f44a823dbcdea4a7fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
namespace BaseGames.Camera
{
/// <summary>
/// 相机服务接口。供 CameraTriggerZone 等调用,
/// 通过 ServiceLocator.Get&lt;ICameraService&gt;() 访问,无需直接依赖 CameraStateController。
/// </summary>
public interface ICameraService
{
/// <summary>
/// 切换到目标相机区域。
/// 区域有专有 VCam 时激活它(高优先级);无专有 VCam 时由全局双 VCam 交替承接。
/// </summary>
void SwitchArea(CameraArea targetArea);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 94f7141340a22a54aa504d7c6a09eeb3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,71 @@
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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: af7e12583264b8c4da8dcd69df274793
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,30 @@
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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 38af2eabab7039c4a919181e4c507d12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: