Files
zeling_v2/Docs/Plan/04_Phase3_WorldProgression.md
2026-05-12 15:34:08 +08:00

147 KiB
Raw Blame History

Phase 3 · 世界与进程系统

周期45 周Week 1014
前置条件Phase 2 全部完成标准通过
核心目标:完整的世界互动(房间切换/可破坏物/机关/移动平台)、液态谜题、进程系统(护符/工具/技能树)、任务系统、地图/商店、存档迁移
产出物:能进行多房间探索;护符装备生效;任务系统可完成至少 2 个任务;商店可购买道具;液态谜题可通关


目录

  1. 实施顺序总览
  2. Week 10世界互动基础组件
  3. Week 11液态谜题模块
  4. Week 12进程模块护符/工具/技能)
  5. Week 13任务与挑战房间
  6. Week 14地图/商店/存档迁移
  7. 完成标准检查清单

1. 实施顺序总览

Week 10: IInteractable + InteractableDetector
           ↓
         RoomTransition + PlayerSpawnPoint + RoomController
           ↓
         HazardZone + CollectibleGeo/道具)
           ↓
         DestructibleTile + MovingPlatform + CrumblePlatform
           ↓
         DirectionalDestructible + DirectionalInteractable + SkillInteractable
           ↓
         WorldStateRegistry房间状态持久化

Week 11: LiquidZone + LiquidPuzzleController + LiquidFlowSimulator骨架
           ↓
         SwimState玩家游泳态完整
           ↓
         液态谜题机关Valve/Pump/Drain 三件套)

Week 12: AbilityType 枚举 + AbilityGate
           ↓
         CharmSO + ICharmEffect + EquipmentManager
           ↓
         ToolSO + FormSkillSO + SkillManager
           ↓
         SkillModifierRegistry

Week 13: QuestSO + QuestObjectiveSO + RewardSO + QuestManager
           ↓
         QuestGiver扩展 InteractableNPC
           ↓
         ChallengeRoomSO + ChallengeRoomManager + ChallengeRoomTrigger

Week 14: MapModuleFog of War + 房间探索记录 + 传送点)
           ↓
         ShopController + ShopInventorySO + ShopItemSO
           ↓
         SaveData 迁移(版本号 + JsonExtensionData 降级兜底)

2. Week 10世界互动基础组件 完成2026-05-10

参考文档08_WorldModule.md

2.1 IInteractable + InteractableDetector

// Assets/Scripts/World/IInteractable.cs  // ⚠️ 路径为 World/(架构 08_WorldModule §7 / 14_NarrativeModule §1
// 命名空间namespace BaseGames.WorldDialogue 程序集通过 asmdef 引用 BaseGames.World
// ⚠️ 实现方如需 PlayerController通过 player.GetComponent<PlayerController>() 获取PlayerController 无 InstanceArchitecture 05 §2
namespace BaseGames.World
{
    public interface IInteractable
    {
        bool   CanInteract    { get; }
        string InteractPrompt { get; }            // UI 提示文本property
        void   Interact(Transform player);        // 传入玩家 Transform
        void   OnPlayerEnterRange(Transform player); // 进入检测范围
        void   OnPlayerExitRange();               // 离开检测范围
    }
}

// Assets/Scripts/World/InteractableDetector.cs
// 挂在 Player 上,检测附近可交互物,管理 UI 提示
public class InteractableDetector : MonoBehaviour
{
    [SerializeField] private float     _detectRadius = 1.5f;    // ⚠️ _detectRadius非 _detectionRadius默认 1.5f(架构 08 §8
    [SerializeField] private LayerMask _interactableLayer;
    [SerializeField] private InputReaderSO _inputReader;         // ⚠️ _inputReader非 _input架构 08 §8
    [SerializeField] private StringEventChannelSO _onShowInteractPrompt;  // ⚠️ StringEventChannelSO架构 08 §8
    [SerializeField] private VoidEventChannelSO   _onHideInteractPrompt;  // ⚠️ VoidEventChannelSO架构 08 §8

    private IInteractable _nearest;
    private IInteractable _previousNearest;

    private void OnEnable()  => _inputReader.InteractEvent += TryInteract;
    private void OnDisable() => _inputReader.InteractEvent -= TryInteract;

    private void Update()
    {
        // OverlapCircleAll → 找最近 IInteractable → 检测变化后通过事件频道显示/隐藏提示 UI
        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);
    }
}

2.2 RoomTransition + RoomController

RoomTransition08_WorldModule.md §2 实现Phase 1 已有 SavePoint 骨架)。

RoomController

// Assets/Scripts/World/RoomController.cs
// 挂在每个房间场景的 [RoomRoot] 上
public class RoomController : MonoBehaviour
{
    [SerializeField] private string _roomId;
    [SerializeField] private PlayerSpawnPoint[] _spawnPoints;
    // ⚠️ SwitchRoom(RoomCameraData) 以架构 17_CameraModule §3 为准,传入 RoomCameraData 而非裸 PolygonCollider2D
    [SerializeField] private Collider2D          _cameraBounds;   // Cinemachine Confiner
    [SerializeField] private Vector3             _cameraOffset;
    [SerializeField] private CameraBlendProfileSO _blendProfile;

    private void Start()
    {
        // 通知 CameraStateController 更新 Confiner 方法名为 SwitchRoom参数为 RoomCameraData
        CameraStateController.Instance.SwitchRoom(new RoomCameraData
        {
            ConfinerCollider = _cameraBounds,
            CameraOffset     = _cameraOffset,
            BlendProfile     = _blendProfile
        });

        // WorldStateRegistry 已在 LoadAsync 中通过 LoadSaveData(data.World) 全量恢复;
        // 各 Collectible/DestructibleTile/SavePoint 在自身 Start/OnEnable 中查询 Instance 获取状态
    }

    // 找到 entryTransitionId 对应的 PlayerSpawnPoint返回出生位置
    public PlayerSpawnPoint GetSpawnPoint(string transitionId);
}

2.3 CollectibleGeo + 道具)

// Assets/Scripts/World/Collectible.cs
// ⚠️ 枚举值与架构 08_WorldModule §5 对齐Geo / Item / HPOrb
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 存档唯一 ID

    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:
                _onCollectiblePickup.Raise(_itemId);
                break;
            case CollectibleType.HPOrb:
                // 恢复少量 HP具体值由 PlayerStats 决定)
                player.Stats.RestoreHP(1);
                break;
        }

        if (_isPersistent)
            _onCollectiblePickup.Raise(_collectibleId);  // 存档标记

        Despawn();
    }

    private void Despawn();   // 归还对象池

    // 敌人死亡时生成 Geo Collectible由 EnemyBase 调用)
    public static void SpawnGeo(Vector2 position, int amount, GlobalObjectPool pool);
}

2.4 HazardZone

// Assets/Scripts/World/HazardZone.cs
// 即死/高伤害区域(深坑、岩浆等)—— 与架构 08_WorldModule §4 对齐
[RequireComponent(typeof(Collider2D))]
public class HazardZone : MonoBehaviour
{
    // ⚠️ RespawnType 枚举(架构 08_WorldModule §4
    public enum RespawnType { AtLastSavePoint, AtRoomEntry }

    [SerializeField] private bool        _isInstantKill = true;
    [SerializeField] private int         _damage = 9999;
    [SerializeField] private RespawnType _respawnType = RespawnType.AtLastSavePoint;  // ⚠️ 架构 08 §4

    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);
    }
}

2.5 DestructibleTile + MovingPlatform + CrumblePlatform

DestructibleTile(可破坏瓦片):

// 实现 IDamageable被攻击击中时破碎
// 从 TilemapCollider2D 移除对应 Tile播放 VFX在 WorldStateRegistry 记录状态
// ⚠️ 字段名与架构 08_WorldModule §10 对齐_maxHP / _destructedId
public class DestructibleTile : MonoBehaviour, IDamageable
{
    [SerializeField] private int                _maxHP = 1;
    [SerializeField] private string             _destructedId;   // 存档唯一 ID
    // ⚠️ WorldStateRegistry 为 ScriptableObject架构 14_NarrativeModule §8不使用静态 Instance
    //    通过 [SerializeField] 注入 SO 引用
    [SerializeField] private WorldStateRegistry _worldState;

    private bool _isDestroyed = false;

    public bool IsInvincible => _isDestroyed;
    public int  Defense      => 0;

    public void TakeDamage(DamageInfo info)
    {
        if (_isDestroyed) return;
        if (!CheckDestroyCondition(info)) return;  // 子类可覆盖DirectionalDestructible 方向校验)
        _isDestroyed = true;
        Destroy();
    }

    // ⚠️ virtualDirectionalDestructible 覆盖此方法做方向校验(架构 08_WorldModule §12
    protected virtual bool CheckDestroyCondition(DamageInfo info) => true;

    private void Destroy()
    {
        // 清除 Tilemap 中对应格子
        // 播放 VFX
        _worldState?.MarkDestroyed(_destructedId);  // ⚠️ SO 注入,非 WorldStateRegistry.Instance
        gameObject.SetActive(false);
    }
}

MovingPlatform

// 动态移动平台Kinematic Rigidbody2D乘客自动跟随Passenger Pattern
// ⚠️ 与架构 08_WorldModule §11 完全对齐MoveType 枚举 / _activationChannel / _passengerSensor
[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;  // 收到信号后单程运动

    [Header("乘客检测")]
    [SerializeField] private BoxCollider2D _passengerSensor;  // IsTrigger仅用于检测

    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, (Vector2)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()
    {
        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
        {
            _waypointIndex = (_waypointIndex + 1) % _wayPoints.Length;
        }
    }

    // Passenger Pattern乘客跟随平台移动SetParent
    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(局部坐标系),附着其上的敌人使用该 LocalNavSurface 寻路。

CrumblePlatform(碎裂平台):

// ⚠️ 与架构 08_WorldModule §14 完全对齐:字段名 / 完整 CrumbleSequence 状态机
[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 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]→ Idle

2.6 DirectionalDestructible + DirectionalInteractable + SkillInteractable

参考文档08_WorldModule.md §12-15

DirectionalDestructible(单向可破坏墙):

// Assets/Scripts/World/DirectionalDestructible.cs
// 继承 DestructibleTile增加攻击方向校验
// ⚠️ 与架构 08_WorldModule §12 完全对齐AttackSide 枚举 / CheckDestroyCondition 覆盖
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);

        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
}

DirectionalInteractable(单向触发机关):

// Assets/Scripts/World/DirectionalInteractable.cs
// ⚠️ 与架构 08_WorldModule §13 完全对齐Interact(Transform player),非 Interact(PlayerController)
[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;

    [Header("事件频道(零耦合连接关卡受体)")]
    [SerializeField] private VoidEventChannelSO _activationChannel;
    [SerializeField] private VoidEventChannelSO _deactivationChannel;

    [Header("反馈")]
    [SerializeField] private MMF_Player _activateFeedback;

    // ⚠️ SO 注入(架构 14_NarrativeModule §8 patch不使用 SaveManager.SetMechanismState/GetMechanismState不存在于架构
    [SerializeField] private WorldStateRegistry _worldState;

    private bool _activated;

    public bool   CanInteract    => !(_isOneShot && _activated);
    public string InteractPrompt => _activated ? "已激活" : "交互";

    public void Interact(Transform player)   // ⚠️ Transform 参数(架构 08 §7 IInteractable 标准)
    {
        if (_triggerCondition != TriggerCondition.InteractKey) return;
        if (!CheckSide(player.position)) return;
        TryActivate();
    }

    public void OnPlayerEnterRange(Transform player) { }
    public void OnPlayerExitRange() { }

    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();
    }

    public void TryInteractFromDamage(DamageInfo info)
    {
        if (_triggerCondition != TriggerCondition.PlayerAttack) return;
        if (!CheckSide(info.SourcePosition)) return;
        TryActivate();
    }

    protected void TryActivate()
    {
        if (_isOneShot && _activated) return;
        _activated = true;
        _activateFeedback?.PlayFeedbacks();
        _activationChannel?.Raise();
        // ⚠️ 用 WorldStateRegistry.SetFlag 替代 SaveManager.SetMechanismState后者不存在于架构
        if (_isOneShot && !string.IsNullOrEmpty(_interactableId))
            _worldState?.SetFlag("mechanism_" + _interactableId);
    }

    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()
    {
        // ⚠️ 用 WorldStateRegistry.HasFlag 替代 SaveManager.GetMechanismState后者不存在于架构
        if (_isOneShot && !string.IsNullOrEmpty(_interactableId)
            && _worldState != null && _worldState.HasFlag("mechanism_" + _interactableId))
        {
            _activated = true;
            _activationChannel?.Raise();
        }
    }
}

SkillInteractableMagicWall / SoftTerrain / PhantomInteractable

// Assets/Scripts/World/MagicWall.cs
// ⚠️ 与架构 08_WorldModule §15.1 完全对齐:仅 Gizmo 可视化,穿越靠 Physics Layer Matrix
[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);

    // 穿越通过 Physics Layer MatrixGhost vs MagicWall = IgnoreCollision无代码逻辑
    // SkillManager 在太虚斩激活/结束时切换玩家 LayerGhost ↔ Player

#if UNITY_EDITOR
    private void OnDrawGizmos()
    {
        Gizmos.color = _normalColor;
        var b = GetComponent<Collider2D>();
        if (b != null)
            Gizmos.DrawWireCube(transform.position, b.bounds.size);
    }
#endif
}

// Assets/Scripts/World/SoftTerrain.cs
// ⚠️ 与架构 08_WorldModule §15.2 完全对齐Marker 组件GroundDiveState 检测用
public class SoftTerrain : MonoBehaviour
{
    // Marker 组件——无逻辑,仅供 GetComponent<SoftTerrain>() 检测
    // GroundDiveState 通过 Physics2D.OverlapPoint 检测当前瓦片:
    //   有 SoftTerrain → SetSoulDrainRate(0);否则 → SetSoulDrainRate(FormSkillSO.soulCostPerSecond)
}

// Assets/Scripts/World/PhantomInteractable.cs
// ⚠️ 与架构 08_WorldModule §15.3 完全对齐:继承 DirectionalInteractable额外响应 PhantomBody 层
public class PhantomInteractable : DirectionalInteractable
{
    private new void OnTriggerEnter2D(Collider2D other)
    {
        bool isPlayer  = other.CompareTag("Player");
        bool isPhantom = other.gameObject.layer == LayerMask.NameToLayer("PhantomBody");
        if (!isPlayer && !isPhantom) return;
        TryActivate();
    }
}

2.6.1 FalseWall假墙/秘密通道)

参考文档Design/08_WorldSystem.md §9.8(架构文档无独立章节,以 Design 文档为准)
⚠️ 注意FalseWall 不销毁,只切换碰撞体启用状态;与 DestructibleTile 的区别在于状态可逆性。

// Assets/Scripts/World/FalseWall.cs
// 假墙:外观与普通墙几乎相同,玩家可通过攻击/接近揭示并穿越
// 实现 IDamageable 接口(接收攻击);揭示后持久化到 WorldSaveData
[RequireComponent(typeof(Collider2D))]
public class FalseWall : MonoBehaviour, IDamageable
{
    public enum RevealCondition { Proximity, AttackOnce, AlwaysOpen }

    [Header("识别")]
    [SerializeField] private string         _wallId;               // 持久化唯一 ID如 "FW_Forest_01_SecretA"

    [Header("揭示条件")]
    [SerializeField] private RevealCondition _revealCondition = RevealCondition.AttackOnce;
    [SerializeField] private float           _proximityRadius = 2.0f;  // Proximity 模式检测半径

    [Header("组件引用")]
    [SerializeField] private Collider2D      _wallCollider;         // 揭示后 enabled = false
    [SerializeField] private SpriteRenderer  _renderer;            // Normal / Revealed 两帧切换
    [SerializeField] private MMF_Player      _revealFeedback;      // Shimmer 粒子 + 空洞回声

    // IDamageable
    public bool IsInvincible => _isRevealed;
    public int  Defense      => 0;

    private bool _isRevealed = false;

    private void Start()
    {
        // 读档恢复:若已揭示则静默还原,不播放演出
        if (_revealCondition == RevealCondition.AlwaysOpen)
        {
            SetPassThroughImmediate();
            return;
        }
        // ⚠️ WorldSaveData.RevealedFalseWallsDesign 31 §6
        // 通过 SaveManager 当前存档检查(实际访问路径由 SaveManager 公开属性决定)
        // 示例bool revealed = SaveManager.Instance?.CurrentSave?.World?.RevealedFalseWalls?.Contains(_wallId) ?? false;
        // if (revealed) SetPassThroughImmediate();
    }

    // IDamageable被攻击时揭示AttackOnce 模式)
    public void TakeDamage(DamageInfo info)
    {
        if (_isRevealed || _revealCondition != RevealCondition.AttackOnce) return;
        Reveal();
    }

    // Proximity 模式:玩家进入范围时触发 Shimmer不开通道
    private void OnTriggerEnter2D(Collider2D other)
    {
        if (_isRevealed || _revealCondition != RevealCondition.Proximity) return;
        if (!other.CompareTag("Player")) return;
        _revealFeedback?.PlayFeedbacks();  // 轻微 Shimmer 暗示(碰撞仍启用)
    }

    private void Reveal()
    {
        _isRevealed = true;
        _revealFeedback?.PlayFeedbacks();
        SetPassThroughImmediate();
        // 持久化:广播事件或直接写入 WorldSaveData.RevealedFalseWalls通过 SaveManager
    }

