928 lines
30 KiB
Markdown
928 lines
30 KiB
Markdown
# 21 · 液体与谜题模块(Liquid & Puzzle Module)
|
||
|
||
> **命名空间** `BaseGames.World.Liquid` / `BaseGames.Puzzle` / `BaseGames.World.Navigation` / `BaseGames.Tutorial`
|
||
> **程序集** `BaseGames.World`(并入世界程序集)
|
||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Player`(PlayerController · FSM)· `BaseGames.World`(HazardZone · IInteractable)
|
||
> **Design 来源** [40_LiquidSwimSystem](../Design/40_LiquidSwimSystem.md) · [35_PuzzleArchitecture](../Design/35_PuzzleArchitecture.md) · [36_NavigationHintSystem](../Design/36_NavigationHintSystem.md) · [45_TutorialSystem](../Design/45_TutorialSystem.md)
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
### Part A — 液体与游泳
|
||
|
||
1. [液体系统职责](#1-液体系统职责)
|
||
2. [LiquidType 枚举](#2-liquidtype-枚举)
|
||
3. [LiquidPhysicsConfigSO](#3-liquidphysicsconfigso)
|
||
4. [LiquidZone](#4-liquidzone)
|
||
5. [SwimState(FSM 状态)](#5-swimstate-fsm-状态)
|
||
6. [玩家进出液体流程](#6-玩家进出液体流程)
|
||
|
||
### Part B — 谜题架构
|
||
|
||
7. [谜题系统职责](#7-谜题系统职责)
|
||
8. [核心接口](#8-核心接口)
|
||
9. [PuzzleSwitch](#9-puzzleswitch)
|
||
10. [PuzzleReceiver](#10-puzzlereceiver)
|
||
11. [PuzzleWire](#11-puzzlewire)
|
||
12. [事件频道](#12-事件频道)
|
||
|
||
### Part C — 导航提示与教程
|
||
|
||
13. [导航提示系统职责(§NavHint)](#13-导航提示系统职责-navhint)
|
||
14. [WorldMarker](#14-worldmarker)
|
||
15. [BreadcrumbTracker](#15-breadcrumbtracker)
|
||
16. [教程系统职责(§Tutorial)](#16-教程系统职责-tutorial)
|
||
17. [TutorialManager](#17-tutorialmanager)
|
||
18. [ContextualHintTrigger](#18-contextualhinttrigger)
|
||
|
||
---
|
||
|
||
## Part A — 液体与游泳
|
||
|
||
---
|
||
|
||
## 1. 液体系统职责
|
||
|
||
```
|
||
液体系统职责:
|
||
├─ LiquidType enum → Water / Acid / Lava
|
||
├─ LiquidPhysicsConfigSO → 浮力、水下速度、进出溅水参数
|
||
├─ LiquidZone → 标记液态区域、触发进出事件
|
||
└─ SwimState → PlayerController FSM 中的游泳状态
|
||
```
|
||
|
||
**零耦合**:`LiquidZone` 通过 SO 事件频道广播进出事件,`PlayerController` 订阅后自行切换 FSM 状态。
|
||
|
||
---
|
||
|
||
## 2. LiquidType 枚举
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Liquid
|
||
{
|
||
public enum LiquidType
|
||
{
|
||
Water, // 可游泳(需 swim 能力)
|
||
ShallowWater, // 浅水(水中慢走,无需游泳能力,速度 ×0.65)
|
||
Mud, // 泥水(移动极慢,无需游泳能力,速度 ×0.50)
|
||
Acid, // 接触即死(HazardZone 处理)
|
||
Lava, // 接触即死(HazardZone 处理)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. LiquidPhysicsConfigSO
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Liquid
|
||
{
|
||
[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; // 最大游泳速度 (m/s)
|
||
public float SwimAcceleration = 8.0f; // 游泳加速度
|
||
public float SurfaceExitSpeed = 5.0f; // 跃出水面时的冲量
|
||
public float SinkSpeed = 2.0f; // 无游泳能力时自然下沉速度 (m/s)
|
||
public float DiveSpeedMultiplier = 1.5f; // 主动下潜时的速度倍率
|
||
|
||
[Header("浅水/泥水速度缩放")]
|
||
[Range(0.1f, 1.0f)]
|
||
public float ShallowSpeedScale = 0.65f; // ShallowWater 类型水平移动泥幕
|
||
[Range(0.1f, 1.0f)]
|
||
public float MudSpeedScale = 0.50f; // Mud 类型水平移动泥幕
|
||
|
||
[Header("溺死计时(无游泳能力时)")]
|
||
public float DrownTime = 3.0f; // 屏气倒计时(秒),倒计时结束则触发死亡
|
||
|
||
[Header("进出液体")]
|
||
public float SplashEntryDelay = 0.05f; // 溅水特效延迟(配合动画)
|
||
public float DragCoefficient = 3.0f; // 水下阻力系数(减缓水平移动)
|
||
|
||
[Header("视觉")]
|
||
public VolumeProfile WaterVolumeProfile; // 水下后处理 Profile(可为 null)
|
||
}
|
||
}
|
||
```
|
||
|
||
**资产路径**:`Assets/ScriptableObjects/World/Liquid_Physics_Config.asset`
|
||
|
||
---
|
||
|
||
## 4. LiquidZone
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Liquid
|
||
{
|
||
/// <summary>
|
||
/// 挂在液态区域根 GameObject 上。
|
||
/// 子物件 [Surface] 的水面触发器触发溅水;[Body] 的主触发器触发进出事件。
|
||
/// 酸液/熔岩时需同时挂载 HazardZone(InstantKill 类型)。
|
||
/// </summary>
|
||
[RequireComponent(typeof(Collider2D))]
|
||
public class LiquidZone : MonoBehaviour
|
||
{
|
||
[Header("液体类型")]
|
||
[SerializeField] LiquidType _liquidType = LiquidType.Water;
|
||
|
||
[Header("伤害(Water 类型专用,Acid/Lava 由 HazardZone 处理)")]
|
||
/// <summary>
|
||
/// 未解锁 Swim 能力时,玩家在 Water 中是否持续受到溺水伤害。
|
||
/// Acid/Lava 类型的即死效果由子节点 HazardZone.cs (InstantKill) 处理,与此字段无关。
|
||
/// </summary>
|
||
[SerializeField] bool _dealsDrowningDamage = false;
|
||
[SerializeField] float _drowningDamagePerSecond = 5f; // 每秒扣减 HP
|
||
|
||
[Header("物理配置")]
|
||
[SerializeField] LiquidPhysicsConfigSO _physicsConfig;
|
||
|
||
[Header("事件频道")]
|
||
[SerializeField] LiquidEventChannelSO _onPlayerEntered;
|
||
[SerializeField] LiquidEventChannelSO _onPlayerExited;
|
||
|
||
[Header("视觉反馈")]
|
||
[SerializeField] MMF_Player _splashEnterFeedback;
|
||
[SerializeField] MMF_Player _splashExitFeedback;
|
||
|
||
public LiquidType Type => _liquidType;
|
||
public LiquidPhysicsConfigSO Physics => _physicsConfig;
|
||
|
||
void OnTriggerEnter2D(Collider2D other)
|
||
{
|
||
if (!other.CompareTag("Player")) return;
|
||
_splashEnterFeedback?.PlayFeedbacks();
|
||
_onPlayerEntered.Raise(this);
|
||
}
|
||
|
||
void OnTriggerExit2D(Collider2D other)
|
||
{
|
||
if (!other.CompareTag("Player")) return;
|
||
_splashExitFeedback?.PlayFeedbacks();
|
||
_onPlayerExited.Raise(this);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### LiquidZone Prefab 层级
|
||
|
||
```
|
||
[LiquidZone_River_01]
|
||
├── SpriteRenderer(水体精灵,带流动 Shader)
|
||
├── PolygonCollider2D (IsTrigger) ← 主区域触发器
|
||
├── LiquidZone.cs
|
||
├── [Surface]
|
||
│ ├── BoxCollider2D (IsTrigger, 高度 ~4px)
|
||
│ └── WaterSurfaceEffect.cs ← 溅水粒子 + 音效
|
||
└── [Hazard](仅酸液/熔岩时存在)
|
||
└── HazardZone.cs (InstantKill)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. SwimState(FSM 状态)
|
||
|
||
在 `05_PlayerModule.md` §12 状态列表中补充的第 18 个状态:
|
||
|
||
```csharp
|
||
namespace BaseGames.Player.States
|
||
{
|
||
/// <summary>
|
||
/// 游泳状态:玩家在液体中时使用。
|
||
/// 需要 AbilityType.Swim 已解锁;若未解锁则自动切换到溺水/死亡流程。
|
||
/// </summary>
|
||
public class SwimState : PlayerStateBase
|
||
{
|
||
[SerializeField] LiquidPhysicsConfigSO _physics; // 由 LiquidZone 注入
|
||
[SerializeField] ClipTransition _swimIdleClip;
|
||
[SerializeField] ClipTransition _swimMoveClip;
|
||
|
||
LiquidZone _currentZone;
|
||
float _originalGravity;
|
||
|
||
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 后切换
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 玩家进出液体流程
|
||
|
||
```
|
||
玩家碰到 LiquidZone.PolygonCollider2D
|
||
│
|
||
▼
|
||
LiquidZone.OnTriggerEnter2D
|
||
→ EVT_LiquidEntered.Raise(liquidZone)
|
||
│
|
||
▼
|
||
PlayerController(订阅 EVT_LiquidEntered)
|
||
├─ 检查 abilities.swim == true
|
||
│ ├─ true → swimState.SetLiquidZone(zone)
|
||
│ │ FSM.TransitionTo(swimState)
|
||
│ └─ false → 检查 liquidType
|
||
│ Water → 无法游泳,自然沉底;若 zone._dealsDrowningDamage == true,
|
||
│ 每帧通过 DamageInfo(DamageTag: Drowning)对 PlayerStats
|
||
│ 施加 zone._drowningDamagePerSecond 伤害(忽略无敌帧)
|
||
│ Acid/Lava → HazardZone 已处理 InstantKill(与 _dealsDrowningDamage 无关)
|
||
│
|
||
玩家离开 LiquidZone
|
||
→ EVT_LiquidExited.Raise(liquidZone)
|
||
│
|
||
▼
|
||
PlayerController → FSM.TransitionTo(fallState / idleState)
|
||
```
|
||
|
||
---
|
||
|
||
## Part B — 谜题架构
|
||
|
||
---
|
||
|
||
## 7. 谜题系统职责
|
||
|
||
```
|
||
谜题架构职责:
|
||
├─ ISwitchable → 可被切换激活/停用的物件接口
|
||
├─ IMovable → 可被玩家推动的物件接口
|
||
├─ IActivatable → 接受激活信号的物件接口
|
||
├─ PuzzleSwitch → 通用开关(玩家交互/踩踏触发)
|
||
├─ PuzzleReceiver → 接收器(门/平台/机关挂载)
|
||
└─ PuzzleWire → 连接 Switch → Receiver,支持 AND/OR/XOR 逻辑
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 核心接口
|
||
|
||
```csharp
|
||
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; }
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. PuzzleSwitch
|
||
|
||
```csharp
|
||
namespace BaseGames.Puzzle
|
||
{
|
||
/// <summary>
|
||
/// 通用谜题开关,支持三种触发模式。
|
||
/// 实现 ISwitchable + IInteractable(玩家手动触发)。
|
||
/// </summary>
|
||
[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; // 开关动画(On/Off 状态)
|
||
[SerializeField] AnimationClip _activeClip;
|
||
[SerializeField] AnimationClip _inactiveClip;
|
||
[SerializeField] MMF_Player _activateFeedback;
|
||
|
||
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) { }
|
||
public void OnPlayerExitRange() { }
|
||
|
||
// ISwitchable
|
||
public void ForceState(bool active) => SetState(active);
|
||
|
||
// 压板模式:OnTriggerEnter2D / OnTriggerExit2D
|
||
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);
|
||
// 持久化到 WorldStateRegistry(跨场景/存档恢复开关状态)
|
||
if (!string.IsNullOrEmpty(_switchId))
|
||
WorldStateRegistry.Instance.SetFlag("switch_" + _switchId, active);
|
||
}
|
||
}
|
||
|
||
public enum SwitchTriggerMode
|
||
{
|
||
InteractOnce, // 玩家交互一次,永久激活
|
||
InteractToggle, // 玩家交互切换开关
|
||
Pressure, // 踩上激活,离开停用
|
||
Hold, // 按住交互键持续激活
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. PuzzleReceiver
|
||
|
||
```csharp
|
||
namespace BaseGames.Puzzle
|
||
{
|
||
/// <summary>
|
||
/// 谜题接收器,由 PuzzleWire 驱动。
|
||
/// 挂在谜题目标物件上(门/平台等),实现 IActivatable。
|
||
/// </summary>
|
||
public class PuzzleReceiver : MonoBehaviour, IActivatable
|
||
{
|
||
[SerializeField] bool _startsActivated = false;
|
||
[SerializeField] string _receiverId; // 持久化唯一 ID(存档用,空串则不持久化)
|
||
[SerializeField] MMF_Player _activateFeedback;
|
||
[SerializeField] MMF_Player _deactivateFeedback;
|
||
|
||
bool _isActivated;
|
||
public bool IsActivated => _isActivated;
|
||
|
||
void Start()
|
||
{
|
||
_isActivated = _startsActivated;
|
||
if (_isActivated) Activate();
|
||
}
|
||
|
||
public void Activate()
|
||
{
|
||
if (_isActivated) return;
|
||
_isActivated = true;
|
||
_activateFeedback?.PlayFeedbacks();
|
||
OnActivate();
|
||
if (!string.IsNullOrEmpty(_receiverId))
|
||
WorldStateRegistry.Instance.SetFlag("receiver_" + _receiverId, true);
|
||
}
|
||
|
||
public void Deactivate()
|
||
{
|
||
if (!_isActivated) return;
|
||
_isActivated = false;
|
||
_deactivateFeedback?.PlayFeedbacks();
|
||
OnDeactivate();
|
||
if (!string.IsNullOrEmpty(_receiverId))
|
||
WorldStateRegistry.Instance.SetFlag("receiver_" + _receiverId, false);
|
||
}
|
||
|
||
// 子类覆写具体行为(门打开、平台移动等)
|
||
protected virtual void OnActivate() { }
|
||
protected virtual void OnDeactivate() { }
|
||
}
|
||
|
||
// 常见子类示例
|
||
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);
|
||
}
|
||
public class MovingPlatform : PuzzleReceiver { /* DOTween 路径移动 */ }
|
||
public class PuzzleSpikeTrap : PuzzleReceiver { /* 启用/禁用 HazardZone */ }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. PuzzleWire
|
||
|
||
```csharp
|
||
namespace BaseGames.Puzzle
|
||
{
|
||
/// <summary>
|
||
/// 连接一个或多个 PuzzleSwitch 到 PuzzleReceiver。
|
||
/// 支持 AND / OR / XOR 激活逻辑。
|
||
/// 关卡设计师在 Inspector 中配置,无需编写代码。
|
||
/// </summary>
|
||
public class PuzzleWire : MonoBehaviour
|
||
{
|
||
[Header("输入开关")]
|
||
[SerializeField] PuzzleSwitch[] _switches;
|
||
|
||
[Header("激活逻辑")]
|
||
[SerializeField] LogicType _logic = LogicType.AND;
|
||
|
||
[Header("目标接收器")]
|
||
[SerializeField] PuzzleReceiver _receiver;
|
||
|
||
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 }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 12. WaterDangerState — 溺水倒计时
|
||
|
||
当玩家进入 `Water` 类型液体且**未解锁游泳能力**时,触发溺水危险状态:
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Liquid
|
||
{
|
||
/// <summary>
|
||
/// 挂在 PlayerController 子节点 [WaterDanger] 上。
|
||
/// 由 LiquidZone 的 EVT_LiquidEntered 触发,在无游泳能力时开始倒计时。
|
||
/// </summary>
|
||
public class WaterDangerState : MonoBehaviour
|
||
{
|
||
[SerializeField] private LiquidPhysicsConfigSO _config;
|
||
[SerializeField] private AbilityInventorySO _abilityInventory; // 检查 swim 能力
|
||
[SerializeField] private FloatEventChannelSO _onDrownProgress; // 0~1 倒计时进度(HUD 用)
|
||
[SerializeField] private VoidEventChannelSO _onPlayerDrowned; // 触发死亡
|
||
|
||
private float _drownTimer;
|
||
private bool _isActive;
|
||
|
||
public void OnEnterLiquid(LiquidZone zone)
|
||
{
|
||
if (zone.Type != LiquidType.Water) return;
|
||
if (_abilityInventory.HasAbility(AbilityType.Swim)) return;
|
||
_isActive = true;
|
||
_drownTimer = _config.DrownTime;
|
||
}
|
||
|
||
public void OnExitLiquid()
|
||
{
|
||
_isActive = false;
|
||
_drownTimer = _config.DrownTime;
|
||
_onDrownProgress.Raise(0f);
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
if (!_isActive) return;
|
||
_drownTimer -= Time.deltaTime;
|
||
_onDrownProgress.Raise(1f - (_drownTimer / _config.DrownTime));
|
||
|
||
if (_drownTimer <= 0f)
|
||
{
|
||
_isActive = false;
|
||
_onPlayerDrowned.Raise();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 13. UnderwaterPostProcessingController
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Liquid
|
||
{
|
||
/// <summary>
|
||
/// 控制水下全屏后处理效果(颜色滤镜、色差、暗角)。
|
||
/// 订阅 EVT_LiquidEntered / EVT_LiquidExited 事件,启用/停用 Water Volume Profile。
|
||
/// </summary>
|
||
public class UnderwaterPostProcessingController : MonoBehaviour
|
||
{
|
||
[SerializeField] private Volume _underwaterVolume; // 水下专属 Volume
|
||
[SerializeField] private float _blendInDuration = 0.3f;
|
||
[SerializeField] private float _blendOutDuration = 0.3f;
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private LiquidZoneEventChannelSO _onLiquidEntered;
|
||
[SerializeField] private VoidEventChannelSO _onLiquidExited;
|
||
|
||
private Coroutine _blendCoroutine;
|
||
|
||
private void OnEnable()
|
||
{
|
||
_onLiquidEntered.OnEventRaised += OnLiquidEntered;
|
||
_onLiquidExited.OnEventRaised += OnLiquidExited;
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
_onLiquidEntered.OnEventRaised -= OnLiquidEntered;
|
||
_onLiquidExited.OnEventRaised -= OnLiquidExited;
|
||
}
|
||
|
||
private void OnLiquidEntered(LiquidZone zone)
|
||
{
|
||
if (zone.Type != LiquidType.Water) return;
|
||
BlendVolume(1f, _blendInDuration);
|
||
}
|
||
|
||
private void OnLiquidExited()
|
||
{
|
||
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;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
elapsed += Time.deltaTime;
|
||
_underwaterVolume.weight = Mathf.Lerp(start, target, elapsed / duration);
|
||
yield return null;
|
||
}
|
||
_underwaterVolume.weight = target;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 14. 事件频道
|
||
|
||
| 频道 SO | Payload | 发布者 | 订阅者 |
|
||
|--------|---------|--------|--------|
|
||
| `EVT_LiquidEntered` | `LiquidZone` | `LiquidZone` | `PlayerController`(切换 SwimState)、`WaterDangerState`、`UnderwaterPostProcessingController` |
|
||
| `EVT_LiquidExited` | `void` | `LiquidZone` | `PlayerController`(退出 SwimState)、`WaterDangerState`、`UnderwaterPostProcessingController` |
|
||
| `EVT_DrownProgress` | `float(0~1)` | `WaterDangerState` | `HUDController`(显示溺水进度条) |
|
||
| `EVT_PlayerDrowned` | `void` | `WaterDangerState` | `GameManager`(触发死亡流程) |
|
||
|
||
> **⚠️ 谜题状态持久化说明**:PuzzleSwitch / PuzzleReceiver 使用 **直接调用** `WorldStateRegistry.Instance.SetFlag()` 记录持久状态(同 DestructibleTile 模式),而非 SO 事件频道。SO 事件频道仅用于跨模块的松耦合通知,不适用于纯持久化场景。
|
||
|
||
---
|
||
|
||
## Part C — 导航提示与教程
|
||
|
||
---
|
||
|
||
## 13. 导航提示系统职责(§NavHint)
|
||
|
||
```
|
||
导航提示系统职责:
|
||
├─ WorldMarker → 场景内的标记点,用于地图/HUD 指引
|
||
└─ BreadcrumbTracker → 记录玩家行进路径,辅助引导迷路玩家
|
||
```
|
||
|
||
**零耦合**:`WorldMarker` 通过 SO 事件频道向 `HUDController`/`MapManager` 报告标记状态;`BreadcrumbTracker` 仅写本地数据,UI 层订阅读取。
|
||
|
||
---
|
||
|
||
## 14. WorldMarker
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Navigation
|
||
{
|
||
/// <summary>
|
||
/// 场景内导航标记点。
|
||
/// 可标记为目标地点、NPC 位置、兴趣点等,通过 EVT_WorldMarkerUpdated 广播给地图/HUD。
|
||
/// </summary>
|
||
public class WorldMarker : MonoBehaviour
|
||
{
|
||
[Header("标记信息")]
|
||
[SerializeField] string _markerId; // 唯一 ID(与 MapDataSO 匹配)
|
||
[SerializeField] WorldMarkerType _markerType; // 类型(见枚举)
|
||
[SerializeField] string _labelKey; // 本地化显示名称 key
|
||
|
||
[Header("可见性")]
|
||
[SerializeField] bool _visibleOnMap = true;
|
||
[SerializeField] bool _visibleOnHUD = false; // 在 HUD 显示箭头指引
|
||
|
||
[Header("事件频道")]
|
||
[SerializeField] WorldMarkerEventChannelSO _onMarkerActivated;
|
||
[SerializeField] WorldMarkerEventChannelSO _onMarkerDeactivated;
|
||
|
||
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;
|
||
}
|
||
|
||
public enum WorldMarkerType
|
||
{
|
||
Objective, // 当前主线目标
|
||
NPC, // NPC 位置
|
||
PointOfInterest,// 兴趣点
|
||
Exit, // 出口/传送点
|
||
Secret, // 隐藏区域(解锁后显示)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 15. BreadcrumbTracker
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Navigation
|
||
{
|
||
/// <summary>
|
||
/// 追踪玩家最近的行进路径(面包屑)。
|
||
/// 用于辅助迷路玩家找到回头路;数据不持久化(每次游戏重置)。
|
||
/// </summary>
|
||
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 = 0f;
|
||
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();
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 16. 教程系统职责(§Tutorial)
|
||
|
||
```
|
||
教程系统职责:
|
||
├─ TutorialManager → 追踪已完成的教程步骤,驱动提示显示/隐藏
|
||
└─ ContextualHintTrigger → 场景中的教程触发器,条件满足时激活提示
|
||
```
|
||
|
||
**显示策略**:提示只显示一次(`TutorialManager` 持久化已完成 ID),同一提示触发后不再重复显示。
|
||
|
||
---
|
||
|
||
## 17. TutorialManager
|
||
|
||
```csharp
|
||
namespace BaseGames.Tutorial
|
||
{
|
||
/// <summary>
|
||
/// 管理所有教程提示的显示/完成状态,挂在 Persistent 场景 [GameManagers] 下。
|
||
/// </summary>
|
||
public class TutorialManager : MonoBehaviour, ISaveable
|
||
{
|
||
[SerializeField] TutorialHintUI _hintUI; // HUD 上的提示 UI 组件
|
||
|
||
readonly HashSet<string> _completedHints = new();
|
||
|
||
public static TutorialManager Instance { get; private set; }
|
||
|
||
void Awake() => Instance = this;
|
||
|
||
/// <summary>显示提示。若已完成则跳过。</summary>
|
||
public void ShowHint(string hintId, string localizedText, float duration = 3f)
|
||
{
|
||
if (_completedHints.Contains(hintId)) return;
|
||
_hintUI.Show(localizedText, duration);
|
||
}
|
||
|
||
/// <summary>标记提示为已完成,不再显示。</summary>
|
||
public void CompleteHint(string hintId)
|
||
{
|
||
_completedHints.Add(hintId);
|
||
}
|
||
|
||
public bool IsCompleted(string hintId) => _completedHints.Contains(hintId);
|
||
|
||
// ── ISaveable ─────────────────────────────────────────────
|
||
public void OnSave(SaveData data)
|
||
{
|
||
data.Tutorial.CompletedHintIds = new List<string>(_completedHints);
|
||
}
|
||
|
||
public void OnLoad(SaveData data)
|
||
{
|
||
_completedHints.Clear();
|
||
if (data.Tutorial?.CompletedHintIds != null)
|
||
foreach (var id in data.Tutorial.CompletedHintIds)
|
||
_completedHints.Add(id);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 18. ContextualHintTrigger
|
||
|
||
```csharp
|
||
namespace BaseGames.Tutorial
|
||
{
|
||
/// <summary>
|
||
/// 场景内的教程触发器。
|
||
/// 玩家进入触发区域时,向 TutorialManager 请求显示对应提示。
|
||
/// </summary>
|
||
[RequireComponent(typeof(Collider2D))]
|
||
public class ContextualHintTrigger : MonoBehaviour
|
||
{
|
||
[Header("提示配置")]
|
||
[SerializeField] string _hintId; // 唯一 ID,对应 TutorialManager 的完成记录
|
||
[SerializeField] string _hintTextKey; // 本地化 key(通过 LocalizationManager 解析)
|
||
[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);
|
||
TutorialManager.Instance.ShowHint(_hintId, text, _displayDuration);
|
||
|
||
if (_onlyOnce)
|
||
{
|
||
TutorialManager.Instance.CompleteHint(_hintId);
|
||
gameObject.SetActive(false); // 触发后禁用自身,避免重复
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|