Files
zeling_v2/Docs/Design/45_TutorialSystem.md
2026-05-08 11:04:00 +08:00

20 KiB
Raw Permalink Blame History

45 · 教程与情境提示系统

命名空间 BaseGames.Tutorial
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.Player(能力查询)· BaseGames.UI(提示面板)· BaseGames.WorldSaveSystem
关联 01_InputSystem按键提示· 03_PlayerSystem能力解锁· 14_ProgressionSystem能力门· 31_SaveDataSchema首次触发记录


目录

  1. 系统总览
  2. 设计哲学:沉默式优先
  3. ContextualHintTrigger — 情境提示触发器
  4. AbilityTutorialSequence — 能力获取教学演出
  5. InGameHintPanel — 控制器参考面板
  6. TooltipPopup — 单行悬浮提示
  7. TutorialManager — 统一管理器
  8. SaveData 集成
  9. 多语言支持
  10. 编辑器友好设计

1. 系统总览

教程系统解决两个核心问题:

  1. 首次遭遇新机制 — 在玩家第一次接触特定机制时,以最小侵入方式给予提示
  2. 随时可查的操作参考 — 玩家按下专用按键可随时查看操作说明
教程系统职责:
  ├─ ContextualHintTrigger  ← 区域/事件触发型提示(仅首次显示)
  ├─ AbilityTutorialSequence ← 能力解锁后的专项教学演出
  ├─ InGameHintPanel        ← 玩家主动打开的完整操作说明面板
  ├─ TooltipPopup           ← 非侵入式单行浮现提示(无需关闭)
  └─ TutorialManager        ← 统一状态管理、SaveData 读写

设计原则

  • 不使用强制暂停式教学("按 X 继续…"),除了第一次能力获取演出
  • 提示持续时间短35 秒自动消失),不阻碍游戏进行
  • 所有提示的"已见过"状态记录在 SaveData不重复显示
  • 提示内容随当前输入设备(键盘/手柄)自动切换图标

2. 设计哲学:沉默式优先

2.1 教学优先级

第 1 选择(最优):通过关卡设计本身教会玩家(无任何文字/UI
  示例:在安全环境放置单独的弱敌,玩家自然尝试攻击

第 2 选择TooltipPopup 非侵入单行提示3秒自动消失
  示例:"↑ + 跳跃 穿越平台"(玩家首次接近单向平台时显示)

第 3 选择ContextualHintTrigger35秒图文结合
  示例:图解弹反时机(玩家首次接触弹反训练区)

第 4 选择最后手段AbilityTutorialSequence暂停游戏专项演出
  仅用于:获得全新能力时,玩家必须完全理解才能继续游戏

2.2 三类教学区域

类型 位置 设计方式
关卡式教学 游戏开始的 Forest 区域 纯关卡设计,无 UI 提示
机制教学区 每个新机制首次出现的房间 ContextualHintTrigger + 关卡辅助
危险预警 新 HazardType 首次出现 TooltipPopup"这里危险,避免触碰"

3. ContextualHintTrigger — 情境提示触发器

3.1 组件定义

namespace BaseGames.Tutorial
{
    /// <summary>
    /// 区域触发型提示:玩家进入触发体区域时首次显示图文提示。
    /// 已显示过的提示不再重复(通过 TutorialManager 记录状态)。
    /// </summary>
    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 — 提示内容数据

[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 — 能力教学配置

[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;       // 简短说明12 行)
    public string[] usageInstructionLocKeys; // 操作说明13 条,含 {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 组件

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 代码接口

// 任意系统调用(通过事件频道,零耦合):
_tooltipChannel.Raise(new TooltipData
{
    localizationKey = "HINT_ONEWAY_PLATFORM",
    duration        = 3f,
    showOnlyOnce    = true,
    hintId          = "OWPlatform_FirstTime"
});

7. TutorialManager — 统一管理器

namespace BaseGames.Tutorial
{
    /// <summary>
    /// 单例管理器:记录已展示提示的状态,派发提示请求到 UI 层。
    /// 位于 Persistent 场景,通过事件频道通信,不持有具体 UI 引用。
    /// </summary>
    [DefaultExecutionOrder(-50)]
    public class TutorialManager : MonoBehaviour
    {
        public static TutorialManager Instance { get; private set; }

        [SerializeField] TooltipEventChannelSO  _tooltipChannel;  // 订阅:发送单行提示
        [SerializeField] HintEventChannelSO     _hintChannel;     // 订阅:发送图文提示
        [SerializeField] AbilityTutorialSO[]    _abilityTutorials;// 所有能力教学 SO 数组

        HashSet<string> _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<string>(data);
        }

        void SaveSeenHints()
        {
            if (SaveManager.Instance?.CurrentSave?.tutorial == null) return;
            SaveManager.Instance.CurrentSave.tutorial.seenHints = new List<string>(_seenHints);
            SaveManager.Instance.MarkDirty();  // 不立刻写盘,等下次存档点触发
        }
    }
}

8. SaveData 集成

8.1 SaveData 字段扩展

31_SaveDataSchema_Unified.md 的 C# 结构中添加 tutorial 节点:

// SaveData.cs 中新增字段
public class TutorialSaveData
{
    public List<string> seenHints = new();       // 已展示过的提示 ID 列表
    public List<string> completedTutorials = new(); // 已完成的能力教学 ID 列表
}

// 主 SaveData 结构
public class SaveData
{
    // ...(原有字段)
    public TutorialSaveData tutorial = new();
}

8.2 JSON Schema 扩展

{
  "tutorial": {
    "seenHints": ["OWPlatform_FirstTime", "Parry_FirstTime", "SavePoint_FirstTime"],
    "completedTutorials": ["DoubleJump", "AerialDash"]
  }
}

8.3 新游戏 / 存档重置行为

  • 新游戏:tutorial 节点为空,所有提示将依序展示
  • 读取存档:从 seenHints 列表恢复已见过的提示,不重复展示
  • 存档删除:整个 tutorial 节点重置为空

9. 多语言支持

所有提示文本通过 localizationKey 引用 22_LocalizationSystem 的字符串表,不在 SO 中硬编码文字

// 使用方式示例
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

// 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