    private void SetPassThroughImmediate()
    {
        if (_wallCollider != null) _wallCollider.enabled = false;  // 禁用碰撞,允许穿越
        // 切换 Sprite 到 Revealed 帧(透明度过渡)
    }

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        // Gizmo紫色虚线矩形框Scene 视图标记)
        Gizmos.color = new Color(0.6f, 0.2f, 1f, 0.8f);
        var col = GetComponent<Collider2D>();
        if (col != null) Gizmos.DrawWireCube(transform.position, col.bounds.size);

        if (_revealCondition == RevealCondition.Proximity)
        {
            Gizmos.color = new Color(0.6f, 0.2f, 1f, 0.2f);
            Gizmos.DrawWireSphere(transform.position, _proximityRadius);
        }
    }
#endif
}

三种揭示条件说明

RevealCondition 行为 典型用途
Proximity 玩家进入 _proximityRadius 时播放 Shimmer仅视觉暗示碰撞仍启用 隐藏提示层(需攻击才能穿越)
AttackOnce 玩家攻击命中后碰撞禁用,永久可穿越 标准假墙(主要用法)
AlwaysOpen 初始即无碰撞,天生可穿越 返程单向暗门(已知通道)

SaveData 持久化:揭示后写入 WorldSaveData.RevealedFalseWallsDesign §31 §6 字段:List<string> RevealedFalseWallsFalseWall.Start() 读档检查该列表。

2.7 WorldStateRegistry

// Assets/Scripts/World/WorldStateRegistry.cs
// ⚠️ ScriptableObject 单例(架构 14_NarrativeModule §8 patch不使用静态 Instance
//    各组件通过 [SerializeField] 注入 SO 引用,避免服务定位器耦合
// SaveManager.SaveAsync 调用 GetAllFlags()SaveManager.LoadAsync 调用 LoadFromSave(data.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);

    // 通用世界状态标记(过场记录、剧情事件等)
    // ⚠️ HasFlag非 IsFlagSetSetFlag(key) 单参数只添加(架构 14 patch
    // ⚠️ 无双参数 SetFlag(key, bool);若需清除标记使用独立 ClearFlag(key)(暂不实现)
    private HashSet<string> _flags = new();
    public bool HasFlag(string key) => _flags.Contains(key);
    public void SetFlag(string key) => _flags.Add(key);

    // SaveManager 调用
    public void LoadFromSave(WorldSaveData data);
    public HashSet<string> GetAllFlags();
}

2.8 WorldMarker + BreadcrumbTracker

参考文档21_LiquidPuzzleModule.md §14§15

// Assets/Scripts/World/Navigation/WorldMarker.cs
// ⚠️ 命名空间 BaseGames.World.Navigation架构 21 §14
namespace BaseGames.World.Navigation
{
    /// <summary>场景内导航标记点,广播给地图/HUD。</summary>
    public class WorldMarker : MonoBehaviour
    {
        [Header("标记信息")]
        [SerializeField] string                    _markerId;        // 唯一 ID与 MapDataSO 匹配)
        [SerializeField] WorldMarkerType           _markerType;      // ⚠️ 枚举 WorldMarkerType
        [SerializeField] string                    _labelKey;        // 本地化显示名称 key

        [Header("可见性")]
        [SerializeField] bool                      _visibleOnMap  = true;
        [SerializeField] bool                      _visibleOnHUD  = false; // 在 HUD 显示箭头指引

        [Header("事件频道")]
        [SerializeField] WorldMarkerEventChannelSO _onMarkerActivated;    // ⚠️ EVT_WorldMarkerActivated
        [SerializeField] WorldMarkerEventChannelSO _onMarkerDeactivated;  // ⚠️ EVT_WorldMarkerDeactivated

        bool _isActive = false;

        void Start()
        {
            if (_visibleOnMap || _visibleOnHUD)
                Activate();
        }

        public void Activate()
        {
            _isActive = true;
            _onMarkerActivated?.Raise(this);
        }

        public void Deactivate()
        {
            _isActive = false;
            _onMarkerDeactivated?.Raise(this);
        }

        public string          MarkerId     => _markerId;
        public WorldMarkerType MarkerType   => _markerType;
        public string          LabelKey     => _labelKey;
        public bool            IsActive     => _isActive;
        public bool            VisibleOnHUD => _visibleOnHUD;
    }

    // ⚠️ 枚举值必须与架构 21 §14 完全一致
    public enum WorldMarkerType
    {
        Objective,        // 当前主线目标
        NPC,              // NPC 位置
        PointOfInterest,  // 兴趣点
        Exit,             // 出口/传送点
        Secret,           // 隐藏区域(解锁后显示)
    }
}
// Assets/Scripts/World/Navigation/BreadcrumbTracker.cs
// ⚠️ 命名空间 BaseGames.World.Navigation架构 21 §15
// 挂在玩家 GameObject 上;数据不持久化(每次游戏重置)
namespace BaseGames.World.Navigation
{
    public class BreadcrumbTracker : MonoBehaviour
    {
        [Header("追踪参数")]
        [SerializeField] float _recordInterval = 2.0f;  // 每隔多少秒记录一次
        [SerializeField] int   _maxCrumbs      = 20;    // 最多保留多少个历史位置
        [SerializeField] float _minMoveDistance = 1.0f; // 移动距离低于此值不记录

        readonly Queue<Vector2> _crumbs = new();
        float   _timer;
        Vector2 _lastPos;

        public IReadOnlyCollection<Vector2> Crumbs => _crumbs;

        void Start()  { _lastPos = transform.position; }

        void Update()
        {
            _timer += Time.deltaTime;
            if (_timer < _recordInterval) return;
            _timer = 0f;

            Vector2 current = transform.position;
            if (Vector2.Distance(current, _lastPos) < _minMoveDistance) return;

            _crumbs.Enqueue(current);
            if (_crumbs.Count > _maxCrumbs) _crumbs.Dequeue();
            _lastPos = current;
        }

        /// <summary>获取最近 N 个面包屑位置(用于地图渲染)。</summary>
        public Vector2[] GetRecentCrumbs(int count)
            => System.Linq.Enumerable.TakeLast(_crumbs, count).ToArray();
    }
}

2.9 TutorialManager + ContextualHintTrigger

参考文档21_LiquidPuzzleModule.md §17§18

// Assets/Scripts/Tutorial/TutorialManager.cs
// ⚠️ 命名空间 BaseGames.Tutorial不实现 ISaveable架构 12_SaveModule §1 SaveData 无 Tutorial 字段)
// ⚠️ 教程进度使用 PlayerPrefs 持久化非存档系统格式key="hint_{hintId}"value=1
// 挂在 Persistent 场景 [GameManagers] 下
namespace BaseGames.Tutorial
{
    public class TutorialManager : MonoBehaviour
    {
        [SerializeField] TutorialHintUI _hintUI;  // HUD 上的提示 UI 组件

        readonly HashSet<string> _completedHints = new();

        // ⚠️ Singleton架构 21 §17 必须有 Instance
        public static TutorialManager Instance { get; private set; }

        void Awake()
        {
            Instance = this;
            // 从 PlayerPrefs 恢复已完成提示列表
            // (架构 12 SaveData 无 Tutorial 字段;教程进度独立于存档系统)
        }

        /// <summary>显示提示。若已完成则跳过。</summary>
        public void ShowHint(string hintId, string localizedText, float duration = 3f)
        {
            if (_completedHints.Contains(hintId)) return;
            _hintUI.Show(localizedText, duration);
        }

        /// <summary>标记提示为已完成,持久化到 PlayerPrefs不再显示。</summary>
        public void CompleteHint(string hintId)
        {
            _completedHints.Add(hintId);
            PlayerPrefs.SetInt("hint_" + hintId, 1);  // ⚠️ PlayerPrefs非 SaveData.Tutorial架构 12 无此字段)
            PlayerPrefs.Save();
        }

        public bool IsCompleted(string hintId) => _completedHints.Contains(hintId);

        // ⚠️ 不实现 ISaveable教程进度通过 PlayerPrefs 读写,无需注入 SaveManager
    }
}
// Assets/Scripts/Tutorial/ContextualHintTrigger.cs
// ⚠️ 命名空间 BaseGames.Tutorial[RequireComponent(Collider2D)](架构 21 §18
namespace BaseGames.Tutorial
{
    [RequireComponent(typeof(Collider2D))]
    public class ContextualHintTrigger : MonoBehaviour
    {
        [Header("提示配置")]
        [SerializeField] string  _hintId;            // 唯一 ID
        [SerializeField] string  _hintTextKey;        // 本地化 key
        [SerializeField] float   _displayDuration = 3f;

        [Header("触发条件(可选)")]
        // ⚠️ AbilityType 枚举Architecture 09 §1无 None 值;用 bool 标记是否要求能力
        [SerializeField] bool        _requiresAbility = false;
        [SerializeField] AbilityType _requiredAbility;
        [SerializeField] bool        _onlyOnce = true;  // ⚠️ 建议保持 true

        void OnTriggerEnter2D(Collider2D other)
        {
            if (!other.CompareTag("Player")) return;

            // 检查能力条件(仅当 _requiresAbility = true 时)
            if (_requiresAbility)
            {
                var stats = other.GetComponent<PlayerStats>();
                if (stats == null || !stats.HasAbility(_requiredAbility)) return;
            }

            var text = LocalizationManager.Get(LocalizationManager.Table_UI, _hintTextKey);  // ⚠️ LocalizationManager 为静态类Architecture 16 §1无 Instance方法为 Get(tableKey, entryKey)
            TutorialManager.Instance.ShowHint(_hintId, text, _displayDuration);

            if (_onlyOnce)
            {
                TutorialManager.Instance.CompleteHint(_hintId);
                gameObject.SetActive(false);  // 触发后禁用自身,避免重复
            }
        }
    }
}

3. Week 11液态谜题模块 完成2026-05-11

参考文档21_LiquidPuzzleModule.md

3.0 液体数据层

// Assets/Scripts/World/Liquid/LiquidType.cs
// ⚠️ 按 Architecture 21_LiquidPuzzleModule §2 实现
namespace BaseGames.World.Liquid
{
    public enum LiquidType
    {
        Water,        // 可游泳(需 Swim 能力)
        ShallowWater, // ⚠️ 浅水(水中慢走,无需游泳能力,速度 ×0.65,架构 21 §2
        Mud,          // ⚠️ 泥水(移动极慢,无需游泳能力,速度 ×0.50,架构 21 §2
        Acid,         // 接触即死HazardZone 处理)
        Lava,         // 接触即死HazardZone 处理)
    }
}

// Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs
// ⚠️ 按 Architecture 21_LiquidPuzzleModule §3 实现
[CreateAssetMenu(menuName = "World/LiquidPhysicsConfig")]
public class LiquidPhysicsConfigSO : ScriptableObject
{
    [Header("水下物理")]
    [Range(0f, 1f)]
    public float GravityScale        = 0.3f;
    [Range(0f, 1f)]
    public float BuoyancyForce       = 0.5f;
    public float MaxSwimSpeed        = 4.0f;
    public float SwimAcceleration    = 8.0f;
    public float SurfaceExitSpeed    = 5.0f;
    public float SinkSpeed           = 2.0f;    // ⚠️ 无游泳能力时自然下沉速度(架构 21 §3
    public float DiveSpeedMultiplier = 1.5f;    // ⚠️ 主动下潜时的速度倍率(架构 21 §3

    [Header("浅水/泥水速度缩放")]
    [Range(0.1f, 1.0f)]
    public float ShallowSpeedScale   = 0.65f;   // ⚠️ ShallowWater 类型水平移动速度倍率(架构 21 §3
    [Range(0.1f, 1.0f)]
    public float MudSpeedScale       = 0.50f;   // ⚠️ Mud 类型水平移动速度倍率(架构 21 §3

    [Header("溺死计时(无游泳能力时)")]
    public float DrownTime           = 3.0f;    // ⚠️ 屏气倒计时(秒),倒计时结束则触发死亡(架构 21 §3

    [Header("进出液体")]
    public float SplashEntryDelay    = 0.05f;
    public float DragCoefficient     = 3.0f;

    [Header("视觉")]
    public VolumeProfile WaterVolumeProfile;  // 水下后处理 Profile可为 null
}

3.1 LiquidZone

// Assets/Scripts/World/Liquid/LiquidZone.cs
// 定义液体区域,玩家进入时切换 SwimState
// ⚠️ 事件频道类型为 LiquidEventChannelSO携带 LiquidZone 引用SwimState 需要 PhysicsConfig
namespace BaseGames.World.Liquid  // ⚠️ 必须有命名空间
{
[RequireComponent(typeof(Collider2D))]
public class LiquidZone : MonoBehaviour
{
    [SerializeField] LiquidType            _liquidType    = LiquidType.Water;  // Water / Acid / Lava

    // ⚠️ 溺水伤害Water 类型专用Acid/Lava 由子节点 HazardZone 处理)——架构 21_LiquidPuzzleModule §3
    [SerializeField] bool                  _dealsDrowningDamage      = false;
    [SerializeField] float                 _drowningDamagePerSecond  = 5f;

    [SerializeField] LiquidPhysicsConfigSO _physicsConfig;  // 浮力/速度配置

    [Header("Event Channels")]
    [SerializeField] LiquidEventChannelSO  _onPlayerEntered;  // payload = thisSwimState 需要读 Physics
    [SerializeField] LiquidEventChannelSO  _onPlayerExited;

    [Header("Feedback")]
    [SerializeField] MMF_Player            _splashEnterFeedback;
    [SerializeField] MMF_Player            _splashExitFeedback;

    public LiquidType            Type    => _liquidType;
    public LiquidPhysicsConfigSO Physics => _physicsConfig;

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;
        _splashEnterFeedback?.PlayFeedbacks();
        _onPlayerEntered.Raise(this);  // 传递 LiquidZone 引用
        // PlayerController 收到 EVT_LiquidEntered → swimState.SetLiquidZone(zone) → 切换 SwimState
    }

    private void OnTriggerExit2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;
        _splashExitFeedback?.PlayFeedbacks();
        _onPlayerExited.Raise(this);
    }
}
}  // namespace BaseGames.World.Liquid

SwimState 完整实现Architecture 21 §505_PlayerModule.md §12 第 18 个状态):

// Assets/Scripts/Player/States/SwimState.cs
namespace BaseGames.Player.States
{
    public class SwimState : PlayerStateBase
    {
        [SerializeField] LiquidPhysicsConfigSO _physics;  // 由 LiquidZone 注入
        [SerializeField] ClipTransition        _swimIdleClip;
        [SerializeField] ClipTransition        _swimMoveClip;

        LiquidZone _currentZone;
        float      _originalGravity;

        // 由 PlayerController 在收到 EVT_LiquidEntered 后调用
        public void SetLiquidZone(LiquidZone zone) => _currentZone = zone;

        public override void OnEnter()
        {
            _originalGravity = RB.gravityScale;
            RB.gravityScale  = _currentZone?.Physics.GravityScale ?? 0.3f;
            Animancer.Play(_swimIdleClip);
        }

        public override void OnExit()
        {
            RB.gravityScale = _originalGravity;
        }

        public override void OnUpdate()
        {
            var input = Input.Move;
            if (input != Vector2.zero)
            {
                var targetVel = input * (_currentZone?.Physics.MaxSwimSpeed ?? 4f);
                RB.linearVelocity = Vector2.MoveTowards(
                    RB.linearVelocity, targetVel,
                    (_currentZone?.Physics.SwimAcceleration ?? 8f) * Time.deltaTime
                );
                Animancer.Play(_swimMoveClip);
            }
            else
            {
                // 水下浮力(持续向上的微弱力)
                RB.AddForce(Vector2.up * (_currentZone?.Physics.BuoyancyForce ?? 0.5f),
                            ForceMode2D.Force);
                Animancer.Play(_swimIdleClip);
            }

            // 跳跃键 = 跃出水面
            if (Input.JumpPressed)
            {
                RB.AddForce(Vector2.up * (_currentZone?.Physics.SurfaceExitSpeed ?? 5f),
                            ForceMode2D.Impulse);
            }

            // 施加水阻
            RB.linearVelocity *= 1f - _currentZone?.Physics.DragCoefficient * Time.deltaTime ?? 0f;
        }

        public override PlayerStateBase GetNextState()
        {
            // 离开液体区域由 PlayerController 订阅 EVT_LiquidExited 后切换到 FallState
            return null;
        }
    }
}

进入条件PlayerStats.HasAbility(AbilityType.Swim) 为 true否则在液体内受伤Acid/Lava 由 HazardZone InstantKill 处理)

3.2 液态谜题机关三件套

Valve.cs      ← 可交互旋转阀,控制液体流动开关
Pump.cs       ← 需消耗 SoulPower 激活,提升液面
Drain.cs      ← 破坏性排水一次性DestructibleTile 变体)

3.3 LiquidPuzzleController

// Assets/Scripts/World/Liquid/LiquidPuzzleController.cs
// 管理一个谜题区域内的液位状态
public class LiquidPuzzleController : MonoBehaviour, ISaveable
{
    [SerializeField] private float _currentLevel;   // 0~1 归一化液位
    [SerializeField] private float _targetLevel;    // 目标液位(谜题目标)
    [SerializeField] private Transform _waterSurface; // 视觉层

