30 KiB
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 · 35_PuzzleArchitecture · 36_NavigationHintSystem · 45_TutorialSystem
目录
Part A — 液体与游泳
Part B — 谜题架构
Part C — 导航提示与教程
- 导航提示系统职责(§NavHint)
- WorldMarker
- BreadcrumbTracker
- 教程系统职责(§Tutorial)
- TutorialManager
- 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] 的主触发器触发进出事件。
/// 酸液/熔岩时需同时挂载 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 个状态:
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. 核心接口
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(切换 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
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); // 触发后禁用自身,避免重复
}
}
}
}