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

3737 lines
147 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 3 · 世界与进程系统
> **周期**45 周Week 1014
> **前置条件**Phase 2 全部完成标准通过
> **核心目标**:完整的世界互动(房间切换/可破坏物/机关/移动平台)、液态谜题、进程系统(护符/工具/技能树)、任务系统、地图/商店、存档迁移
> **产出物**:能进行多房间探索;护符装备生效;任务系统可完成至少 2 个任务;商店可购买道具;液态谜题可通关
---
## 目录
1. [实施顺序总览](#1-实施顺序总览)
2. [Week 10世界互动基础组件](#2-week-10世界互动基础组件)
3. [Week 11液态谜题模块](#3-week-11液态谜题模块)
4. [Week 12进程模块护符/工具/技能)](#4-week-12进程模块护符工具技能)
5. [Week 13任务与挑战房间](#5-week-13任务与挑战房间)
6. [Week 14地图/商店/存档迁移](#6-week-14地图商店存档迁移)
7. [完成标准检查清单](#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
```csharp
// 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
**RoomTransition**`08_WorldModule.md §2` 实现Phase 1 已有 SavePoint 骨架)。
**RoomController**
```csharp
// 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 + 道具)
```csharp
// 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
```csharp
// 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**(可破坏瓦片):
```csharp
// 实现 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**
```csharp
// 动态移动平台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**(碎裂平台):
```csharp
// ⚠️ 与架构 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**(单向可破坏墙):
```csharp
// 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**(单向触发机关):
```csharp
// 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**
```csharp
// 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` 的区别在于状态可逆性。
```csharp
// 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.RevealedFalseWalls`Design §31 §6 字段:`List<string> RevealedFalseWalls``FalseWall.Start()` 读档检查该列表。
### 2.7 WorldStateRegistry
```csharp
// 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`
```csharp
// 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, // 隐藏区域(解锁后显示)
}
}
```
```csharp
// 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`
```csharp
// 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
}
}
```
```csharp
// 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 液体数据层
```csharp
// 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
```csharp
// 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 §5`05_PlayerModule.md` §12 第 18 个状态):
```csharp
// 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
```csharp
// 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§11`Part B · 谜题架构)
> **命名空间**`BaseGames.Puzzle`
**核心接口**`namespace BaseGames.Puzzle`,对应 Architecture 21 §8
```csharp
// 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**(通用开关):
```csharp
// 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**(接收器 + 门子类):
```csharp
// 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**(逻辑连接器):
```csharp
// 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 一并集成。
```csharp
// 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
```csharp
// 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]` 上。
```csharp
// 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_DrownProgress``FloatEventChannelSO``WaterDangerState``HUDController`(溺水进度条)
- `EVT_PlayerDrowned``VoidEventChannelSO``WaterDangerState``GameManager`(触发死亡流程)
---
### 3.6 UnderwaterPostProcessingController — 水下后处理(⚠️ 架构 21 §13原 Plan 遗漏)
> 订阅 `EVT_LiquidEntered` / `EVT_LiquidExited`,渐变启用/停用 水下专属 `Volume`(颜色滤镜/色差/暗角)。使用 Coroutine 混合权重,不依赖 DOTween。
```csharp
// 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.cs` → `Assets/Scripts/Player/AbilityType.cs`,程序集 `BaseGames.Player`
> - `AbilityGate.cs` → `Assets/Scripts/World/AbilityGate.cs`,程序集 `BaseGames.World`
```csharp
// 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` 事件频道。
```csharp
// ⚠️ 完整实现以架构 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
```csharp
// 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
```csharp
// 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`**
> - `FormSkillSO`、`SkillManager`、`SkillModifierRegistry` → `Assets/Scripts/Skills/`,程序集 `BaseGames.Spells`
> - `ToolSO`、`EquipmentManager` → `Assets/Scripts/Equipment/`,程序集 `BaseGames.Equipment`
**ToolSO**(主动工具,如抓钩/炸弹/气球):
```csharp
// 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**(各形态魂技能):
```csharp
// 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
```csharp
// 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
```csharp
// 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`
```csharp
// 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 击败/道具持有而非能力解锁。
```csharp
// 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 死亡事件并通知存档系统。
```csharp
// 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。
```csharp
// 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`
```csharp
// 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;
}
}
```
```csharp
// 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;
}
}
```
```csharp
// 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 实际实现时替换为事件频道
}
}
}
```
```csharp
// 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 }
}
```
```csharp
// 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;
}
}
```
```csharp
// 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;
}
}
```
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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.cs`Architecture §1.3)渲染:
```csharp
// 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 显示玩家位置图标。
```csharp
// 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 要求**:玩家可在地图上放置自定义标记,通过 `MapPinManager``ISaveable`)持久化。
```csharp
// 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
```csharp
// 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
```csharp
// 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.cs`Architecture §2.4)触发商店:
```csharp
// 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 存档版本迁移
```csharp
// 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|transitionId` 字符串 |
| `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=1f``GetRecentCrumbs(int)``IReadOnlyList<Vector2>`oldest→newest`Clear()` |
| `TutorialManager.cs` | ✅ | 单例 (`Instance`);实现 `ISaveable``ShowHint`/`CompleteHint`;进度写入 `SaveData.Tutorial`(非 PlayerPrefs与架构 12 §1 注解不同) |
| `TutorialHintUI.cs` | ✅ | HUD 提示 UI`Show(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_RoomEntered``SetMapped` |
| `MapPanel.cs` | ✅ | `MapPanel` + `MapRoomCellUI`OnEnable 重建格子;`EVT_MapUpdated` 增量刷新 |
| `MapPlayerTracker.cs` | ✅ | `WorldToCell`18f/格LateUpdate 找所在房间;`NormalizedPositionInRoom` |
| `MapPin.cs` | ✅ | `MapPinManager` ISaveableMapPin/PinType 定义在 SaveData.cs 避免循环依赖) |
| `ShopItemSO.cs` | ✅ | `ShopItemSO` + `ShopItemType` 枚举CharmSO 引用 |
| `ShopInventorySO.cs` | ✅ | `ShopInventorySO` + `RestockPolicy` 枚举 |
| `ShopController.cs` | ✅ | ISaveable`TryPurchase``GetAvailableItems``Restock``ShopPanel` 存根 |
| `ShopNPC.cs` | ✅ | IInteractable`DialogueEventChannelSO` 触发招呼对话→打开商店 |
| `Editor/Map/MapRoomDataEditor.cs` | ✅ | `[CustomEditor(typeof(MapRoomDataSO))]`Scene 句柄拖拽;居中按钮 |
| `SaveData.cs` | ✅ | `MapSaveData``ExploredRooms/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 需要) |
> **编辑器工具**`AddressReferenceGraphWindow``Assets/Editor/Assets/AddressReferenceGraphWindow.cs`)扫描所有 `.cs` 文件对 `AddressKeys.X` 的引用,标红孤儿 key0 引用),标黄单次引用 key标绿 ≥2 次引用 key支持导出 CSV架构 13 §11P3 优化)。
**Phase 3 完成后进入 Phase 4。**