    // 阀门/泵驱动液位变化
    public void RaiseLiquid(float amount);
    public void DrainLiquid(float amount);

    // 每帧平滑插值 _waterSurface.position
    // 液位达到 _targetLevel → 触发谜题完成事件
}

3.4 谜题机关系统PuzzleSwitch / PuzzleReceiver / PuzzleWire

参考文档21_LiquidPuzzleModule.md §7§11Part B · 谜题架构)
命名空间BaseGames.Puzzle

核心接口namespace BaseGames.Puzzle,对应 Architecture 21 §8

// Assets/Scripts/World/Puzzle/  (均属于 namespace BaseGames.Puzzle
namespace BaseGames.Puzzle
{
    /// <summary>任何可被切换激活/停用状态的谜题元素。</summary>
    public interface ISwitchable
    {
        bool   IsActive { get; }
        event  Action<bool> OnStateChanged;
        void   ForceState(bool active);   // SaveData 恢复时调用
    }

    /// <summary>可被玩家推动的物件(需 Rigidbody2D。</summary>
    public interface IMovable
    {
        bool CanBePushed       { get; }
        void OnPushStart(Vector2 direction);
        void OnPushEnd();
    }

    /// <summary>接受激活信号后改变自身状态的物件。</summary>
    public interface IActivatable
    {
        void Activate();
        void Deactivate();
        bool IsActivated { get; }
    }
}

PuzzleSwitch(通用开关):

// Assets/Scripts/World/Puzzle/PuzzleSwitch.cs
namespace BaseGames.Puzzle  // ⚠️ 必须有命名空间
{
    // 实现 ISwitchable + IInteractable
    [RequireComponent(typeof(Collider2D))]
    public class PuzzleSwitch : MonoBehaviour, ISwitchable, IInteractable
    {
        [Header("触发模式")]
        [SerializeField] SwitchTriggerMode  _mode = SwitchTriggerMode.InteractOnce;
        [Header("状态")]
        [SerializeField] bool               _startsActive = false;
        [SerializeField] string             _switchId;           // 持久化唯一 ID存档用空串则不持久化
        [Header("视觉")]
        [SerializeField] AnimancerComponent _animancer;
        [SerializeField] AnimationClip      _activeClip;
        [SerializeField] AnimationClip      _inactiveClip;
        [SerializeField] MMF_Player         _activateFeedback;

        // ⚠️ SO 注入(架构 14_NarrativeModule §8 patch不使用 WorldStateRegistry.Instance
        [SerializeField] WorldStateRegistry _worldState;

        bool _isActive;
        public bool IsActive => _isActive;
        public event Action<bool> OnStateChanged;

        void Start() => _isActive = _startsActive;  // ⚠️ 初始化初始状态

        // IInteractable
        public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互";
        public bool   CanInteract    => true;

        public void Interact(Transform player)
        {
            if (_mode == SwitchTriggerMode.InteractOnce && _isActive) return;
            SetState(!_isActive);
        }

        public void OnPlayerEnterRange(Transform player) { }  // ⚠️ IInteractable 必需实现
        public void OnPlayerExitRange() { }                   // ⚠️ IInteractable 必需实现

        // ISwitchable
        public void ForceState(bool active) => SetState(active);

        // 压板模式OnTriggerEnter2D / OnTriggerExit2D  ⚠️ 缺少则 Pressure 模式无法工作
        void OnTriggerEnter2D(Collider2D col)
        {
            if (_mode != SwitchTriggerMode.Pressure) return;
            if (col.CompareTag("Player") || col.CompareTag("PushBox"))
                SetState(true);
        }

        void OnTriggerExit2D(Collider2D col)
        {
            if (_mode != SwitchTriggerMode.Pressure) return;
            if (col.CompareTag("Player") || col.CompareTag("PushBox"))
                SetState(false);
        }

        void SetState(bool active)
        {
            if (_isActive == active) return;
            _isActive = active;
            if (active) _animancer?.Play(_activeClip);
            else        _animancer?.Play(_inactiveClip);
            _activateFeedback?.PlayFeedbacks();
            OnStateChanged?.Invoke(active);
            // ⚠️ SetFlag 双参数(架构 08_WorldModule §9key + bool value激活和停用均需持久化
            if (!string.IsNullOrEmpty(_switchId))
                _worldState?.SetFlag("switch_" + _switchId, active);
        }
    }

    public enum SwitchTriggerMode { InteractOnce, InteractToggle, Pressure, Hold }
}  // namespace BaseGames.Puzzle

PuzzleReceiver + PuzzleDoor(接收器 + 门子类):

// Assets/Scripts/World/Puzzle/PuzzleReceiver.cs
namespace BaseGames.Puzzle  // ⚠️ 必须有命名空间
{
    // 基类:子类覆写 OnActivate/OnDeactivate 实现具体行为
    public class PuzzleReceiver : MonoBehaviour, IActivatable
    {
        [SerializeField] bool         _startsActivated = false;  // ⚠️ 缺少则无法初始化激活状态
        [SerializeField] string       _receiverId;               // 持久化唯一 ID存档用空串则不持久化
        [SerializeField] MMF_Player   _activateFeedback;
        [SerializeField] MMF_Player   _deactivateFeedback;

        // ⚠️ SO 注入(架构 14_NarrativeModule §8 patch不使用 WorldStateRegistry.Instance
        [SerializeField] WorldStateRegistry _worldState;

        bool _isActivated;
        public bool IsActivated => _isActivated;

        void Start()  // ⚠️ 初始化起始激活状态
        {
            _isActivated = _startsActivated;
            if (_isActivated) Activate();
        }

        public void Activate()
        {
            if (_isActivated) return;
            _isActivated = true;
            _activateFeedback?.PlayFeedbacks();
            OnActivate();
            // ⚠️ SetFlag 双参数(架构 08_WorldModule §9key + true激活时持久化
            if (!string.IsNullOrEmpty(_receiverId))
                _worldState?.SetFlag("receiver_" + _receiverId, true);
        }

        public void Deactivate()
        {
            if (!_isActivated) return;
            _isActivated = false;
            _deactivateFeedback?.PlayFeedbacks();
            OnDeactivate();
            // ⚠️ SetFlag 双参数(架构 08_WorldModule §9key + false停用时也需持久化架构 21 §10
            if (!string.IsNullOrEmpty(_receiverId))
                _worldState?.SetFlag("receiver_" + _receiverId, false);
        }

        protected virtual void OnActivate()   { }
        protected virtual void OnDeactivate() { }
    }

    // Assets/Scripts/World/Puzzle/PuzzleDoor.cs
    public class PuzzleDoor : PuzzleReceiver
    {
        [SerializeField] AnimancerComponent _animancer;
        [SerializeField] AnimationClip      _openClip;
        [SerializeField] AnimationClip      _closeClip;

        protected override void OnActivate()   => _animancer.Play(_openClip);
        protected override void OnDeactivate() => _animancer.Play(_closeClip);
    }
}  // namespace BaseGames.Puzzle

PuzzleWire(逻辑连接器):

// Assets/Scripts/World/Puzzle/PuzzleWire.cs
// 将一个或多个 PuzzleSwitch 连接到 PuzzleReceiver支持 AND/OR/XOR 逻辑
// Inspector 中配置,关卡设计师无需写代码
// ⚠️ 字段名_switches / _receiver枚举类型LogicType架构 §3.4 patch非 WireLogic/WireLogicMode
namespace BaseGames.Puzzle  // ⚠️ 必须有命名空间
{
    public class PuzzleWire : MonoBehaviour
    {
        [Header("输入开关")]
        [SerializeField] PuzzleSwitch[]  _switches;   // ⚠️ 非 _inputs
        [Header("激活逻辑")]
        [SerializeField] LogicType       _logic = LogicType.AND;  // ⚠️ 枚举 LogicType架构 patch
        [Header("目标接收器")]
        [SerializeField] PuzzleReceiver  _receiver;   // ⚠️ 非 _output

        void Start()
        {
            foreach (var sw in _switches)
                sw.OnStateChanged += _ => Evaluate();
            Evaluate(); // 初始求值
        }

        void Evaluate()
        {
            bool shouldActivate = _logic switch
            {
                LogicType.AND => System.Array.TrueForAll(_switches, s => s.IsActive),
                LogicType.OR  => System.Array.Exists(_switches, s => s.IsActive),
                LogicType.XOR => _switches.Count(s => s.IsActive) % 2 == 1,
                _             => false,
            };
            if (shouldActivate) _receiver.Activate();
            else                _receiver.Deactivate();
        }
    }

    public enum LogicType { AND, OR, XOR }  // ⚠️ 枚举名为 LogicType架构 §3.4 patch
}  // namespace BaseGames.Puzzle

3.3 脚步声材质系统FootstepMaterial

