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

535 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 继续…"),除了第一次能力获取演出
- 提示持续时间短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 组件定义
```csharp
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 — 提示内容数据
```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; // 简短说明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 组件
```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
{
/// <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` 节点:
```csharp
// 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 扩展
```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*