147 KiB
Phase 3 · 世界与进程系统
周期:4–5 周(Week 10–14)
前置条件:Phase 2 全部完成标准通过
核心目标:完整的世界互动(房间切换/可破坏物/机关/移动平台)、液态谜题、进程系统(护符/工具/技能树)、任务系统、地图/商店、存档迁移
产出物:能进行多房间探索;护符装备生效;任务系统可完成至少 2 个任务;商店可购买道具;液态谜题可通关
目录
- 实施顺序总览
- Week 10:世界互动基础组件
- Week 11:液态谜题模块
- Week 12:进程模块(护符/工具/技能)
- Week 13:任务与挑战房间
- Week 14:地图/商店/存档迁移
- 完成标准检查清单
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
// Assets/Scripts/World/IInteractable.cs // ⚠️ 路径为 World/(架构 08_WorldModule §7 / 14_NarrativeModule §1)
// 命名空间:namespace BaseGames.World;Dialogue 程序集通过 asmdef 引用 BaseGames.World
// ⚠️ 实现方如需 PlayerController,通过 player.GetComponent<PlayerController>() 获取(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:
// 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 + 道具)
// Assets/Scripts/World/Collectible.cs
// ⚠️ 枚举值与架构 08_WorldModule §5 对齐:Geo / Item / HPOrb
public class Collectible : MonoBehaviour
{
[Header("Config")]
[SerializeField] private CollectibleType _type;
[SerializeField] private int _geoAmount; // type = Geo 时
[SerializeField] private string _itemId; // type = Item 时
[SerializeField] private bool _isPersistent; // false = 敌人掉落; true = 固定位置(存档)
[Header("Physics")]
[SerializeField] private float _bounceForce = 5f;
[Header("Event Channel")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
private string _collectibleId; // 持久化 Collectible 存档唯一 ID
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
var player = other.GetComponentInParent<PlayerController>();
if (player == null) return;
switch (_type)
{
case CollectibleType.Geo:
player.Stats.AddGeo(_geoAmount);
break;
case CollectibleType.Item:
_onCollectiblePickup.Raise(_itemId);
break;
case CollectibleType.HPOrb:
// 恢复少量 HP(具体值由 PlayerStats 决定)
player.Stats.RestoreHP(1);
break;
}
if (_isPersistent)
_onCollectiblePickup.Raise(_collectibleId); // 存档标记
Despawn();
}
private void Despawn(); // 归还对象池
// 敌人死亡时生成 Geo Collectible(由 EnemyBase 调用)
public static void SpawnGeo(Vector2 position, int amount, GlobalObjectPool pool);
}
2.4 HazardZone
// Assets/Scripts/World/HazardZone.cs
// 即死/高伤害区域(深坑、岩浆等)—— 与架构 08_WorldModule §4 对齐
[RequireComponent(typeof(Collider2D))]
public class HazardZone : MonoBehaviour
{
// ⚠️ RespawnType 枚举(架构 08_WorldModule §4)
public enum RespawnType { AtLastSavePoint, AtRoomEntry }
[SerializeField] private bool _isInstantKill = true;
[SerializeField] private int _damage = 9999;
[SerializeField] private RespawnType _respawnType = RespawnType.AtLastSavePoint; // ⚠️ 架构 08 §4
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
var stats = other.GetComponentInParent<PlayerStats>();
if (stats == null) return;
if (_isInstantKill)
stats.TakeDamage(stats.MaxHP * 2); // 确保即死
else
stats.TakeDamage(_damage);
}
}
2.5 DestructibleTile + MovingPlatform + CrumblePlatform
DestructibleTile(可破坏瓦片):
// 实现 IDamageable,被攻击击中时破碎
// 从 TilemapCollider2D 移除对应 Tile,播放 VFX,在 WorldStateRegistry 记录状态
// ⚠️ 字段名与架构 08_WorldModule §10 对齐:_maxHP / _destructedId
public class DestructibleTile : MonoBehaviour, IDamageable
{
[SerializeField] private int _maxHP = 1;
[SerializeField] private string _destructedId; // 存档唯一 ID
// ⚠️ WorldStateRegistry 为 ScriptableObject(架构 14_NarrativeModule §8),不使用静态 Instance;
// 通过 [SerializeField] 注入 SO 引用
[SerializeField] private WorldStateRegistry _worldState;
private bool _isDestroyed = false;
public bool IsInvincible => _isDestroyed;
public int Defense => 0;
public void TakeDamage(DamageInfo info)
{
if (_isDestroyed) return;
if (!CheckDestroyCondition(info)) return; // 子类可覆盖(DirectionalDestructible 方向校验)
_isDestroyed = true;
Destroy();
}
// ⚠️ 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:
// 动态移动平台:Kinematic Rigidbody2D,乘客自动跟随(Passenger Pattern)
// ⚠️ 与架构 08_WorldModule §11 完全对齐:MoveType 枚举 / _activationChannel / _passengerSensor
[RequireComponent(typeof(Rigidbody2D))]
public class MovingPlatform : MonoBehaviour
{
public enum MoveType { LinearAB, WayPoints, TriggeredLinear }
[Header("移动配置")]
[SerializeField] private MoveType _moveType = MoveType.LinearAB;
[SerializeField] private Transform[] _wayPoints; // LinearAB 仅用 [0][1]
[SerializeField] private float _speed = 3f; // u/s
[SerializeField] private float _waitAtEndpoint = 0.5f; // 端点停留秒数
[Header("TriggeredLinear 模式")]
[SerializeField] private VoidEventChannelSO _activationChannel; // 收到信号后单程运动
[Header("乘客检测")]
[SerializeField] private BoxCollider2D _passengerSensor; // IsTrigger,仅用于检测
private Rigidbody2D _rb;
private List<Transform> _passengers = new();
private int _waypointIndex;
private bool _movingForward = true;
private bool _triggered;
private void Awake() => _rb = GetComponent<Rigidbody2D>();
private void FixedUpdate()
{
if (_moveType == MoveType.TriggeredLinear && !_triggered) return;
MoveTowardsNextWaypoint();
}
private void MoveTowardsNextWaypoint()
{
var target = _wayPoints[_waypointIndex].position;
var next = Vector2.MoveTowards(_rb.position, (Vector2)target, _speed * Time.fixedDeltaTime);
_rb.MovePosition(next);
if (Vector2.Distance(_rb.position, target) < 0.02f)
StartCoroutine(WaitAndAdvance());
}
private IEnumerator WaitAndAdvance()
{
yield return new WaitForSeconds(_waitAtEndpoint);
AdvanceWaypoint();
}
private void AdvanceWaypoint()
{
if (_moveType == MoveType.TriggeredLinear)
{
_waypointIndex = Mathf.Min(_waypointIndex + 1, _wayPoints.Length - 1);
if (_waypointIndex == _wayPoints.Length - 1) _triggered = false;
return;
}
if (_moveType == MoveType.LinearAB)
{
_movingForward = !_movingForward;
_waypointIndex = _movingForward ? 1 : 0;
}
else
{
_waypointIndex = (_waypointIndex + 1) % _wayPoints.Length;
}
}
// Passenger Pattern:乘客跟随平台移动(SetParent)
private void OnTriggerEnter2D(Collider2D other)
{
if ((1 << other.gameObject.layer & LayerMask.GetMask("Player", "Enemy")) == 0) return;
other.transform.SetParent(transform);
_passengers.Add(other.transform);
}
private void OnTriggerExit2D(Collider2D other)
{
if (!_passengers.Contains(other.transform)) return;
other.transform.SetParent(null);
_passengers.Remove(other.transform);
if (other.CompareTag("Player"))
other.GetComponentInParent<Rigidbody2D>()?.AddForce(
_rb.velocity, ForceMode2D.Impulse);
}
private void OnEnable()
{
if (_activationChannel != null)
_activationChannel.OnEventRaised += OnTriggered;
}
private void OnDisable()
{
if (_activationChannel != null)
_activationChannel.OnEventRaised -= OnTriggered;
}
private void OnTriggered() => _triggered = true;
}
MoveType 说明:
LinearAB:_wayPoints[0]↔[1]往返循环WayPoints:按_wayPoints[]顺序环形循环TriggeredLinear:监听_activationChannel,收到信号后单程[0]→[n-1],到达后停止NavSurface 联动:每个移动平台挂载独立
LocalNavSurface(局部坐标系),附着其上的敌人使用该 LocalNavSurface 寻路。
CrumblePlatform(碎裂平台):
// ⚠️ 与架构 08_WorldModule §14 完全对齐:字段名 / 完整 CrumbleSequence 状态机
[RequireComponent(typeof(BoxCollider2D))]
public class CrumblePlatform : MonoBehaviour
{
[SerializeField] private float _warningDuration = 0.6f; // 踩上后警告时长(抖动)
[SerializeField] private float _crumbleDuration = 0.3f; // 碎裂动画时长
[SerializeField] private float _respawnDelay = 3.0f; // 0 = 永久消失
[SerializeField] private bool _isOneShot = false; // true = 碎裂后永久消失
[SerializeField] private MMF_Player _crumbleFeedback; // 预警震动 + 碎裂粒子 + 音效
[SerializeField] private BoxCollider2D _passengerSensor; // Trigger,检测玩家踩踏
private BoxCollider2D _col;
private SpriteRenderer _sr;
private bool _isCrumbling;
private void Awake()
{
_col = GetComponent<BoxCollider2D>();
_sr = GetComponent<SpriteRenderer>();
}
private void OnTriggerEnter2D(Collider2D other)
{
if (_isCrumbling || !other.CompareTag("Player")) return;
StartCoroutine(CrumbleSequence());
}
private IEnumerator CrumbleSequence()
{
_isCrumbling = true;
// 1. Warning(抖动)
_crumbleFeedback?.PlayFeedbacks();
yield return new WaitForSeconds(_warningDuration);
// 2. Crumbling
yield return new WaitForSeconds(_crumbleDuration);
// 3. Gone:禁用碰撞体 + 隐藏 Sprite
_col.enabled = false;
_sr.enabled = false;
_passengerSensor.enabled = false;
if (_isOneShot || _respawnDelay <= 0f)
{
yield break; // 永久消失
}
// 4. Respawn
yield return new WaitForSeconds(_respawnDelay);
_col.enabled = true;
_sr.enabled = true;
_passengerSensor.enabled = true;
_isCrumbling = false;
}
}
状态机:
Idle→[玩家踩上]→Warning(抖动)→[warningDuration]→Crumbling→[动画]→Gone→[respawnDelay,非 OneShot]→Idle
2.6 DirectionalDestructible + DirectionalInteractable + SkillInteractable
参考文档:08_WorldModule.md §12-15
DirectionalDestructible(单向可破坏墙):
// Assets/Scripts/World/DirectionalDestructible.cs
// 继承 DestructibleTile,增加攻击方向校验
// ⚠️ 与架构 08_WorldModule §12 完全对齐:AttackSide 枚举 / CheckDestroyCondition 覆盖
public class DirectionalDestructible : DestructibleTile
{
public enum AttackSide { Left, Right, Top, Bottom, Any }
[SerializeField] private AttackSide _validAttackSide = AttackSide.Any;
protected override bool CheckDestroyCondition(DamageInfo info)
{
if (_validAttackSide == AttackSide.Any)
return base.CheckDestroyCondition(info);
var dir = (info.SourcePosition - (Vector2)transform.position).normalized;
bool valid = _validAttackSide switch
{
AttackSide.Left => dir.x < -0.5f,
AttackSide.Right => dir.x > 0.5f,
AttackSide.Top => dir.y > 0.5f,
AttackSide.Bottom => dir.y < -0.5f,
_ => true
};
return valid && base.CheckDestroyCondition(info);
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
var arrow = _validAttackSide switch
{
AttackSide.Left => Vector2.left,
AttackSide.Right => Vector2.right,
AttackSide.Top => Vector2.up,
AttackSide.Bottom => Vector2.down,
_ => Vector2.zero
};
if (arrow == Vector2.zero) return;
Gizmos.color = new Color(1f, 0.5f, 0f, 0.9f);
var origin = (Vector2)transform.position;
Gizmos.DrawLine(origin, origin + arrow * 0.8f);
}
#endif
}
DirectionalInteractable(单向触发机关):
// Assets/Scripts/World/DirectionalInteractable.cs
// ⚠️ 与架构 08_WorldModule §13 完全对齐:Interact(Transform player),非 Interact(PlayerController)
[RequireComponent(typeof(Collider2D))]
public class DirectionalInteractable : MonoBehaviour, IInteractable
{
public enum TriggerSide { Left, Right, Top, Any }
public enum TriggerCondition { PlayerAttack, PlayerBody, InteractKey }
[Header("触发条件")]
[SerializeField] private TriggerSide _triggerSide = TriggerSide.Any;
[SerializeField] private TriggerCondition _triggerCondition = TriggerCondition.InteractKey;
[Header("行为")]
[SerializeField] private bool _isOneShot;
[SerializeField] private string _interactableId;
[Header("事件频道(零耦合连接关卡受体)")]
[SerializeField] private VoidEventChannelSO _activationChannel;
[SerializeField] private VoidEventChannelSO _deactivationChannel;
[Header("反馈")]
[SerializeField] private MMF_Player _activateFeedback;
// ⚠️ SO 注入(架构 14_NarrativeModule §8 patch):不使用 SaveManager.SetMechanismState/GetMechanismState(不存在于架构)
[SerializeField] private WorldStateRegistry _worldState;
private bool _activated;
public bool CanInteract => !(_isOneShot && _activated);
public string InteractPrompt => _activated ? "已激活" : "交互";
public void Interact(Transform player) // ⚠️ Transform 参数(架构 08 §7 IInteractable 标准)
{
if (_triggerCondition != TriggerCondition.InteractKey) return;
if (!CheckSide(player.position)) return;
TryActivate();
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
private void OnTriggerEnter2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player")) return;
if (!CheckSide(other.transform.position)) return;
TryActivate();
}
private void OnTriggerExit2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player") || _isOneShot) return;
_activated = false;
_deactivationChannel?.Raise();
}
public void TryInteractFromDamage(DamageInfo info)
{
if (_triggerCondition != TriggerCondition.PlayerAttack) return;
if (!CheckSide(info.SourcePosition)) return;
TryActivate();
}
protected void TryActivate()
{
if (_isOneShot && _activated) return;
_activated = true;
_activateFeedback?.PlayFeedbacks();
_activationChannel?.Raise();
// ⚠️ 用 WorldStateRegistry.SetFlag 替代 SaveManager.SetMechanismState(后者不存在于架构)
if (_isOneShot && !string.IsNullOrEmpty(_interactableId))
_worldState?.SetFlag("mechanism_" + _interactableId);
}
private bool CheckSide(Vector2 sourcePos)
{
if (_triggerSide == TriggerSide.Any) return true;
var dir = (sourcePos - (Vector2)transform.position).normalized;
return _triggerSide switch
{
TriggerSide.Left => dir.x < -0.4f,
TriggerSide.Right => dir.x > 0.4f,
TriggerSide.Top => dir.y > 0.4f,
_ => true
};
}
private void Start()
{
// ⚠️ 用 WorldStateRegistry.HasFlag 替代 SaveManager.GetMechanismState(后者不存在于架构)
if (_isOneShot && !string.IsNullOrEmpty(_interactableId)
&& _worldState != null && _worldState.HasFlag("mechanism_" + _interactableId))
{
_activated = true;
_activationChannel?.Raise();
}
}
}
SkillInteractable(MagicWall / SoftTerrain / PhantomInteractable):
// Assets/Scripts/World/MagicWall.cs
// ⚠️ 与架构 08_WorldModule §15.1 完全对齐:仅 Gizmo 可视化,穿越靠 Physics Layer Matrix
[ExecuteAlways]
public class MagicWall : MonoBehaviour
{
[SerializeField] private Color _normalColor = new(0.4f, 0.2f, 1f, 0.8f);
[SerializeField] private Color _ghostColor = new(0.4f, 0.2f, 1f, 0.15f);
// 穿越通过 Physics Layer Matrix:Ghost vs MagicWall = IgnoreCollision(无代码逻辑)
// SkillManager 在太虚斩激活/结束时切换玩家 Layer(Ghost ↔ Player)
#if UNITY_EDITOR
private void OnDrawGizmos()
{
Gizmos.color = _normalColor;
var b = GetComponent<Collider2D>();
if (b != null)
Gizmos.DrawWireCube(transform.position, b.bounds.size);
}
#endif
}
// Assets/Scripts/World/SoftTerrain.cs
// ⚠️ 与架构 08_WorldModule §15.2 完全对齐:Marker 组件,GroundDiveState 检测用
public class SoftTerrain : MonoBehaviour
{
// Marker 组件——无逻辑,仅供 GetComponent<SoftTerrain>() 检测
// GroundDiveState 通过 Physics2D.OverlapPoint 检测当前瓦片:
// 有 SoftTerrain → SetSoulDrainRate(0);否则 → SetSoulDrainRate(FormSkillSO.soulCostPerSecond)
}
// Assets/Scripts/World/PhantomInteractable.cs
// ⚠️ 与架构 08_WorldModule §15.3 完全对齐:继承 DirectionalInteractable,额外响应 PhantomBody 层
public class PhantomInteractable : DirectionalInteractable
{
private new void OnTriggerEnter2D(Collider2D other)
{
bool isPlayer = other.CompareTag("Player");
bool isPhantom = other.gameObject.layer == LayerMask.NameToLayer("PhantomBody");
if (!isPlayer && !isPhantom) return;
TryActivate();
}
}
2.6.1 FalseWall(假墙/秘密通道)
参考文档:
Design/08_WorldSystem.md §9.8(架构文档无独立章节,以 Design 文档为准)
⚠️ 注意:FalseWall 不销毁,只切换碰撞体启用状态;与DestructibleTile的区别在于状态可逆性。
// Assets/Scripts/World/FalseWall.cs
// 假墙:外观与普通墙几乎相同,玩家可通过攻击/接近揭示并穿越
// 实现 IDamageable 接口(接收攻击);揭示后持久化到 WorldSaveData
[RequireComponent(typeof(Collider2D))]
public class FalseWall : MonoBehaviour, IDamageable
{
public enum RevealCondition { Proximity, AttackOnce, AlwaysOpen }
[Header("识别")]
[SerializeField] private string _wallId; // 持久化唯一 ID(如 "FW_Forest_01_SecretA")
[Header("揭示条件")]
[SerializeField] private RevealCondition _revealCondition = RevealCondition.AttackOnce;
[SerializeField] private float _proximityRadius = 2.0f; // Proximity 模式检测半径
[Header("组件引用")]
[SerializeField] private Collider2D _wallCollider; // 揭示后 enabled = false
[SerializeField] private SpriteRenderer _renderer; // Normal / Revealed 两帧切换
[SerializeField] private MMF_Player _revealFeedback; // Shimmer 粒子 + 空洞回声
// IDamageable
public bool IsInvincible => _isRevealed;
public int Defense => 0;
private bool _isRevealed = false;
private void Start()
{
// 读档恢复:若已揭示则静默还原,不播放演出
if (_revealCondition == RevealCondition.AlwaysOpen)
{
SetPassThroughImmediate();
return;
}
// ⚠️ WorldSaveData.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<Collider2D>();
if (col != null) Gizmos.DrawWireCube(transform.position, col.bounds.size);
if (_revealCondition == RevealCondition.Proximity)
{
Gizmos.color = new Color(0.6f, 0.2f, 1f, 0.2f);
Gizmos.DrawWireSphere(transform.position, _proximityRadius);
}
}
#endif
}
三种揭示条件说明:
| RevealCondition | 行为 | 典型用途 |
|---|---|---|
Proximity |
玩家进入 _proximityRadius 时播放 Shimmer(仅视觉暗示,碰撞仍启用) |
隐藏提示层(需攻击才能穿越) |
AttackOnce |
玩家攻击命中后碰撞禁用,永久可穿越 | 标准假墙(主要用法) |
AlwaysOpen |
初始即无碰撞,天生可穿越 | 返程单向暗门(已知通道) |
SaveData 持久化:揭示后写入 WorldSaveData.RevealedFalseWalls(Design §31 §6 字段:List<string> RevealedFalseWalls);FalseWall.Start() 读档检查该列表。
2.7 WorldStateRegistry
// Assets/Scripts/World/WorldStateRegistry.cs
// ⚠️ ScriptableObject 单例(架构 14_NarrativeModule §8 patch):不使用静态 Instance;
// 各组件通过 [SerializeField] 注入 SO 引用,避免服务定位器耦合
// SaveManager.SaveAsync 调用 GetAllFlags();SaveManager.LoadAsync 调用 LoadFromSave(data.World)
[CreateAssetMenu(menuName = "World/WorldStateRegistry")]
public class WorldStateRegistry : ScriptableObject
{
private HashSet<string> _collectedIds = new();
private HashSet<string> _activatedSavePoints = new();
private HashSet<string> _openedDoors = new();
private HashSet<string> _destroyedObjects = new();
public bool IsCollected(string id) => _collectedIds.Contains(id);
public void MarkCollected(string id) => _collectedIds.Add(id);
public bool IsSavePointActivated(string id) => _activatedSavePoints.Contains(id);
public void MarkSavePointActivated(string id) => _activatedSavePoints.Add(id);
public bool IsDestroyed(string id) => _destroyedObjects.Contains(id);
public void MarkDestroyed(string id) => _destroyedObjects.Add(id);
public bool IsDoorOpened(string id) => _openedDoors.Contains(id);
public void MarkDoorOpened(string id) => _openedDoors.Add(id);
// 通用世界状态标记(过场记录、剧情事件等)
// ⚠️ HasFlag(非 IsFlagSet);SetFlag(key) 单参数只添加(架构 14 patch)
// ⚠️ 无双参数 SetFlag(key, bool);若需清除标记使用独立 ClearFlag(key)(暂不实现)
private HashSet<string> _flags = new();
public bool HasFlag(string key) => _flags.Contains(key);
public void SetFlag(string key) => _flags.Add(key);
// SaveManager 调用
public void LoadFromSave(WorldSaveData data);
public HashSet<string> GetAllFlags();
}
2.8 WorldMarker + BreadcrumbTracker
参考文档:21_LiquidPuzzleModule.md §14–§15
// Assets/Scripts/World/Navigation/WorldMarker.cs
// ⚠️ 命名空间 BaseGames.World.Navigation(架构 21 §14)
namespace BaseGames.World.Navigation
{
/// <summary>场景内导航标记点,广播给地图/HUD。</summary>
public class WorldMarker : MonoBehaviour
{
[Header("标记信息")]
[SerializeField] string _markerId; // 唯一 ID(与 MapDataSO 匹配)
[SerializeField] WorldMarkerType _markerType; // ⚠️ 枚举 WorldMarkerType
[SerializeField] string _labelKey; // 本地化显示名称 key
[Header("可见性")]
[SerializeField] bool _visibleOnMap = true;
[SerializeField] bool _visibleOnHUD = false; // 在 HUD 显示箭头指引
[Header("事件频道")]
[SerializeField] WorldMarkerEventChannelSO _onMarkerActivated; // ⚠️ EVT_WorldMarkerActivated
[SerializeField] WorldMarkerEventChannelSO _onMarkerDeactivated; // ⚠️ EVT_WorldMarkerDeactivated
bool _isActive = false;
void Start()
{
if (_visibleOnMap || _visibleOnHUD)
Activate();
}
public void Activate()
{
_isActive = true;
_onMarkerActivated?.Raise(this);
}
public void Deactivate()
{
_isActive = false;
_onMarkerDeactivated?.Raise(this);
}
public string MarkerId => _markerId;
public WorldMarkerType MarkerType => _markerType;
public string LabelKey => _labelKey;
public bool IsActive => _isActive;
public bool VisibleOnHUD => _visibleOnHUD;
}
// ⚠️ 枚举值必须与架构 21 §14 完全一致
public enum WorldMarkerType
{
Objective, // 当前主线目标
NPC, // NPC 位置
PointOfInterest, // 兴趣点
Exit, // 出口/传送点
Secret, // 隐藏区域(解锁后显示)
}
}
// Assets/Scripts/World/Navigation/BreadcrumbTracker.cs
// ⚠️ 命名空间 BaseGames.World.Navigation(架构 21 §15)
// 挂在玩家 GameObject 上;数据不持久化(每次游戏重置)
namespace BaseGames.World.Navigation
{
public class BreadcrumbTracker : MonoBehaviour
{
[Header("追踪参数")]
[SerializeField] float _recordInterval = 2.0f; // 每隔多少秒记录一次
[SerializeField] int _maxCrumbs = 20; // 最多保留多少个历史位置
[SerializeField] float _minMoveDistance = 1.0f; // 移动距离低于此值不记录
readonly Queue<Vector2> _crumbs = new();
float _timer;
Vector2 _lastPos;
public IReadOnlyCollection<Vector2> Crumbs => _crumbs;
void Start() { _lastPos = transform.position; }
void Update()
{
_timer += Time.deltaTime;
if (_timer < _recordInterval) return;
_timer = 0f;
Vector2 current = transform.position;
if (Vector2.Distance(current, _lastPos) < _minMoveDistance) return;
_crumbs.Enqueue(current);
if (_crumbs.Count > _maxCrumbs) _crumbs.Dequeue();
_lastPos = current;
}
/// <summary>获取最近 N 个面包屑位置(用于地图渲染)。</summary>
public Vector2[] GetRecentCrumbs(int count)
=> System.Linq.Enumerable.TakeLast(_crumbs, count).ToArray();
}
}
2.9 TutorialManager + ContextualHintTrigger
参考文档:21_LiquidPuzzleModule.md §17–§18
// Assets/Scripts/Tutorial/TutorialManager.cs
// ⚠️ 命名空间 BaseGames.Tutorial;不实现 ISaveable(架构 12_SaveModule §1 SaveData 无 Tutorial 字段)
// ⚠️ 教程进度使用 PlayerPrefs 持久化(非存档系统),格式:key="hint_{hintId}",value=1
// 挂在 Persistent 场景 [GameManagers] 下
namespace BaseGames.Tutorial
{
public class TutorialManager : MonoBehaviour
{
[SerializeField] TutorialHintUI _hintUI; // HUD 上的提示 UI 组件
readonly HashSet<string> _completedHints = new();
// ⚠️ Singleton(架构 21 §17 必须有 Instance)
public static TutorialManager Instance { get; private set; }
void Awake()
{
Instance = this;
// 从 PlayerPrefs 恢复已完成提示列表
// (架构 12 SaveData 无 Tutorial 字段;教程进度独立于存档系统)
}
/// <summary>显示提示。若已完成则跳过。</summary>
public void ShowHint(string hintId, string localizedText, float duration = 3f)
{
if (_completedHints.Contains(hintId)) return;
_hintUI.Show(localizedText, duration);
}
/// <summary>标记提示为已完成,持久化到 PlayerPrefs,不再显示。</summary>
public void CompleteHint(string hintId)
{
_completedHints.Add(hintId);
PlayerPrefs.SetInt("hint_" + hintId, 1); // ⚠️ PlayerPrefs(非 SaveData.Tutorial,架构 12 无此字段)
PlayerPrefs.Save();
}
public bool IsCompleted(string hintId) => _completedHints.Contains(hintId);
// ⚠️ 不实现 ISaveable;教程进度通过 PlayerPrefs 读写,无需注入 SaveManager
}
}
// Assets/Scripts/Tutorial/ContextualHintTrigger.cs
// ⚠️ 命名空间 BaseGames.Tutorial;[RequireComponent(Collider2D)](架构 21 §18)
namespace BaseGames.Tutorial
{
[RequireComponent(typeof(Collider2D))]
public class ContextualHintTrigger : MonoBehaviour
{
[Header("提示配置")]
[SerializeField] string _hintId; // 唯一 ID
[SerializeField] string _hintTextKey; // 本地化 key
[SerializeField] float _displayDuration = 3f;
[Header("触发条件(可选)")]
// ⚠️ AbilityType 枚举(Architecture 09 §1)无 None 值;用 bool 标记是否要求能力
[SerializeField] bool _requiresAbility = false;
[SerializeField] AbilityType _requiredAbility;
[SerializeField] bool _onlyOnce = true; // ⚠️ 建议保持 true
void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
// 检查能力条件(仅当 _requiresAbility = true 时)
if (_requiresAbility)
{
var stats = other.GetComponent<PlayerStats>();
if (stats == null || !stats.HasAbility(_requiredAbility)) return;
}
var text = LocalizationManager.Get(LocalizationManager.Table_UI, _hintTextKey); // ⚠️ LocalizationManager 为静态类(Architecture 16 §1),无 Instance;方法为 Get(tableKey, entryKey)
TutorialManager.Instance.ShowHint(_hintId, text, _displayDuration);
if (_onlyOnce)
{
TutorialManager.Instance.CompleteHint(_hintId);
gameObject.SetActive(false); // 触发后禁用自身,避免重复
}
}
}
}
3. Week 11:液态谜题模块 ✅ 完成(2026-05-11)
参考文档:21_LiquidPuzzleModule.md
3.0 液体数据层
// Assets/Scripts/World/Liquid/LiquidType.cs
// ⚠️ 按 Architecture 21_LiquidPuzzleModule §2 实现
namespace BaseGames.World.Liquid
{
public enum LiquidType
{
Water, // 可游泳(需 Swim 能力)
ShallowWater, // ⚠️ 浅水(水中慢走,无需游泳能力,速度 ×0.65,架构 21 §2)
Mud, // ⚠️ 泥水(移动极慢,无需游泳能力,速度 ×0.50,架构 21 §2)
Acid, // 接触即死(HazardZone 处理)
Lava, // 接触即死(HazardZone 处理)
}
}
// Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs
// ⚠️ 按 Architecture 21_LiquidPuzzleModule §3 实现
[CreateAssetMenu(menuName = "World/LiquidPhysicsConfig")]
public class LiquidPhysicsConfigSO : ScriptableObject
{
[Header("水下物理")]
[Range(0f, 1f)]
public float GravityScale = 0.3f;
[Range(0f, 1f)]
public float BuoyancyForce = 0.5f;
public float MaxSwimSpeed = 4.0f;
public float SwimAcceleration = 8.0f;
public float SurfaceExitSpeed = 5.0f;
public float SinkSpeed = 2.0f; // ⚠️ 无游泳能力时自然下沉速度(架构 21 §3)
public float DiveSpeedMultiplier = 1.5f; // ⚠️ 主动下潜时的速度倍率(架构 21 §3)
[Header("浅水/泥水速度缩放")]
[Range(0.1f, 1.0f)]
public float ShallowSpeedScale = 0.65f; // ⚠️ ShallowWater 类型水平移动速度倍率(架构 21 §3)
[Range(0.1f, 1.0f)]
public float MudSpeedScale = 0.50f; // ⚠️ Mud 类型水平移动速度倍率(架构 21 §3)
[Header("溺死计时(无游泳能力时)")]
public float DrownTime = 3.0f; // ⚠️ 屏气倒计时(秒),倒计时结束则触发死亡(架构 21 §3)
[Header("进出液体")]
public float SplashEntryDelay = 0.05f;
public float DragCoefficient = 3.0f;
[Header("视觉")]
public VolumeProfile WaterVolumeProfile; // 水下后处理 Profile(可为 null)
}
3.1 LiquidZone
// Assets/Scripts/World/Liquid/LiquidZone.cs
// 定义液体区域,玩家进入时切换 SwimState
// ⚠️ 事件频道类型为 LiquidEventChannelSO,携带 LiquidZone 引用(SwimState 需要 PhysicsConfig)
namespace BaseGames.World.Liquid // ⚠️ 必须有命名空间
{
[RequireComponent(typeof(Collider2D))]
public class LiquidZone : MonoBehaviour
{
[SerializeField] LiquidType _liquidType = LiquidType.Water; // Water / Acid / Lava
// ⚠️ 溺水伤害(Water 类型专用;Acid/Lava 由子节点 HazardZone 处理)——架构 21_LiquidPuzzleModule §3
[SerializeField] bool _dealsDrowningDamage = false;
[SerializeField] float _drowningDamagePerSecond = 5f;
[SerializeField] LiquidPhysicsConfigSO _physicsConfig; // 浮力/速度配置
[Header("Event Channels")]
[SerializeField] LiquidEventChannelSO _onPlayerEntered; // payload = 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 个状态):
// Assets/Scripts/Player/States/SwimState.cs
namespace BaseGames.Player.States
{
public class SwimState : PlayerStateBase
{
[SerializeField] LiquidPhysicsConfigSO _physics; // 由 LiquidZone 注入
[SerializeField] ClipTransition _swimIdleClip;
[SerializeField] ClipTransition _swimMoveClip;
LiquidZone _currentZone;
float _originalGravity;
// 由 PlayerController 在收到 EVT_LiquidEntered 后调用
public void SetLiquidZone(LiquidZone zone) => _currentZone = zone;
public override void OnEnter()
{
_originalGravity = RB.gravityScale;
RB.gravityScale = _currentZone?.Physics.GravityScale ?? 0.3f;
Animancer.Play(_swimIdleClip);
}
public override void OnExit()
{
RB.gravityScale = _originalGravity;
}
public override void OnUpdate()
{
var input = Input.Move;
if (input != Vector2.zero)
{
var targetVel = input * (_currentZone?.Physics.MaxSwimSpeed ?? 4f);
RB.linearVelocity = Vector2.MoveTowards(
RB.linearVelocity, targetVel,
(_currentZone?.Physics.SwimAcceleration ?? 8f) * Time.deltaTime
);
Animancer.Play(_swimMoveClip);
}
else
{
// 水下浮力(持续向上的微弱力)
RB.AddForce(Vector2.up * (_currentZone?.Physics.BuoyancyForce ?? 0.5f),
ForceMode2D.Force);
Animancer.Play(_swimIdleClip);
}
// 跳跃键 = 跃出水面
if (Input.JumpPressed)
{
RB.AddForce(Vector2.up * (_currentZone?.Physics.SurfaceExitSpeed ?? 5f),
ForceMode2D.Impulse);
}
// 施加水阻
RB.linearVelocity *= 1f - _currentZone?.Physics.DragCoefficient * Time.deltaTime ?? 0f;
}
public override PlayerStateBase GetNextState()
{
// 离开液体区域由 PlayerController 订阅 EVT_LiquidExited 后切换到 FallState
return null;
}
}
}
进入条件:PlayerStats.HasAbility(AbilityType.Swim) 为 true(否则在液体内受伤;Acid/Lava 由 HazardZone InstantKill 处理)
3.2 液态谜题机关三件套
Valve.cs ← 可交互旋转阀,控制液体流动开关
Pump.cs ← 需消耗 SoulPower 激活,提升液面
Drain.cs ← 破坏性排水(一次性,DestructibleTile 变体)
3.3 LiquidPuzzleController
// Assets/Scripts/World/Liquid/LiquidPuzzleController.cs
// 管理一个谜题区域内的液位状态
public class LiquidPuzzleController : MonoBehaviour, ISaveable
{
[SerializeField] private float _currentLevel; // 0~1 归一化液位
[SerializeField] private float _targetLevel; // 目标液位(谜题目标)
[SerializeField] private Transform _waterSurface; // 视觉层
// 阀门/泵驱动液位变化
public void RaiseLiquid(float amount);
public void DrainLiquid(float amount);
// 每帧平滑插值 _waterSurface.position
// 液位达到 _targetLevel → 触发谜题完成事件
}
3.4 谜题机关系统(PuzzleSwitch / PuzzleReceiver / PuzzleWire)
参考文档:
21_LiquidPuzzleModule.md §7–§11(Part B · 谜题架构)
命名空间:BaseGames.Puzzle
核心接口(namespace BaseGames.Puzzle,对应 Architecture 21 §8):
// Assets/Scripts/World/Puzzle/ (均属于 namespace BaseGames.Puzzle)
namespace BaseGames.Puzzle
{
/// <summary>任何可被切换激活/停用状态的谜题元素。</summary>
public interface ISwitchable
{
bool IsActive { get; }
event Action<bool> OnStateChanged;
void ForceState(bool active); // SaveData 恢复时调用
}
/// <summary>可被玩家推动的物件(需 Rigidbody2D)。</summary>
public interface IMovable
{
bool CanBePushed { get; }
void OnPushStart(Vector2 direction);
void OnPushEnd();
}
/// <summary>接受激活信号后改变自身状态的物件。</summary>
public interface IActivatable
{
void Activate();
void Deactivate();
bool IsActivated { get; }
}
}
PuzzleSwitch(通用开关):
// Assets/Scripts/World/Puzzle/PuzzleSwitch.cs
namespace BaseGames.Puzzle // ⚠️ 必须有命名空间
{
// 实现 ISwitchable + IInteractable
[RequireComponent(typeof(Collider2D))]
public class PuzzleSwitch : MonoBehaviour, ISwitchable, IInteractable
{
[Header("触发模式")]
[SerializeField] SwitchTriggerMode _mode = SwitchTriggerMode.InteractOnce;
[Header("状态")]
[SerializeField] bool _startsActive = false;
[SerializeField] string _switchId; // 持久化唯一 ID(存档用,空串则不持久化)
[Header("视觉")]
[SerializeField] AnimancerComponent _animancer;
[SerializeField] AnimationClip _activeClip;
[SerializeField] AnimationClip _inactiveClip;
[SerializeField] MMF_Player _activateFeedback;
// ⚠️ SO 注入(架构 14_NarrativeModule §8 patch):不使用 WorldStateRegistry.Instance
[SerializeField] WorldStateRegistry _worldState;
bool _isActive;
public bool IsActive => _isActive;
public event Action<bool> OnStateChanged;
void Start() => _isActive = _startsActive; // ⚠️ 初始化初始状态
// IInteractable
public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互";
public bool CanInteract => true;
public void Interact(Transform player)
{
if (_mode == SwitchTriggerMode.InteractOnce && _isActive) return;
SetState(!_isActive);
}
public void OnPlayerEnterRange(Transform player) { } // ⚠️ IInteractable 必需实现
public void OnPlayerExitRange() { } // ⚠️ IInteractable 必需实现
// ISwitchable
public void ForceState(bool active) => SetState(active);
// 压板模式:OnTriggerEnter2D / OnTriggerExit2D ⚠️ 缺少则 Pressure 模式无法工作
void OnTriggerEnter2D(Collider2D col)
{
if (_mode != SwitchTriggerMode.Pressure) return;
if (col.CompareTag("Player") || col.CompareTag("PushBox"))
SetState(true);
}
void OnTriggerExit2D(Collider2D col)
{
if (_mode != SwitchTriggerMode.Pressure) return;
if (col.CompareTag("Player") || col.CompareTag("PushBox"))
SetState(false);
}
void SetState(bool active)
{
if (_isActive == active) return;
_isActive = active;
if (active) _animancer?.Play(_activeClip);
else _animancer?.Play(_inactiveClip);
_activateFeedback?.PlayFeedbacks();
OnStateChanged?.Invoke(active);
// ⚠️ SetFlag 双参数(架构 08_WorldModule §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(接收器 + 门子类):
// 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(逻辑连接器):
// Assets/Scripts/World/Puzzle/PuzzleWire.cs
// 将一个或多个 PuzzleSwitch 连接到 PuzzleReceiver,支持 AND/OR/XOR 逻辑
// Inspector 中配置,关卡设计师无需写代码
// ⚠️ 字段名:_switches / _receiver;枚举类型:LogicType(架构 §3.4 patch,非 WireLogic/WireLogicMode)
namespace BaseGames.Puzzle // ⚠️ 必须有命名空间
{
public class PuzzleWire : MonoBehaviour
{
[Header("输入开关")]
[SerializeField] PuzzleSwitch[] _switches; // ⚠️ 非 _inputs
[Header("激活逻辑")]
[SerializeField] LogicType _logic = LogicType.AND; // ⚠️ 枚举 LogicType(架构 patch)
[Header("目标接收器")]
[SerializeField] PuzzleReceiver _receiver; // ⚠️ 非 _output
void Start()
{
foreach (var sw in _switches)
sw.OnStateChanged += _ => Evaluate();
Evaluate(); // 初始求值
}
void Evaluate()
{
bool shouldActivate = _logic switch
{
LogicType.AND => System.Array.TrueForAll(_switches, s => s.IsActive),
LogicType.OR => System.Array.Exists(_switches, s => s.IsActive),
LogicType.XOR => _switches.Count(s => s.IsActive) % 2 == 1,
_ => false,
};
if (shouldActivate) _receiver.Activate();
else _receiver.Deactivate();
}
}
public enum LogicType { AND, OR, XOR } // ⚠️ 枚举名为 LogicType(架构 §3.4 patch)
} // namespace BaseGames.Puzzle
3.3 脚步声材质系统(FootstepMaterial)
⚠️ 延迟集成:本系统在 Phase 2 中已预留(架构 11 §9-10),与玩家游泳状态(SwimState)及 Animancer 动画事件联动,至 Phase 3 Week 11 随 LiquidZone 一并集成。
// Assets/Scripts/Audio/FootstepMaterial.cs
public enum FootstepMaterial
{
Stone, // 石板地(默认)
Dirt, // 泥土/草地
Wood, // 木板
Metal, // 金属格栅
Water, // 浅水区(溅水声)
Sand, // 沙地
Grass, // 草丛
Cave, // 洞穴(回响加强)
}
// Assets/Scripts/Audio/FootstepAudioConfigSO.cs
[CreateAssetMenu(menuName = "BaseGames/Audio/FootstepAudioConfig")]
public class FootstepAudioConfigSO : ScriptableObject
{
[System.Serializable]
public struct MaterialEntry
{
public FootstepMaterial material;
public AudioClip[] clips; // 随机选一个,防止重复感
[Range(0f, 1f)] public float volume;
[Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围
}
public MaterialEntry[] entries;
public MaterialEntry? GetEntry(FootstepMaterial mat)
{
foreach (var e in entries)
if (e.material == mat) return e;
return null;
}
}
// Assets/Scripts/Audio/FootstepMaterialMarker.cs
// 挂载到地面碰撞体所在 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)
// Assets/Scripts/Audio/UnderwaterAudioController.cs
// 挂载于 PlayerController 所在 GameObject;LiquidZone 调用 EnterWater/ExitWater
public class UnderwaterAudioController : MonoBehaviour
{
[SerializeField] AudioMixer _mixer;
[SerializeField] float _transitionDuration = 0.3f;
/// <summary>LiquidZone.OnTriggerEnter2D 时调用</summary>
public void EnterWater()
{
_mixer.FindSnapshot("Underwater").TransitionTo(_transitionDuration);
}
/// <summary>LiquidZone.OnTriggerExit2D 时调用</summary>
public void ExitWater()
{
_mixer.FindSnapshot("Default").TransitionTo(_transitionDuration);
}
}
Underwater Snapshot DSP 配置(AudioMixer 预设):
| Bus | 处理 |
|---|---|
| BGM | Low-Pass 800 Hz |
| SFX | Low-Pass 1200 Hz + Volume ×0.7 |
| Ambient | Volume ×0,替换为水下环境音(气泡声) |
| PlayerSFX | Low-Pass 1000 Hz |
3.5 WaterDangerState — 溺水倒计时(⚠️ 架构 21 §12,原 Plan 遗漏)
当玩家进入
Water类型液体且未解锁游泳能力时,触发溺水倒计时。挂在PlayerController子节点[WaterDanger]上。
// Assets/Scripts/World/Liquid/WaterDangerState.cs
// ⚠️ 订阅 LiquidEventChannelSO(携带 LiquidEvent struct,非 LiquidZone 引用)
// ⚠️ 使用 PlayerStats.HasAbility 替代 AbilityInventorySO(项目实际 API,无额外 SO 依赖)
public class WaterDangerState : MonoBehaviour
{
[SerializeField] private LiquidPhysicsConfigSO _config;
[SerializeField] private PlayerStats _playerStats; // 检查 Swim 能力
[SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered
[SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited
[SerializeField] private FloatEventChannelSO _onDrownProgress; // 0~1 倒计时进度(HUD 用)→ EVT_DrownProgress
[SerializeField] private VoidEventChannelSO _onPlayerDrowned; // 触发死亡 → EVT_PlayerDrowned
private float _drownTimer;
private bool _isActive;
private void OnEnable()
{
if (_onLiquidEntered != null) _onLiquidEntered.OnEventRaised += OnEnterLiquid;
if (_onLiquidExited != null) _onLiquidExited.OnEventRaised += OnExitLiquid;
}
private void OnDisable()
{
if (_onLiquidEntered != null) _onLiquidEntered.OnEventRaised -= OnEnterLiquid;
if (_onLiquidExited != null) _onLiquidExited.OnEventRaised -= OnExitLiquid;
}
public void OnEnterLiquid(LiquidEvent evt)
{
if (evt.LiquidType != nameof(LiquidType.Water)) return;
if (_playerStats != null && _playerStats.HasAbility(AbilityType.Swim)) return;
_isActive = true;
_drownTimer = _config != null ? _config.DrownTime : 3f;
}
public void OnExitLiquid(LiquidEvent evt)
{
_isActive = false;
_drownTimer = _config != null ? _config.DrownTime : 3f;
_onDrownProgress?.Raise(0f);
}
private void Update()
{
if (!_isActive) return;
_drownTimer -= Time.deltaTime;
float drownTime = _config != null ? _config.DrownTime : 3f;
_onDrownProgress?.Raise(1f - (_drownTimer / drownTime));
if (_drownTimer <= 0f)
{
_isActive = false;
_onPlayerDrowned?.Raise();
}
}
}
事件频道补充:
EVT_DrownProgress(FloatEventChannelSO):WaterDangerState→HUDController(溺水进度条)EVT_PlayerDrowned(VoidEventChannelSO):WaterDangerState→GameManager(触发死亡流程)
3.6 UnderwaterPostProcessingController — 水下后处理(⚠️ 架构 21 §13,原 Plan 遗漏)
订阅
EVT_LiquidEntered/EVT_LiquidExited,渐变启用/停用 水下专属Volume(颜色滤镜/色差/暗角)。使用 Coroutine 混合权重,不依赖 DOTween。
// Assets/Scripts/World/Liquid/UnderwaterPostProcessingController.cs
public class UnderwaterPostProcessingController : MonoBehaviour
{
[SerializeField] private Volume _underwaterVolume; // 水下专属 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.PlayerAbilityGate.cs→Assets/Scripts/World/AbilityGate.cs,程序集BaseGames.World
// Assets/Scripts/Player/AbilityType.cs — 程序集 BaseGames.Player
// ⚠️ 此枚举在 Player 程序集,非 Equipment 或 Spells
// ⚠️ 枚举值必须与架构 09_ProgressionModule §1 完全一致
namespace BaseGames.Player
{
public enum AbilityType
{
// 移动
WallCling,
WallJump,
Dash,
AerialDash, // ⚠️ 空中冲刺,默认锁定,升级后解锁(架构 09_ProgressionModule §1)
InvincibleDash, // ⚠️ 冲刺全程无敌,Dash 升级版(架构 09_ProgressionModule §1)
DoubleJump,
ClimbVines,
Swim, // 游泳(LiquidZone 内切换 SwimState)
// 战斗
Parry,
Spring, // 灵泉反弹
UseTools,
// 互动
ReadShrine,
UseGrapple,
}
}
// Assets/Scripts/World/AbilityGate.cs — 程序集 BaseGames.World
// 检测玩家是否持有对应能力,不满足则阻拦(触发器 + 提示 UI)
// ⚠️ 以架构 09_ProgressionModule §2 为准:含 _blockingObject、_hintUI、_gateId、_saveData 注入
[RequireComponent(typeof(Collider2D))]
public class AbilityGate : MonoBehaviour
{
[SerializeField] private AbilityType _requiredAbility;
[SerializeField] private GameObject _blockingObject; // 实际阻挡物件(禁/启用)
[SerializeField] private GameObject _hintUI; // ⚠️ 提示 UI(能力图标 + "???"),架构 09 §2
[SerializeField] private string _gateId; // 存档 ID(已开启的门不再重置)
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onAbilityUnlocked; // ⚠️ StringEventChannelSO(架构 02 §4 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事件频道。
// ⚠️ 完整实现以架构 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<PlayerController>()?.Stats.UnlockAbility(_abilityToUnlock);
_onCollectiblePickup.Raise(_unlockId); // → WorldStateRegistry 记录 + QuestManager 追踪
// 触发解锁演出(Cutscene / UI 提示;Phase 4 完善)
gameObject.SetActive(false);
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
// 存档集成(由 WorldStateRegistry 通过 _onCollectiblePickup 驱动)
public void SetCollected(bool val)
{
_isCollected = val;
if (val) gameObject.SetActive(false);
}
}
4.1 CharmSO + ICharmEffect + EquipmentContext
// Assets/Scripts/Equipment/CharmSO.cs — 程序集 BaseGames.Equipment
[CreateAssetMenu(menuName = "Equipment/Charm")]
public class CharmSO : ScriptableObject
{
[Header("Identity")]
public string charmId;
public string displayNameKey; // 本地化 Key
[TextArea(2,4)]
public string descriptionKey; // 本地化 Key
[Header("Visual")]
public Sprite icon;
public Color glowColor;
[Header("Slot Cost")]
[Range(1,4)]
public int notchCost; // 占用笔记数(1~4)
[Header("Effects")]
[SerializeReference]
public List<ICharmEffect> effects; // 多态序列化,支持多个效果叠加
[Header("Lore")]
public bool isUnique; // 唯一物品,不可重复装备
public string unlockHint;
}
// Assets/Scripts/Equipment/ICharmEffect.cs
[System.Serializable]
public interface ICharmEffect
{
void OnEquip(EquipmentContext ctx);
void OnUnequip(EquipmentContext ctx);
string GetEffectDescription();
}
// ⚠️ 解耦上下文:struct(非 class),架构 09 §4
public struct EquipmentContext
{
public PlayerStats Stats;
public PlayerFeedback Feedback; // ⚠️ 架构 09 §4(原 Plan 遗漏)
public EventChannelRegistry Events; // ⚠️ SO 事件频道注册表(架构 09 §4,原 Plan 遗漏)
public SkillModifierRegistry SkillMods; // ⚠️ 字段名 SkillMods(非 SkillModifiers),架构 09 §4
public WeaponManager WeaponMgr; // ⚠️ 字段名 WeaponMgr(非 Weapons),架构 09 §4
}
// Assets/Scripts/Equipment/Effects/ — 内置 CharmEffect 实现(Architecture 09 §5)
// ── 属性加成 ──────────────────────────────────────────────────────────
[Serializable]
public class StatModifierEffect : ICharmEffect
{
public StatType statType; // ⚠️ 字段名 statType(非 Stat),架构 09 §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<DamageInfoEventChannelSO>("OnHitConfirmed"); // ⚠️ 架构 09 §5
_onHitChannel.OnEventRaised += HandleHit;
}
public void OnUnequip(EquipmentContext ctx) => _onHitChannel.OnEventRaised -= HandleHit;
private void HandleHit(DamageInfo info)
{
if (UnityEngine.Random.value > chance) return;
// 触发对应效果(由 StatusEffectManager 处理,见 06_CombatModule §12)
}
public string GetEffectDescription() => $"命中时 {chance * 100:0}% 概率附加 {effectType}";
}
// ── 灵魂法术强化 ──────────────────────────────────────────────────────
[Serializable]
public class SoulSpellEffect : ICharmEffect // ⚠️ 架构 09 §5(原 Plan 遗漏)
{
public SpellType spellType; // SoulAttack / HealingWave
public int soulCostReduction; // 减少消耗 Soul 点数
public void OnEquip(EquipmentContext ctx)
=> ctx.Stats.RegisterSpellModifier(spellType, soulCostReduction, 0f);
public void OnUnequip(EquipmentContext ctx)
=> ctx.Stats.UnregisterSpellModifier(spellType, soulCostReduction, 0f);
public string GetEffectDescription() => $"{spellType} 消耗减少 {soulCostReduction} Soul";
}
// ── 技能数值修改 ──────────────────────────────────────────────────────
[Serializable]
public class SkillNumericModifierEffect : ICharmEffect
{
public string TargetSkillId;
public SkillStat Stat; // enum: Damage, Cost, Cooldown, Range, Duration
public float Delta;
public bool IsPercent;
public void OnEquip(EquipmentContext ctx) => ctx.SkillMods.Register(TargetSkillId, Stat, Delta, IsPercent); // ⚠️ ctx.SkillMods(非 ctx.SkillModifiers),架构 09 §5
public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.Unregister(TargetSkillId, Stat, Delta, IsPercent);
public string GetEffectDescription() => $"{TargetSkillId}.{Stat} {(Delta >= 0 ? "+" : "")}{Delta}";
}
// ── 技能插槽替换 ──────────────────────────────────────────────────────
[Serializable]
public class SkillSlotOverrideEffect : ICharmEffect // ⚠️ 架构 09 §5(原 Plan 遗漏)
{
public SkillSlotOverride overrideData; // targetForm / targetSlot / replacementSkill / priority
public void OnEquip(EquipmentContext ctx) => ctx.SkillMods.AddSlotOverride(overrideData);
public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.RemoveSlotOverride(overrideData);
public string GetEffectDescription()
{
string formStr = overrideData.targetForm != null ? overrideData.targetForm.name : "所有形态";
string skillName = overrideData.replacementSkill != null ? overrideData.replacementSkill.displayNameKey : "null";
return $"{formStr}的 {overrideData.targetSlot} 替换为 [{skillName}]";
}
}
Phase 3 实现以下 Charm(最小集,验证系统可用):
| CharmId | 效果 |
|---|---|
Charm_VoidHeart |
MaxHP +2 |
Charm_QuickSlash |
AttackSpeed ×1.3(通过 AnimancerClip 速度倍率) |
Charm_SoulCatcher |
命中时获得的 SoulPower ×1.5 |
4.2 EquipmentManager
// Assets/Scripts/Equipment/EquipmentManager.cs — 程序集 BaseGames.Equipment
public class EquipmentManager : MonoBehaviour
{
[Header("配置")]
[SerializeField] private EquipmentConfigSO _config; // ⚠️ SO 配置(非 int _totalNotches),含 initialNotchCount,架构 09 §6
[Header("Event Channels")]
[SerializeField] private CharmEventChannelSO _onCharmEquipped; // ⚠️ 架构 09 §6(原 Plan 遗漏)
[SerializeField] private CharmEventChannelSO _onCharmUnequipped; // ⚠️ 架构 09 §6(原 Plan 遗漏)
[SerializeField] private VoidEventChannelSO _onEquipmentChanged;
private List<CharmSO> _equipped = new(4);
private List<CharmSO> _collected = new(32);
private int _currentNotchCapacity;
private EquipmentContext _ctx; // ⚠️ 私有字段,在 Awake() 中构建(非 [SerializeField]),架构 09 §6
private void Awake()
{
// ⚠️ EquipmentContext 在 Awake 中通过 GetComponent 构建(架构 09 §6)
_ctx = new EquipmentContext
{
Stats = GetComponent<PlayerStats>(),
Feedback = GetComponent<PlayerFeedback>(),
Events = EventChannelRegistry.Instance,
SkillMods = GetComponent<SkillModifierRegistry>(),
WeaponMgr = GetComponent<WeaponManager>(),
};
_currentNotchCapacity = _config != null ? _config.initialNotchCount : 3;
}
public int UsedNotches => _equipped.Sum(c => c.notchCost);
public int TotalNotches => _currentNotchCapacity;
public IReadOnlyList<CharmSO> Equipped => _equipped;
public IReadOnlyList<CharmSO> Collected => _collected;
/// <summary>装备护符。返回失败原因(null = 成功)。⚠️ 返回 string(非 bool),架构 09 §6</summary>
public string TryEquipCharm(CharmSO charm) // ⚠️ 方法名 TryEquipCharm(非 TryEquip),返回 string,架构 09 §6
{
if (_equipped.Contains(charm)) return "已经装备";
if (!_collected.Contains(charm)) return "尚未收集此魅力";
if (UsedNotches + charm.notchCost > _currentNotchCapacity)
return $"笔记不足(需要 {charm.notchCost},剩余 {_currentNotchCapacity - UsedNotches})";
_equipped.Add(charm);
foreach (var fx in charm.effects) fx.OnEquip(_ctx);
_onCharmEquipped.Raise(charm);
_onEquipmentChanged.Raise();
return null;
}
public void UnequipCharm(CharmSO charm) // ⚠️ 方法名 UnequipCharm(非 Unequip),架构 09 §6
{
if (!_equipped.Remove(charm)) return;
foreach (var fx in charm.effects) fx.OnUnequip(_ctx);
_onCharmUnequipped.Raise(charm);
_onEquipmentChanged.Raise();
}
// 收集(从 Collectible / ShopController 调用,传 charmId 字符串)
public void AddToCollection(string charmId);
public void IncreaseNotches(int amount) => _currentNotchCapacity += amount;
// 存档集成(非 ISaveable,直接调用)
public EquipmentSaveData GetSaveData();
public void LoadSaveData(EquipmentSaveData data);
}
4.3 ToolSO + FormSkillSO
程序集位置(架构
09_ProgressionModule §7–8):
FormSkillSO、SkillManager、SkillModifierRegistry→Assets/Scripts/Skills/,程序集BaseGames.SpellsToolSO、EquipmentManager→Assets/Scripts/Equipment/,程序集BaseGames.Equipment
ToolSO(主动工具,如抓钩/炸弹/气球):
// Assets/Scripts/Equipment/ToolSO.cs — 程序集 BaseGames.Equipment
[CreateAssetMenu(menuName = "Equipment/Tool")]
public class ToolSO : ScriptableObject
{
public string toolId;
public string displayNameKey; // 本地化 Key
public Sprite icon;
public int maxUses; // -1 = 无限次数
[SerializeReference]
public IToolEffect effect; // 工具使用效果(多态,Strategy 模式)
}
public interface IToolEffect
{
void Use(PlayerController player);
}
FormSkillSO(各形态魂技能):
// Assets/Scripts/Skills/FormSkillSO.cs — 程序集 BaseGames.Spells
[CreateAssetMenu(menuName = "Skills/FormSkill")]
public class FormSkillSO : ScriptableObject
{
[Header("Identity")]
public string skillId;
public string displayNameKey;
[TextArea(1,3)] public string descriptionKey;
public Sprite icon;
[Header("Resource")]
public SkillResourceType resourceType; // SoulPower / SpiritPower
public int baseCost;
public float cooldown;
[Header("Animation")]
public ClipTransition castAnimation; // Animancer Pro ClipTransition
public float castLockDuration; // 秒,动画锁帧时长
[Header("Effect")]
public SkillEffectType effectType;
public DamageSourceSO damageSource;
[Header("Projectile")]
public ProjectileConfigSO projectileConfig;
public bool isHoming;
public bool holdForContinuous;
[Header("Dash")]
public float dashForce;
public float dashDuration;
public bool isInvincibleDuringDash;
[Header("Explosion")]
public float explosionDelay;
public float explosionRadius;
[Header("Feedback")]
public FeedbackPresetSO castFeedback;
[Header("HitBox Prefab")]
public GameObject SkillHitBoxPrefab; // 近战/爆炸技能命中盒 Prefab;投射物技能留空
}
public enum SkillResourceType { SoulPower, SpiritPower }
public enum SkillEffectType
{
MeleeAoE, Projectile, BarrierAura, GroundDive,
DragonKick, WraithDash, ShadowDecoy, DelayedExplosion
}
4.3.5 ToolSlotManager + ToolHUD(Architecture 09 §7.5)
// Assets/Scripts/Equipment/ToolSlotManager.cs — 程序集 BaseGames.Equipment
public class ToolSlotManager : MonoBehaviour, ISaveable
{
private const int SlotCount = 2;
[SerializeField] private ToolSO[] _slots = new ToolSO[SlotCount];
[SerializeField] private int[] _remainingUses = new int[SlotCount]; // -1 = 无限
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
private float[] _cooldowns = new float[SlotCount];
/// <summary>装备工具到指定槽位</summary>
public void EquipTool(int slotIndex, ToolSO tool)
{
_slots[slotIndex] = tool;
_remainingUses[slotIndex] = tool?.maxUses ?? -1;
_cooldowns[slotIndex] = 0f;
}
/// <summary>使用槽位工具:检查冷却/次数 → 执行 IToolEffect → 触发事件</summary>
public bool TryUseTool(int slotIndex, PlayerController player)
{
var tool = _slots[slotIndex];
if (tool == null) return false;
if (_cooldowns[slotIndex] > 0f) return false;
if (_remainingUses[slotIndex] == 0) return false;
tool.effect?.Use(player);
if (_remainingUses[slotIndex] > 0) _remainingUses[slotIndex]--;
_cooldowns[slotIndex] = (tool is IToolCooldown cd) ? cd.CooldownDuration : 0f;
_onToolUsed.Raise(new ToolUsedPayload { SlotIndex = slotIndex, Tool = tool });
return true;
}
private void Update()
{
for (int i = 0; i < SlotCount; i++)
if (_cooldowns[i] > 0f) _cooldowns[i] -= Time.deltaTime;
}
public ToolSO GetTool(int slotIndex) => _slots[slotIndex];
public float GetCooldownRatio(int slotIndex) => _cooldowns[slotIndex] / GetMaxCooldown(slotIndex);
public int GetRemainingUses(int slotIndex) => _remainingUses[slotIndex];
private float GetMaxCooldown(int i)
=> (_slots[i] is IToolCooldown cd) ? cd.CooldownDuration : 1f;
// ISaveable
public void OnSave(SaveData data)
{
data.Tools.ToolSlot0 = _slots[0]?.toolId;
data.Tools.ToolSlot1 = _slots[1]?.toolId;
}
public void OnLoad(SaveData data);
}
/// <summary>工具冷却接口(实现该接口的 ToolSO 才会有冷却)</summary>
public interface IToolCooldown
{
float CooldownDuration { get; }
}
// Assets/Scripts/UI/ToolHUD.cs
public class ToolHUD : MonoBehaviour
{
[SerializeField] private ToolSlotUI[] _slots;
[SerializeField] private ToolSlotManager _slotManager;
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
private void OnEnable() => _onToolUsed.OnEventRaised += RefreshSlot;
private void OnDisable() => _onToolUsed.OnEventRaised -= RefreshSlot;
private void RefreshSlot(ToolUsedPayload payload)
=> _slots[payload.SlotIndex].Refresh(
_slotManager.GetTool(payload.SlotIndex),
_slotManager.GetRemainingUses(payload.SlotIndex),
_slotManager.GetCooldownRatio(payload.SlotIndex));
private void Update()
{
// 实时更新冷却遮罩(每帧刷新)
for (int i = 0; i < _slots.Length; i++)
_slots[i].SetCooldownFill(_slotManager.GetCooldownRatio(i));
}
}
public class ToolSlotUI : MonoBehaviour
{
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _usesText;
[SerializeField] private Image _cooldownMask; // fillAmount 冷却遮罩
public void Refresh(ToolSO tool, int remainingUses, float cooldownRatio)
{
_icon.sprite = tool != null ? tool.icon : null;
_usesText.text = tool == null || tool.maxUses < 0 ? "" : remainingUses.ToString();
_cooldownMask.fillAmount = cooldownRatio;
}
public void SetCooldownFill(float ratio) => _cooldownMask.fillAmount = ratio;
}
4.4 SkillManager + SkillModifierRegistry
// Assets/Scripts/Skills/SkillManager.cs — 程序集 BaseGames.Spells
// SkillManager:管理当前形态可用技能,通过 InputReaderSO 事件驱动技能释放
public class SkillManager : MonoBehaviour
{
[SerializeField] private PlayerStats _stats;
[SerializeField] private PlayerController _controller;
[SerializeField] private InputReaderSO _input;
[SerializeField] private SkillModifierRegistry _modifiers;
[SerializeField] private Transform _skillSocket; // [SkillSocket] 子节点引用
[SerializeField] private GlobalObjectPool _pool; // ⚠️ 投射物/技能命中盒从池中取(Architecture 09 §9)
private FormSkillSO _soulSkill, _spirit1, _spirit2;
private float _soulCooldown, _spirit1Cooldown, _spirit2Cooldown;
private void OnEnable()
{
_input.SoulSkillEvent += TrySoulSkill;
_input.SpiritSkill1StartedEvent += TrySpiritSkill1; // ⚠️ SpiritSkill1StartedEvent(架构 04_InputModule §2 line 103),非 SpiritSkill1Event
_input.SpiritSkill2StartedEvent += TrySpiritSkill2; // ⚠️ SpiritSkill2StartedEvent(架构 04_InputModule §2 line 105),非 SpiritSkill2Event
}
private void OnDisable()
{
_input.SoulSkillEvent -= TrySoulSkill;
_input.SpiritSkill1StartedEvent -= TrySpiritSkill1;
_input.SpiritSkill2StartedEvent -= TrySpiritSkill2;
}
// 切换形态时由 FormController 调用(⚠️ 单参数 FormSO,架构 09 §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<DamageInfo> OnHitConfirmed;
private void Awake()
{
foreach (var hb in _hitBoxes)
hb.OnHitConfirmed += info => OnHitConfirmed?.Invoke(info);
}
public void Activate(DamageSourceSO source, Transform attacker)
{
foreach (var hb in _hitBoxes) hb.Activate(source, attacker);
}
public void AutoDestroyAfter(float duration) => Destroy(gameObject, duration);
private void OnDestroy()
{
foreach (var hb in _hitBoxes) hb.Deactivate();
}
}
// Assets/Scripts/Skills/SkillModifierRegistry.cs — 程序集 BaseGames.Spells
// 收集护符对技能数值的修改;SkillManager 查询最终消耗/冷却等
public class SkillModifierRegistry
{
private Dictionary<string, Dictionary<SkillStat, float>> _overrides = new();
public void Register(string skillId, SkillStat stat, float delta, bool isPercent);
public void Unregister(string skillId, SkillStat stat, float delta, bool isPercent);
// ⚠️ 主查询方法(架构 09 §10):一次调用获取全部有效参数快照,供 SkillManager.CastRoutine() 使用
public EffectiveSkillParams GetEffectiveParams(FormSkillSO skill);
// 向后兼容:单字段查询(内部调用 GetEffectiveParams 后提取)
public float GetModifiedValue(string skillId, SkillStat stat, float baseVal);
// ⚠️ 技能插槽覆盖(供 SkillSlotOverrideEffect 使用,架构 09 §5/10)
public void AddSlotOverride(SkillSlotOverride overrideData);
public void RemoveSlotOverride(SkillSlotOverride overrideData);
}
// ⚠️ 所有数值修改器叠加后的运行时参数快照(架构 09 §10,原 Plan 遗漏)
public struct EffectiveSkillParams
{
public FormSkillSO baseSkill; // 原始 SO 引用(不变,供判断 effectType)
public int effectiveCost; // 修改后消耗量
public float effectiveCooldown; // 修改后冷却(秒)
public float damageMult; // 伤害倍率(1.0 = 无增益)
public float rangeMult; // 范围倍率(AoE 半径 / 障壁半径 / 爆炸半径)
public FeedbackPresetSO effectiveFeedback; // 最终特效预设(护符可替换,null = 回退原始)
public ClipTransition effectiveAnimation; // 最终施法动画(护符可替换,null = 回退原始)
/// <summary>以技能 SO 默认值初始化,无任何修改器加成。</summary>
public static EffectiveSkillParams FromBase(FormSkillSO skill) => new()
{
baseSkill = skill,
effectiveCost = skill.baseCost,
effectiveCooldown = skill.cooldown,
damageMult = 1f,
rangeMult = 1f,
effectiveFeedback = null,
effectiveAnimation = null,
};
}
public enum SkillStat { Damage, Cost, Cooldown, Range, Duration }
4.5 RegionDefinitionSO(区域定义)
⚠️ 此节内容来自架构 09_ProgressionModule §11,原 Plan 遗漏;已补充。
文件:Assets/Scripts/Progression/RegionDefinitionSO.cs,命名规范:Region_{RegionId}.asset
// Assets/Scripts/Progression/RegionDefinitionSO.cs — 程序集 BaseGames.Progression
// 每个区域一个 SO 资产,集中管理区域元数据(音频区域、地图颜色、解锁条件等)
[CreateAssetMenu(menuName = "Progression/RegionDefinition")]
public class RegionDefinitionSO : ScriptableObject
{
public string regionId; // 如 "Cave"(与 AudioZone.regionId 一致)
public string displayName; // 如 "腐蚀洞穴"
public Color mapColor; // 地图 UI 上该区域的颜色标识
public Sprite mapIconSprite; // 地图图标
[Header("解锁条件")]
public string requiredBossDefeated; // 空字符串 = 无条件
public AbilityType requiredAbility; // 默认值 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 击败/道具持有而非能力解锁。
// 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 死亡事件并通知存档系统。
// 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。
// 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
// Assets/Scripts/Quest/QuestSO.cs
// ⚠️ menuName = "Quest/Quest"(架构 22 §2)
namespace BaseGames.Quest
{
[CreateAssetMenu(menuName = "Quest/Quest")]
public class QuestSO : ScriptableObject
{
[Header("标识")]
public string questId;
public string displayName;
[TextArea(2, 6)]
public string description;
public Sprite icon;
[Header("目标链")]
public QuestObjectiveSO[] objectives;
[Header("前置条件")]
public string[] prerequisiteQuestIds;
public int minAffinityToAccept;
[Header("奖励")]
public RewardSO reward;
[Header("失败条件(可选)")]
public bool canFail;
public QuestObjectiveSO failCondition;
[Header("完成后续任务(分支)")]
public QuestBranch[] branches;
}
[Serializable]
public class QuestBranch
{
public string conditionQuestId;
public QuestSO nextQuest;
public string npcDialogueKey;
}
}
// Assets/Scripts/Quest/QuestObjectiveSO.cs
// ⚠️ 多态目标体系(架构 22 §3),抽象基类 + 5 个具体子类,替代旧的单类+ObjectiveType枚举方案
namespace BaseGames.Quest
{
/// <summary>任务目标基类(抽象)。所有具体目标类型均继承此类。</summary>
public abstract class QuestObjectiveSO : ScriptableObject
{
[Header("标识")]
public string objectiveId;
[TextArea(1, 4)]
public string displayText;
public bool IsOptional;
public abstract void RegisterListeners(IQuestObjectiveListener listener);
public abstract void UnregisterListeners(IQuestObjectiveListener listener);
/// <summary>根据当前进度判断目标是否完成。</summary>
public abstract bool EvaluateCompletion(QuestObjectiveState state);
}
[CreateAssetMenu(menuName = "Quest/Objective/TalkToNPC")]
public class TalkToNPCObjective : QuestObjectiveSO
{
public string targetNpcId;
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
}
[CreateAssetMenu(menuName = "Quest/Objective/Defeat")]
public class DefeatEnemyObjective : QuestObjectiveSO
{
public string targetEnemyId;
[Min(1)] public int defeatCount = 1;
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount;
}
[CreateAssetMenu(menuName = "Quest/Objective/Collect")]
public class CollectItemObjective : QuestObjectiveSO
{
public string itemId;
[Min(1)] public int collectCount = 1;
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount;
}
[CreateAssetMenu(menuName = "Quest/Objective/Reach")]
public class ReachAreaObjective : QuestObjectiveSO
{
public string sceneName;
public string markerTag;
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
}
[CreateAssetMenu(menuName = "Quest/Objective/UseSkill")]
public class UseSkillObjective : QuestObjectiveSO
{
public AbilityType requiredAbility;
[Min(1)] public int useCount = 1;
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount;
}
}
// Assets/Scripts/Quest/RewardSO.cs
// ⚠️ 用 unlocksAbility bool + unlockedAbility 字段替代 AbilityType.None(架构 09 §1 无 None 值)
namespace BaseGames.Quest
{
[CreateAssetMenu(menuName = "Quest/Reward")]
public class RewardSO : ScriptableObject
{
public int geo;
public int soulBonus;
public string[] itemIds;
public int affinityBonus;
public string unlockDialogueKey;
public bool unlocksAbility = false; // ⚠️ 替代 AbilityType.None(架构 09 §1)
public AbilityType unlockedAbility; // 仅 unlocksAbility == true 时有效
public void Apply(PlayerStats player)
{
if (geo > 0) player.AddGeo(geo);
if (soulBonus > 0) player.ExtendSoulMax(soulBonus);
if (unlocksAbility) player.UnlockAbility(unlockedAbility);
// ⚠️ InventoryManager 不在架构中(Architecture 总览无此类)
// 物品发放通过 EVT_CollectiblePickup 事件频道路由(itemId string)
// 实际接线在 QuestManager.CompleteQuest 调用处注入 StringEventChannelSO
foreach (var id in itemIds)
Debug.Log($"[Reward] Grant item: {id}"); // 占位 → Phase 实际实现时替换为事件频道
}
}
}
// Assets/Scripts/Quest/ChallengeRoomSO.cs
namespace BaseGames.Challenge
{
[CreateAssetMenu(menuName = "Challenge/ChallengeRoom")]
public class ChallengeRoomSO : ScriptableObject
{
[Header("标识")]
public string challengeId;
public string displayName;
public ChallengeType challengeType;
[Header("波次(非 BossRush)")]
public ChallengeEncounterSO[] encounters;
[Header("Boss Rush")]
public BossRushSequenceSO bossRushSequence;
[Header("限制")]
public float timeLimit; // 0 = 无时限
public bool requireNoHit;
public int minComboRequired;
[Header("奖励")]
public RewardSO firstClearReward;
public RewardSO repeatedReward;
[Header("解锁条件")]
public string[] prerequisiteBossIds;
}
public enum ChallengeType { Survival, TimeTrial, BossRush, NoHit }
}
// Assets/Scripts/Quest/ChallengeEncounterSO.cs
namespace BaseGames.Challenge
{
[CreateAssetMenu(menuName = "Challenge/Encounter")]
public class ChallengeEncounterSO : ScriptableObject
{
[Serializable]
public struct SpawnEntry
{
public string enemyAddressKey;
public Transform spawnPoint;
public int count;
}
public SpawnEntry[] enemies;
public float waveDelay;
}
}
// Assets/Scripts/Quest/BossRushSequenceSO.cs
namespace BaseGames.Challenge
{
[CreateAssetMenu(menuName = "Challenge/BossRushSequence")]
public class BossRushSequenceSO : ScriptableObject
{
[Serializable]
public struct BossEntry
{
public string bossSceneName;
public string bossId;
public float hpRestoreRatio; // 击败本 Boss 后玩家恢复 HP 比例(默认 0.3)
}
public BossEntry[] bosses;
}
}
// Assets/Scripts/Quest/ChallengeRoomTrigger.cs
// ⚠️ 通过 EVT_SceneLoadRequest 频道触发加载(SceneLoader 无 Instance,架构 03 §3)
namespace BaseGames.Challenge
{
[RequireComponent(typeof(Collider2D))]
public class ChallengeRoomTrigger : MonoBehaviour, IInteractable
{
[SerializeField] ChallengeRoomSO _challengeData;
[SerializeField] string _challengeSceneName;
[SerializeField] SceneLoadRequestEventChannelSO _onSceneLoadRequest; // EVT_SceneLoadRequest
public string InteractPrompt => $"进入挑战:{_challengeData.displayName}";
public bool CanInteract => IsUnlocked();
public void Interact(Transform player)
{
if (!IsUnlocked()) return;
_onSceneLoadRequest.Raise(new SceneLoadRequest
{
SceneName = _challengeSceneName,
EntryTransitionId = string.Empty,
ShowLoadingScreen = false,
IsRespawn = false,
});
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
bool IsUnlocked()
{
foreach (var bossId in _challengeData.prerequisiteBossIds)
if (!SaveManager.Instance.IsBossDefeated(bossId)) return false; // ⚠️ 架构 12 §8 确认存在 IsBossDefeated(bossId)
return true;
}
}
}
5.1 QuestManager
// Assets/Scripts/Quest/QuestManager.cs
// ⚠️ 事件频道已分拆(架构 22 §5):替代旧 QuestStateChangedEventChannel 单频道
namespace BaseGames.Quest
{
/// <summary>
/// 运行时任务管理器,挂在 Persistent 场景 [GameManagers] 下。
/// 通过事件频道追踪目标进度,不主动轮询。
/// </summary>
public class QuestManager : MonoBehaviour
{
// ── Inspector ────────────────────────────────────────
[SerializeField] QuestSO[] _allQuests;
[SerializeField] TransformEventChannelSO _onEnemyDied; // EVT_EnemyDied
[SerializeField] StringEventChannelSO _onCollectiblePickup; // EVT_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<string, QuestState> _questStates = new();
readonly Dictionary<string, QuestObjectiveState> _objectiveStates = new(); // ⚠️ 替代旧 _objectiveProgress: Dictionary<string,int>
public static QuestManager Instance { get; private set; }
// ⚠️ 公开属性供 QuestGiver / QuestLogUI 订阅(架构 22 §5)
public StringEventChannelSO OnQuestStarted => _onQuestStarted;
public StringEventChannelSO OnQuestCompleted => _onQuestCompleted;
void Awake() => Instance = this;
void OnEnable()
{
_onEnemyDied.OnEventRaised += HandleEnemyDefeated;
_onCollectiblePickup.OnEventRaised += HandleItemCollected;
_onSceneLoaded.OnEventRaised += HandleSceneLoaded;
_onNpcDialogueCompleted.OnEventRaised += HandleNpcDialogue;
}
void OnDisable()
{
_onEnemyDied.OnEventRaised -= HandleEnemyDefeated;
_onCollectiblePickup.OnEventRaised -= HandleItemCollected;
_onSceneLoaded.OnEventRaised -= HandleSceneLoaded;
_onNpcDialogueCompleted.OnEventRaised -= HandleNpcDialogue;
}
// ── 公共 API ──────────────────────────────────────────
public void AcceptQuest(string questId)
{
if (!CanAccept(questId)) return;
_questStates[questId] = QuestState.Active;
_onQuestStarted.Raise(questId); // ⚠️ 独立频道(架构 22 §5,非 QuestStateChangedEvent)
}
public void CompleteQuest(string questId, PlayerStats player)
{
if (!IsReadyToComplete(questId)) return;
var quest = GetQuestSO(questId);
quest.reward?.Apply(player);
_questStates[questId] = QuestState.Completed;
_onQuestCompleted.Raise(questId); // ⚠️ 独立频道(架构 22 §5)
// 解锁后续任务
foreach (var branch in quest.branches)
{
if (string.IsNullOrEmpty(branch.conditionQuestId) ||
GetState(branch.conditionQuestId) == QuestState.Completed)
{
if (branch.nextQuest != null)
_questStates[branch.nextQuest.questId] = QuestState.Available;
break;
}
}
}
public QuestState GetState(string questId)
=> _questStates.TryGetValue(questId, out var s) ? s : QuestState.Unavailable;
public bool IsReadyToComplete(string questId)
{
var quest = GetQuestSO(questId);
if (quest == null || GetState(questId) != QuestState.Active) return false;
foreach (var obj in quest.objectives)
{
if (!obj.IsOptional && !IsObjectiveComplete(obj)) return false;
}
return true;
}
// ── 存档(非 ISaveable,由 SaveManager 直接访问) ────────────
public IReadOnlyDictionary<string, QuestState> QuestStates => _questStates;
public void LoadFromSaveData(QuestSaveData data)
{
_questStates.Clear();
_objectiveStates.Clear();
foreach (var (id, stateInt) in data.QuestStates)
_questStates[id] = (QuestState)stateInt;
foreach (var (id, progress) in data.ObjectiveProgress)
_objectiveStates[id] = new QuestObjectiveState { progressCount = progress };
}
// ── 私有 ─────────────────────────────────────────────
bool CanAccept(string questId)
{
if (GetState(questId) != QuestState.Available) return false;
var quest = GetQuestSO(questId);
foreach (var pre in quest.prerequisiteQuestIds)
if (GetState(pre) != QuestState.Completed) return false;
return true;
}
bool IsObjectiveComplete(QuestObjectiveSO obj)
{
_objectiveStates.TryGetValue(obj.objectiveId, out var s);
s ??= new QuestObjectiveState();
return obj.EvaluateCompletion(s); // ⚠️ 多态调用(架构 22 §3),替代旧 ObjectiveType switch
}
void HandleEnemyDefeated(Transform enemyTransform)
{
var enemyBase = enemyTransform.GetComponent<EnemyBase>();
if (enemyBase == null) return;
// ⚠️ EnemyId 在 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 }
/// <summary>记录单个目标的运行时进度(架构 22 §5)。</summary>
public class QuestObjectiveState
{
public bool completed = false;
public int progressCount = 0;
}
}
5.2 ChallengeRoomManager
// Assets/Scripts/Quest/ChallengeRoomManager.cs
// ⚠️ 字段名、方法名、事件频道与架构 22_QuestChallengeModule §12 完全对齐
namespace BaseGames.Challenge
{
public class ChallengeRoomManager : MonoBehaviour
{
[SerializeField] ChallengeRoomSO _challengeData; // ⚠️ _challengeData(非 _config)
[SerializeField] StringEventChannelSO _onChallengeCompleted; // → EVT_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<EnemyBase>(out var enemy))
enemy.OnDied += OnEnemyDefeated;
};
}
}
}
void OnEnemyDefeated() // ⚠️ OnEnemyDefeated(),无参数(非 OnEnemyDied(Transform))
{
_remainingEnemies--;
if (_remainingEnemies > 0) return;
_currentEncounterIndex++;
if (_currentEncounterIndex >= _challengeData.encounters.Length)
CompleteChallenge();
else
StartCoroutine(DelayedNextWave(_challengeData.encounters[_currentEncounterIndex].waveDelay));
}
IEnumerator DelayedNextWave(float delay)
{
yield return new WaitForSeconds(delay);
SpawnWave(_currentEncounterIndex);
}
void CompleteChallenge()
{
_isRunning = false;
// ⚠️ 架构 12 §8 确认存在 IsFirstClear(challengeId)
var reward = SaveManager.Instance.IsFirstClear(_challengeData.challengeId)
? _challengeData.firstClearReward
: _challengeData.repeatedReward;
reward?.Apply(_player.Stats);
_onChallengeCompleted.Raise(_challengeData.challengeId);
}
void FailChallenge()
{
_isRunning = false;
_onChallengeFailed.Raise(_challengeData.challengeId);
SaveManager.Instance.QuickLoad(); // ⚠️ 架构 12 §8 确认存在 QuickLoad()(读回快速存档槽)
}
}
} // namespace BaseGames.Challenge
5.3 QuestGiver
// Assets/Scripts/Quest/QuestGiver.cs
// ⚠️ 继承 InteractableNPC(架构 22 §6),不用 [RequireComponent]+MonoBehaviour 组合方式
namespace BaseGames.Quest
{
// 继承 InteractableNPC,负责发布/完成任务并根据任务状态切换对话
public class QuestGiver : InteractableNPC
{
[Header("任务")]
[SerializeField] QuestSO[] _offeredQuests; // ⚠️ QuestSO 数组(架构 22 §6,非单个 string _questId)
[Header("对话版本(根据任务状态切换)")]
[SerializeField] DialogueSequenceSO _availableDialogue; // ⚠️ DialogueSequenceSO 引用(非 string key)
[SerializeField] DialogueSequenceSO _activeDialogue;
[SerializeField] DialogueSequenceSO _readyDialogue;
[SerializeField] DialogueSequenceSO _completedDialogue;
// ⚠️ 不需要 OnEnable/OnDisable 订阅 QuestManager.OnQuestStateChanged(该频道已废弃)
// 对话切换通过 override GetCurrentDialogue() 在每次 Interact 时动态获取
// ── Interact_Internal 覆盖(在启动对话前处理任务逻辑)────────
protected override void Interact_Internal(Transform player) // ⚠️ override Interact_Internal(架构 22 §6)
{
var quest = GetCurrentQuest();
if (quest == null) return;
var state = QuestManager.Instance.GetState(quest.questId);
if (state == QuestState.Available)
QuestManager.Instance.AcceptQuest(quest.questId);
else if (QuestManager.Instance.IsReadyToComplete(quest.questId))
{
// ⚠️ PlayerController 无 Instance;通过 player 参数获取
QuestManager.Instance.CompleteQuest(quest.questId,
player.GetComponent<PlayerController>()?.Stats);
}
}
// ── 返回与当前最高优先级任务状态匹配的对话 SO ──────────────────
protected override DialogueSequenceSO GetCurrentDialogue() // ⚠️ override(架构 22 §6)
{
var quest = GetCurrentQuest();
if (quest == null) return base.GetCurrentDialogue();
return QuestManager.Instance.GetState(quest.questId) switch
{
QuestState.Available => _availableDialogue,
QuestState.Active => QuestManager.Instance.IsReadyToComplete(quest.questId)
? _readyDialogue : _activeDialogue,
QuestState.Completed => _completedDialogue,
_ => base.GetCurrentDialogue(),
};
}
// 返回当前处于 Available 或 Active 状态的第一个任务
QuestSO GetCurrentQuest()
{
if (_offeredQuests == null) return null;
foreach (var q in _offeredQuests)
{
var s = QuestManager.Instance.GetState(q.questId);
if (s == QuestState.Available || s == QuestState.Active) return q;
}
return null;
}
}
} // namespace BaseGames.Quest
6. Week 14:地图/商店/存档迁移 ✅ 完成(P3-5,2026-05-11)
参考文档:15_MapShopModule.md
6.0 Map/Shop 数据层 SO
// Assets/Scripts/World/Map/MapRoomDataSO.cs
[CreateAssetMenu(menuName = "World/Map/RoomData")]
public class MapRoomDataSO : ScriptableObject
{
[Header("基础信息")]
public string RoomId;
public string RegionId;
public string DisplayName;
[Header("地图布局(格子坐标,单位:格)")]
public Vector2Int GridPosition;
public Vector2Int GridSize;
[Header("房间轮廓纹理")]
public Texture2D RoomOutlineTex; // ⚠️ 用于地图 UI 显示房间形状(架构 15 §1.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<string, MapRoomDataSO> _index;
public MapRoomDataSO GetRoom(string roomId)
{
if (_index == null)
_index = AllRooms.ToDictionary(r => r.RoomId);
_index.TryGetValue(roomId, out var r);
return r;
}
}
6.1 MapModule(Fog of War)
// Assets/Scripts/World/Map/MapManager.cs
// ⚠️ 类名为 MapManager,以架构 15_MapShopModule §1 为准(非 MapController)
[DefaultExecutionOrder(-700)]
public class MapManager : MonoBehaviour, ISaveable
{
[SerializeField] private MapDatabaseSO _database; // ⚠️ MapDatabaseSO(非 MapDataSO)
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onRoomEntered; // ⚠️ 订阅此频道(非 EVT_SceneLoaded,房间进入由 RoomTransition 专用频道广播)
[SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时刷新地图
// ⚠️ 必须有 Instance Singleton(架构 15_MapShopModule §1.2;MapPanel.BuildGrid 依赖此字段)
public static MapManager Instance { get; private set; }
// ⚠️ 三级可见性(架构 15 §1.2):
// Unknown → 未进入过(默认)
// Explored → 进入过但未购买地图(显示轮廓/格子)
// Mapped → 已完整获取地图信息(显示图标/名称)
private HashSet<string> _exploredRooms = new(); // ⚠️ 玩家踏入过(非 _discoveredRooms)
private HashSet<string> _mappedRooms = new(); // ⚠️ 完整地图信息(购买 MapFragment 或存档点揭示)
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
}
// ── 事件订阅 ────────────────────────────────────────────────────
private void OnEnable() => _onRoomEntered.OnEventRaised += OnRoomEntered;
private void OnDisable() => _onRoomEntered.OnEventRaised -= OnRoomEntered;
private void OnRoomEntered(string roomId) // ⚠️ private(非 public MarkDiscovered)
{
bool changed = _exploredRooms.Add(roomId);
if (changed) _onMapUpdated.Raise(roomId); // 通知 MapPanel 刷新
}
/// <summary>标记为已完整获取地图信息(购买 MapFragment SO 触发)。⚠️ 架构 15 §1.2</summary>
public void SetMapped(string roomId)
{
_exploredRooms.Add(roomId);
if (_mappedRooms.Add(roomId))
_onMapUpdated.Raise(roomId);
}
// UI 查询
public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId); // ⚠️ 架构 15 §1.2
public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId); // ⚠️ 架构 15 §1.2
public bool IsDiscovered(string roomId) => _exploredRooms.Contains(roomId); // 向后兼容别名
// ── ISaveable ─────────────────────────────────────────────────────
// ⚠️ 存储 ExploredRooms + MappedRooms 两个字段(List<string>),架构 15 §1.2 + §3
public void OnSave(SaveData data)
{
data.Map.ExploredRooms = _exploredRooms.ToList();
data.Map.MappedRooms = _mappedRooms.ToList();
}
public void OnLoad(SaveData data)
{
_exploredRooms = new HashSet<string>(data.Map.ExploredRooms ?? new List<string>());
_mappedRooms = new HashSet<string>(data.Map.MappedRooms ?? new List<string>());
}
}
地图 UI 通过 MapPanel.cs(Architecture §1.3)渲染:
// Assets/Scripts/World/Map/MapPanel.cs
// 全屏地图 UI,由 UIManager PanelStack 管理
public class MapPanel : MonoBehaviour
{
[SerializeField] private MapDatabaseSO _database;
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
[SerializeField] private MapRoomCellUI _cellPrefab;
[Header("图标 Sprites")]
[SerializeField] private Sprite _iconSavePoint;
[SerializeField] private Sprite _iconBossRoom;
[SerializeField] private Sprite _iconShop;
[SerializeField] private Sprite _iconPlayerPos;
[Header("颜色")]
[SerializeField] private Color _colorDiscovered = Color.white;
[SerializeField] private Color _colorUndiscovered = Color.black;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现时刷新
private Dictionary<string, MapRoomCellUI> _cells = new();
private void OnEnable()
{
BuildGrid();
_onMapUpdated.OnEventRaised += OnMapUpdated;
}
private void OnDisable()
=> _onMapUpdated.OnEventRaised -= OnMapUpdated;
private void BuildGrid()
{
foreach (var room in _database.AllRooms)
{
var cell = Instantiate(_cellPrefab, _roomContainer);
cell.Setup(room, MapManager.Instance.IsDiscovered(room.RoomId));
_cells[room.RoomId] = cell;
}
}
private void OnMapUpdated(string roomId)
{
if (_cells.TryGetValue(roomId, out var cell))
cell.SetDiscovered(true);
}
}
// 单个地图格子 UI 组件
public class MapRoomCellUI : MonoBehaviour
{
[SerializeField] private Image _bg;
[SerializeField] private Image _icon;
public void Setup(MapRoomDataSO room, bool discovered) { /* 设置 grid 位置+颜色 */ }
public void SetDiscovered(bool v) => _bg.color = v ? Color.white : Color.black;
}
6.1.1 MapPlayerTracker
⚠️ 架构 15_MapShopModule §1.4 要求:将玩家世界坐标转换为地图格子坐标,供 MapPanel 显示玩家位置图标。
// Assets/Scripts/World/Map/MapPlayerTracker.cs
public class MapPlayerTracker : MonoBehaviour
{
[SerializeField] private Transform _playerTransform;
[SerializeField] private MapDatabaseSO _database;
[SerializeField] private MapManager _mapManager;
[Header("世界坐标 → 格子坐标换算参数")]
[SerializeField] private float _worldUnitsPerCell = 18f; // 1 格 = N 世界单位
/// <summary>返回玩家当前所在房间 ID(用于地图高亮当前房间)。</summary>
public string CurrentRoomId { get; private set; }
/// <summary>玩家在当前格子房间内的归一化坐标(0~1)。</summary>
public Vector2 NormalizedPositionInRoom { get; private set; }
private void LateUpdate()
{
if (_playerTransform == null) return;
Vector2 worldPos = _playerTransform.position;
Vector2Int cellPos = WorldToCell(worldPos);
foreach (var room in _database.AllRooms)
{
var rect = new RectInt(room.GridPosition, room.GridSize);
if (rect.Contains(cellPos))
{
CurrentRoomId = room.RoomId;
Vector2 inRoom = (Vector2)(cellPos - room.GridPosition);
NormalizedPositionInRoom = new Vector2(
inRoom.x / room.GridSize.x,
inRoom.y / room.GridSize.y);
return;
}
}
}
private Vector2Int WorldToCell(Vector2 worldPos)
=> new Vector2Int(
Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell),
Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
}
6.1.2 MapPin 系统
⚠️ 架构 15_MapShopModule §1.5 要求:玩家可在地图上放置自定义标记,通过
MapPinManager(ISaveable)持久化。
// Assets/Scripts/World/Map/MapPin.cs
[Serializable]
public class MapPin
{
public string RoomId; // 所在房间 ID
public Vector2 NormalizedPos; // 房间内归一化位置(0~1)
public PinType Type;
public string Note; // 玩家文字备注(最多 64 字符)
}
public enum PinType
{
Marker, // 通用标记
Chest, // 宝箱/收藏品
Enemy, // 危险/敌人
Path, // 路径指引
Note, // 笔记
}
// Assets/Scripts/World/Map/MapPinManager.cs
// ⚠️ MapPinManager 实现 ISaveable,存档路径 data.Map.Pins(架构 15 §1.5)
public class MapPinManager : MonoBehaviour, ISaveable
{
private List<MapPin> _pins = new();
public IReadOnlyList<MapPin> Pins => _pins;
public void AddPin(MapPin pin) => _pins.Add(pin);
public void RemovePin(MapPin pin) => _pins.Remove(pin);
public void OnSave(SaveData data) => data.Map.Pins = _pins;
public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List<MapPin>();
}
6.2 Shop 数据层 SO
// Assets/Scripts/World/Shop/ShopItemSO.cs
[CreateAssetMenu(menuName = "World/Shop/ShopItem")]
public class ShopItemSO : ScriptableObject
{
[Header("标识")]
public string ItemId;
public string DisplayName;
[TextArea(2, 5)]
public string Description;
public Sprite Icon;
[Header("价格")]
public int BasePrice;
public bool IsUnique;
[Header("商品类型")]
public ShopItemType ItemType;
public int HealthRestoreAmount;
public CharmSO CharmReference;
public string KeyItemId;
public int MaxPurchaseCount = -1;
}
public enum ShopItemType { HealthRestoration, CharmItem, KeyItem, ConsumableBuff, MapFragment }
// Assets/Scripts/World/Shop/ShopInventorySO.cs
[CreateAssetMenu(menuName = "World/Shop/ShopInventory")]
public class ShopInventorySO : ScriptableObject
{
public string ShopId;
public List<ShopItemSO> DefaultInventory;
public int MaxDisplaySlots = 6; // ⚠️ UI 最多同时显示的商品格数(架构 15 §2.2)
public RestockPolicy RestockPolicy = RestockPolicy.Never; // ⚠️ 补货策略(架构 15 §2.2)
public Sprite KeeperPortrait;
public string KeeperName;
}
/// <summary>⚠️ 库存补货时机策略(架构 15_MapShopModule §2.2)</summary>
public enum RestockPolicy
{
Never, // 永不补货(唯一商品卖完即消失)
OnSavePoint, // 激活存档点时补货
OnBossDefeat, // 击败 Boss 后补货
Periodic, // 周期性补货
}
6.2 ShopController
// Assets/Scripts/World/Shop/ShopController.cs
// 挂在 NPC 商人上(⚠️ 以架构 15_MapShopModule §2.3 为准)
public class ShopController : MonoBehaviour, ISaveable
{
[SerializeField] private ShopInventorySO _inventory;
[SerializeField] private ShopPanel _shopPanel;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onShopOpen; // Raise 商店开启
[SerializeField] private ShopPurchaseEventChannelSO _onItemPurchased; // ⚠️ ShopPurchaseEventChannelSO(架构 15 §2.3,非 ShopTransactionEventChannelSO)
[SerializeField] private StringEventChannelSO _onBossDefeated; // ⚠️ 订阅 → OnBossDefeat 时补货(架构 15 §2.3)
[SerializeField] private VoidEventChannelSO _onSavePointActivated; // ⚠️ 订阅 → OnSavePoint 时补货(架构 15 §2.3)
// key = itemId,value = 已购次数
private Dictionary<string, int> _purchaseCounts = new();
private HashSet<string> _soldUniqueItems = new();
// ⚠️ OnEnable/OnDisable 按 RestockPolicy 订阅补货事件(架构 15 §2.3)
private void OnEnable()
{
if (_inventory.RestockPolicy == RestockPolicy.OnBossDefeat && _onBossDefeated != null)
_onBossDefeated.OnEventRaised += _ => Restock();
if (_inventory.RestockPolicy == RestockPolicy.OnSavePoint && _onSavePointActivated != null)
_onSavePointActivated.OnEventRaised += Restock;
}
private void OnDisable()
{
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= _ => Restock();
if (_onSavePointActivated != null) _onSavePointActivated.OnEventRaised -= Restock;
}
public void Open()
{
_shopPanel.Show(GetAvailableItems(), this);
_onShopOpen.Raise(_inventory.ShopId);
}
public void Close() => _shopPanel.Hide();
// 过滤商品:移除已售尽的唯一品 / 超出最大购买次数的商品
public List<ShopItemSO> GetAvailableItems()
{
return _inventory.DefaultInventory
.Take(_inventory.MaxDisplaySlots)
.Where(item =>
!_soldUniqueItems.Contains(item.ItemId) &&
(item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
.ToList();
}
// ⚠️ 按 RestockPolicy 补货:重置非唯一商品的购买次数(架构 15 §2.3)
public void Restock()
{
var nonUniqueIds = _inventory.DefaultInventory
.Where(i => !i.IsUnique)
.Select(i => i.ItemId);
foreach (var id in nonUniqueIds)
_purchaseCounts.Remove(id);
}
// 由 ShopPanel 购买按钮调用:所有购买动作通过 _onItemPurchased 事件频道路由
public bool TryPurchase(ShopItemSO item, int playerGeo)
{
if (playerGeo < item.BasePrice) return false;
if (_soldUniqueItems.Contains(item.ItemId)) return false;
// ⚠️ 扣 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<string, int>(_purchaseCounts);
}
public void OnLoad(SaveData data)
{
if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record))
{
_soldUniqueItems = new HashSet<string>(record.SoldUniqueItems ?? new List<string>());
_purchaseCounts = record.PurchaseCounts ?? new Dictionary<string, int>();
}
}
}
NPC 商人通过 ShopNPC.cs(Architecture §2.4)触发商店:
// Assets/Scripts/World/Shop/ShopNPC.cs
public class ShopNPC : MonoBehaviour, IInteractable
{
[SerializeField] private ShopController _shopController;
[SerializeField] private DialogueSequenceSO _greetDialogue; // 可选开场白
[SerializeField] private DialogueManager _dialogueManager;
[SerializeField] private VoidEventChannelSO _onDialogueEnded; // 订阅:对话结束后开商店
public bool CanInteract => true;
public string InteractPrompt => "购物";
public void Interact(Transform player)
{
if (_greetDialogue != null)
{
_dialogueManager.StartDialogue(_greetDialogue);
void OpenAfterDialogue()
{
_shopController.Open();
_onDialogueEnded.OnEventRaised -= OpenAfterDialogue;
}
_onDialogueEnded.OnEventRaised += OpenAfterDialogue;
}
else
{
_shopController.Open();
}
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
}
6.3 存档版本迁移
// Assets/Scripts/Core/Save/SaveMigrator.cs
// ⚠️ 以架构 12_SaveModule §5 为准:单参数 Migrate(SaveData),字符串版本,switch/goto 模式
public static class SaveMigrator
{
private const string CurrentVersion = "2.0";
public static SaveData Migrate(SaveData data)
{
switch (data.Meta.Version)
{
case "1.0": data = MigrateFrom1_0(data); goto case "1.1";
case "1.1": data = MigrateFrom1_1(data); goto case "2.0";
case "2.0": break; // 当前版本,无需迁移
default: Debug.LogWarning($"Unknown save version: {data.Meta.Version}"); break;
}
data.Meta.Version = CurrentVersion;
return data;
}
private static SaveData MigrateFrom1_0(SaveData d)
{
// 防御性 null-check:若旧版本缺少整个子结构体,先补全对象(架构 12_SaveModule §5)
d.Equipment ??= new EquipmentSaveData();
d.Player ??= new PlayerSaveData();
d.World ??= new WorldSaveData();
// 1.0 → 1.1:新增 Equipment.UpgradedCharmIds 字段,旧存档初始化为空列表
d.Equipment.UpgradedCharmIds ??= new List<string>();
return d;
}
private static SaveData MigrateFrom1_1(SaveData d)
{
// 防御性 null-check:子结构体若为 null 先补全(架构 12_SaveModule §5)
d.Stats ??= new StatsSaveData();
// 1.1 → 2.0:Stats 新增 SkillUseCounts;World 新增 ChallengeFirstClears
d.Stats.SkillUseCounts ??= new Dictionary<string, int>();
d.World.ChallengeFirstClears ??= new HashSet<string>();
// 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 |
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<WorldMarker> 事件频道;命名空间 BaseGames.World |
WorldMarker.cs |
✅ | 场景导航标记点;Activate()/Deactivate() 广播事件频道;WorldMarkerType 枚举(Objective/NPC/PointOfInterest/Exit/Secret) |
BreadcrumbTracker.cs |
✅ | 玩家位置历史追踪;_recordInterval=2f/_maxCrumbs=20/_minMoveDistance=1f;GetRecentCrumbs(int) → IReadOnlyList<Vector2>(oldest→newest);Clear() |
TutorialManager.cs |
✅ | 单例 (Instance);实现 ISaveable;ShowHint/CompleteHint;进度写入 SaveData.Tutorial(非 PlayerPrefs,与架构 12 §1 注解不同) |
TutorialHintUI.cs |
✅ | HUD 提示 UI;Show(text, duration) + Hide();AutoHideRoutine 协程;TMP_Text 标签 |
ContextualHintTrigger.cs |
✅ | [RequireComponent(Collider2D)];_requiresAbility/_requiredAbility(AbilityType) 条件;调用 LocalizationManager.Get;首次触发后 gameObject.SetActive(false) |
SaveData.cs |
✅ | 追加 TutorialSaveData Tutorial = new();新增 TutorialSaveData 类(含 List<string> CompletedHintIds) |
BaseGames.Tutorial.asmdef |
✅ | 引用 Core.Events/Core.Save/World/Player/Localization |
☑ 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<string>) + Pins(List<MapPin>) |
BaseGames.World.Map.asmdef |
✅ | 新增 BaseGames.Core.Save + BaseGames.Core.Events 引用 |
BaseGames.World.Shop.asmdef |
✅ | 新增 BaseGames.Core.Save + BaseGames.Equipment + BaseGames.Dialogue 引用 |
BaseGames.Editor.asmdef |
✅ | 新增 BaseGames.World.Map 引用(MapRoomDataEditor 需要) |
编辑器工具:
AddressReferenceGraphWindow(Assets/Editor/Assets/AddressReferenceGraphWindow.cs)扫描所有.cs文件对AddressKeys.X的引用,标红孤儿 key(0 引用),标黄单次引用 key,标绿 ≥2 次引用 key,支持导出 CSV(架构 13 §11,P3 优化)。
Phase 3 完成后进入 Phase 4。