⚠️ 延迟集成:本系统在 Phase 2 中已预留(架构 11 §9-10与玩家游泳状态SwimState及 Animancer 动画事件联动,至 Phase 3 Week 11 随 LiquidZone 一并集成。

// Assets/Scripts/Audio/FootstepMaterial.cs
public enum FootstepMaterial
{
    Stone,   // 石板地(默认)
    Dirt,    // 泥土/草地
    Wood,    // 木板
    Metal,   // 金属格栅
    Water,   // 浅水区(溅水声)
    Sand,    // 沙地
    Grass,   // 草丛
    Cave,    // 洞穴(回响加强)
}

// Assets/Scripts/Audio/FootstepAudioConfigSO.cs
[CreateAssetMenu(menuName = "BaseGames/Audio/FootstepAudioConfig")]
public class FootstepAudioConfigSO : ScriptableObject
{
    [System.Serializable]
    public struct MaterialEntry
    {
        public FootstepMaterial       material;
        public AudioClip[]            clips;            // 随机选一个,防止重复感
        [Range(0f, 1f)]   public float volume;
        [Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围
    }
    public MaterialEntry[] entries;

    public MaterialEntry? GetEntry(FootstepMaterial mat)
    {
        foreach (var e in entries)
            if (e.material == mat) return e;
        return null;
    }
}

// Assets/Scripts/Audio/FootstepMaterialMarker.cs
// 挂载到地面碰撞体所在 GameObjectTilemap 图层 or 单体地形 Prefab
public class FootstepMaterialMarker : MonoBehaviour
{
    public FootstepMaterial material;
}

播放时机

  • 落地PlayerController.OnLanded() 触发(音量 ×1.5
  • 行走Animancer 动画事件 FootstepL / FootstepR(见架构 24 §AnimEventModule触发
  • 冲刺起步Dash 动画第 2 帧触发专属 DashSFX(不走 Footstep 通道)

玩家若脚下 GameObject 无 FootstepMaterialMarker,默认使用 Stone。(架构 11 §9


3.4 水下音效处理UnderwaterAudioController

进入 LiquidZone 时,全局音效自动应用水下 DSP 处理。(架构 11 §10

// Assets/Scripts/Audio/UnderwaterAudioController.cs
// 挂载于 PlayerController 所在 GameObjectLiquidZone 调用 EnterWater/ExitWater
public class UnderwaterAudioController : MonoBehaviour
{
    [SerializeField] AudioMixer _mixer;
    [SerializeField] float      _transitionDuration = 0.3f;

    /// <summary>LiquidZone.OnTriggerEnter2D 时调用</summary>
    public void EnterWater()
    {
        _mixer.FindSnapshot("Underwater").TransitionTo(_transitionDuration);
    }

    /// <summary>LiquidZone.OnTriggerExit2D 时调用</summary>
    public void ExitWater()
    {
        _mixer.FindSnapshot("Default").TransitionTo(_transitionDuration);
    }
}

Underwater Snapshot DSP 配置AudioMixer 预设):

Bus 处理
BGM Low-Pass 800 Hz
SFX Low-Pass 1200 Hz + Volume ×0.7
Ambient Volume ×0替换为水下环境音气泡声
PlayerSFX Low-Pass 1000 Hz

3.5 WaterDangerState — 溺水倒计时(⚠️ 架构 21 §12原 Plan 遗漏)

当玩家进入 Water 类型液体且未解锁游泳能力时,触发溺水倒计时。挂在 PlayerController 子节点 [WaterDanger] 上。

// Assets/Scripts/World/Liquid/WaterDangerState.cs
// ⚠️ 订阅 LiquidEventChannelSO携带 LiquidEvent struct非 LiquidZone 引用)
// ⚠️ 使用 PlayerStats.HasAbility 替代 AbilityInventorySO项目实际 API无额外 SO 依赖)
public class WaterDangerState : MonoBehaviour
{
    [SerializeField] private LiquidPhysicsConfigSO _config;
    [SerializeField] private PlayerStats           _playerStats;       // 检查 Swim 能力
    [SerializeField] private LiquidEventChannelSO  _onLiquidEntered;   // EVT_LiquidEntered
    [SerializeField] private LiquidEventChannelSO  _onLiquidExited;    // EVT_LiquidExited
    [SerializeField] private FloatEventChannelSO   _onDrownProgress;   // 0~1 倒计时进度HUD 用)→ EVT_DrownProgress
    [SerializeField] private VoidEventChannelSO    _onPlayerDrowned;   // 触发死亡 → EVT_PlayerDrowned

    private float _drownTimer;
    private bool  _isActive;

    private void OnEnable()
    {
        if (_onLiquidEntered != null) _onLiquidEntered.OnEventRaised += OnEnterLiquid;
        if (_onLiquidExited  != null) _onLiquidExited.OnEventRaised  += OnExitLiquid;
    }

    private void OnDisable()
    {
        if (_onLiquidEntered != null) _onLiquidEntered.OnEventRaised -= OnEnterLiquid;
        if (_onLiquidExited  != null) _onLiquidExited.OnEventRaised  -= OnExitLiquid;
    }

    public void OnEnterLiquid(LiquidEvent evt)
    {
        if (evt.LiquidType != nameof(LiquidType.Water)) return;
        if (_playerStats != null && _playerStats.HasAbility(AbilityType.Swim)) return;
        _isActive   = true;
        _drownTimer = _config != null ? _config.DrownTime : 3f;
    }

    public void OnExitLiquid(LiquidEvent evt)
    {
        _isActive   = false;
        _drownTimer = _config != null ? _config.DrownTime : 3f;
        _onDrownProgress?.Raise(0f);
    }

    private void Update()
    {
        if (!_isActive) return;
        _drownTimer -= Time.deltaTime;
        float drownTime = _config != null ? _config.DrownTime : 3f;
        _onDrownProgress?.Raise(1f - (_drownTimer / drownTime));
        if (_drownTimer <= 0f)
        {
            _isActive = false;
            _onPlayerDrowned?.Raise();
        }
    }
}

事件频道补充

  • EVT_DrownProgressFloatEventChannelSOWaterDangerStateHUDController(溺水进度条)
  • EVT_PlayerDrownedVoidEventChannelSOWaterDangerStateGameManager(触发死亡流程)

3.6 UnderwaterPostProcessingController — 水下后处理(⚠️ 架构 21 §13原 Plan 遗漏)

订阅 EVT_LiquidEntered / EVT_LiquidExited,渐变启用/停用 水下专属 Volume(颜色滤镜/色差/暗角)。使用 Coroutine 混合权重,不依赖 DOTween。

// Assets/Scripts/World/Liquid/UnderwaterPostProcessingController.cs
public class UnderwaterPostProcessingController : MonoBehaviour
{
    [SerializeField] private Volume                    _underwaterVolume;   // 水下专属 VolumeWeightMode
    [SerializeField] private float                     _blendInDuration  = 0.3f;
    [SerializeField] private float                     _blendOutDuration = 0.3f;
    [SerializeField] private LiquidEventChannelSO _onLiquidEntered;    // EVT_LiquidEnteredpayload: LiquidEvent struct
    [SerializeField] private LiquidEventChannelSO _onLiquidExited;     // EVT_LiquidExited与 Enter 同类型,保持一致)

    private Coroutine _blendCoroutine;

    private void OnEnable()
    {
        _onLiquidEntered.OnEventRaised += OnLiquidEntered;
        _onLiquidExited.OnEventRaised  += OnLiquidExited;
    }
    private void OnDisable()
    {
        _onLiquidEntered.OnEventRaised -= OnLiquidEntered;
        _onLiquidExited.OnEventRaised  -= OnLiquidExited;
    }

    private void OnLiquidEntered(LiquidEvent evt)
    {
        if (evt.LiquidType != nameof(LiquidType.Water)) return;
        BlendVolume(1f, _blendInDuration);
    }
    private void OnLiquidExited(LiquidEvent evt) => BlendVolume(0f, _blendOutDuration);

    private void BlendVolume(float target, float duration)
    {
        if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
        _blendCoroutine = StartCoroutine(BlendRoutine(target, duration));
    }
    private IEnumerator BlendRoutine(float target, float duration)
    {
        float start = _underwaterVolume.weight, elapsed = 0f;
        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            _underwaterVolume.weight = Mathf.Lerp(start, target, elapsed / duration);
            yield return null;
        }
        _underwaterVolume.weight = target;
    }
}

4. Week 12进程模块护符/工具/技能) 完成2026-05-10

参考文档09_ProgressionModule.md

4.0 AbilityType 枚举 + AbilityGate

文件位置(架构 09_ProgressionModule §2.1

  • AbilityType.csAssets/Scripts/Player/AbilityType.cs,程序集 BaseGames.Player
  • AbilityGate.csAssets/Scripts/World/AbilityGate.cs,程序集 BaseGames.World
// Assets/Scripts/Player/AbilityType.cs — 程序集 BaseGames.Player
// ⚠️ 此枚举在 Player 程序集,非 Equipment 或 Spells
// ⚠️ 枚举值必须与架构 09_ProgressionModule §1 完全一致
namespace BaseGames.Player
{
    public enum AbilityType
    {
        // 移动
        WallCling,
        WallJump,
        Dash,
        AerialDash,         // ⚠️ 空中冲刺,默认锁定,升级后解锁(架构 09_ProgressionModule §1
        InvincibleDash,     // ⚠️ 冲刺全程无敌Dash 升级版(架构 09_ProgressionModule §1
        DoubleJump,
        ClimbVines,
        Swim,               // 游泳LiquidZone 内切换 SwimState

        // 战斗
        Parry,
        Spring,             // 灵泉反弹
        UseTools,

        // 互动
        ReadShrine,
        UseGrapple,
    }
}

// Assets/Scripts/World/AbilityGate.cs — 程序集 BaseGames.World
// 检测玩家是否持有对应能力,不满足则阻拦(触发器 + 提示 UI
// ⚠️ 以架构 09_ProgressionModule §2 为准:含 _blockingObject、_hintUI、_gateId、_saveData 注入
[RequireComponent(typeof(Collider2D))]
public class AbilityGate : MonoBehaviour
{
    [SerializeField] private AbilityType _requiredAbility;
    [SerializeField] private GameObject  _blockingObject;  // 实际阻挡物件(禁/启用)
    [SerializeField] private GameObject  _hintUI;          // ⚠️ 提示 UI能力图标 + "???"),架构 09 §2
    [SerializeField] private string      _gateId;          // 存档 ID已开启的门不再重置

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onAbilityUnlocked;  // ⚠️ StringEventChannelSO架构 02 §4 patchEVT_AbilityUnlocked payload 为 abilityId string非 AbilityTypeEventChannelSO

    // ⚠️ _saveData 由 GameInitializer 在 Awake 时注入(零耦合,避免 SaveManager.Instance架构 09 §2
    private SaveData _saveData;
    public void InjectSaveData(SaveData data) => _saveData = data;

    private void OnEnable()  => _onAbilityUnlocked.OnEventRaised += OnAbilityUnlocked;
    private void OnDisable() => _onAbilityUnlocked.OnEventRaised -= OnAbilityUnlocked;

    private void Start()
    {
        // ⚠️ 读档检查:若已持有该能力则直接开放(架构 09 §2
        bool hasAbility = _saveData != null
            && _saveData.Player.Abilities.TryGetValue(_requiredAbility.ToString(), out bool val)
            && val;
        _blockingObject.SetActive(!hasAbility);
        if (_hintUI != null) _hintUI.SetActive(!hasAbility);
    }

    private void OnAbilityUnlocked(string abilityId)  // ⚠️ string 参数(非 AbilityType 枚举)
    {
        if (abilityId != _requiredAbility.ToString()) return;
        Open();
    }

    public void Open()
    {
        _blockingObject.SetActive(false);
        if (_hintUI != null) _hintUI.SetActive(false);
        // P1播放解锁动画如荆棘收缩、道路开通特效
    }
}

4.0b AbilityUnlock能力解锁交互物

文件Assets/Scripts/World/AbilityUnlock.cs(架构 08_WorldModule §6

世界中固定位置的能力解锁物;玩家与之交互后获得新技能,触发 EVT_AbilityUnlocked 事件频道。

// ⚠️ 完整实现以架构 08_WorldModule §6 为准
// ⚠️ IInteractable 定义在 BaseGames.World 命名空间Architecture 08 §7 / 14 §1
public class AbilityUnlock : MonoBehaviour, IInteractable
{
    [SerializeField] private AbilityType    _abilityToUnlock;
    [SerializeField] private string         _unlockId;          // 存档用(全局唯一)

    [Header("Event Channel")]
    [SerializeField] private StringEventChannelSO _onCollectiblePickup;  // ⚠️ payload: _unlockId通知 WorldStateRegistry + QuestManager

    private bool _isCollected = false;

    public bool   CanInteract    => !_isCollected;
    public string InteractPrompt => "获得能力";

    public void Interact(Transform player)
    {
        if (_isCollected) return;
        _isCollected = true;
        // ⚠️ PlayerController 无 InstanceArchitecture 05 §2通过 player 参数获取
        player.GetComponent<PlayerController>()?.Stats.UnlockAbility(_abilityToUnlock);
        _onCollectiblePickup.Raise(_unlockId);  // → WorldStateRegistry 记录 + QuestManager 追踪
        // 触发解锁演出Cutscene / UI 提示Phase 4 完善)
        gameObject.SetActive(false);
    }

    public void OnPlayerEnterRange(Transform player) { }
    public void OnPlayerExitRange() { }

    // 存档集成(由 WorldStateRegistry 通过 _onCollectiblePickup 驱动)
    public void SetCollected(bool val)
    {
        _isCollected = val;
        if (val) gameObject.SetActive(false);
    }
}

4.1 CharmSO + ICharmEffect + EquipmentContext

// Assets/Scripts/Equipment/CharmSO.cs — 程序集 BaseGames.Equipment
[CreateAssetMenu(menuName = "Equipment/Charm")]
public class CharmSO : ScriptableObject
{
    [Header("Identity")]
    public string  charmId;
    public string  displayNameKey;     // 本地化 Key
    [TextArea(2,4)]
    public string  descriptionKey;     // 本地化 Key

    [Header("Visual")]
    public Sprite  icon;
    public Color   glowColor;

    [Header("Slot Cost")]
    [Range(1,4)]
    public int     notchCost;          // 占用笔记数1~4

    [Header("Effects")]
    [SerializeReference]
    public List<ICharmEffect> effects; // 多态序列化,支持多个效果叠加

    [Header("Lore")]
    public bool    isUnique;           // 唯一物品,不可重复装备
    public string  unlockHint;
}

// Assets/Scripts/Equipment/ICharmEffect.cs
[System.Serializable]
public interface ICharmEffect
{
    void OnEquip(EquipmentContext ctx);
    void OnUnequip(EquipmentContext ctx);
    string GetEffectDescription();
}

// ⚠️ 解耦上下文struct非 class架构 09 §4
public struct EquipmentContext
{
    public PlayerStats           Stats;
    public PlayerFeedback        Feedback;        // ⚠️ 架构 09 §4原 Plan 遗漏)
    public EventChannelRegistry  Events;          // ⚠️ SO 事件频道注册表(架构 09 §4原 Plan 遗漏)
    public SkillModifierRegistry SkillMods;       // ⚠️ 字段名 SkillMods非 SkillModifiers架构 09 §4
    public WeaponManager         WeaponMgr;       // ⚠️ 字段名 WeaponMgr非 Weapons架构 09 §4
}

// Assets/Scripts/Equipment/Effects/ — 内置 CharmEffect 实现Architecture 09 §5

// ── 属性加成 ──────────────────────────────────────────────────────────
[Serializable]
public class StatModifierEffect : ICharmEffect
{
    public StatType statType;      // ⚠️ 字段名 statType非 Stat架构 09 §5MaxHP / AttackDamage / MoveSpeed / JumpHeight / SoulGain / Defense
    public float    flatBonus;     // ⚠️ 固定加成(非 Value + IsPercent bool架构 09 §5
    public float    percentBonus;  // ⚠️ 百分比加成(如 +0.2 = +20%),架构 09 §5

    public void OnEquip(EquipmentContext ctx)   => ctx.Stats.AddModifier(statType, flatBonus, percentBonus);   // ⚠️ AddModifier非 ApplyModifier架构 09 §5
    public void OnUnequip(EquipmentContext ctx) => ctx.Stats.RemoveModifier(statType, flatBonus, percentBonus);
    public string GetEffectDescription() => $"{statType}: +{flatBonus} +{percentBonus*100:0}%";
}

// ── 攻击速度加成 ──────────────────────────────────────────────────────
[Serializable]
public class AttackSpeedEffect : ICharmEffect
{
    [Range(0.1f, 2.0f)]
    public float speedMultiplier = 1.2f;   // ⚠️ 字段名 speedMultiplier非 SpeedMultiplier架构 09 §5

    public void OnEquip(EquipmentContext ctx)   => ctx.Stats.AnimatorSpeedMultiplier += (speedMultiplier - 1f);   // ⚠️ 直接修改 AnimatorSpeedMultiplier非 ApplyAttackSpeedMult架构 09 §5
    public void OnUnequip(EquipmentContext ctx) => ctx.Stats.AnimatorSpeedMultiplier -= (speedMultiplier - 1f);
    public string GetEffectDescription() => $"攻击速度 +{(speedMultiplier - 1) * 100:0}%";
}

// ── 命中触发效果 ──────────────────────────────────────────────────────
[Serializable]
public class OnHitEffect : ICharmEffect
{
    public OnHitEffectType effectType;   // ⚠️ OnHitEffectType 枚举(非 DamageType架构 09 §5ApplyPoison / ApplyFire / KnockbackBoost
    [Range(0f, 1f)]
    public float           chance;      // ⚠️ 字段名 chance非 Chance架构 09 §5

    private DamageInfoEventChannelSO _onHitChannel;  // ⚠️ 通过 EventChannelRegistry 取得(架构 09 §5

    public void OnEquip(EquipmentContext ctx)
    {
        _onHitChannel = ctx.Events.Get<DamageInfoEventChannelSO>("OnHitConfirmed");  // ⚠️ 架构 09 §5
        _onHitChannel.OnEventRaised += HandleHit;
    }
    public void OnUnequip(EquipmentContext ctx) => _onHitChannel.OnEventRaised -= HandleHit;

    private void HandleHit(DamageInfo info)
    {
        if (UnityEngine.Random.value > chance) return;
        // 触发对应效果(由 StatusEffectManager 处理,见 06_CombatModule §12
    }
    public string GetEffectDescription() => $"命中时 {chance * 100:0}% 概率附加 {effectType}";
}

// ── 灵魂法术强化 ──────────────────────────────────────────────────────
[Serializable]
public class SoulSpellEffect : ICharmEffect   // ⚠️ 架构 09 §5原 Plan 遗漏)
{
    public SpellType spellType;          // SoulAttack / HealingWave
    public int       soulCostReduction;  // 减少消耗 Soul 点数

    public void OnEquip(EquipmentContext ctx)
        => ctx.Stats.RegisterSpellModifier(spellType, soulCostReduction, 0f);
    public void OnUnequip(EquipmentContext ctx)
        => ctx.Stats.UnregisterSpellModifier(spellType, soulCostReduction, 0f);
    public string GetEffectDescription() => $"{spellType} 消耗减少 {soulCostReduction} Soul";
}

// ── 技能数值修改 ──────────────────────────────────────────────────────
[Serializable]
public class SkillNumericModifierEffect : ICharmEffect
{
    public string    TargetSkillId;
    public SkillStat Stat;    // enum: Damage, Cost, Cooldown, Range, Duration
    public float     Delta;
    public bool      IsPercent;

    public void OnEquip(EquipmentContext ctx)   => ctx.SkillMods.Register(TargetSkillId, Stat, Delta, IsPercent);   // ⚠️ ctx.SkillMods非 ctx.SkillModifiers架构 09 §5
    public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.Unregister(TargetSkillId, Stat, Delta, IsPercent);
    public string GetEffectDescription() => $"{TargetSkillId}.{Stat} {(Delta >= 0 ? "+" : "")}{Delta}";
}

// ── 技能插槽替换 ──────────────────────────────────────────────────────
[Serializable]
public class SkillSlotOverrideEffect : ICharmEffect   // ⚠️ 架构 09 §5原 Plan 遗漏)
{
    public SkillSlotOverride overrideData;  // targetForm / targetSlot / replacementSkill / priority

    public void OnEquip(EquipmentContext ctx)   => ctx.SkillMods.AddSlotOverride(overrideData);
    public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.RemoveSlotOverride(overrideData);
    public string GetEffectDescription()
    {
        string formStr  = overrideData.targetForm != null ? overrideData.targetForm.name : "所有形态";
        string skillName = overrideData.replacementSkill != null ? overrideData.replacementSkill.displayNameKey : "null";
        return $"{formStr}的 {overrideData.targetSlot} 替换为 [{skillName}]";
    }
}

Phase 3 实现以下 Charm(最小集,验证系统可用):

CharmId 效果
Charm_VoidHeart MaxHP +2
Charm_QuickSlash AttackSpeed ×1.3(通过 AnimancerClip 速度倍率)
Charm_SoulCatcher 命中时获得的 SoulPower ×1.5

4.2 EquipmentManager

// Assets/Scripts/Equipment/EquipmentManager.cs — 程序集 BaseGames.Equipment
public class EquipmentManager : MonoBehaviour
{
    [Header("配置")]
    [SerializeField] private EquipmentConfigSO _config;   // ⚠️ SO 配置(非 int _totalNotches含 initialNotchCount架构 09 §6

    [Header("Event Channels")]
    [SerializeField] private CharmEventChannelSO  _onCharmEquipped;    // ⚠️ 架构 09 §6原 Plan 遗漏)
    [SerializeField] private CharmEventChannelSO  _onCharmUnequipped;  // ⚠️ 架构 09 §6原 Plan 遗漏)
    [SerializeField] private VoidEventChannelSO   _onEquipmentChanged;

    private List<CharmSO> _equipped  = new(4);
    private List<CharmSO> _collected = new(32);
    private int _currentNotchCapacity;

    private EquipmentContext _ctx;  // ⚠️ 私有字段,在 Awake() 中构建(非 [SerializeField]),架构 09 §6

    private void Awake()
    {
        // ⚠️ EquipmentContext 在 Awake 中通过 GetComponent 构建(架构 09 §6
        _ctx = new EquipmentContext
        {
            Stats     = GetComponent<PlayerStats>(),
            Feedback  = GetComponent<PlayerFeedback>(),
            Events    = EventChannelRegistry.Instance,
            SkillMods = GetComponent<SkillModifierRegistry>(),
            WeaponMgr = GetComponent<WeaponManager>(),
        };
        _currentNotchCapacity = _config != null ? _config.initialNotchCount : 3;
    }

    public int UsedNotches   => _equipped.Sum(c => c.notchCost);
    public int TotalNotches  => _currentNotchCapacity;
    public IReadOnlyList<CharmSO> Equipped   => _equipped;
    public IReadOnlyList<CharmSO> Collected  => _collected;

    /// <summary>装备护符。返回失败原因null = 成功)。⚠️ 返回 string非 bool架构 09 §6</summary>
    public string TryEquipCharm(CharmSO charm)   // ⚠️ 方法名 TryEquipCharm非 TryEquip返回 string架构 09 §6
    {
        if (_equipped.Contains(charm)) return "已经装备";
        if (!_collected.Contains(charm)) return "尚未收集此魅力";
        if (UsedNotches + charm.notchCost > _currentNotchCapacity)
            return $"笔记不足(需要 {charm.notchCost},剩余 {_currentNotchCapacity - UsedNotches}";

        _equipped.Add(charm);
        foreach (var fx in charm.effects) fx.OnEquip(_ctx);
        _onCharmEquipped.Raise(charm);
        _onEquipmentChanged.Raise();
        return null;
    }

    public void UnequipCharm(CharmSO charm)   // ⚠️ 方法名 UnequipCharm非 Unequip架构 09 §6
    {
        if (!_equipped.Remove(charm)) return;
        foreach (var fx in charm.effects) fx.OnUnequip(_ctx);
        _onCharmUnequipped.Raise(charm);
        _onEquipmentChanged.Raise();
    }

    // 收集(从 Collectible / ShopController 调用,传 charmId 字符串)
    public void AddToCollection(string charmId);

    public void IncreaseNotches(int amount) => _currentNotchCapacity += amount;

    // 存档集成(非 ISaveable直接调用
    public EquipmentSaveData GetSaveData();
    public void LoadSaveData(EquipmentSaveData data);
}

4.3 ToolSO + FormSkillSO

程序集位置(架构 09_ProgressionModule §78

  • FormSkillSOSkillManagerSkillModifierRegistryAssets/Scripts/Skills/,程序集 BaseGames.Spells
  • ToolSOEquipmentManagerAssets/Scripts/Equipment/,程序集 BaseGames.Equipment

ToolSO(主动工具,如抓钩/炸弹/气球):

// Assets/Scripts/Equipment/ToolSO.cs — 程序集 BaseGames.Equipment
[CreateAssetMenu(menuName = "Equipment/Tool")]
public class ToolSO : ScriptableObject
{
    public string   toolId;
    public string   displayNameKey;    // 本地化 Key
    public Sprite   icon;
    public int      maxUses;           // -1 = 无限次数

    [SerializeReference]
    public IToolEffect effect;         // 工具使用效果多态Strategy 模式)
}

public interface IToolEffect
{
    void Use(PlayerController player);
}

FormSkillSO(各形态魂技能):

// Assets/Scripts/Skills/FormSkillSO.cs — 程序集 BaseGames.Spells
[CreateAssetMenu(menuName = "Skills/FormSkill")]
public class FormSkillSO : ScriptableObject
{
    [Header("Identity")]
    public string  skillId;
    public string  displayNameKey;
    [TextArea(1,3)] public string descriptionKey;
    public Sprite  icon;

    [Header("Resource")]
    public SkillResourceType resourceType;   // SoulPower / SpiritPower
    public int     baseCost;
    public float   cooldown;

    [Header("Animation")]
    public ClipTransition castAnimation;     // Animancer Pro ClipTransition
    public float   castLockDuration;         // 秒,动画锁帧时长

    [Header("Effect")]
    public SkillEffectType effectType;
    public DamageSourceSO  damageSource;

    [Header("Projectile")]
    public ProjectileConfigSO projectileConfig;
    public bool    isHoming;
    public bool    holdForContinuous;

    [Header("Dash")]
    public float   dashForce;
    public float   dashDuration;
    public bool    isInvincibleDuringDash;

    [Header("Explosion")]
    public float   explosionDelay;
    public float   explosionRadius;

    [Header("Feedback")]
    public FeedbackPresetSO castFeedback;

    [Header("HitBox Prefab")]
    public GameObject SkillHitBoxPrefab;     // 近战/爆炸技能命中盒 Prefab投射物技能留空
}

public enum SkillResourceType { SoulPower, SpiritPower }
public enum SkillEffectType
{
    MeleeAoE, Projectile, BarrierAura, GroundDive,
    DragonKick, WraithDash, ShadowDecoy, DelayedExplosion
}

4.3.5 ToolSlotManager + ToolHUDArchitecture 09 §7.5

// Assets/Scripts/Equipment/ToolSlotManager.cs — 程序集 BaseGames.Equipment
public class ToolSlotManager : MonoBehaviour, ISaveable
{
    private const int SlotCount = 2;

    [SerializeField] private ToolSO[]                  _slots          = new ToolSO[SlotCount];
    [SerializeField] private int[]                     _remainingUses  = new int[SlotCount];  // -1 = 无限
    [SerializeField] private ToolUsedEventChannelSO    _onToolUsed;
    private float[] _cooldowns = new float[SlotCount];

    /// <summary>装备工具到指定槽位</summary>
    public void EquipTool(int slotIndex, ToolSO tool)
    {
        _slots[slotIndex]         = tool;
        _remainingUses[slotIndex] = tool?.maxUses ?? -1;
        _cooldowns[slotIndex]     = 0f;
    }

    /// <summary>使用槽位工具:检查冷却/次数 → 执行 IToolEffect → 触发事件</summary>
    public bool TryUseTool(int slotIndex, PlayerController player)
    {
        var tool = _slots[slotIndex];
        if (tool == null) return false;
        if (_cooldowns[slotIndex] > 0f) return false;
        if (_remainingUses[slotIndex] == 0) return false;

        tool.effect?.Use(player);
        if (_remainingUses[slotIndex] > 0) _remainingUses[slotIndex]--;
        _cooldowns[slotIndex] = (tool is IToolCooldown cd) ? cd.CooldownDuration : 0f;
        _onToolUsed.Raise(new ToolUsedPayload { SlotIndex = slotIndex, Tool = tool });
        return true;
    }

    private void Update()
    {
        for (int i = 0; i < SlotCount; i++)
            if (_cooldowns[i] > 0f) _cooldowns[i] -= Time.deltaTime;
    }

    public ToolSO  GetTool(int slotIndex)            => _slots[slotIndex];
    public float   GetCooldownRatio(int slotIndex)   => _cooldowns[slotIndex] / GetMaxCooldown(slotIndex);
    public int     GetRemainingUses(int slotIndex)   => _remainingUses[slotIndex];

    private float GetMaxCooldown(int i)
        => (_slots[i] is IToolCooldown cd) ? cd.CooldownDuration : 1f;

    // ISaveable
    public void OnSave(SaveData data)
    {
        data.Tools.ToolSlot0 = _slots[0]?.toolId;
        data.Tools.ToolSlot1 = _slots[1]?.toolId;
    }
    public void OnLoad(SaveData data);
}

/// <summary>工具冷却接口(实现该接口的 ToolSO 才会有冷却)</summary>
public interface IToolCooldown
{
    float CooldownDuration { get; }
}

// Assets/Scripts/UI/ToolHUD.cs
public class ToolHUD : MonoBehaviour
{
    [SerializeField] private ToolSlotUI[]           _slots;
    [SerializeField] private ToolSlotManager        _slotManager;
    [SerializeField] private ToolUsedEventChannelSO _onToolUsed;

    private void OnEnable()  => _onToolUsed.OnEventRaised += RefreshSlot;
    private void OnDisable() => _onToolUsed.OnEventRaised -= RefreshSlot;

    private void RefreshSlot(ToolUsedPayload payload)
        => _slots[payload.SlotIndex].Refresh(
               _slotManager.GetTool(payload.SlotIndex),
               _slotManager.GetRemainingUses(payload.SlotIndex),
               _slotManager.GetCooldownRatio(payload.SlotIndex));

    private void Update()
    {
        // 实时更新冷却遮罩(每帧刷新)
        for (int i = 0; i < _slots.Length; i++)
            _slots[i].SetCooldownFill(_slotManager.GetCooldownRatio(i));
    }
}

public class ToolSlotUI : MonoBehaviour
{
    [SerializeField] private Image    _icon;
    [SerializeField] private TMP_Text _usesText;
    [SerializeField] private Image    _cooldownMask;  // fillAmount 冷却遮罩

    public void Refresh(ToolSO tool, int remainingUses, float cooldownRatio)
    {
        _icon.sprite         = tool != null ? tool.icon : null;
        _usesText.text       = tool == null || tool.maxUses < 0 ? "" : remainingUses.ToString();
        _cooldownMask.fillAmount = cooldownRatio;
    }

    public void SetCooldownFill(float ratio) => _cooldownMask.fillAmount = ratio;
}

4.4 SkillManager + SkillModifierRegistry

// Assets/Scripts/Skills/SkillManager.cs — 程序集 BaseGames.Spells
// SkillManager管理当前形态可用技能通过 InputReaderSO 事件驱动技能释放
public class SkillManager : MonoBehaviour
{
    [SerializeField] private PlayerStats           _stats;
    [SerializeField] private PlayerController      _controller;
    [SerializeField] private InputReaderSO         _input;
    [SerializeField] private SkillModifierRegistry _modifiers;
    [SerializeField] private Transform             _skillSocket;  // [SkillSocket] 子节点引用
    [SerializeField] private GlobalObjectPool      _pool;         // ⚠️ 投射物/技能命中盒从池中取Architecture 09 §9

    private FormSkillSO _soulSkill, _spirit1, _spirit2;
    private float _soulCooldown, _spirit1Cooldown, _spirit2Cooldown;

    private void OnEnable()
    {
        _input.SoulSkillEvent             += TrySoulSkill;
        _input.SpiritSkill1StartedEvent   += TrySpiritSkill1;   // ⚠️ SpiritSkill1StartedEvent架构 04_InputModule §2 line 103非 SpiritSkill1Event
        _input.SpiritSkill2StartedEvent   += TrySpiritSkill2;   // ⚠️ SpiritSkill2StartedEvent架构 04_InputModule §2 line 105非 SpiritSkill2Event
    }

    private void OnDisable()
    {
        _input.SoulSkillEvent             -= TrySoulSkill;
        _input.SpiritSkill1StartedEvent   -= TrySpiritSkill1;
        _input.SpiritSkill2StartedEvent   -= TrySpiritSkill2;
    }

    // 切换形态时由 FormController 调用(⚠️ 单参数 FormSO架构 09 §6FormController 调用 _skillManager?.UpdateSkillSet(newForm)
    public void UpdateSkillSet(FormSO form);   // 内部从 form 提取 SoulSkill/SpiritSkill1/SpiritSkill2

    // 校验冷却 → 消耗资源GetFinalCost→ 播放 castAnimation → 激活 SkillHitBox
    private void TrySoulSkill();
    private void TrySpiritSkill1();
    private void TrySpiritSkill2();

    // baseCost 经 SkillModifierRegistry 调整后的最终消耗
    private int GetFinalCost(FormSkillSO skill);
}

// Assets/Scripts/Combat/SkillHitBoxInstance.csArchitecture 09 §9.5
// Prefab: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
public class SkillHitBoxInstance : MonoBehaviour
{
    [SerializeField] private HitBox[] _hitBoxes;
    public System.Action<DamageInfo> OnHitConfirmed;

    private void Awake()
    {
        foreach (var hb in _hitBoxes)
            hb.OnHitConfirmed += info => OnHitConfirmed?.Invoke(info);
    }

    public void Activate(DamageSourceSO source, Transform attacker)
    {
        foreach (var hb in _hitBoxes) hb.Activate(source, attacker);
    }

    public void AutoDestroyAfter(float duration) => Destroy(gameObject, duration);

    private void OnDestroy()
    {
        foreach (var hb in _hitBoxes) hb.Deactivate();
    }
}

// Assets/Scripts/Skills/SkillModifierRegistry.cs — 程序集 BaseGames.Spells
// 收集护符对技能数值的修改SkillManager 查询最终消耗/冷却等
public class SkillModifierRegistry
{
    private Dictionary<string, Dictionary<SkillStat, float>> _overrides = new();

    public void Register(string skillId, SkillStat stat, float delta, bool isPercent);
    public void Unregister(string skillId, SkillStat stat, float delta, bool isPercent);

    // ⚠️ 主查询方法(架构 09 §10一次调用获取全部有效参数快照供 SkillManager.CastRoutine() 使用
    public EffectiveSkillParams GetEffectiveParams(FormSkillSO skill);

    // 向后兼容:单字段查询(内部调用 GetEffectiveParams 后提取)
    public float GetModifiedValue(string skillId, SkillStat stat, float baseVal);

    // ⚠️ 技能插槽覆盖(供 SkillSlotOverrideEffect 使用,架构 09 §5/10
    public void AddSlotOverride(SkillSlotOverride overrideData);
    public void RemoveSlotOverride(SkillSlotOverride overrideData);
}

// ⚠️ 所有数值修改器叠加后的运行时参数快照(架构 09 §10原 Plan 遗漏)
public struct EffectiveSkillParams
{
    public FormSkillSO      baseSkill;          // 原始 SO 引用(不变,供判断 effectType
    public int              effectiveCost;      // 修改后消耗量
    public float            effectiveCooldown;  // 修改后冷却(秒)
    public float            damageMult;         // 伤害倍率1.0 = 无增益)
    public float            rangeMult;          // 范围倍率AoE 半径 / 障壁半径 / 爆炸半径)
    public FeedbackPresetSO effectiveFeedback;  // 最终特效预设护符可替换null = 回退原始)
    public ClipTransition   effectiveAnimation; // 最终施法动画护符可替换null = 回退原始)

    /// <summary>以技能 SO 默认值初始化,无任何修改器加成。</summary>
    public static EffectiveSkillParams FromBase(FormSkillSO skill) => new()
    {
        baseSkill          = skill,
        effectiveCost      = skill.baseCost,
        effectiveCooldown  = skill.cooldown,
        damageMult         = 1f,
        rangeMult          = 1f,
        effectiveFeedback  = null,
        effectiveAnimation = null,
    };
}

public enum SkillStat { Damage, Cost, Cooldown, Range, Duration }

4.5 RegionDefinitionSO区域定义

⚠️ 此节内容来自架构 09_ProgressionModule §11原 Plan 遗漏;已补充。
文件Assets/Scripts/Progression/RegionDefinitionSO.cs,命名规范:Region_{RegionId}.asset

// Assets/Scripts/Progression/RegionDefinitionSO.cs — 程序集 BaseGames.Progression
// 每个区域一个 SO 资产,集中管理区域元数据(音频区域、地图颜色、解锁条件等)
[CreateAssetMenu(menuName = "Progression/RegionDefinition")]
public class RegionDefinitionSO : ScriptableObject
{
    public string  regionId;             // 如 "Cave"(与 AudioZone.regionId 一致)
    public string  displayName;          // 如 "腐蚀洞穴"
    public Color   mapColor;             // 地图 UI 上该区域的颜色标识
    public Sprite  mapIconSprite;        // 地图图标

    [Header("解锁条件")]
    public string  requiredBossDefeated; // 空字符串 = 无条件
    public AbilityType requiredAbility;  // 默认值 0WallCling= 无要求时按惯例留默认值并忽略

    [Header("关联房间")]
    public string[] roomSceneNames;      // 该区域包含的所有场景名
    public string   bossSceneName;       // Boss 房间场景名
    public string   entrySceneName;      // 从外部进入该区域的第一个房间
}

区域 ID 对照表(架构 09 §11

区域 ID 中文名 Boss 开放条件
Forest 扎根森林 Boss_SpiderGuard 无(起始区域)
Cave 腐蚀洞穴 Boss_CorrosionWorm 击败 Boss_SpiderGuard
Ruins 坍塌废墟 Boss_RuinsKnight 获得 Dash 能力
Abyss 深渊裂隙 Boss_AbyssThroat 击败 Boss_RuinsKnight
Core 核心熔炉 FinalBoss 击败 Boss_AbyssThroat

4.6 ProgressLock进程锁

⚠️ 此节内容来自架构 09_ProgressionModule §12原 Plan 遗漏;已补充。
文件Assets/Scripts/Progression/ProgressLock.cs
单向/永久性阻挡,需满足特定条件(击败 Boss 或持有道具)才能解锁。与 AbilityGate 的区别ProgressLock 基于 Boss 击败/道具持有而非能力解锁。

// Assets/Scripts/Progression/ProgressLock.cs — 程序集 BaseGames.Progression
public class ProgressLock : MonoBehaviour
{
    [Header("解锁条件")]
    [SerializeField] private string _requiredBossId;   // 空 = 不检查 Boss
    [SerializeField] private string _requiredItemId;   // 空 = 不检查道具

    [Header("物理表现")]
    [SerializeField] private GameObject _lockedVisuals;    // 锁住状态视觉
    [SerializeField] private GameObject _unlockedVisuals;  // 开启状态视觉(可 null
    [SerializeField] private Collider2D _blockCollider;

    [Header("存档")]
    [SerializeField] private string _lockId;              // 唯一 ID存档记录开启状态

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onBossDefeated;    // EVT_BossDefeated

    private void Start()
    {
        bool isUnlocked = CheckUnlocked();
        ApplyState(isUnlocked);
        if (!isUnlocked)
            _onBossDefeated.OnEventRaised += OnBossDefeated;
    }

    private void OnDestroy() => _onBossDefeated.OnEventRaised -= OnBossDefeated;

    private void OnBossDefeated(string bossId)
    {
        if (_requiredBossId == bossId && CheckUnlocked())
            ApplyState(true);
    }

    private bool CheckUnlocked()
    {
        var save = SaveManager.Instance.Data;
        if (!string.IsNullOrEmpty(_requiredBossId) && !save.World.DefeatedBossIds.Contains(_requiredBossId))
            return false;
        return save.World.OpenedDoors.Contains(_lockId);
    }

    private void ApplyState(bool unlocked)
    {
        _blockCollider.enabled = !unlocked;
        _lockedVisuals.SetActive(!unlocked);
        if (_unlockedVisuals != null)
            _unlockedVisuals.SetActive(unlocked);
    }
}

4.7 BossProgressTrackerBoss 进程追踪)

⚠️ 此节内容来自架构 09_ProgressionModule §13原 Plan 遗漏;已补充。
文件Assets/Scripts/Progression/BossProgressTracker.cs
轻量辅助组件,挂载在 Boss 房间的 BossTrigger 同一对象上,监听 Boss 死亡事件并通知存档系统。

// Assets/Scripts/Progression/BossProgressTracker.cs — 程序集 BaseGames.Progression
public class BossProgressTracker : MonoBehaviour
{
    [SerializeField] private string   _bossId;                    // 如 "Boss_SpiderGuard"
    [SerializeField] private string[] _unlocksProgressLockIds;    // 击败后解锁哪些 ProgressLock

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onBossDefeated;        // 监听EVT_BossDefeated
    [SerializeField] private StringEventChannelSO _onBossDefeatedForSave; // 广播 → SaveSystem

    private void OnEnable()  => _onBossDefeated.OnEventRaised += OnBossDefeated;
    private void OnDisable() => _onBossDefeated.OnEventRaised -= OnBossDefeated;

    private void OnBossDefeated(string bossId)
    {
        if (bossId != _bossId) return;
        // 通过事件频道通知 SaveSystem零耦合
        _onBossDefeatedForSave.Raise(bossId);
        // SaveSystem 收到后data.World.DefeatedBossIds.Add(bossId); 并解锁相关 ProgressLock
    }
}

4.8 HPContainerPickupHP 容器拾取)

⚠️ 此节内容来自架构 09_ProgressionModule §14原 Plan 遗漏;已补充。
文件Assets/Scripts/Progression/HPContainerPickup.cs
永久 MaxHP +2 的可拾取物件,通过事件频道零耦合通知 SaveSystem。

// Assets/Scripts/Progression/HPContainerPickup.cs — 程序集 BaseGames.Progression
public class HPContainerPickup : MonoBehaviour
{
    [SerializeField] private string _collectibleId;      // 存档用唯一 ID
    [SerializeField] private InputReaderSO _inputReader; // ⚠️ 架构 09 §14原 Plan 遗漏):禁用/恢复玩家输入

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onMaxHPContainerPickedUp; // → SaveSystem
    [SerializeField] private IntEventChannelSO    _onMaxHPChanged;           // → HUDController

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;
        var save = SaveManager.Instance.Data;
        // ⚠️ 防重复:已收集则跳过(存档 CollectedIds 检查)
        if (save != null && save.World.CollectedIds.Contains(_collectibleId)) return;
        StartCoroutine(PickupSequence());
    }

    private IEnumerator PickupSequence()
    {
        _inputReader.EnableGameplayInput(false);  // ⚠️ 架构 09 §14原 Plan 遗漏)
        gameObject.SetActive(false);

        // Feel MMF_Player 播放获取特效(外部引用或 GetComponent
        yield return new WaitForSeconds(0.8f);

        // 零耦合:通过事件频道通知 SaveSystem
        _onMaxHPContainerPickedUp.Raise(_collectibleId);
        // SaveSystemdata.Player.MaxHP += 2; data.World.CollectedIds.Add(id); Save();

        yield return new WaitForSeconds(0.5f);
        _inputReader.EnableGameplayInput(true);  // ⚠️ 架构 09 §14原 Plan 遗漏)
    }
}

5. Week 13任务与挑战房间 完成2026-05-11

参考文档22_QuestChallengeModule.md

5.0 任务与挑战数据层 SO创建顺序

依赖最底层的数据 SO 必须最先创建:QuestObjectiveSO → RewardSO → QuestSO → ChallengeEncounterSO → BossRushSequenceSO → ChallengeRoomSO

// Assets/Scripts/Quest/QuestSO.cs
// ⚠️ menuName = "Quest/Quest"(架构 22 §2
namespace BaseGames.Quest
{
    [CreateAssetMenu(menuName = "Quest/Quest")]
    public class QuestSO : ScriptableObject
    {
        [Header("标识")]
        public string questId;
        public string displayName;
        [TextArea(2, 6)]
        public string description;
        public Sprite icon;

        [Header("目标链")]
        public QuestObjectiveSO[] objectives;

        [Header("前置条件")]
        public string[] prerequisiteQuestIds;
        public int      minAffinityToAccept;

        [Header("奖励")]
        public RewardSO reward;

        [Header("失败条件(可选)")]
        public bool             canFail;
        public QuestObjectiveSO failCondition;

        [Header("完成后续任务(分支)")]
        public QuestBranch[]    branches;
    }

    [Serializable]
    public class QuestBranch
    {
        public string   conditionQuestId;
        public QuestSO  nextQuest;
        public string   npcDialogueKey;
    }
}
// Assets/Scripts/Quest/QuestObjectiveSO.cs
// ⚠️ 多态目标体系(架构 22 §3抽象基类 + 5 个具体子类,替代旧的单类+ObjectiveType枚举方案
namespace BaseGames.Quest
{
    /// <summary>任务目标基类(抽象)。所有具体目标类型均继承此类。</summary>
    public abstract class QuestObjectiveSO : ScriptableObject
    {
        [Header("标识")]
        public string objectiveId;
        [TextArea(1, 4)]
        public string displayText;
        public bool   IsOptional;

        public abstract void RegisterListeners(IQuestObjectiveListener listener);
        public abstract void UnregisterListeners(IQuestObjectiveListener listener);
        /// <summary>根据当前进度判断目标是否完成。</summary>
        public abstract bool EvaluateCompletion(QuestObjectiveState state);
    }

    [CreateAssetMenu(menuName = "Quest/Objective/TalkToNPC")]
    public class TalkToNPCObjective : QuestObjectiveSO
    {
        public string targetNpcId;
        public override void RegisterListeners(IQuestObjectiveListener l)   => l.RegisterListeners(this);
        public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
        public override bool EvaluateCompletion(QuestObjectiveState s)      => s.progressCount >= 1;
    }

    [CreateAssetMenu(menuName = "Quest/Objective/Defeat")]
    public class DefeatEnemyObjective : QuestObjectiveSO
    {
        public string targetEnemyId;
        [Min(1)] public int defeatCount = 1;
        public override void RegisterListeners(IQuestObjectiveListener l)   => l.RegisterListeners(this);
        public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
        public override bool EvaluateCompletion(QuestObjectiveState s)      => s.progressCount >= defeatCount;
    }

    [CreateAssetMenu(menuName = "Quest/Objective/Collect")]
    public class CollectItemObjective : QuestObjectiveSO
    {
        public string itemId;
        [Min(1)] public int collectCount = 1;
        public override void RegisterListeners(IQuestObjectiveListener l)   => l.RegisterListeners(this);
        public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
        public override bool EvaluateCompletion(QuestObjectiveState s)      => s.progressCount >= collectCount;
    }

    [CreateAssetMenu(menuName = "Quest/Objective/Reach")]
    public class ReachAreaObjective : QuestObjectiveSO
    {
        public string sceneName;
        public string markerTag;
        public override void RegisterListeners(IQuestObjectiveListener l)   => l.RegisterListeners(this);
        public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
        public override bool EvaluateCompletion(QuestObjectiveState s)      => s.progressCount >= 1;
    }

    [CreateAssetMenu(menuName = "Quest/Objective/UseSkill")]
    public class UseSkillObjective : QuestObjectiveSO
    {
        public AbilityType requiredAbility;
        [Min(1)] public int useCount = 1;
        public override void RegisterListeners(IQuestObjectiveListener l)   => l.RegisterListeners(this);
        public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
        public override bool EvaluateCompletion(QuestObjectiveState s)      => s.progressCount >= useCount;
    }
}
// Assets/Scripts/Quest/RewardSO.cs
// ⚠️ 用 unlocksAbility bool + unlockedAbility 字段替代 AbilityType.None架构 09 §1 无 None 值)
namespace BaseGames.Quest
{
    [CreateAssetMenu(menuName = "Quest/Reward")]
    public class RewardSO : ScriptableObject
    {
        public int         geo;
        public int         soulBonus;
        public string[]    itemIds;
        public int         affinityBonus;
        public string      unlockDialogueKey;
        public bool        unlocksAbility = false;   // ⚠️ 替代 AbilityType.None架构 09 §1
        public AbilityType unlockedAbility;          // 仅 unlocksAbility == true 时有效

        public void Apply(PlayerStats player)
        {
            if (geo       > 0) player.AddGeo(geo);
            if (soulBonus > 0) player.ExtendSoulMax(soulBonus);
            if (unlocksAbility) player.UnlockAbility(unlockedAbility);
            // ⚠️ InventoryManager 不在架构中Architecture 总览无此类)
            // 物品发放通过 EVT_CollectiblePickup 事件频道路由itemId string
            // 实际接线在 QuestManager.CompleteQuest 调用处注入 StringEventChannelSO
            foreach (var id in itemIds)
                Debug.Log($"[Reward] Grant item: {id}");   // 占位 → Phase 实际实现时替换为事件频道
        }
    }
}
// Assets/Scripts/Quest/ChallengeRoomSO.cs
namespace BaseGames.Challenge
{
    [CreateAssetMenu(menuName = "Challenge/ChallengeRoom")]
    public class ChallengeRoomSO : ScriptableObject
    {
        [Header("标识")]
        public string           challengeId;
        public string           displayName;
        public ChallengeType    challengeType;

        [Header("波次(非 BossRush")]
        public ChallengeEncounterSO[] encounters;

        [Header("Boss Rush")]
        public BossRushSequenceSO bossRushSequence;

        [Header("限制")]
        public float timeLimit;         // 0 = 无时限
        public bool  requireNoHit;
        public int   minComboRequired;

        [Header("奖励")]
        public RewardSO firstClearReward;
        public RewardSO repeatedReward;

        [Header("解锁条件")]
        public string[] prerequisiteBossIds;
    }

    public enum ChallengeType { Survival, TimeTrial, BossRush, NoHit }
}
// Assets/Scripts/Quest/ChallengeEncounterSO.cs
namespace BaseGames.Challenge
{
    [CreateAssetMenu(menuName = "Challenge/Encounter")]
    public class ChallengeEncounterSO : ScriptableObject
    {
        [Serializable]
        public struct SpawnEntry
        {
            public string    enemyAddressKey;
            public Transform spawnPoint;
            public int       count;
        }

        public SpawnEntry[] enemies;
        public float        waveDelay;
    }
}
// Assets/Scripts/Quest/BossRushSequenceSO.cs
namespace BaseGames.Challenge
{
    [CreateAssetMenu(menuName = "Challenge/BossRushSequence")]
    public class BossRushSequenceSO : ScriptableObject
    {
        [Serializable]
        public struct BossEntry
        {
            public string bossSceneName;
            public string bossId;
            public float  hpRestoreRatio;   // 击败本 Boss 后玩家恢复 HP 比例(默认 0.3
        }

        public BossEntry[] bosses;
    }
}
// Assets/Scripts/Quest/ChallengeRoomTrigger.cs
// ⚠️ 通过 EVT_SceneLoadRequest 频道触发加载SceneLoader 无 Instance架构 03 §3
namespace BaseGames.Challenge
{
    [RequireComponent(typeof(Collider2D))]
    public class ChallengeRoomTrigger : MonoBehaviour, IInteractable
    {
        [SerializeField] ChallengeRoomSO                _challengeData;
        [SerializeField] string                         _challengeSceneName;
        [SerializeField] SceneLoadRequestEventChannelSO _onSceneLoadRequest;  // EVT_SceneLoadRequest

        public string InteractPrompt => $"进入挑战:{_challengeData.displayName}";
        public bool   CanInteract    => IsUnlocked();

        public void Interact(Transform player)
        {
            if (!IsUnlocked()) return;
            _onSceneLoadRequest.Raise(new SceneLoadRequest
            {
                SceneName         = _challengeSceneName,
                EntryTransitionId = string.Empty,
                ShowLoadingScreen = false,
                IsRespawn         = false,
            });
        }

        public void OnPlayerEnterRange(Transform player) { }
        public void OnPlayerExitRange() { }

        bool IsUnlocked()
        {
            foreach (var bossId in _challengeData.prerequisiteBossIds)
                if (!SaveManager.Instance.IsBossDefeated(bossId)) return false;  // ⚠️ 架构 12 §8 确认存在 IsBossDefeated(bossId)
            return true;
        }
    }
}

5.1 QuestManager

// Assets/Scripts/Quest/QuestManager.cs
// ⚠️ 事件频道已分拆(架构 22 §5替代旧 QuestStateChangedEventChannel 单频道
namespace BaseGames.Quest
{
    /// <summary>
    /// 运行时任务管理器,挂在 Persistent 场景 [GameManagers] 下。
    /// 通过事件频道追踪目标进度,不主动轮询。
    /// </summary>
    public class QuestManager : MonoBehaviour
    {
        // ── Inspector ────────────────────────────────────────
        [SerializeField] QuestSO[]                      _allQuests;
        [SerializeField] TransformEventChannelSO        _onEnemyDied;            // EVT_EnemyDied
        [SerializeField] StringEventChannelSO           _onCollectiblePickup;    // EVT_CollectiblePickupitemId
        [SerializeField] StringEventChannelSO           _onSceneLoaded;          // EVT_SceneLoadedsceneName
        [SerializeField] StringEventChannelSO           _onNpcDialogueCompleted; // EVT_NpcDialogueCompletednpcId

        // ⚠️ 分拆为粒度更细的事件频道(架构 22 §5替代旧 _onQuestStateChanged 单频道)
        [SerializeField] StringEventChannelSO           _onQuestStarted;         // RaisequestId
        [SerializeField] StringEventChannelSO           _onQuestCompleted;       // RaisequestId
        [SerializeField] StringEventChannelSO           _onQuestFailed;          // RaisequestId
        [SerializeField] QuestObjectiveEventChannelSO   _onObjectiveUpdated;     // RaiseobjectiveId + progress

        // ── Runtime State ────────────────────────────────────
        readonly Dictionary<string, QuestState>          _questStates     = new();
        readonly Dictionary<string, QuestObjectiveState> _objectiveStates = new(); // ⚠️ 替代旧 _objectiveProgress: Dictionary<string,int>

        public static QuestManager Instance { get; private set; }

        // ⚠️ 公开属性供 QuestGiver / QuestLogUI 订阅(架构 22 §5
        public StringEventChannelSO OnQuestStarted   => _onQuestStarted;
        public StringEventChannelSO OnQuestCompleted => _onQuestCompleted;

        void Awake() => Instance = this;

        void OnEnable()
        {
            _onEnemyDied.OnEventRaised             += HandleEnemyDefeated;
            _onCollectiblePickup.OnEventRaised     += HandleItemCollected;
            _onSceneLoaded.OnEventRaised           += HandleSceneLoaded;
            _onNpcDialogueCompleted.OnEventRaised  += HandleNpcDialogue;
        }

        void OnDisable()
        {
            _onEnemyDied.OnEventRaised             -= HandleEnemyDefeated;
            _onCollectiblePickup.OnEventRaised     -= HandleItemCollected;
            _onSceneLoaded.OnEventRaised           -= HandleSceneLoaded;
            _onNpcDialogueCompleted.OnEventRaised  -= HandleNpcDialogue;
        }

        // ── 公共 API ──────────────────────────────────────────

        public void AcceptQuest(string questId)
        {
            if (!CanAccept(questId)) return;
            _questStates[questId] = QuestState.Active;
            _onQuestStarted.Raise(questId);  // ⚠️ 独立频道(架构 22 §5非 QuestStateChangedEvent
        }

        public void CompleteQuest(string questId, PlayerStats player)
        {
            if (!IsReadyToComplete(questId)) return;
            var quest = GetQuestSO(questId);
            quest.reward?.Apply(player);
            _questStates[questId] = QuestState.Completed;
            _onQuestCompleted.Raise(questId);  // ⚠️ 独立频道(架构 22 §5

            // 解锁后续任务
            foreach (var branch in quest.branches)
            {
                if (string.IsNullOrEmpty(branch.conditionQuestId) ||
                    GetState(branch.conditionQuestId) == QuestState.Completed)
                {
                    if (branch.nextQuest != null)
                        _questStates[branch.nextQuest.questId] = QuestState.Available;
                    break;
                }
            }
        }

        public QuestState GetState(string questId)
            => _questStates.TryGetValue(questId, out var s) ? s : QuestState.Unavailable;

        public bool IsReadyToComplete(string questId)
        {
            var quest = GetQuestSO(questId);
            if (quest == null || GetState(questId) != QuestState.Active) return false;
            foreach (var obj in quest.objectives)
            {
                if (!obj.IsOptional && !IsObjectiveComplete(obj)) return false;
            }
            return true;
        }

        // ── 存档(非 ISaveable由 SaveManager 直接访问) ────────────

        public IReadOnlyDictionary<string, QuestState> QuestStates => _questStates;

        public void LoadFromSaveData(QuestSaveData data)
        {
            _questStates.Clear();
            _objectiveStates.Clear();
            foreach (var (id, stateInt) in data.QuestStates)
                _questStates[id] = (QuestState)stateInt;
            foreach (var (id, progress) in data.ObjectiveProgress)
                _objectiveStates[id] = new QuestObjectiveState { progressCount = progress };
        }

        // ── 私有 ─────────────────────────────────────────────

        bool CanAccept(string questId)
        {
            if (GetState(questId) != QuestState.Available) return false;
            var quest = GetQuestSO(questId);
            foreach (var pre in quest.prerequisiteQuestIds)
                if (GetState(pre) != QuestState.Completed) return false;
            return true;
        }

        bool IsObjectiveComplete(QuestObjectiveSO obj)
        {
            _objectiveStates.TryGetValue(obj.objectiveId, out var s);
            s ??= new QuestObjectiveState();
            return obj.EvaluateCompletion(s);  // ⚠️ 多态调用(架构 22 §3替代旧 ObjectiveType switch
        }

        void HandleEnemyDefeated(Transform enemyTransform)
        {
            var enemyBase = enemyTransform.GetComponent<EnemyBase>();
            if (enemyBase == null) return;
            // ⚠️ EnemyId 在 EnemyStatsSOArchitecture 07 §6不在 EnemyBase 上;
            //    EnemyBase 需暴露 public string EnemyId => _statsSO?.EnemyId; 便捷属性
            string enemyId = enemyBase.EnemyId;
            foreach (var (qid, state) in _questStates)
            {
                if (state != QuestState.Active) continue;
                var quest = GetQuestSO(qid);
                foreach (var obj in quest.objectives)
                {
                    if (obj is DefeatEnemyObjective def && def.targetEnemyId == enemyId)
                        IncrementProgress(obj.objectiveId);  // ⚠️ 用 is 模式匹配替代旧 ObjectiveType 枚举
                }
            }
        }

        void HandleItemCollected(string itemId)  { /* 同上,匹配 CollectItemObjective */ }
        void HandleNpcDialogue(string npcId)     { /* 同上,匹配 TalkToNPCObjective */ }
        void HandleSceneLoaded(string sceneName) { /* 同上,匹配 ReachAreaObjective */ }

        void IncrementProgress(string objectiveId)
        {
            if (!_objectiveStates.TryGetValue(objectiveId, out var s))
                s = _objectiveStates[objectiveId] = new QuestObjectiveState();
            s.progressCount++;
            _onObjectiveUpdated.Raise(new QuestObjectiveEvent { ObjectiveId = objectiveId, Progress = s.progressCount });
        }

        QuestSO GetQuestSO(string id) => System.Array.Find(_allQuests, q => q.questId == id);
    }

    public enum QuestState { Unavailable, Available, Active, Completed, Failed }

    /// <summary>记录单个目标的运行时进度(架构 22 §5。</summary>
    public class QuestObjectiveState
    {
        public bool completed     = false;
        public int  progressCount = 0;
    }
}

5.2 ChallengeRoomManager

// Assets/Scripts/Quest/ChallengeRoomManager.cs
// ⚠️ 字段名、方法名、事件频道与架构 22_QuestChallengeModule §12 完全对齐
namespace BaseGames.Challenge
{
public class ChallengeRoomManager : MonoBehaviour
{
    [SerializeField] ChallengeRoomSO          _challengeData;          // ⚠️ _challengeData非 _config
    [SerializeField] StringEventChannelSO     _onChallengeCompleted;   // → EVT_ChallengeCompletedchallengeId
    [SerializeField] StringEventChannelSO     _onChallengeFailed;      // → EVT_ChallengeFailedchallengeId
    // ⚠️ PlayerController 无 InstanceArchitecture 05 §2挑战房间场景持有玩家引用
    [SerializeField] PlayerController         _player;

    int   _currentEncounterIndex;
    int   _remainingEnemies;    // ⚠️ _remainingEnemies非 _aliveEnemyCount
    float _elapsedTime;         // 超时检测用
    bool  _isRunning;
    bool  _noHitViolated;

    void Start() => StartChallenge();

    void Update()
    {
        if (!_isRunning) return;
        _elapsedTime += Time.deltaTime;
        // 超时失败
        if (_challengeData.timeLimit > 0 && _elapsedTime >= _challengeData.timeLimit)
            FailChallenge();
    }

    void StartChallenge()
    {
        SaveManager.Instance.QuickSave();  // ⚠️ 架构 12 §8 确认存在 QuickSave()(专用快速存档槽)
        _isRunning             = true;
        _currentEncounterIndex = 0;
        SpawnWave(_currentEncounterIndex);  // ⚠️ SpawnWave非 StartNextEncounter
    }

    void SpawnWave(int index)               // ⚠️ 方法名 SpawnWave(int index)
    {
        var enc = _challengeData.encounters[index];
        _remainingEnemies = 0;
        foreach (var entry in enc.enemies)
        {
            for (int i = 0; i < entry.count; i++)
            {
                _remainingEnemies++;
                Addressables.InstantiateAsync(entry.enemyAddressKey, entry.spawnPoint.position, Quaternion.identity)
                    .Completed += handle =>
                    {
                        if (handle.Result.TryGetComponent<EnemyBase>(out var enemy))
                            enemy.OnDied += OnEnemyDefeated;
                    };
            }
        }
    }

    void OnEnemyDefeated()                  // ⚠️ OnEnemyDefeated(),无参数(非 OnEnemyDied(Transform)
    {
        _remainingEnemies--;
        if (_remainingEnemies > 0) return;

        _currentEncounterIndex++;
        if (_currentEncounterIndex >= _challengeData.encounters.Length)
            CompleteChallenge();
        else
            StartCoroutine(DelayedNextWave(_challengeData.encounters[_currentEncounterIndex].waveDelay));
    }

    IEnumerator DelayedNextWave(float delay)
    {
        yield return new WaitForSeconds(delay);
        SpawnWave(_currentEncounterIndex);
    }

    void CompleteChallenge()
    {
        _isRunning = false;
        // ⚠️ 架构 12 §8 确认存在 IsFirstClear(challengeId)
        var reward = SaveManager.Instance.IsFirstClear(_challengeData.challengeId)
                     ? _challengeData.firstClearReward
                     : _challengeData.repeatedReward;
        reward?.Apply(_player.Stats);
        _onChallengeCompleted.Raise(_challengeData.challengeId);
    }

    void FailChallenge()
    {
        _isRunning = false;
        _onChallengeFailed.Raise(_challengeData.challengeId);
        SaveManager.Instance.QuickLoad();  // ⚠️ 架构 12 §8 确认存在 QuickLoad()(读回快速存档槽)
    }
}
} // namespace BaseGames.Challenge

5.3 QuestGiver

// Assets/Scripts/Quest/QuestGiver.cs
// ⚠️ 继承 InteractableNPC架构 22 §6不用 [RequireComponent]+MonoBehaviour 组合方式
namespace BaseGames.Quest
{
// 继承 InteractableNPC负责发布/完成任务并根据任务状态切换对话
public class QuestGiver : InteractableNPC
{
    [Header("任务")]
    [SerializeField] QuestSO[] _offeredQuests;   // ⚠️ QuestSO 数组(架构 22 §6非单个 string _questId

    [Header("对话版本(根据任务状态切换)")]
    [SerializeField] DialogueSequenceSO _availableDialogue;   // ⚠️ DialogueSequenceSO 引用(非 string key
    [SerializeField] DialogueSequenceSO _activeDialogue;
    [SerializeField] DialogueSequenceSO _readyDialogue;
    [SerializeField] DialogueSequenceSO _completedDialogue;

    // ⚠️ 不需要 OnEnable/OnDisable 订阅 QuestManager.OnQuestStateChanged该频道已废弃
    // 对话切换通过 override GetCurrentDialogue() 在每次 Interact 时动态获取

    // ── Interact_Internal 覆盖(在启动对话前处理任务逻辑)────────
    protected override void Interact_Internal(Transform player)  // ⚠️ override Interact_Internal架构 22 §6
    {
        var quest = GetCurrentQuest();
        if (quest == null) return;

        var state = QuestManager.Instance.GetState(quest.questId);
        if (state == QuestState.Available)
            QuestManager.Instance.AcceptQuest(quest.questId);
        else if (QuestManager.Instance.IsReadyToComplete(quest.questId))
        {
            // ⚠️ PlayerController 无 Instance通过 player 参数获取
            QuestManager.Instance.CompleteQuest(quest.questId,
                player.GetComponent<PlayerController>()?.Stats);
        }
    }

    // ── 返回与当前最高优先级任务状态匹配的对话 SO ──────────────────
    protected override DialogueSequenceSO GetCurrentDialogue()  // ⚠️ override架构 22 §6
    {
        var quest = GetCurrentQuest();
        if (quest == null) return base.GetCurrentDialogue();

        return QuestManager.Instance.GetState(quest.questId) switch
        {
            QuestState.Available => _availableDialogue,
            QuestState.Active    => QuestManager.Instance.IsReadyToComplete(quest.questId)
                                        ? _readyDialogue : _activeDialogue,
            QuestState.Completed => _completedDialogue,
            _                    => base.GetCurrentDialogue(),
        };
    }

    // 返回当前处于 Available 或 Active 状态的第一个任务
    QuestSO GetCurrentQuest()
    {
        if (_offeredQuests == null) return null;
        foreach (var q in _offeredQuests)
        {
            var s = QuestManager.Instance.GetState(q.questId);
            if (s == QuestState.Available || s == QuestState.Active) return q;
        }
        return null;
    }
}
} // namespace BaseGames.Quest

6. Week 14地图/商店/存档迁移 完成P3-52026-05-11

参考文档15_MapShopModule.md

6.0 Map/Shop 数据层 SO

// Assets/Scripts/World/Map/MapRoomDataSO.cs
[CreateAssetMenu(menuName = "World/Map/RoomData")]
public class MapRoomDataSO : ScriptableObject
{
    [Header("基础信息")]
    public string     RoomId;
    public string     RegionId;
    public string     DisplayName;

    [Header("地图布局(格子坐标,单位:格)")]
    public Vector2Int GridPosition;
    public Vector2Int GridSize;

    [Header("房间轮廓纹理")]
    public Texture2D  RoomOutlineTex;  // ⚠️ 用于地图 UI 显示房间形状(架构 15 §1.1null = 回退到矩形格子)

    [Header("出口信息")]
    public RoomExitData[] Exits;       // ⚠️ 所有出口定义(架构 15 §1.1

    [Header("特殊标记")]
    public bool IsBossRoom;
    public bool IsSavePoint;
    public bool IsShop;
    public Sprite MapIconOverride;     // ⚠️ null = 按 isXxx 自动选择图标(架构 15 §1.1
}

// ⚠️ 出口数据(架构 15_MapShopModule §1.1
[Serializable]
public struct RoomExitData
{
    public string      TargetRoomId;    // 连接的目标房间 ID
    public Vector2Int  ExitGridPos;     // 出口在格子地图上的位置
    public ExitDirection Direction;     // 出口方向
}

public enum ExitDirection { Up, Down, Left, Right }  // ⚠️ 架构 15 §1.1

// Assets/Scripts/World/Map/MapDatabaseSO.cs
[CreateAssetMenu(menuName = "World/Map/MapDatabase")]
public class MapDatabaseSO : ScriptableObject
{
    public MapRoomDataSO[] AllRooms;

    private Dictionary<string, MapRoomDataSO> _index;
    public MapRoomDataSO GetRoom(string roomId)
    {
        if (_index == null)
            _index = AllRooms.ToDictionary(r => r.RoomId);
        _index.TryGetValue(roomId, out var r);
        return r;
    }
}

6.1 MapModuleFog of War

// Assets/Scripts/World/Map/MapManager.cs
// ⚠️ 类名为 MapManager以架构 15_MapShopModule §1 为准(非 MapController
[DefaultExecutionOrder(-700)]
public class MapManager : MonoBehaviour, ISaveable
{
    [SerializeField] private MapDatabaseSO        _database;       // ⚠️ MapDatabaseSO非 MapDataSO

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onRoomEntered;  // ⚠️ 订阅此频道(非 EVT_SceneLoaded房间进入由 RoomTransition 专用频道广播)
    [SerializeField] private StringEventChannelSO _onMapUpdated;   // 发布:房间发现时刷新地图

    // ⚠️ 必须有 Instance Singleton架构 15_MapShopModule §1.2MapPanel.BuildGrid 依赖此字段)
    public static MapManager Instance { get; private set; }

    // ⚠️ 三级可见性(架构 15 §1.2
    //   Unknown  → 未进入过(默认)
    //   Explored → 进入过但未购买地图(显示轮廓/格子)
    //   Mapped   → 已完整获取地图信息(显示图标/名称)
    private HashSet<string> _exploredRooms = new();  // ⚠️ 玩家踏入过(非 _discoveredRooms
    private HashSet<string> _mappedRooms   = new();  // ⚠️ 完整地图信息(购买 MapFragment 或存档点揭示)

    private void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
    }

    // ── 事件订阅 ────────────────────────────────────────────────────
    private void OnEnable()  => _onRoomEntered.OnEventRaised += OnRoomEntered;
    private void OnDisable() => _onRoomEntered.OnEventRaised -= OnRoomEntered;

    private void OnRoomEntered(string roomId)  // ⚠️ private非 public MarkDiscovered
    {
        bool changed = _exploredRooms.Add(roomId);
        if (changed) _onMapUpdated.Raise(roomId);    // 通知 MapPanel 刷新
    }

    /// <summary>标记为已完整获取地图信息(购买 MapFragment SO 触发)。⚠️ 架构 15 §1.2</summary>
    public void SetMapped(string roomId)
    {
        _exploredRooms.Add(roomId);
        if (_mappedRooms.Add(roomId))
            _onMapUpdated.Raise(roomId);
    }

    // UI 查询
    public bool IsExplored(string roomId)  => _exploredRooms.Contains(roomId);  // ⚠️ 架构 15 §1.2
    public bool IsMapped(string roomId)    => _mappedRooms.Contains(roomId);    // ⚠️ 架构 15 §1.2
    public bool IsDiscovered(string roomId) => _exploredRooms.Contains(roomId); // 向后兼容别名

    // ── ISaveable ─────────────────────────────────────────────────────
    // ⚠️ 存储 ExploredRooms + MappedRooms 两个字段List<string>),架构 15 §1.2 + §3
    public void OnSave(SaveData data)
    {
        data.Map.ExploredRooms = _exploredRooms.ToList();
        data.Map.MappedRooms   = _mappedRooms.ToList();
    }

    public void OnLoad(SaveData data)
    {
        _exploredRooms = new HashSet<string>(data.Map.ExploredRooms ?? new List<string>());
        _mappedRooms   = new HashSet<string>(data.Map.MappedRooms   ?? new List<string>());
    }
}

地图 UI 通过 MapPanel.csArchitecture §1.3)渲染:

// Assets/Scripts/World/Map/MapPanel.cs
// 全屏地图 UI由 UIManager PanelStack 管理
public class MapPanel : MonoBehaviour
{
    [SerializeField] private MapDatabaseSO    _database;
    [SerializeField] private RectTransform    _roomContainer;      // 格子图放置根节点
    [SerializeField] private MapRoomCellUI    _cellPrefab;

    [Header("图标 Sprites")]
    [SerializeField] private Sprite _iconSavePoint;
    [SerializeField] private Sprite _iconBossRoom;
    [SerializeField] private Sprite _iconShop;
    [SerializeField] private Sprite _iconPlayerPos;

    [Header("颜色")]
    [SerializeField] private Color _colorDiscovered   = Color.white;
    [SerializeField] private Color _colorUndiscovered = Color.black;

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onMapUpdated;   // 房间发现时刷新

    private Dictionary<string, MapRoomCellUI> _cells = new();

    private void OnEnable()
    {
        BuildGrid();
        _onMapUpdated.OnEventRaised += OnMapUpdated;
    }

    private void OnDisable()
        => _onMapUpdated.OnEventRaised -= OnMapUpdated;

    private void BuildGrid()
    {
        foreach (var room in _database.AllRooms)
        {
            var cell = Instantiate(_cellPrefab, _roomContainer);
            cell.Setup(room, MapManager.Instance.IsDiscovered(room.RoomId));
            _cells[room.RoomId] = cell;
        }
    }

    private void OnMapUpdated(string roomId)
    {
        if (_cells.TryGetValue(roomId, out var cell))
            cell.SetDiscovered(true);
    }
}

// 单个地图格子 UI 组件
public class MapRoomCellUI : MonoBehaviour
{
    [SerializeField] private Image _bg;
    [SerializeField] private Image _icon;

    public void Setup(MapRoomDataSO room, bool discovered) { /* 设置 grid 位置+颜色 */ }
    public void SetDiscovered(bool v) => _bg.color = v ? Color.white : Color.black;
}

6.1.1 MapPlayerTracker

⚠️ 架构 15_MapShopModule §1.4 要求:将玩家世界坐标转换为地图格子坐标,供 MapPanel 显示玩家位置图标。

// Assets/Scripts/World/Map/MapPlayerTracker.cs
public class MapPlayerTracker : MonoBehaviour
{
    [SerializeField] private Transform     _playerTransform;
    [SerializeField] private MapDatabaseSO _database;
    [SerializeField] private MapManager   _mapManager;

    [Header("世界坐标 → 格子坐标换算参数")]
    [SerializeField] private float         _worldUnitsPerCell = 18f;  // 1 格 = N 世界单位

    /// <summary>返回玩家当前所在房间 ID用于地图高亮当前房间。</summary>
    public string CurrentRoomId { get; private set; }

    /// <summary>玩家在当前格子房间内的归一化坐标0~1。</summary>
    public Vector2 NormalizedPositionInRoom { get; private set; }

    private void LateUpdate()
    {
        if (_playerTransform == null) return;
        Vector2 worldPos = _playerTransform.position;
        Vector2Int cellPos = WorldToCell(worldPos);

        foreach (var room in _database.AllRooms)
        {
            var rect = new RectInt(room.GridPosition, room.GridSize);
            if (rect.Contains(cellPos))
            {
                CurrentRoomId = room.RoomId;
                Vector2 inRoom = (Vector2)(cellPos - room.GridPosition);
                NormalizedPositionInRoom = new Vector2(
                    inRoom.x / room.GridSize.x,
                    inRoom.y / room.GridSize.y);
                return;
            }
        }
    }

    private Vector2Int WorldToCell(Vector2 worldPos)
        => new Vector2Int(
            Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell),
            Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
}

6.1.2 MapPin 系统

⚠️ 架构 15_MapShopModule §1.5 要求:玩家可在地图上放置自定义标记,通过 MapPinManagerISaveable)持久化。

// Assets/Scripts/World/Map/MapPin.cs
[Serializable]
public class MapPin
{
    public string   RoomId;           // 所在房间 ID
    public Vector2  NormalizedPos;    // 房间内归一化位置0~1
    public PinType  Type;
    public string   Note;             // 玩家文字备注(最多 64 字符)
}

public enum PinType
{
    Marker,   // 通用标记
    Chest,    // 宝箱/收藏品
    Enemy,    // 危险/敌人
    Path,     // 路径指引
    Note,     // 笔记
}

// Assets/Scripts/World/Map/MapPinManager.cs
// ⚠️ MapPinManager 实现 ISaveable存档路径 data.Map.Pins架构 15 §1.5
public class MapPinManager : MonoBehaviour, ISaveable
{
    private List<MapPin> _pins = new();

    public IReadOnlyList<MapPin> Pins => _pins;

    public void AddPin(MapPin pin)    => _pins.Add(pin);
    public void RemovePin(MapPin pin) => _pins.Remove(pin);

    public void OnSave(SaveData data) => data.Map.Pins = _pins;
    public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List<MapPin>();
}

6.2 Shop 数据层 SO

// Assets/Scripts/World/Shop/ShopItemSO.cs
[CreateAssetMenu(menuName = "World/Shop/ShopItem")]
public class ShopItemSO : ScriptableObject
{
    [Header("标识")]
    public string    ItemId;
    public string    DisplayName;
    [TextArea(2, 5)]
    public string    Description;
    public Sprite    Icon;

    [Header("价格")]
    public int       BasePrice;
    public bool      IsUnique;

    [Header("商品类型")]
    public ShopItemType ItemType;

    public int       HealthRestoreAmount;
    public CharmSO   CharmReference;
    public string    KeyItemId;
    public int       MaxPurchaseCount = -1;
}

public enum ShopItemType { HealthRestoration, CharmItem, KeyItem, ConsumableBuff, MapFragment }

// Assets/Scripts/World/Shop/ShopInventorySO.cs
[CreateAssetMenu(menuName = "World/Shop/ShopInventory")]
public class ShopInventorySO : ScriptableObject
{
    public string           ShopId;
    public List<ShopItemSO> DefaultInventory;
    public int              MaxDisplaySlots = 6;   // ⚠️ UI 最多同时显示的商品格数(架构 15 §2.2
    public RestockPolicy    RestockPolicy = RestockPolicy.Never;  // ⚠️ 补货策略(架构 15 §2.2
    public Sprite           KeeperPortrait;
    public string           KeeperName;
}

/// <summary>⚠️ 库存补货时机策略(架构 15_MapShopModule §2.2</summary>
public enum RestockPolicy
{
    Never,         // 永不补货(唯一商品卖完即消失)
    OnSavePoint,   // 激活存档点时补货
    OnBossDefeat,  // 击败 Boss 后补货
    Periodic,      // 周期性补货
}

6.2 ShopController

// Assets/Scripts/World/Shop/ShopController.cs
// 挂在 NPC 商人上(⚠️ 以架构 15_MapShopModule §2.3 为准)
public class ShopController : MonoBehaviour, ISaveable
{
    [SerializeField] private ShopInventorySO _inventory;
    [SerializeField] private ShopPanel       _shopPanel;

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO           _onShopOpen;           // Raise 商店开启
    [SerializeField] private ShopPurchaseEventChannelSO     _onItemPurchased;      // ⚠️ ShopPurchaseEventChannelSO架构 15 §2.3,非 ShopTransactionEventChannelSO
    [SerializeField] private StringEventChannelSO           _onBossDefeated;       // ⚠️ 订阅 → OnBossDefeat 时补货(架构 15 §2.3
    [SerializeField] private VoidEventChannelSO             _onSavePointActivated; // ⚠️ 订阅 → OnSavePoint 时补货(架构 15 §2.3

    // key = itemIdvalue = 已购次数
    private Dictionary<string, int>  _purchaseCounts = new();
    private HashSet<string>          _soldUniqueItems = new();

    // ⚠️ OnEnable/OnDisable 按 RestockPolicy 订阅补货事件(架构 15 §2.3
    private void OnEnable()
    {
        if (_inventory.RestockPolicy == RestockPolicy.OnBossDefeat && _onBossDefeated != null)
            _onBossDefeated.OnEventRaised += _ => Restock();
        if (_inventory.RestockPolicy == RestockPolicy.OnSavePoint && _onSavePointActivated != null)
            _onSavePointActivated.OnEventRaised += Restock;
    }

    private void OnDisable()
    {
        if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= _ => Restock();
        if (_onSavePointActivated != null) _onSavePointActivated.OnEventRaised -= Restock;
    }

    public void Open()
    {
        _shopPanel.Show(GetAvailableItems(), this);
        _onShopOpen.Raise(_inventory.ShopId);
    }
    public void Close() => _shopPanel.Hide();

    // 过滤商品:移除已售尽的唯一品 / 超出最大购买次数的商品
    public List<ShopItemSO> GetAvailableItems()
    {
        return _inventory.DefaultInventory
            .Take(_inventory.MaxDisplaySlots)
            .Where(item =>
                !_soldUniqueItems.Contains(item.ItemId) &&
                (item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
            .ToList();
    }

    // ⚠️ 按 RestockPolicy 补货:重置非唯一商品的购买次数(架构 15 §2.3
    public void Restock()
    {
        var nonUniqueIds = _inventory.DefaultInventory
            .Where(i => !i.IsUnique)
            .Select(i => i.ItemId);
        foreach (var id in nonUniqueIds)
            _purchaseCounts.Remove(id);
    }

    // 由 ShopPanel 购买按钮调用:所有购买动作通过 _onItemPurchased 事件频道路由
    public bool TryPurchase(ShopItemSO item, int playerGeo)
    {
        if (playerGeo < item.BasePrice) return false;
        if (_soldUniqueItems.Contains(item.ItemId)) return false;

        // ⚠️ 扣 GeoShopPurchaseEvent { Item, Price }(架构 15 §2.3,非 ShopTransactionEvent
        _onItemPurchased.Raise(new ShopPurchaseEvent { Item = item, Price = item.BasePrice });

        // 更新库存
        _purchaseCounts[item.ItemId] = GetPurchaseCount(item.ItemId) + 1;
        if (item.IsUnique) _soldUniqueItems.Add(item.ItemId);
        return true;
    }

    // ⚠️ 难度价格倍率(架构 19_DifficultyModule §5
    public int GetPrice(ShopItemSO item)
    {
        var scaler = DifficultyManager.Instance.CurrentScaler;
        return Mathf.RoundToInt(item.BasePrice * scaler.ShopPriceMultiplier);
    }

    private int GetPurchaseCount(string id)
        => _purchaseCounts.TryGetValue(id, out var c) ? c : 0;

    // ── ISaveabledata.Shops.ShopRecordskey=ShopId架构 15 §2.3 + §3──────
    public void OnSave(SaveData data)
    {
        if (!data.Shops.ShopRecords.ContainsKey(_inventory.ShopId))
            data.Shops.ShopRecords[_inventory.ShopId] = new ShopRecord();

        var record = data.Shops.ShopRecords[_inventory.ShopId];
        record.SoldUniqueItems = _soldUniqueItems.ToList();
        record.PurchaseCounts  = new Dictionary<string, int>(_purchaseCounts);
    }

    public void OnLoad(SaveData data)
    {
        if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record))
        {
            _soldUniqueItems = new HashSet<string>(record.SoldUniqueItems ?? new List<string>());
            _purchaseCounts  = record.PurchaseCounts ?? new Dictionary<string, int>();
        }
    }
}

NPC 商人通过 ShopNPC.csArchitecture §2.4)触发商店:

