960 lines
35 KiB
Markdown
960 lines
35 KiB
Markdown
# 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 key(AddressKeys 常量)
|
||
[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.Player,World.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); // 传入玩家自身 Transform;IInteractable 内部通过 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(非 IsFlagSet);SetFlag(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;
|
||
|
||
// ── IInteractable(InteractKey 模式)─────────────────────────────
|
||
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 模式 ───────────────────────────────
|
||
// PlayerBody:OnTriggerEnter2D(Collider 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>() 检测
|
||
// GroundDiveState(PlayerFSM)在每帧对角色脚下 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 挂载 Rigidbody2D,Layer = "PhantomBody"
|
||
// 本组件的 Collider(Trigger)对 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();
|
||
}
|
||
}
|
||
```
|
||
|
||
**典型谜题**:
|
||
|
||
```
|
||
场景设置:
|
||
PhantomInteractable(PressurePlate 型)── _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` |
|