# 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 { /// /// 挂在液态区域根 GameObject 上。 /// 子物件 [Surface] 的水面触发器触发溅水;[Body] 的主触发器触发进出事件。 /// 酸液/熔岩时需同时挂载 HazardZone(InstantKill 类型)。 /// [RequireComponent(typeof(Collider2D))] public class LiquidZone : MonoBehaviour { [Header("液体类型")] [SerializeField] LiquidType _liquidType = LiquidType.Water; [Header("伤害(Water 类型专用,Acid/Lava 由 HazardZone 处理)")] /// /// 未解锁 Swim 能力时,玩家在 Water 中是否持续受到溺水伤害。 /// Acid/Lava 类型的即死效果由子节点 HazardZone.cs (InstantKill) 处理,与此字段无关。 /// [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 { /// /// 游泳状态:玩家在液体中时使用。 /// 需要 AbilityType.Swim 已解锁;若未解锁则自动切换到溺水/死亡流程。 /// 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 { /// 任何可被切换激活/停用状态的谜题元素。 public interface ISwitchable { bool IsActive { get; } event Action OnStateChanged; void ForceState(bool active); // SaveData 恢复时调用 } /// 可被玩家推动的物件(需 Rigidbody2D)。 public interface IMovable { bool CanBePushed { get; } void OnPushStart(Vector2 direction); void OnPushEnd(); } /// 接受激活信号后改变自身状态的物件。 public interface IActivatable { void Activate(); void Deactivate(); bool IsActivated { get; } } } ``` --- ## 9. PuzzleSwitch ```csharp 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; // 开关动画(On/Off 状态) [SerializeField] AnimationClip _activeClip; [SerializeField] AnimationClip _inactiveClip; [SerializeField] MMF_Player _activateFeedback; bool _isActive; public bool IsActive => _isActive; public event Action OnStateChanged; void Start() => _isActive = _startsActive; // IInteractable public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互"; public bool CanInteract => true; public void Interact(Transform player) { if (_mode == SwitchTriggerMode.InteractOnce && _isActive) return; SetState(!_isActive); } public void OnPlayerEnterRange(Transform player) { } 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 { /// /// 谜题接收器,由 PuzzleWire 驱动。 /// 挂在谜题目标物件上(门/平台等),实现 IActivatable。 /// 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 { /// /// 连接一个或多个 PuzzleSwitch 到 PuzzleReceiver。 /// 支持 AND / OR / XOR 激活逻辑。 /// 关卡设计师在 Inspector 中配置,无需编写代码。 /// 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 { /// /// 挂在 PlayerController 子节点 [WaterDanger] 上。 /// 由 LiquidZone 的 EVT_LiquidEntered 触发,在无游泳能力时开始倒计时。 /// 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 { /// /// 控制水下全屏后处理效果(颜色滤镜、色差、暗角)。 /// 订阅 EVT_LiquidEntered / EVT_LiquidExited 事件,启用/停用 Water Volume Profile。 /// 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 { /// /// 场景内导航标记点。 /// 可标记为目标地点、NPC 位置、兴趣点等,通过 EVT_WorldMarkerUpdated 广播给地图/HUD。 /// 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 { /// /// 追踪玩家最近的行进路径(面包屑)。 /// 用于辅助迷路玩家找到回头路;数据不持久化(每次游戏重置)。 /// public class BreadcrumbTracker : MonoBehaviour { [Header("追踪参数")] [SerializeField] float _recordInterval = 2.0f; // 每隔多少秒记录一次位置 [SerializeField] int _maxCrumbs = 20; // 最多保留多少个历史位置 [SerializeField] float _minMoveDistance = 1.0f; // 移动距离低于此值不记录 readonly Queue _crumbs = new(); float _timer = 0f; Vector2 _lastPos; public IReadOnlyCollection Crumbs => _crumbs; void Start() { _lastPos = transform.position; } void Update() { _timer += Time.deltaTime; if (_timer < _recordInterval) return; _timer = 0f; Vector2 current = transform.position; if (Vector2.Distance(current, _lastPos) < _minMoveDistance) return; _crumbs.Enqueue(current); if (_crumbs.Count > _maxCrumbs) _crumbs.Dequeue(); _lastPos = current; } /// 获取最近 N 个面包屑位置(用于地图渲染)。 public Vector2[] GetRecentCrumbs(int count) => System.Linq.Enumerable.TakeLast(_crumbs, count).ToArray(); } } ``` --- ## 16. 教程系统职责(§Tutorial) ``` 教程系统职责: ├─ TutorialManager → 追踪已完成的教程步骤,驱动提示显示/隐藏 └─ ContextualHintTrigger → 场景中的教程触发器,条件满足时激活提示 ``` **显示策略**:提示只显示一次(`TutorialManager` 持久化已完成 ID),同一提示触发后不再重复显示。 --- ## 17. TutorialManager ```csharp namespace BaseGames.Tutorial { /// /// 管理所有教程提示的显示/完成状态,挂在 Persistent 场景 [GameManagers] 下。 /// public class TutorialManager : MonoBehaviour, ISaveable { [SerializeField] TutorialHintUI _hintUI; // HUD 上的提示 UI 组件 readonly HashSet _completedHints = new(); public static TutorialManager Instance { get; private set; } void Awake() => Instance = this; /// 显示提示。若已完成则跳过。 public void ShowHint(string hintId, string localizedText, float duration = 3f) { if (_completedHints.Contains(hintId)) return; _hintUI.Show(localizedText, duration); } /// 标记提示为已完成,不再显示。 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(_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 { /// /// 场景内的教程触发器。 /// 玩家进入触发区域时,向 TutorialManager 请求显示对应提示。 /// [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(); 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); // 触发后禁用自身,避免重复 } } } } ```