// Assets/Scripts/World/Shop/ShopNPC.cs
public class ShopNPC : MonoBehaviour, IInteractable
{
    [SerializeField] private ShopController     _shopController;
    [SerializeField] private DialogueSequenceSO _greetDialogue;   // 可选开场白
    [SerializeField] private DialogueManager    _dialogueManager;
    [SerializeField] private VoidEventChannelSO _onDialogueEnded; // 订阅:对话结束后开商店

    public bool   CanInteract    => true;
    public string InteractPrompt => "购物";

    public void Interact(Transform player)
    {
        if (_greetDialogue != null)
        {
            _dialogueManager.StartDialogue(_greetDialogue);
            void OpenAfterDialogue()
            {
                _shopController.Open();
                _onDialogueEnded.OnEventRaised -= OpenAfterDialogue;
            }
            _onDialogueEnded.OnEventRaised += OpenAfterDialogue;
        }
        else
        {
            _shopController.Open();
        }
    }

    public void OnPlayerEnterRange(Transform player) { }
    public void OnPlayerExitRange() { }
}

6.3 存档版本迁移

// Assets/Scripts/Core/Save/SaveMigrator.cs
// ⚠️ 以架构 12_SaveModule §5 为准:单参数 Migrate(SaveData)字符串版本switch/goto 模式
public static class SaveMigrator
{
    private const string CurrentVersion = "2.0";

