Files
zeling_v2/Docs/Architecture/21_LiquidPuzzleModule.md
2026-05-08 11:04:00 +08:00

928 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. [SwimStateFSM 状态)](#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] 的主触发器触发进出事件。
/// 酸液/熔岩时需同时挂载 HazardZoneInstantKill 类型)。
/// </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. SwimStateFSM 状态)
`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
│ 每帧通过 DamageInfoDamageTag: 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); // 触发后禁用自身,避免重复
}
}
}
}
```