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