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

30 KiB
Raw Permalink Blame History

21 · 液体与谜题模块Liquid & Puzzle Module

命名空间 BaseGames.World.Liquid / BaseGames.Puzzle / BaseGames.World.Navigation / BaseGames.Tutorial
程序集 BaseGames.World(并入世界程序集)
依赖 BaseGames.Core.Events · BaseGames.PlayerPlayerController · FSM· BaseGames.WorldHazardZone · IInteractable
Design 来源 40_LiquidSwimSystem · 35_PuzzleArchitecture · 36_NavigationHintSystem · 45_TutorialSystem


目录

Part A — 液体与游泳

  1. 液体系统职责
  2. LiquidType 枚举
  3. LiquidPhysicsConfigSO
  4. LiquidZone
  5. SwimStateFSM 状态)
  6. 玩家进出液体流程

Part B — 谜题架构

  1. 谜题系统职责
  2. 核心接口
  3. PuzzleSwitch
  4. PuzzleReceiver
  5. PuzzleWire
  6. 事件频道

Part C — 导航提示与教程

  1. 导航提示系统职责§NavHint
  2. WorldMarker
  3. BreadcrumbTracker
  4. 教程系统职责§Tutorial
  5. TutorialManager
  6. ContextualHintTrigger

Part A — 液体与游泳


1. 液体系统职责

液体系统职责:
  ├─ LiquidType enum           → Water / Acid / Lava
  ├─ LiquidPhysicsConfigSO     → 浮力、水下速度、进出溅水参数
  ├─ LiquidZone                → 标记液态区域、触发进出事件
  └─ SwimState                 → PlayerController FSM 中的游泳状态

零耦合LiquidZone 通过 SO 事件频道广播进出事件,PlayerController 订阅后自行切换 FSM 状态。


2. LiquidType 枚举

namespace BaseGames.World.Liquid
{
    public enum LiquidType
    {
        Water,        // 可游泳(需 swim 能力)
        ShallowWater, // 浅水(水中慢走,无需游泳能力,速度 ×0.65
        Mud,          // 泥水(移动极慢,无需游泳能力,速度 ×0.50
        Acid,         // 接触即死HazardZone 处理)
        Lava,         // 接触即死HazardZone 处理)
    }
}

3. LiquidPhysicsConfigSO

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

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 个状态:

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. 核心接口

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

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

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

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 类型液体且未解锁游泳能力时,触发溺水危险状态:

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

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(切换 SwimStateWaterDangerStateUnderwaterPostProcessingController
EVT_LiquidExited void LiquidZone PlayerController(退出 SwimStateWaterDangerStateUnderwaterPostProcessingController
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

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

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

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

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);  // 触发后禁用自身,避免重复
            }
        }
    }
}