# Phase 3 · 世界与进程系统 > **周期**:4–5 周(Week 10–14) > **前置条件**: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 + Collectible(Geo/道具) ↓ 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: MapModule(Fog 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.World;Dialogue 程序集通过 asmdef 引用 BaseGames.World // ⚠️ 实现方如需 PlayerController,通过 player.GetComponent() 获取(PlayerController 无 Instance,Architecture 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 Collectible(Geo + 道具) ```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(); 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(); 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(); } // ⚠️ virtual,DirectionalDestructible 覆盖此方法做方向校验(架构 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 _passengers = new(); private int _waypointIndex; private bool _movingForward = true; private bool _triggered; private void Awake() => _rb = GetComponent(); 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()?.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(); _sr = GetComponent(); } 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(); } } } ``` **SkillInteractable(MagicWall / 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 Matrix:Ghost vs MagicWall = IgnoreCollision(无代码逻辑) // SkillManager 在太虚斩激活/结束时切换玩家 Layer(Ghost ↔ Player) #if UNITY_EDITOR private void OnDrawGizmos() { Gizmos.color = _normalColor; var b = GetComponent(); 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() 检测 // 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.RevealedFalseWalls(Design 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(); 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 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 _collectedIds = new(); private HashSet _activatedSavePoints = new(); private HashSet _openedDoors = new(); private HashSet _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(非 IsFlagSet);SetFlag(key) 单参数只添加(架构 14 patch) // ⚠️ 无双参数 SetFlag(key, bool);若需清除标记使用独立 ClearFlag(key)(暂不实现) private HashSet _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 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 { /// 场景内导航标记点,广播给地图/HUD。 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 _crumbs = new(); float _timer; Vector2 _lastPos; public IReadOnlyCollection 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; } /// 获取最近 N 个面包屑位置(用于地图渲染)。 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 _completedHints = new(); // ⚠️ Singleton(架构 21 §17 必须有 Instance) public static TutorialManager Instance { get; private set; } void Awake() { Instance = this; // 从 PlayerPrefs 恢复已完成提示列表 // (架构 12 SaveData 无 Tutorial 字段;教程进度独立于存档系统) } /// 显示提示。若已完成则跳过。 public void ShowHint(string hintId, string localizedText, float duration = 3f) { if (_completedHints.Contains(hintId)) return; _hintUI.Show(localizedText, duration); } /// 标记提示为已完成,持久化到 PlayerPrefs,不再显示。 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(); 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 = this(SwimState 需要读 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 { /// 任何可被切换激活/停用状态的谜题元素。 public interface ISwitchable { bool IsActive { get; } event Action OnStateChanged; void ForceState(bool active); // SaveData 恢复时调用 } /// 可被玩家推动的物件(需 Rigidbody2D)。 public interface IMovable { bool CanBePushed { get; } void OnPushStart(Vector2 direction); void OnPushEnd(); } /// 接受激活信号后改变自身状态的物件。 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 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 §9):key + 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 §9):key + true,激活时持久化 if (!string.IsNullOrEmpty(_receiverId)) _worldState?.SetFlag("receiver_" + _receiverId, true); } public void Deactivate() { if (!_isActivated) return; _isActivated = false; _deactivateFeedback?.PlayFeedbacks(); OnDeactivate(); // ⚠️ SetFlag 双参数(架构 08_WorldModule §9):key + 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 // 挂载到地面碰撞体所在 GameObject(Tilemap 图层 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 所在 GameObject;LiquidZone 调用 EnterWater/ExitWater public class UnderwaterAudioController : MonoBehaviour { [SerializeField] AudioMixer _mixer; [SerializeField] float _transitionDuration = 0.3f; /// LiquidZone.OnTriggerEnter2D 时调用 public void EnterWater() { _mixer.FindSnapshot("Underwater").TransitionTo(_transitionDuration); } /// LiquidZone.OnTriggerExit2D 时调用 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; // 水下专属 Volume(WeightMode) [SerializeField] private float _blendInDuration = 0.3f; [SerializeField] private float _blendOutDuration = 0.3f; [SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered(payload: 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 patch):EVT_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 无 Instance(Architecture 05 §2);通过 player 参数获取 player.GetComponent()?.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 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 §5:MaxHP / 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 §5:ApplyPoison / 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("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 _equipped = new(4); private List _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(), Feedback = GetComponent(), Events = EventChannelRegistry.Instance, SkillMods = GetComponent(), WeaponMgr = GetComponent(), }; _currentNotchCapacity = _config != null ? _config.initialNotchCount : 3; } public int UsedNotches => _equipped.Sum(c => c.notchCost); public int TotalNotches => _currentNotchCapacity; public IReadOnlyList Equipped => _equipped; public IReadOnlyList Collected => _collected; /// 装备护符。返回失败原因(null = 成功)。⚠️ 返回 string(非 bool),架构 09 §6 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 §7–8`)**: > - `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 + ToolHUD(Architecture 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]; /// 装备工具到指定槽位 public void EquipTool(int slotIndex, ToolSO tool) { _slots[slotIndex] = tool; _remainingUses[slotIndex] = tool?.maxUses ?? -1; _cooldowns[slotIndex] = 0f; } /// 使用槽位工具:检查冷却/次数 → 执行 IToolEffect → 触发事件 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); } /// 工具冷却接口(实现该接口的 ToolSO 才会有冷却) 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 §6;FormController 调用 _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.cs(Architecture 09 §9.5) // Prefab: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab public class SkillHitBoxInstance : MonoBehaviour { [SerializeField] private HitBox[] _hitBoxes; public System.Action 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> _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 = 回退原始) /// 以技能 SO 默认值初始化,无任何修改器加成。 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; // 默认值 0(WallCling)= 无要求时按惯例留默认值并忽略 [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 BossProgressTracker(Boss 进程追踪) > **⚠️ 此节内容来自架构 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 HPContainerPickup(HP 容器拾取) > **⚠️ 此节内容来自架构 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); // SaveSystem:data.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 { /// 任务目标基类(抽象)。所有具体目标类型均继承此类。 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); /// 根据当前进度判断目标是否完成。 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 { /// /// 运行时任务管理器,挂在 Persistent 场景 [GameManagers] 下。 /// 通过事件频道追踪目标进度,不主动轮询。 /// public class QuestManager : MonoBehaviour { // ── Inspector ──────────────────────────────────────── [SerializeField] QuestSO[] _allQuests; [SerializeField] TransformEventChannelSO _onEnemyDied; // EVT_EnemyDied [SerializeField] StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId) [SerializeField] StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName) [SerializeField] StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId) // ⚠️ 分拆为粒度更细的事件频道(架构 22 §5,替代旧 _onQuestStateChanged 单频道) [SerializeField] StringEventChannelSO _onQuestStarted; // Raise:questId [SerializeField] StringEventChannelSO _onQuestCompleted; // Raise:questId [SerializeField] StringEventChannelSO _onQuestFailed; // Raise:questId [SerializeField] QuestObjectiveEventChannelSO _onObjectiveUpdated; // Raise:objectiveId + progress // ── Runtime State ──────────────────────────────────── readonly Dictionary _questStates = new(); readonly Dictionary _objectiveStates = new(); // ⚠️ 替代旧 _objectiveProgress: Dictionary 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 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(); if (enemyBase == null) return; // ⚠️ EnemyId 在 EnemyStatsSO(Architecture 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 } /// 记录单个目标的运行时进度(架构 22 §5)。 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_ChallengeCompleted(challengeId) [SerializeField] StringEventChannelSO _onChallengeFailed; // → EVT_ChallengeFailed(challengeId) // ⚠️ PlayerController 无 Instance(Architecture 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(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()?.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-5,2026-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.1;null = 回退到矩形格子) [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 _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 MapModule(Fog 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.2;MapPanel.BuildGrid 依赖此字段) public static MapManager Instance { get; private set; } // ⚠️ 三级可见性(架构 15 §1.2): // Unknown → 未进入过(默认) // Explored → 进入过但未购买地图(显示轮廓/格子) // Mapped → 已完整获取地图信息(显示图标/名称) private HashSet _exploredRooms = new(); // ⚠️ 玩家踏入过(非 _discoveredRooms) private HashSet _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 刷新 } /// 标记为已完整获取地图信息(购买 MapFragment SO 触发)。⚠️ 架构 15 §1.2 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),架构 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(data.Map.ExploredRooms ?? new List()); _mappedRooms = new HashSet(data.Map.MappedRooms ?? new List()); } } ``` 地图 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 _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 世界单位 /// 返回玩家当前所在房间 ID(用于地图高亮当前房间)。 public string CurrentRoomId { get; private set; } /// 玩家在当前格子房间内的归一化坐标(0~1)。 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 _pins = new(); public IReadOnlyList 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(); } ``` ### 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 DefaultInventory; public int MaxDisplaySlots = 6; // ⚠️ UI 最多同时显示的商品格数(架构 15 §2.2) public RestockPolicy RestockPolicy = RestockPolicy.Never; // ⚠️ 补货策略(架构 15 §2.2) public Sprite KeeperPortrait; public string KeeperName; } /// ⚠️ 库存补货时机策略(架构 15_MapShopModule §2.2) 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 = itemId,value = 已购次数 private Dictionary _purchaseCounts = new(); private HashSet _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 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; // ⚠️ 扣 Geo:ShopPurchaseEvent { 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; // ── ISaveable(data.Shops.ShopRecords,key=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(_purchaseCounts); } public void OnLoad(SaveData data) { if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record)) { _soldUniqueItems = new HashSet(record.SoldUniqueItems ?? new List()); _purchaseCounts = record.PurchaseCounts ?? new Dictionary(); } } } ``` 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(); return d; } private static SaveData MigrateFrom1_1(SaveData d) { // 防御性 null-check:子结构体若为 null 先补全(架构 12_SaveModule §5) d.Stats ??= new StatsSaveData(); // 1.1 → 2.0:Stats 新增 SkillUseCounts;World 新增 ChallengeFirstClears d.Stats.SkillUseCounts ??= new Dictionary(); d.World.ChallengeFirstClears ??= new HashSet(); // 1.1 → 2.0:Player 新增护盾字段(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` | ✅ | ScriptableObject,Contains/Mark 系列 API + HasFlag/SetFlag + LoadFromSave | | `InteractableDetector.cs` | ✅ | OverlapCircleAll + FindNearest + InputReaderSO.InteractEvent 绑定 | | `PlayerSpawnPoint.cs` | ✅ | TransitionId + SpawnPosition,Gizmo 绿球标记 | | `RoomTransition.cs` | ✅ | IInteractable,自动触发或按键,广播 `scene|transitionId` 字符串 | | `RoomController.cs` | ✅ | Start 切换 RoomCamera,GetSpawnPoint 查询出生点 | | `HazardZone.cs` | ✅ | 即死/定值伤害,RespawnType 枚举 | | `Collectible.cs` | ✅ | Geo/Item/HPOrb 三类,PlayerStats 直接调用 | | `DestructibleTile.cs` | ✅ | IDamageable,CheckDestroyCondition virtual hook,Start 读档恢复 | | `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` | ✅ | IDamageable,Proximity/AttackOnce/AlwaysOpen 三种揭示方式 | | `BaseGames.World.asmdef` | ✅ | 新增 Input/Combat/Player/Camera/MoreMountains.Tools 引用 | ### P3-2 补充实现(2026-05-12) | 文件 | 状态 | 说明 | |------|------|------| | `WorldMarkerEventChannelSO.cs` | ✅ | `BaseEventChannelSO` 事件频道;命名空间 `BaseGames.World` | | `WorldMarker.cs` | ✅ | 场景导航标记点;`Activate()`/`Deactivate()` 广播事件频道;`WorldMarkerType` 枚举(Objective/NPC/PointOfInterest/Exit/Secret) | | `BreadcrumbTracker.cs` | ✅ | 玩家位置历史追踪;`_recordInterval=2f`/`_maxCrumbs=20`/`_minMoveDistance=1f`;`GetRecentCrumbs(int)` → `IReadOnlyList`(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 CompletedHintIds`) | | `BaseGames.Tutorial.asmdef` | ✅ | 引用 Core.Events/Core.Save/World/Player/Localization | ``` ☑ InteractableDetector:OverlapCircleAll 检测最近交互物,驱动 UI 提示显示/隐藏(代码完成) ☑ WorldStateRegistry:HashSet 持久化状态,LoadFromSave/GetAllFlags 接口完成 ☑ RoomTransition + RoomController + PlayerSpawnPoint:房间切换框架完成(待 SceneLoader 集成) ☑ HazardZone:即死/定值伤害(代码完成,待 Unity 内配置 Layer 和 Tag 验证) ☑ Collectible:Geo/Item/HPOrb 拾取(代码完成,待 Unity 内配置 Prefab 验证) ☑ DestructibleTile + DirectionalDestructible:IDamageable + 方向校验(代码完成) ☑ DirectionalInteractable + PhantomInteractable:三种触发模式 + WorldStateRegistry 持久化 ☑ MagicWall + SoftTerrain:标记组件(无逻辑) ☑ MovingPlatform:三种移动模式 + Passenger SetParent 方案(代码完成) ☑ CrumblePlatform:四态协程,MMF_Player 反馈(代码完成) ☑ FalseWall:三种揭示条件 + IDamageable(代码完成) □ 场景内端对端验证(待 Unity 编辑器内装配 Prefab 并运行) □ Console 无 Error(Unity 编辑器内编译验证) ``` ### Week 11–14 待实施 ``` □ RoomTransition:触发切换 → 淡出 → 加载目标场景 → 玩家在对应 SpawnPoint 出生 □ HazardZone:掉入深渊 → 瞬间死亡 → 正常死亡流程 □ DestructibleTile:Heavy 攻击命中破碎 + WorldStateRegistry 记录 → 重载场景后仍为破碎状态 □ MovingPlatform:玩家站在平台上随平台移动,不抖动,不穿透 □ CrumblePlatform:落上后 0.6s 碎裂,3s 后复原 □ LiquidZone:进入水域 → SwimState(已解锁)/ HazardZone 伤害(未解锁) □ 液态谜题:Valve → LiquidPuzzleController 液位上升 → 达目标液位 → 谜题完成事件 □ CharmSO 装备:VoidHeart 装备后玩家 MaxHP +2(HUD 更新),卸载后恢复 □ 护符凹槽:总 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` ISaveable(MapPin/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)` + `Pins(List)` | | `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` 的引用,标红孤儿 key(0 引用),标黄单次引用 key,标绿 ≥2 次引用 key,支持导出 CSV(架构 13 §11,P3 优化)。 **Phase 3 完成后进入 Phase 4。**