    public static SaveData Migrate(SaveData data)
    {
        switch (data.Meta.Version)
        {
            case "1.0": data = MigrateFrom1_0(data); goto case "1.1";
            case "1.1": data = MigrateFrom1_1(data); goto case "2.0";
            case "2.0": break; // 当前版本,无需迁移
            default: Debug.LogWarning($"Unknown save version: {data.Meta.Version}"); break;
        }
        data.Meta.Version = CurrentVersion;
        return data;
    }

    private static SaveData MigrateFrom1_0(SaveData d)
    {
        // 防御性 null-check若旧版本缺少整个子结构体先补全对象架构 12_SaveModule §5
        d.Equipment ??= new EquipmentSaveData();
        d.Player    ??= new PlayerSaveData();
        d.World     ??= new WorldSaveData();
        // 1.0 → 1.1:新增 Equipment.UpgradedCharmIds 字段,旧存档初始化为空列表
        d.Equipment.UpgradedCharmIds ??= new List<string>();
        return d;
    }

    private static SaveData MigrateFrom1_1(SaveData d)
    {
        // 防御性 null-check子结构体若为 null 先补全(架构 12_SaveModule §5
        d.Stats ??= new StatsSaveData();
        // 1.1 → 2.0Stats 新增 SkillUseCountsWorld 新增 ChallengeFirstClears
        d.Stats.SkillUseCounts        ??= new Dictionary<string, int>();
        d.World.ChallengeFirstClears  ??= new HashSet<string>();
        // 1.1 → 2.0Player 新增护盾字段ShieldHP = -1 表示满护盾)
        // ⚠️ ShieldHP 为 int值类型默认 0用 -1 作哨兵值表示"未记录"→恢复满护盾
        if (d.Player.ShieldHP == 0 && !d.Player.ShieldIsBroken)
            d.Player.ShieldHP = -1;   // 旧存档没有护盾字段时恢复为满护盾
        return d;
    }
}

