Files
zeling_v2/Docs/Architecture/08_WorldModule.md
2026-05-12 15:34:08 +08:00

960 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# 08 · 世界模块
> **命名空间** `BaseGames.World`
> **程序集** `BaseGames.World`
> **路径** `Assets/Scripts/World/`
> **依赖** `BaseGames.Core`、`BaseGames.Core.Events`、`BaseGames.Core.Save`
> (通过 `IRestoreOnSave`(定义于 Core与玩家层解耦无需直接引用 `BaseGames.Player`
---
## 目录
1. [场景结构规范(总览)](#1-场景结构规范)
2. [RoomTransition](#2-roomtransition)
3. [SavePoint](#3-savepoint)
4. [HazardZone](#4-hazardzone)
5. [Collectible](#5-collectible)
6. [AbilityUnlock](#6-abilityunlock)
7. [IInteractable 接口](#7-iinteractable-接口)
8. [InteractableDetector](#8-interactabledetector)
9. [WorldStateRegistry](#9-worldstateregistry)
10. [DestructibleTile](#10-destructibletile)
11. [MovingPlatform](#11-movingplatform)
12. [DirectionalDestructible — 单向可破坏墙](#12-directionaldestructible--单向可破坏墙)
13. [DirectionalInteractable — 单向触发机关](#13-directionalinteractable--单向触发机关)
14. [CrumblePlatform — 碎裂平台](#14-crumbleplatform--碎裂平台)
15. [SkillInteractable — 技能专属交互物](#15-skillinteractable--技能专属交互物)
16. [世界事件频道清单](#16-世界事件频道清单)
---
## 1. 场景结构规范
**场景命名**(见 [01_ProjectStructure.md](./01_ProjectStructure.md) §8
**房间场景标准层级**(详见 Architecture README
重要约束:
- 每个房间场景必须包含 `RoomController` 组件(挂在 `[RoomRoot]` GameObject 上)
- 必须有至少一个 `RoomTransition`(出入口)
- 玩家出生点:`PlayerSpawnPoint` 组件,由 `SceneLoadRequest.EntryTransitionId` 匹配
---
## 2. RoomTransition
```csharp
// 路径: Assets/Scripts/World/RoomTransition.cs
// 挂在出入口 Trigger Collider2D 上
[RequireComponent(typeof(Collider2D))]
public class RoomTransition : MonoBehaviour
{
[Header("Config")]
[SerializeField] private string _transitionId; // 唯一 ID目标出口用于匹配出生点
[SerializeField] private string _targetSceneAddress; // 目标场景 Addressable keyAddressKeys 常量)
[SerializeField] private string _targetTransitionId; // 目标场景中对应出口的 ID
[SerializeField] private bool _autoTrigger = true; // true = 进入触发器自动触发false = 需交互
[SerializeField] private bool _requiresKeyItem; // 是否需要持有鑰匙物品
[SerializeField] private string _requiredItemId; // 鑰匙物品 ID
[Header("Event Channel")]
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
[Header("世界状态")]
[SerializeField] private WorldStateRegistry _worldState; // 持有物品检查
// 玩家进入触发器
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
if (_requiresKeyItem && !HasItem(_requiredItemId)) return;
_onSceneLoadRequest.Raise(new SceneLoadRequest
{
SceneName = _targetSceneName,
EntryTransitionId = _targetTransitionId,
ShowLoadingScreen = false,
IsRespawn = false
});
}
private bool HasItem(string itemId)
{
if (string.IsNullOrEmpty(itemId)) return true;
if (_worldState == null) return false; // 未配置则拦截(警告日志)
return _worldState.IsCollected(itemId);
}
// Editor在 Scene View 显示箭头 Gizmo
private void OnDrawGizmos();
}
// 玩家出生点,与 RoomTransition.transitionId 对应
public class PlayerSpawnPoint : MonoBehaviour
{
public string TransitionId;
public Vector2 SpawnPosition => transform.position;
public int FacingDirection = 1; // +1 右, -1 左
private void OnDrawGizmos() { /* 绿色标记 */ }
}
```
---
## 3. SavePoint
```csharp
// 路径: Assets/Scripts/World/SavePoint.cs
// 实现 IInteractable + ISaveable玩家交互时触发存档
// 架构决策:通过 IRestoreOnSave定义于 BaseGames.Core调用玩家回血/灵泉,
// 避免 World 层反向依赖 BaseGames.PlayerWorld.asmdef 无需引用 Player 程序集。
public class SavePoint : MonoBehaviour, IInteractable, ISaveable
{
[Header("Config")]
[SerializeField] private string _savePointId;
[SerializeField] private bool _restoreSpring = true;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onSavePointActivated;
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
private bool _isActivated;
// IInteractable 参数为 Transform与 14_NarrativeModule §1 / 07 §7 对齐)
public bool CanInteract => true;
public string InteractPrompt => _isActivated ? "休息" : "激活";
public void Interact(Transform player)
{
_isActivated = true;
// 1. 通过 IRestoreOnSave 恢复玩家World 不感知具体 Player 类型)
var restorer = player.GetComponentInChildren<IRestoreOnSave>();
if (restorer != null)
{
restorer.FullRestore();
if (_restoreSpring) restorer.RestoreSpring();
}
// 2. 广播存档点激活GameManager 响应并调用 SaveManager.SaveAsync
_onSavePointActivated?.Raise(_savePointId);
// 3. 若该场景已有多个存档点激活,打开快速旅行 UI
// 4. 播放激活动画 / 特效
}
// ISaveable 存档集成
public bool IsActivated => _isActivated;
public void SetActivated(bool val) => _isActivated = val;
}
```
> **IRestoreOnSave 接口**`Assets/Scripts/Core/IRestoreOnSave.cs`,命名空间 `BaseGames.Core`
> `PlayerStats` 显式实现:`FullRestore()` → `FullHeal()``RestoreSpring()` → `RestoreSpringCharges()`。
> 同一接口可扩展至其他可被存档点恢复的对象(伙伴、坐骑等),无需修改 SavePoint 本身。
---
## 4. HazardZone
```csharp
// 路径: Assets/Scripts/World/HazardZone.cs
// 即死区域(深坑、岩浆等)
[RequireComponent(typeof(Collider2D))]
public class HazardZone : MonoBehaviour
{
public enum RespawnType { AtLastSavePoint, AtRoomEntry }
[SerializeField] private bool _isInstantKill = true;
[SerializeField] private int _damage = 9999;
[SerializeField] private RespawnType _respawnType = RespawnType.AtLastSavePoint;
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
var stats = other.GetComponentInParent<PlayerStats>();
if (stats == null) return;
if (_isInstantKill)
stats.TakeDamage(stats.MaxHP * 2); // 确保即死
else
stats.TakeDamage(_damage);
}
}
```
---
## 5. Collectible
```csharp
// 路径: Assets/Scripts/World/Collectible.cs
// Geo 货币 / 物品掉落
public class Collectible : MonoBehaviour
{
[Header("Config")]
[SerializeField] private CollectibleType _type;
[SerializeField] private int _geoAmount; // type = Geo 时
[SerializeField] private string _itemId; // type = Item 时
[SerializeField] private bool _isPersistent; // false = 敌人掉落(不存档); true = 固定位置(存档)
[Header("Physics")]
[SerializeField] private float _bounceForce = 5f;
[Header("Event Channel")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
private string _collectibleId; // 用于存档(持久化 Collectible 专用)
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
var player = other.GetComponentInParent<PlayerController>();
if (player == null) return;
switch (_type)
{
case CollectibleType.Geo:
player.Stats.AddGeo(_geoAmount);
break;
case CollectibleType.Item:
// 通知 Inventory / QuestManager
_onCollectiblePickup.Raise(_itemId);
break;
}
if (_isPersistent)
_onCollectiblePickup.Raise(_collectibleId); // 存档标记
Despawn();
}
private void Despawn(); // 归还对象池
// ―― 运行时配置(由 CollectibleSpawner 在实例化后调用)――――――――――――――――
/// <summary>将此 Collectible 配置为 Geo 掉落。</summary>
public void SetGeo(int amount);
/// <summary>将此 Collectible 配置为道具掉落。</summary>
public void SetItem(string itemId);
}
public enum CollectibleType { Geo, Item, HPOrb }
```
---
## 5b. CollectibleSpawner
```csharp
// 路径: Assets/Scripts/World/CollectibleSpawner.cs + CollectibleSpawnerConfig.cs
// 封装 Collectible Prefab 实例化逻辑,供 LootResolver 等静态调用。
// CollectibleSpawnerConfig 挂在 Persistent 场景 [World] GameObject 上持有 Prefab 引用,
// Awake 调用 CollectibleSpawner.Register(this) 注入静态配置。
public static class CollectibleSpawner
{
internal static void Register(CollectibleSpawnerConfig config);
public static void SpawnGeo(Vector2 position, int amount); // 实例化 GeoPrefab 并调用 SetGeo(amount)
public static void SpawnItem(Vector2 position, string itemId); // 实例化 ItemPrefab 并调用 SetItem(itemId)
}
public class CollectibleSpawnerConfig : MonoBehaviour
{
[SerializeField] internal GameObject GeoPrefab;
[SerializeField] internal GameObject ItemPrefab;
private void Awake() => CollectibleSpawner.Register(this);
}
```
---
## 6. AbilityUnlock
```csharp
// 路径: Assets/Scripts/World/AbilityUnlock.cs
// 世界中固定位置的能力解锁物(获取新技能)
public class AbilityUnlock : MonoBehaviour, IInteractable
{
[SerializeField] private AbilityType _abilityToUnlock;
[SerializeField] private string _unlockId; // 存档用
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // 通知存档已拾取
private bool _isCollected = false;
public bool CanInteract => !_isCollected;
public string InteractPrompt => "获得能力";
public void Interact(Transform player)
{
if (_isCollected) return;
_isCollected = true;
// ⚠️ PlayerController 无 Instance通过 player 参数获取
player.GetComponent<PlayerController>()?.Stats.UnlockAbility(_abilityToUnlock);
_onCollectiblePickup.Raise(_unlockId);
// 触发解锁演出Cutscene / UI 提示)
gameObject.SetActive(false);
}
public void SetCollected(bool val)
{
_isCollected = val;
if (val) gameObject.SetActive(false);
}
}
```
---
## 7. IInteractable 接口
```csharp
// 路径: Assets/Scripts/World/IInteractable.cs
// ⚠️ 与 14_NarrativeModule §1 对齐权威定义Transform 参数 + 5 成员
namespace BaseGames.World
{
public interface IInteractable
{
bool CanInteract { get; } // 当前是否可交互
string InteractPrompt { get; } // 显示在交互提示 UI 上的文字
void Interact(Transform player); // 传入玩家 Transform需要 PlayerController 时通过 player.GetComponent<PlayerController>() 获取PlayerController 无 Instance
void OnPlayerEnterRange(Transform player); // 进入检测范围
void OnPlayerExitRange(); // 离开检测范围
}
}
```
---
## 8. InteractableDetector
```csharp
// 路径: Assets/Scripts/World/InteractableDetector.cs
// 挂在玩家上,检测周围可交互物并驱动交互 UI
public class InteractableDetector : MonoBehaviour
{
[SerializeField] private float _detectRadius = 1.5f;
[SerializeField] private LayerMask _interactableLayer;
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private StringEventChannelSO _onShowInteractPrompt; // 发布:显示交互提示
[SerializeField] private VoidEventChannelSO _onHideInteractPrompt; // 发布:隐藏交互提示
private IInteractable _nearest;
private IInteractable _previousNearest;
private void OnEnable() => _inputReader.InteractEvent += TryInteract;
private void OnDisable() => _inputReader.InteractEvent -= TryInteract;
private void Update()
{
// OverlapCircle → 找最近 IInteractable
var hits = Physics2D.OverlapCircleAll(transform.position, _detectRadius, _interactableLayer);
_nearest = FindNearest(hits);
if (_nearest != _previousNearest)
{
if (_previousNearest != null) { _previousNearest.OnPlayerExitRange(); _onHideInteractPrompt.Raise(); }
if (_nearest != null) { _nearest.OnPlayerEnterRange(transform); _onShowInteractPrompt.Raise(_nearest.InteractPrompt); }
_previousNearest = _nearest;
}
}
private void TryInteract()
{
_nearest?.Interact(transform); // 传入玩家自身 TransformIInteractable 内部通过 player.GetComponent<PlayerController>() 获取组件PlayerController 无 Instance
}
}
```
---
## 9. WorldStateRegistry
> **⚠️ 与 `14_NarrativeModule §8` 统一**`WorldStateRegistry` 已改为 **ScriptableObject**`CreateAssetMenu`
> 通过 `[SerializeField]` 注入,不再使用静态 `Instance`。`HasFlag` / `SetFlag(key)` 接口与 Architecture 14 §8 保持一致。
```csharp
// 路径: Assets/Scripts/World/Narrative/WorldStateRegistry.cs
// 管理世界持久化状态(已收集物、已激活存档点、已开门、已销毁对象、通用标志)
// ScriptableObject 形式,各组件通过 [SerializeField] 注入,零耦合;与 14_NarrativeModule §8 统一
namespace BaseGames.World
{
[CreateAssetMenu(menuName = "World/WorldStateRegistry")]
public class WorldStateRegistry : ScriptableObject
{
private HashSet<string> _collectedIds = new();
private HashSet<string> _activatedSavePoints = new();
private HashSet<string> _openedDoors = new();
private HashSet<string> _destroyedObjects = new();
public bool IsCollected(string id) => _collectedIds.Contains(id);
public void MarkCollected(string id) => _collectedIds.Add(id);
public bool IsSavePointActivated(string id) => _activatedSavePoints.Contains(id);
public void MarkSavePointActivated(string id) => _activatedSavePoints.Add(id);
public bool IsDestroyed(string id) => _destroyedObjects.Contains(id);
public void MarkDestroyed(string id) => _destroyedObjects.Add(id);
public bool IsDoorOpened(string id) => _openedDoors.Contains(id);
public void MarkDoorOpened(string id) => _openedDoors.Add(id);
// 通用世界状态标记(过场记录、剧情事件等)
// ⚠️ 接口与 14_NarrativeModule §8 统一HasFlag非 IsFlagSetSetFlag(key) 单参数添加
private HashSet<string> _flags = new();
public bool HasFlag(string key) => _flags.Contains(key);
public void SetFlag(string key) => _flags.Add(key);
// SaveManager 集成(非 ISaveable由 SaveManager 在 SaveAsync/LoadAsync 中直接调用)
public void LoadFromSave(WorldSaveData data);
public HashSet<string> GetAllFlags();
}
}
```
> **SaveManager 集成**`WorldStateRegistry` 不实现 `ISaveable` 接口ScriptableObject非 MonoBehaviour
> `SaveManager` 在保存/加载时直接调用:
> ```csharp
> // SaveManager.CollectAllData() 内:
> saveData.World = WorldStateRegistry.Instance.GetSaveData(); // 通过 SO 引用而非静态 Instance
>
> // SaveManager.ApplyLoadedData() 内:
> WorldStateRegistry.Instance.LoadFromSave(saveData.World);
> ```
> `WorldStateRegistry` SO 资产路径:`Assets/Data/World/WorldStateRegistry.asset`。
---
## 10. DestructibleTile
```csharp
// 路径: Assets/Scripts/World/DestructibleTile.cs
// 可被攻击破坏的地形块(影响导航网格)
public class DestructibleTile : MonoBehaviour, IDamageable
{
[SerializeField] private int _maxHP = 1;
[SerializeField] private string _destructedId; // 存档唯一 ID
private bool _isDestroyed = false;
// IDamageable
public bool IsInvincible => _isDestroyed;
public int Defense => 0;
public void TakeDamage(DamageInfo info)
{
if (_isDestroyed) return;
if (!CheckDestroyCondition(info)) return; // 子类可覆盖DirectionalDestructible 方向校验)
_isDestroyed = true;
// 禁用 Renderer + 碰撞体
// 通知 PathBerserker2d 重新烘焙局部导航网格
// 记录到 WorldStateRegistry
}
// 子类覆盖以附加销毁前提条件(默认:无条件销毁)
protected virtual bool CheckDestroyCondition(DamageInfo info) => true;
}
```
---
## 11. MovingPlatform
```csharp
// 路径: Assets/Scripts/World/MovingPlatform.cs
// 动态移动平台Kinematic Rigidbody2D乘客自动跟随Passenger Pattern
[RequireComponent(typeof(Rigidbody2D))]
public class MovingPlatform : MonoBehaviour
{
public enum MoveType { LinearAB, WayPoints, TriggeredLinear }
[Header("移动配置")]
[SerializeField] private MoveType _moveType = MoveType.LinearAB;
[SerializeField] private Transform[] _wayPoints; // LinearAB 仅用 [0][1]
[SerializeField] private float _speed = 3f; // u/s
[SerializeField] private float _waitAtEndpoint = 0.5f; // 端点停留秒数
[Header("TriggeredLinear 模式")]
[SerializeField] private VoidEventChannelSO _activationChannel; // 接收信号后单程运动
// 乘客检测:顶面上方 0.05f 的 IsTrigger BoxCollider2D检测到 Player/Enemy 层
[Header("乘客检测")]
[SerializeField] private BoxCollider2D _passengerSensor; // Trigger仅用于检测
private Rigidbody2D _rb;
private List<Transform> _passengers = new();
private int _waypointIndex;
private bool _movingForward = true;
private bool _triggered;
private void Awake() => _rb = GetComponent<Rigidbody2D>();
private void FixedUpdate()
{
if (_moveType == MoveType.TriggeredLinear && !_triggered) return;
MoveTowardsNextWaypoint();
}
private void MoveTowardsNextWaypoint()
{
var target = _wayPoints[_waypointIndex].position;
var next = Vector2.MoveTowards(_rb.position, target, _speed * Time.fixedDeltaTime);
_rb.MovePosition(next);
if (Vector2.Distance(_rb.position, target) < 0.02f)
StartCoroutine(WaitAndAdvance());
}
private IEnumerator WaitAndAdvance()
{
yield return new WaitForSeconds(_waitAtEndpoint);
AdvanceWaypoint();
}
private void AdvanceWaypoint()
{
// LinearAB: 往返; WayPoints: 环形; TriggeredLinear: 到达终点后停止
if (_moveType == MoveType.TriggeredLinear)
{
_waypointIndex = Mathf.Min(_waypointIndex + 1, _wayPoints.Length - 1);
if (_waypointIndex == _wayPoints.Length - 1) _triggered = false;
return;
}
if (_moveType == MoveType.LinearAB)
{
_movingForward = !_movingForward;
_waypointIndex = _movingForward ? 1 : 0;
}
else // WayPoints
{
_waypointIndex = (_waypointIndex + 1) % _wayPoints.Length;
}
}
// ── 乘客跟随Passenger Pattern─────────────────────────────────
private void OnTriggerEnter2D(Collider2D other)
{
if ((1 << other.gameObject.layer & LayerMask.GetMask("Player", "Enemy")) == 0) return;
other.transform.SetParent(transform);
_passengers.Add(other.transform);
}
private void OnTriggerExit2D(Collider2D other)
{
if (!_passengers.Contains(other.transform)) return;
other.transform.SetParent(null);
_passengers.Remove(other.transform);
// 继承平台当前速度(仅玩家)
if (other.CompareTag("Player"))
other.GetComponentInParent<Rigidbody2D>()?.AddForce(
_rb.velocity, ForceMode2D.Impulse);
}
private void OnEnable()
{
if (_activationChannel != null)
_activationChannel.OnEventRaised += OnTriggered;
}
private void OnDisable()
{
if (_activationChannel != null)
_activationChannel.OnEventRaised -= OnTriggered;
}
private void OnTriggered() => _triggered = true;
}
```
**MoveType 说明**
| 类型 | 行为 |
|------|------|
| `LinearAB` | `_wayPoints[0]``[1]` 往返循环 |
| `WayPoints` | 按 `_wayPoints[]` 顺序环形循环 |
| `TriggeredLinear` | 监听 `_activationChannel`,收到信号后单程 `[0]→[n-1]`,到达后停止 |
> **NavSurface 联动**:每个移动平台挂载独立 `LocalNavSurface`(局部坐标系),附着其上的敌人 NavAgent 使用该 LocalNavSurface 寻路;参见 Guides/PathBerserker2d_Technical_Evaluation.md §5。
---
## 12. DirectionalDestructible — 单向可破坏墙
继承 `DestructibleTile`,在其基础上增加**攻击方向校验**
```csharp
// 路径: Assets/Scripts/World/DirectionalDestructible.cs
public class DirectionalDestructible : DestructibleTile
{
public enum AttackSide { Left, Right, Top, Bottom, Any }
[SerializeField] private AttackSide _validAttackSide = AttackSide.Any;
protected override bool CheckDestroyCondition(DamageInfo info)
{
if (_validAttackSide == AttackSide.Any)
return base.CheckDestroyCondition(info);
// 判断攻击来源方向info.SourcePosition 由 HitBox 传入)
var dir = (info.SourcePosition - (Vector2)transform.position).normalized;
bool valid = _validAttackSide switch
{
AttackSide.Left => dir.x < -0.5f,
AttackSide.Right => dir.x > 0.5f,
AttackSide.Top => dir.y > 0.5f,
AttackSide.Bottom => dir.y < -0.5f,
_ => true
};
return valid && base.CheckDestroyCondition(info);
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
// 有效攻击方向:橙色箭头;无效方向:灰色叉号
var arrow = _validAttackSide switch
{
AttackSide.Left => Vector2.left,
AttackSide.Right => Vector2.right,
AttackSide.Top => Vector2.up,
AttackSide.Bottom => Vector2.down,
_ => Vector2.zero
};
if (arrow == Vector2.zero) return;
Gizmos.color = new Color(1f, 0.5f, 0f, 0.9f);
var origin = (Vector2)transform.position;
Gizmos.DrawLine(origin, origin + arrow * 0.8f);
}
#endif
}
```
| 典型场景 | 配置 |
|---------|------|
| 地板薄板:只能从下方砸穿 | `_validAttackSide = Bottom` |
| 密室封墙:仅能从房间内打开 | `_validAttackSide = Right`(依据朝向) |
| 普通脆弱墙 | 基类 `DestructibleTile``AnyAttack`)即可,不需此子类 |
---
## 13. DirectionalInteractable — 单向触发机关
```csharp
// 路径: Assets/Scripts/World/DirectionalInteractable.cs
// 可从特定方向触发的单向机关(零耦合:通过 SO 事件频道连接受体)
[RequireComponent(typeof(Collider2D))]
public class DirectionalInteractable : MonoBehaviour, IInteractable
{
public enum TriggerSide { Left, Right, Top, Any }
public enum TriggerCondition { PlayerAttack, PlayerBody, InteractKey }
[Header("触发条件")]
[SerializeField] private TriggerSide _triggerSide = TriggerSide.Any;
[SerializeField] private TriggerCondition _triggerCondition = TriggerCondition.InteractKey;
[Header("行为")]
[SerializeField] private bool _isOneShot; // 一次性,触发后永久激活
[SerializeField] private string _interactableId; // 存档用唯一 ID
[Header("事件频道(零耦合连接关卡受体)")]
[SerializeField] private VoidEventChannelSO _activationChannel;
[SerializeField] private VoidEventChannelSO _deactivationChannel; // 非 OneShot 离开时
[Header("反馈")]
[SerializeField] private MMF_Player _activateFeedback;
private bool _activated;
// ── IInteractableInteractKey 模式)─────────────────────────────
public string InteractPrompt => _activated ? "已激活" : "交互";
public void Interact(Transform player) // ⚠️ Transform 参数(与 §7 IInteractable 对齐)
{
if (_triggerCondition != TriggerCondition.InteractKey) return;
if (!CheckSide(player.position)) return;
TryActivate();
}
// ── PlayerBody / PlayerAttack 模式 ───────────────────────────────
// PlayerBodyOnTriggerEnter2DCollider IsTrigger
// PlayerAttack挂配套 HurtBox → DamageInfo → TryInteractFromDamage
private void OnTriggerEnter2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player")) return;
if (!CheckSide(other.transform.position)) return;
TryActivate();
}
private void OnTriggerExit2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player") || _isOneShot) return;
_activated = false;
_deactivationChannel?.Raise();
}
// 由外部 HurtBox 转发PlayerAttack 模式)
public void TryInteractFromDamage(DamageInfo info)
{
if (_triggerCondition != TriggerCondition.PlayerAttack) return;
if (!CheckSide(info.SourcePosition)) return;
TryActivate();
}
private void TryActivate()
{
if (_isOneShot && _activated) return;
_activated = true;
_activateFeedback?.PlayFeedbacks();
_activationChannel?.Raise();
if (_isOneShot)
{
// 持久化
SaveManager.Instance?.SetMechanismState(_interactableId, true);
}
}
private bool CheckSide(Vector2 sourcePos)
{
if (_triggerSide == TriggerSide.Any) return true;
var dir = (sourcePos - (Vector2)transform.position).normalized;
return _triggerSide switch
{
TriggerSide.Left => dir.x < -0.4f,
TriggerSide.Right => dir.x > 0.4f,
TriggerSide.Top => dir.y > 0.4f,
_ => true
};
}
private void Start()
{
// 读档恢复
if (_isOneShot && !string.IsNullOrEmpty(_interactableId)
&& (SaveManager.Instance?.GetMechanismState(_interactableId) ?? false))
{
_activated = true;
_activationChannel?.Raise(); // 静默恢复联动状态
}
}
}
```
**零耦合连接示例**Inspector 拖入同一 SO 资产):
```
Switch_Forest_01._activationChannel ──► MovingPlatform._activationChannel
──► Door_Locked._openChannel
──► HazardZone_Spikes._disableChannel
```
---
## 14. CrumblePlatform — 碎裂平台
```csharp
// 路径: Assets/Scripts/World/CrumblePlatform.cs
[RequireComponent(typeof(BoxCollider2D))]
public class CrumblePlatform : MonoBehaviour
{
[SerializeField] private float _warningDuration = 0.6f; // 踩上后警告时长(抖动)
[SerializeField] private float _crumbleDuration = 0.3f; // 碎裂动画时长
[SerializeField] private float _respawnDelay = 3.0f; // 0 = 永久消失
[SerializeField] private bool _isOneShot = false; // true = 碎裂后永久消失
[SerializeField] private MMF_Player _crumbleFeedback; // 预警震动 + 碎裂粒子 + 音效
[SerializeField] private BoxCollider2D _passengerSensor; // Trigger检测玩家踩踏
private BoxCollider2D _col;
private SpriteRenderer _sr;
private bool _isCrumbling;
private static readonly int[] StateFrames = { 0, 1, 2, 3 }; // Idle/Warning/Crumbling/Gone
private void Awake()
{
_col = GetComponent<BoxCollider2D>();
_sr = GetComponent<SpriteRenderer>();
}
private void OnTriggerEnter2D(Collider2D other)
{
if (_isCrumbling || !other.CompareTag("Player")) return;
StartCoroutine(CrumbleSequence());
}
private IEnumerator CrumbleSequence()
{
_isCrumbling = true;
// 1. Warning抖动
_crumbleFeedback?.PlayFeedbacks();
yield return new WaitForSeconds(_warningDuration);
// 2. Crumbling
yield return new WaitForSeconds(_crumbleDuration);
// 3. Gone禁用碰撞体 + 隐藏 Sprite
_col.enabled = false;
_sr.enabled = false;
_passengerSensor.enabled = false;
if (_isOneShot || _respawnDelay <= 0f)
{
yield break; // 永久消失
}
// 4. Respawn
yield return new WaitForSeconds(_respawnDelay);
_col.enabled = true;
_sr.enabled = true;
_passengerSensor.enabled = true;
_isCrumbling = false;
}
}
```
**状态机**
```
[玩家踩上]
Idle ────────────► Warning ──[warningDuration]──► Crumbling ──[动画]──► Gone
抖动 │
←──[respawnDelay非 OneShot]─┘
```
---
## 15. SkillInteractable — 技能专属交互物
> 这类物体不走伤害管线,而是监听**角色技能状态**或**物理层叠加**实现交互。
> 三种类型对应游戏内三个形态的专属技能机关。
### 15.1 MagicWall — 魔法障壁(太虚斩专属)
太虚斩(命魂 SoulSkill施放时玩家进入 `PhysicsLayer: Ghost``MagicWall``Ghost` 层**无碰撞**,允许穿越。
```csharp
// 路径: Assets/Scripts/World/MagicWall.cs
// 组件挂法MagicWall GO 同时挂 TilemapCollider2D / BoxCollider2D
// 不与 Ghost 层碰撞Physics Layer Matrix 配置,非代码控制)
// 脚本职责Gizmo 可视化 + 颜色联动(普通/幽灵两态视觉区分)
[ExecuteAlways]
public class MagicWall : MonoBehaviour
{
[SerializeField] private Color _normalColor = new(0.4f, 0.2f, 1f, 0.8f); // 紫色(可见)
[SerializeField] private Color _ghostColor = new(0.4f, 0.2f, 1f, 0.15f); // 淡紫(穿越提示)
// 在 FormSkillSO太虚斩canPassMagicWalls = true 时,
// SkillManager 在技能开始/结束时切换玩家的物理层:
// 开始: player.gameObject.layer = LayerMask.NameToLayer("Ghost")
// 结束: player.gameObject.layer = LayerMask.NameToLayer("Player")
// Physics Layer Matrix 设置: Ghost vs MagicWall = IgnoreCollision
// 因此 MagicWall 本身无需额外代码,只靠层矩阵实现穿越。
#if UNITY_EDITOR
private void OnDrawGizmos()
{
Gizmos.color = _normalColor;
var b = GetComponent<Collider2D>();
if (b != null)
Gizmos.DrawWireCube(transform.position, b.bounds.size);
}
#endif
}
```
**Physics Layer Matrix 配置**
| Layer A | Layer B | 碰撞 |
|---------|---------|------|
| `Player` | `MagicWall` | ✅ 碰撞(正常阻挡)|
| `Ghost` | `MagicWall` | ❌ 忽略(太虚斩穿越)|
| `Enemy` | `MagicWall` | ✅ 碰撞(敌人不能穿越)|
> 参见 [57_PhysicsLayerMatrix.md](../Design/57_PhysicsLayerMatrix.md)。
> `SkillManager.TrySoulSkill()` 在技能激活/结束时调用 `SetPlayerLayer("Ghost"/"Player")`。
---
### 15.2 SoftTerrain — 松软地面(地行术专属)
地行术(地魂 SoulSkill`GroundDive` 状态中,玩家进入地面移动。`SoftTerrain` 地块降低地行术**灵力消耗速率**(松软地面不消耗灵力)。
```csharp
// 路径: Assets/Scripts/World/SoftTerrain.cs
// 挂在松软地面的 Tilemap/GameObject 上
// GroundDiveState 通过 OverlapPoint 检测当前站立/穿行瓦片,查询是否 IsSoftTerrain
public class SoftTerrain : MonoBehaviour
{
// Marker 组件——无逻辑,仅用于 GetComponent<SoftTerrain>() 检测
// GroundDiveStatePlayerFSM在每帧对角色脚下 Physics2D.OverlapPoint() 检测:
// 若碰到实现了 SoftTerrain 的 Tilemap → SetSoulDrainRate(0)
// 否则 → SetSoulDrainRate(FormSkillSO.soulCostPerSecond)
}
```
**关卡搭建**
-`[Level]` 下新增 `Tilemap_SoftGround` 层,铺设松软地面 Tile
- 该 Tilemap GameObject 挂载 `SoftTerrain` 组件
- `TilemapCollider2D.isTrigger = false`(正常地面碰撞,`GroundDiveState` 穿越时物理层切换为 `Ghost` 忽略该层)
**与 MagicWall 的关键区别**
| | MagicWall | SoftTerrain |
|-|-----------|-------------|
| 穿越条件 | 太虚斩激活(`Ghost` 层)| 地行术激活(另一 `Ghost` 变体层)|
| 其余情况 | 实体阻挡 | 实体地面 |
| 游戏效果 | 到达秘密区域 / 跑图捷径 | 降低灵力消耗 / 速度加成 |
---
### 15.3 PhantomInteractable — 幻影机关(残阴术专属)
残阴术(命魂 SpiritSkill1在原地留下灵体灵体可代替玩家触发特定机关。
普通 `PressurePlate` 仅响应玩家,`PhantomInteractable` 额外响应 `PhantomBody` 层。
```csharp
// 路径: Assets/Scripts/World/PhantomInteractable.cs
// 继承 DirectionalInteractable额外监听 PhantomBody 层的 Collider 进入
// 用途:需要延迟触发的机关(先放灵体踩住,再操控玩家本体做其他事)
public class PhantomInteractable : DirectionalInteractable
{
// 残阴术SpiritSkill1实例化 PhantomBody Prefab
// PhantomBody 挂载 Rigidbody2DLayer = "PhantomBody"
// 本组件的 ColliderTrigger对 PhantomBody 层同样响应
private new void OnTriggerEnter2D(Collider2D other)
{
bool isPlayer = other.CompareTag("Player");
bool isPhantom = other.gameObject.layer ==
LayerMask.NameToLayer("PhantomBody");
if (!isPlayer && !isPhantom) return;
// 方向校验:幻影机关通常 TriggerSide = Any灵体无方向约束
TryActivate();
}
}
```
**典型谜题**
```
场景设置:
PhantomInteractablePressurePlate 型)── _activationChannel ──► Door
解谜流程:
1. 玩家施放残阴术 → 留下灵体踩住 PhantomInteractable → 门打开
2. 玩家快速通过门洞
3. 残阴术持续时间结束 → 灵体消失 → PhantomInteractable 失活 → 门关闭(若非 OneShot
```
---
## 16. 世界事件频道清单
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|--------|------|---------|-------------|
| `EVT_SavePointActivated` | `StringEventChannelSO` | `SavePoint` | `GameManager`(触发存档)、`HUDController`(显示提示) |
| `EVT_RoomTransitionRequest` | `SceneLoadRequestEventChannelSO` | `RoomTransition` | `SceneLoader` |
| `EVT_CollectiblePickup` | `StringEventChannelSO` | `Collectible``AbilityUnlock` | `WorldStateRegistry``QuestManager``AnalyticsManager` |
| `EVT_FastTravelOpen` | `VoidEventChannelSO` | `SavePoint` | `UIManager`(显示 FastTravel 面板) |
| `EVT_ShowInteractPrompt` | `StringEventChannelSO` | `InteractableDetector` | `HUDController` |
| `EVT_HideInteractPrompt` | `VoidEventChannelSO` | `InteractableDetector` | `HUDController` |