# 45 · 教程与情境提示系统 > **命名空间** `BaseGames.Tutorial` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Player`(能力查询)· `BaseGames.UI`(提示面板)· `BaseGames.World`(SaveSystem) > **关联** 01_InputSystem(按键提示)· 03_PlayerSystem(能力解锁)· 14_ProgressionSystem(能力门)· 31_SaveDataSchema(首次触发记录) --- ## 目录 1. [系统总览](#1-系统总览) 2. [设计哲学:沉默式优先](#2-设计哲学沉默式优先) 3. [ContextualHintTrigger — 情境提示触发器](#3-contextualhinttrigger--情境提示触发器) 4. [AbilityTutorialSequence — 能力获取教学演出](#4-abilitytutorialsequence--能力获取教学演出) 5. [InGameHintPanel — 控制器参考面板](#5-ingamehintpanel--控制器参考面板) 6. [TooltipPopup — 单行悬浮提示](#6-tooltippopup--单行悬浮提示) 7. [TutorialManager — 统一管理器](#7-tutorialmanager--统一管理器) 8. [SaveData 集成](#8-savedata-集成) 9. [多语言支持](#9-多语言支持) 10. [编辑器友好设计](#10-编辑器友好设计) --- ## 1. 系统总览 教程系统解决两个核心问题: 1. **首次遭遇新机制** — 在玩家第一次接触特定机制时,以最小侵入方式给予提示 2. **随时可查的操作参考** — 玩家按下专用按键可随时查看操作说明 ``` 教程系统职责: ├─ ContextualHintTrigger ← 区域/事件触发型提示(仅首次显示) ├─ AbilityTutorialSequence ← 能力解锁后的专项教学演出 ├─ InGameHintPanel ← 玩家主动打开的完整操作说明面板 ├─ TooltipPopup ← 非侵入式单行浮现提示(无需关闭) └─ TutorialManager ← 统一状态管理、SaveData 读写 ``` **设计原则**: - 不使用强制暂停式教学("按 X 继续…"),除了第一次能力获取演出 - 提示持续时间短(3–5 秒自动消失),不阻碍游戏进行 - 所有提示的"已见过"状态记录在 SaveData,不重复显示 - 提示内容随当前输入设备(键盘/手柄)自动切换图标 --- ## 2. 设计哲学:沉默式优先 ### 2.1 教学优先级 ``` 第 1 选择(最优):通过关卡设计本身教会玩家(无任何文字/UI) 示例:在安全环境放置单独的弱敌,玩家自然尝试攻击 第 2 选择:TooltipPopup 非侵入单行提示(3秒自动消失) 示例:"↑ + 跳跃 穿越平台"(玩家首次接近单向平台时显示) 第 3 选择:ContextualHintTrigger(3–5秒,图文结合) 示例:图解弹反时机(玩家首次接触弹反训练区) 第 4 选择(最后手段):AbilityTutorialSequence(暂停游戏,专项演出) 仅用于:获得全新能力时,玩家必须完全理解才能继续游戏 ``` ### 2.2 三类教学区域 | 类型 | 位置 | 设计方式 | |------|------|---------| | **关卡式教学** | 游戏开始的 Forest 区域 | 纯关卡设计,无 UI 提示 | | **机制教学区** | 每个新机制首次出现的房间 | ContextualHintTrigger + 关卡辅助 | | **危险预警** | 新 HazardType 首次出现 | TooltipPopup("这里危险,避免触碰")| --- ## 3. ContextualHintTrigger — 情境提示触发器 ### 3.1 组件定义 ```csharp namespace BaseGames.Tutorial { /// /// 区域触发型提示:玩家进入触发体区域时首次显示图文提示。 /// 已显示过的提示不再重复(通过 TutorialManager 记录状态)。 /// public class ContextualHintTrigger : MonoBehaviour { [SerializeField] ContextualHintSO _hint; // 提示内容 SO [SerializeField] TriggerMode _triggerMode = TriggerMode.OnPlayerEnter; [SerializeField] bool _showOnlyOnce = true; // false = 每次进入都显示 public enum TriggerMode { OnPlayerEnter, // 玩家进入触发体 OnPlayerInRange, // 玩家持续在范围内(首次进入触发) OnEventRaised, // 收到指定事件频道信号 } // TriggerMode.OnEventRaised 时使用 [SerializeField] VoidEventChannelSO _triggerChannel; void OnTriggerEnter2D(Collider2D other) { if (_triggerMode != TriggerMode.OnPlayerEnter) return; if (!other.CompareTag("Player")) return; TryShowHint(); } void TryShowHint() { if (_showOnlyOnce && TutorialManager.Instance.HasSeen(_hint.hintId)) return; TutorialManager.Instance.ShowContextualHint(_hint); } } } ``` ### 3.2 ContextualHintSO — 提示内容数据 ```csharp [CreateAssetMenu(menuName = "Tutorial/ContextualHint")] public class ContextualHintSO : ScriptableObject { [Header("标识")] public string hintId; // 唯一 ID,如 "Hint_Parry_FirstTime"(存 SaveData) [Header("显示内容")] public string localizationKey; // 本地化键(见 22_LocalizationSystem) public Sprite illustrationSprite; // 图示(可选,null = 纯文字) [Header("输入图标替换")] public string[] actionNames; // InputActionAsset 中的 Action 名(自动替换 {Jump} 等占位符) [Header("显示参数")] public float displayDuration = 4f; // 0 = 手动关闭 public HintPosition position = HintPosition.BottomCenter; public enum HintPosition { TopCenter, BottomCenter, TopLeft, Center } } ``` **文本格式**(本地化字符串中): 使用 `{ActionName}` 占位符,运行时替换为当前设备的按键图标: `"按下 {Parry} 在正确时机弹反敌人攻击"` → 键盘显示 `[A]`,手柄显示 `[LB]` ### 3.3 ContextualHint UI 样式 ``` ╔══════════════════════════════════════╗ ║ [插图区域 48×48px] 弹反机制 ║ ║ ║ ║ 在敌人攻击即将命中的瞬间 ║ ║ 按下 [LB] 弹反攻击 ║ ║ ║ ║ ○ 淡出消失 ║ ╚══════════════════════════════════════╝ ``` - 出现动画:从底部滑入 + 淡入(0.3s) - 消失动画:淡出(0.5s) - 背景:半透明黑色圆角框 - 不阻挡玩家操作(HUD Canvas Overlay 层,不捕获输入) --- ## 4. AbilityTutorialSequence — 能力获取教学演出 ### 4.1 触发时机 每当玩家通过 `AbilityUnlock` 获取新能力时,自动播放专项教学演出: ``` AbilityUnlock.OnTriggerEnter2D → PlayerStats.UnlockAbility(abilityType) → SaveManager.MarkCollected(unlockId) → 播放 acquisitionFeedback → TutorialManager.PlayAbilityTutorial(abilityType) ← 新增 ``` ### 4.2 教学演出流程 ``` AbilityTutorialSequence 播放流程: 1. 暂停游戏(Time.timeScale = 0,但 UIAnimator 不受影响) 2. 全屏黑色遮罩淡入(0.4s) 3. 画面中央显示能力图标 + 动画(Scale 放大 + 粒子效果,1.2s) 4. 显示能力名称(大标题,从下向上飞入,0.6s) 5. 显示能力说明(简短一行,0.4s 延迟) 6. 显示操作说明(包含输入图标,0.4s 延迟) 7. 显示"按任意键继续"提示(0.6s 延迟后出现) 8. 玩家输入 → 遮罩淡出 → 恢复 Time.timeScale ``` ### 4.3 AbilityTutorialSO — 能力教学配置 ```csharp [CreateAssetMenu(menuName = "Tutorial/AbilityTutorial")] public class AbilityTutorialSO : ScriptableObject { public AbilityType abilityType; // 对应能力类型 [Header("演出资产")] public Sprite abilityIcon; // 能力大图标(128×128px) public string abilityNameLocKey; // 本地化键:"ABILITY_DOUBLE_JUMP" public string descriptionLocKey; // 简短说明(1–2 行) public string[] usageInstructionLocKeys; // 操作说明(1–3 条,含 {ActionName} 占位) public MMF_Player acquisitionFeedback; // 播放音效和粒子的 Feel Player } ``` **资产存放**:`Assets/ScriptableObjects/Tutorial/AbilityTutorials/` ### 4.4 能力教学 SO 对照表 | 能力 | abilityType | 说明示例 | |------|------------|---------| | 双跳 | `DoubleJump` | "在空中再次跳跃,到达更高的地方" | | 冲刺 | `AerialDash` | "快速冲向一个方向,躲避攻击或穿越间隙" | | WallGrab | `WallGrab` | "按住方向键贴向墙壁,抓住墙面停下" | | 游泳 | `Swim` | "进入液体后自动激活,按跳跃键上浮" | | 天魂形态 | `SkyForm` | "切换到天魂形态,解锁天魂技能" | | 地魂形态 | `EarthForm` | "切换到地魂形态,解锁地魂技能" | | 命魂形态 | `DeathForm` | "切换到命魂形态,解锁命魂技能" | --- ## 5. InGameHintPanel — 控制器参考面板 ### 5.1 功能概述 玩家随时按下 `Help` 按键(默认 `F1` / `Select` 按钮)打开完整操作说明面板,**不暂停游戏**: ``` ╔════════════════════════════════════════════════════╗ ║ 🎮 操作说明 [ESC关闭] ║ ║────────────────────────────────────────────────────║ ║ 基础操作 战斗 ║ ║ ←/→ 移动 [Z/LB] 攻击 ║ ║ [X/A] 跳跃 [X/A] 弹反 ║ ║ 双击 [X/A] 双跳 ★ ←↓→ + [A] 方向攻击 ║ ║ [C/RT] 冲刺 ★ ║ ║ 形态切换 ║ ║ 进阶移动 [1/D-↑] 天魂形态 ★ ║ ║ 贴墙+方向 WallGrab ★ [2/D-↓] 地魂形态 ★ ║ ║ WallGrab中 [X/A] 墙跳 [3/D-→] 命魂形态 ★ ║ ║ ║ ║ ★ = 需解锁后可用 [翻页 ▶] ║ ╚════════════════════════════════════════════════════╝ ``` **灰显规则**:已解锁的能力正常显示;未解锁的能力文字灰色+问号替代图标(保留神秘感但告知存在)。 ### 5.2 InGameHintPanel 组件 ```csharp public class InGameHintPanel : MonoBehaviour { // 由 CanvasGroup 控制显示/隐藏,不暂停 Time.timeScale [SerializeField] CanvasGroup _panelGroup; [SerializeField] InputReaderSO _input; [SerializeField] PlayerStats _playerStats; // 查询已解锁能力 void OnEnable() => _input.HelpEvent += Toggle; void OnDisable() => _input.HelpEvent -= Toggle; void Toggle() { bool show = _panelGroup.alpha < 0.5f; _panelGroup.DOFade(show ? 1f : 0f, 0.2f).SetAutoKill(true); _panelGroup.interactable = _panelGroup.blocksRaycasts = show; RefreshAbilityHighlights(); } void RefreshAbilityHighlights() { // 根据 _playerStats.HasAbility() 动态更新图标颜色/灰显 foreach (var entry in _abilityEntries) entry.SetUnlocked(_playerStats.HasAbility(entry.abilityType)); } } ``` --- ## 6. TooltipPopup — 单行悬浮提示 ### 6.1 用途 最轻量的提示方式,不含图片,不阻塞操作: ``` 示例显示效果(屏幕底部): ┌─────────────────────────────────────┐ │ ↓ + [X] 可以穿过此平台 │ └─────────────────────────────────────┘ (3秒后自动消失) ``` ### 6.2 使用场景 | 触发时机 | 提示内容 | |---------|---------| | 玩家首次站上单向平台 | "↓ + {Jump} 可以穿过此平台" | | 玩家首次接近 SavePoint | "按 {Interact} 存档并恢复 HP" | | 玩家首次进入暗区 | "此区域能见度降低" | | 玩家首次遇到可弹反弹射物 | "可弹反的弹射物会发出白光" | | Geo 首次掉落在死亡处 | "取回死亡时掉落的 Geo" | ### 6.3 代码接口 ```csharp // 任意系统调用(通过事件频道,零耦合): _tooltipChannel.Raise(new TooltipData { localizationKey = "HINT_ONEWAY_PLATFORM", duration = 3f, showOnlyOnce = true, hintId = "OWPlatform_FirstTime" }); ``` --- ## 7. TutorialManager — 统一管理器 ```csharp namespace BaseGames.Tutorial { /// /// 单例管理器:记录已展示提示的状态,派发提示请求到 UI 层。 /// 位于 Persistent 场景,通过事件频道通信,不持有具体 UI 引用。 /// [DefaultExecutionOrder(-50)] public class TutorialManager : MonoBehaviour { public static TutorialManager Instance { get; private set; } [SerializeField] TooltipEventChannelSO _tooltipChannel; // 订阅:发送单行提示 [SerializeField] HintEventChannelSO _hintChannel; // 订阅:发送图文提示 [SerializeField] AbilityTutorialSO[] _abilityTutorials;// 所有能力教学 SO 数组 HashSet _seenHints = new(); // 运行时缓存,启动时从 SaveData 加载 void Awake() { Instance = this; LoadSeenHints(); } public bool HasSeen(string hintId) => _seenHints.Contains(hintId); public void ShowContextualHint(ContextualHintSO hint) { _seenHints.Add(hint.hintId); SaveSeenHints(); _hintChannel.Raise(hint); } public void PlayAbilityTutorial(AbilityType type) { var tutorial = System.Array.Find(_abilityTutorials, t => t.abilityType == type); if (tutorial == null) return; StartCoroutine(PlayTutorialSequence(tutorial)); } IEnumerator PlayTutorialSequence(AbilityTutorialSO tutorial) { Time.timeScale = 0f; _hintChannel.Raise(tutorial); // 专用全屏演出 // 等待玩家任意键确认(UnscaledTime) yield return new WaitUntil(() => Input.anyKeyDown); Time.timeScale = 1f; } void LoadSeenHints() { var data = SaveManager.Instance?.CurrentSave?.tutorial?.seenHints; if (data != null) _seenHints = new HashSet(data); } void SaveSeenHints() { if (SaveManager.Instance?.CurrentSave?.tutorial == null) return; SaveManager.Instance.CurrentSave.tutorial.seenHints = new List(_seenHints); SaveManager.Instance.MarkDirty(); // 不立刻写盘,等下次存档点触发 } } } ``` --- ## 8. SaveData 集成 ### 8.1 SaveData 字段扩展 在 `31_SaveDataSchema_Unified.md` 的 C# 结构中添加 `tutorial` 节点: ```csharp // SaveData.cs 中新增字段 public class TutorialSaveData { public List seenHints = new(); // 已展示过的提示 ID 列表 public List completedTutorials = new(); // 已完成的能力教学 ID 列表 } // 主 SaveData 结构 public class SaveData { // ...(原有字段) public TutorialSaveData tutorial = new(); } ``` ### 8.2 JSON Schema 扩展 ```json { "tutorial": { "seenHints": ["OWPlatform_FirstTime", "Parry_FirstTime", "SavePoint_FirstTime"], "completedTutorials": ["DoubleJump", "AerialDash"] } } ``` ### 8.3 新游戏 / 存档重置行为 - 新游戏:`tutorial` 节点为空,所有提示将依序展示 - 读取存档:从 `seenHints` 列表恢复已见过的提示,不重复展示 - 存档删除:整个 `tutorial` 节点重置为空 --- ## 9. 多语言支持 所有提示文本通过 `localizationKey` 引用 `22_LocalizationSystem` 的字符串表,**不在 SO 中硬编码文字**: ```csharp // 使用方式示例 string text = LanguageManagerSO.Instance.GetString(hint.localizationKey); // 输入图标替换(替换 {ActionName} 占位符) text = InputIconReplacer.Replace(text, hint.actionNames); // 输出示例(手柄):"按下 [图标:LB] 在弹反窗口内反击" ``` **本地化键命名规范**:`HINT_{机制简称}_{情境}_{语言变体}` 示例: - `HINT_ONEWAY_FIRST` → "↓ + {Jump} 可以穿过此平台" - `HINT_PARRY_TIMING` → "在攻击命中瞬间按 {Parry}" - `HINT_SHADE_COLLECT` → "拾取遗骸可取回死亡时掉落的 Geo" --- ## 10. 编辑器友好设计 ### 10.1 TutorialDebugOverlay 运行时叠加层(仅 `UNITY_EDITOR || DEVELOPMENT_BUILD`): ``` TutorialDebugOverlay(按 F2 切换显示): ┌─ Tutorial Debug ─────────────────────────────────┐ │ 已见提示数: 12 / 28 │ │ 当前队列: [空] │ │ ─────────────────────────────────────────────── │ │ [重置所有提示] [标记所有已见] [导出未见列表] │ └──────────────────────────────────────────────────┘ ``` ### 10.2 ContextualHintTrigger 自定义 Inspector ``` ContextualHintTrigger Inspector: ┌─ Hint SO ──────────────────────────────────────┐ │ Hint: [OWPlatform_Hint] │ │ hintId: "OWPlatform_FirstTime" │ │ Duration: 3.0s │ │ ───────────────────────────────────────────── │ │ [▶ 预览提示效果(Editor Play Mode 中)] │ │ [已见状态: 未见 ▶ 点击模拟"已见"] │ └────────────────────────────────────────────────┘ ``` ### 10.3 HintTrigger Gizmos - `ContextualHintTrigger` 的触发区域在 Scene 视图显示为**浅蓝色边框虚线框** - 框内显示 hintId 字符串(白色小字) - 鼠标悬停时显示提示文本预览(Tool Tip) --- ## 附录:推荐提示 ID 规范 所有 hintId 字符串集中在静态类中定义(类似 `GameFlags`): ```csharp // Assets/Scripts/Tutorial/TutorialHintIds.cs public static class TutorialHintIds { // 移动 public const string OWPlatformDropDown = "OWPlatform_DropDown"; public const string WallGrabIntro = "WallGrab_Intro"; public const string DoubleJumpIntro = "DoubleJump_Intro"; public const string DashIntro = "Dash_Intro"; // 战斗 public const string ParryTimingIntro = "Parry_Timing_Intro"; public const string ParryableProjectile = "Parry_Projectile"; public const string BossPhaseChange = "Boss_PhaseChange"; // 世界 public const string SavePointFirst = "SavePoint_First"; public const string ShadeCollect = "Shade_Collect"; public const string BenchFastTravel = "Bench_FastTravel"; public const string ShopFirst = "Shop_First"; // 形态系统 public const string FormSwitchIntro = "Form_Switch_Intro"; public const string SkyFormSkillIntro = "Form_Sky_Skill"; // 危险 public const string PoisonZoneFirst = "Hazard_Poison_First"; public const string LavaZoneFirst = "Hazard_Lava_First"; public const string InstantKillFirst = "Hazard_InstantKill_First"; } ``` --- *文档版本 1.0 · 2026*