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

35 KiB
Raw Permalink Blame History

08 · 世界模块

命名空间 BaseGames.World
程序集 BaseGames.World
路径 Assets/Scripts/World/
依赖 BaseGames.CoreBaseGames.Core.EventsBaseGames.Core.Save
(通过 IRestoreOnSave(定义于 Core与玩家层解耦无需直接引用 BaseGames.Player


目录

  1. 场景结构规范(总览)
  2. RoomTransition
  3. SavePoint
  4. HazardZone
  5. Collectible
  6. AbilityUnlock
  7. IInteractable 接口
  8. InteractableDetector
  9. WorldStateRegistry
  10. DestructibleTile
  11. MovingPlatform
  12. DirectionalDestructible — 单向可破坏墙
  13. DirectionalInteractable — 单向触发机关
  14. CrumblePlatform — 碎裂平台
  15. SkillInteractable — 技能专属交互物
  16. 世界事件频道清单

1. 场景结构规范

场景命名(见 01_ProjectStructure.md §8

房间场景标准层级(详见 Architecture README
重要约束:

  • 每个房间场景必须包含 RoomController 组件(挂在 [RoomRoot] GameObject 上)
  • 必须有至少一个 RoomTransition(出入口)
  • 玩家出生点:PlayerSpawnPoint 组件,由 SceneLoadRequest.EntryTransitionId 匹配

2. RoomTransition

// 路径: 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

// 路径: 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

// 路径: 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

// 路径: 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

// 路径: 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

// 路径: 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 接口

// 路径: 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

// 路径: 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 已改为 ScriptableObjectCreateAssetMenu 通过 [SerializeField] 注入,不再使用静态 InstanceHasFlag / SetFlag(key) 接口与 Architecture 14 §8 保持一致。

// 路径: 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非 MonoBehaviourSaveManager 在保存/加载时直接调用:

// 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

// 路径: 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

// 路径: 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,在其基础上增加攻击方向校验

// 路径: 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(依据朝向)
普通脆弱墙 基类 DestructibleTileAnyAttack)即可,不需此子类

13. DirectionalInteractable — 单向触发机关

// 路径: 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 — 碎裂平台

// 路径: 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: GhostMagicWallGhost无碰撞,允许穿越。

// 路径: 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
SkillManager.TrySoulSkill() 在技能激活/结束时调用 SetPlayerLayer("Ghost"/"Player")


15.2 SoftTerrain — 松软地面(地行术专属)

地行术(地魂 SoulSkillGroundDive 状态中,玩家进入地面移动。SoftTerrain 地块降低地行术灵力消耗速率(松软地面不消耗灵力)。

// 路径: 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 层。

// 路径: 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 CollectibleAbilityUnlock WorldStateRegistryQuestManagerAnalyticsManager
EVT_FastTravelOpen VoidEventChannelSO SavePoint UIManager(显示 FastTravel 面板)
EVT_ShowInteractPrompt StringEventChannelSO InteractableDetector HUDController
EVT_HideInteractPrompt VoidEventChannelSO InteractableDetector HUDController