LocalFileStorage.LoadAsync⚠️LocalFileSaveStorage,以架构 12_SaveModule 为准)调用 SaveMigrator.Migrate 在反序列化后立即执行。JsonExtensionData 保证旧存档中未知字段不丢失,向前兼容。


7. 完成标准检查清单

Week 10 已完成实现2026-05-10

文件 状态 说明
WorldStateRegistry.cs ScriptableObjectContains/Mark 系列 API + HasFlag/SetFlag + LoadFromSave
InteractableDetector.cs OverlapCircleAll + FindNearest + InputReaderSO.InteractEvent 绑定
PlayerSpawnPoint.cs TransitionId + SpawnPositionGizmo 绿球标记
RoomTransition.cs IInteractable自动触发或按键广播 `scene
RoomController.cs Start 切换 RoomCameraGetSpawnPoint 查询出生点
HazardZone.cs 即死/定值伤害RespawnType 枚举
Collectible.cs Geo/Item/HPOrb 三类PlayerStats 直接调用
DestructibleTile.cs IDamageableCheckDestroyCondition virtual hookStart 读档恢复
DirectionalDestructible.cs AttackSide 枚举 + SourcePosition 方向校验
DirectionalInteractable.cs 三触发模式 + TriggerSide + OneShot 持久化
MagicWall.cs Gizmo-only 标记,穿越靠 Layer Matrix
SoftTerrain.cs Marker 组件(无逻辑)
PhantomInteractable.cs 继承 DirectionalInteractable额外响应 PhantomBody 层
MovingPlatform.cs LinearAB/WayPoints/TriggeredLinear + Passenger SetParent 方案
CrumblePlatform.cs Warning/Crumbling/Gone/Respawn 四态协程 + MMF_Player
FalseWall.cs IDamageableProximity/AttackOnce/AlwaysOpen 三种揭示方式
BaseGames.World.asmdef 新增 Input/Combat/Player/Camera/MoreMountains.Tools 引用

P3-2 补充实现2026-05-12

文件 状态 说明
WorldMarkerEventChannelSO.cs BaseEventChannelSO<WorldMarker> 事件频道;命名空间 BaseGames.World
WorldMarker.cs 场景导航标记点;Activate()/Deactivate() 广播事件频道;WorldMarkerType 枚举Objective/NPC/PointOfInterest/Exit/Secret
BreadcrumbTracker.cs 玩家位置历史追踪;_recordInterval=2f/_maxCrumbs=20/_minMoveDistance=1fGetRecentCrumbs(int)IReadOnlyList<Vector2>oldest→newestClear()
TutorialManager.cs 单例 (Instance);实现 ISaveableShowHint/CompleteHint;进度写入 SaveData.Tutorial(非 PlayerPrefs与架构 12 §1 注解不同)
TutorialHintUI.cs HUD 提示 UIShow(text, duration) + Hide()AutoHideRoutine 协程TMP_Text 标签
ContextualHintTrigger.cs [RequireComponent(Collider2D)]_requiresAbility/_requiredAbility(AbilityType) 条件;调用 LocalizationManager.Get;首次触发后 gameObject.SetActive(false)
SaveData.cs 追加 TutorialSaveData Tutorial = new();新增 TutorialSaveData 类(含 List<string> CompletedHintIds
BaseGames.Tutorial.asmdef 引用 Core.Events/Core.Save/World/Player/Localization
☑ InteractableDetectorOverlapCircleAll 检测最近交互物,驱动 UI 提示显示/隐藏(代码完成)
☑ WorldStateRegistryHashSet 持久化状态LoadFromSave/GetAllFlags 接口完成
☑ RoomTransition + RoomController + PlayerSpawnPoint房间切换框架完成待 SceneLoader 集成)
☑ HazardZone即死/定值伤害(代码完成,待 Unity 内配置 Layer 和 Tag 验证)
☑ CollectibleGeo/Item/HPOrb 拾取(代码完成,待 Unity 内配置 Prefab 验证)
☑ DestructibleTile + DirectionalDestructibleIDamageable + 方向校验(代码完成)
☑ DirectionalInteractable + PhantomInteractable三种触发模式 + WorldStateRegistry 持久化
☑ MagicWall + SoftTerrain标记组件无逻辑
☑ MovingPlatform三种移动模式 + Passenger SetParent 方案(代码完成)
☑ CrumblePlatform四态协程MMF_Player 反馈(代码完成)
☑ FalseWall三种揭示条件 + IDamageable代码完成
□ 场景内端对端验证(待 Unity 编辑器内装配 Prefab 并运行)
□ Console 无 ErrorUnity 编辑器内编译验证)

Week 1114 待实施

□ RoomTransition触发切换 → 淡出 → 加载目标场景 → 玩家在对应 SpawnPoint 出生
□ HazardZone掉入深渊 → 瞬间死亡 → 正常死亡流程
□ DestructibleTileHeavy 攻击命中破碎 + WorldStateRegistry 记录 → 重载场景后仍为破碎状态
□ MovingPlatform玩家站在平台上随平台移动不抖动不穿透
□ CrumblePlatform落上后 0.6s 碎裂3s 后复原
□ LiquidZone进入水域 → SwimState已解锁/ HazardZone 伤害(未解锁)
□ 液态谜题Valve → LiquidPuzzleController 液位上升 → 达目标液位 → 谜题完成事件
□ CharmSO 装备VoidHeart 装备后玩家 MaxHP +2HUD 更新),卸载后恢复
□ 护符凹槽:总 notchCost 超出 maxNotches → 装备被拒绝
□ FormSkillSO切换形态 → 对应技能可用 → 释放消耗 SoulPower
□ QuestManager接任务 → 击杀指定敌人 → 进度推进 → 交任务 → 获得奖励
□ ChallengeRoom进入 → 锁门 → 三波敌人依次生成 → 通关 → 奖励 + 开门
☑ MapPanel探索新房间后地图格子变亮已探索持久化MapManager ISaveable 已实现)
☑ ShopController购买护符 → Geo 减少EVT_ItemPurchased→ 商店标记已售出IsUnique 机制)
□ 存档迁移:旧版本存档文件加载时无报错,缺失字段填充默认值
□ Console 无 Error

Week 14 已完成实现P3-5地图与商店模块

文件 状态 说明
MapRoomDataSO.cs MapRoomDataSO + MapDatabaseSO + RoomExitData + ExitDirection
MapManager.cs ISaveable[DefaultExecutionOrder(-700)];订阅 EVT_RoomEnteredSetMapped
MapPanel.cs MapPanel + MapRoomCellUIOnEnable 重建格子;EVT_MapUpdated 增量刷新
MapPlayerTracker.cs WorldToCell18f/格LateUpdate 找所在房间;NormalizedPositionInRoom
MapPin.cs MapPinManager ISaveableMapPin/PinType 定义在 SaveData.cs 避免循环依赖)
ShopItemSO.cs ShopItemSO + ShopItemType 枚举CharmSO 引用
ShopInventorySO.cs ShopInventorySO + RestockPolicy 枚举
ShopController.cs ISaveableTryPurchaseGetAvailableItemsRestockShopPanel 存根
ShopNPC.cs IInteractableDialogueEventChannelSO 触发招呼对话→打开商店
Editor/Map/MapRoomDataEditor.cs [CustomEditor(typeof(MapRoomDataSO))]Scene 句柄拖拽;居中按钮
SaveData.cs MapSaveDataExploredRooms/MappedRooms(List<string>) + Pins(List<MapPin>)
BaseGames.World.Map.asmdef 新增 BaseGames.Core.Save + BaseGames.Core.Events 引用
BaseGames.World.Shop.asmdef 新增 BaseGames.Core.Save + BaseGames.Equipment + BaseGames.Dialogue 引用
BaseGames.Editor.asmdef 新增 BaseGames.World.Map 引用MapRoomDataEditor 需要)

编辑器工具AddressReferenceGraphWindowAssets/Editor/Assets/AddressReferenceGraphWindow.cs)扫描所有 .cs 文件对 AddressKeys.X 的引用,标红孤儿 key0 引用),标黄单次引用 key标绿 ≥2 次引用 key支持导出 CSV架构 13 §11P3 优化)。

Phase 3 完成后进入 Phase 4。