Files
spellforge/docs/design/tutorial_design.md
T
2026-07-20 10:56:52 +08:00

285 lines
10 KiB
Markdown
Raw 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.
# FTUE / 新手引导系统架构设计
# Tutorial & Onboarding System Architecture
> **范围**:本文档涵盖游戏的首次用户体验(FTUE)、上下文提示、引导状态机,以及
> `TutorialManager` Autoload 的完整设计规范。
> 本文档以 `architecture_design.md` 为基础,所有运行时接口遵循 §6.1 语言分区原则。
---
## 一、设计目标
| 目标 | 度量标准 |
| :--- | :--- |
| 前 3 分钟玩家留存 | 90%+ 玩家完成首波(Wave 1)且尝试第一次施法 |
| 零文字强制阅读 | 教学信息通过上下文提示传达,不弹出强制阅读对话框 |
| 可跳过原则 | 所有引导步骤可在设置中永久关闭,或在进行中跳过 |
| 渐进式复杂度 | W1–W3 仅展示核心移动+射击;W4+ 逐步解锁进阶提示 |
---
## 二、TutorialManager Autoload
### 2.1 职责边界
- **GDScript 层**`tutorial_manager.gd`):状态读写、EventBus 订阅、提示节点显示/隐藏。
- **不含热路径**:提示触发频率极低(每场景级别),全部保留在 GDScript。
```gdscript
# scripts/autoloads/tutorial_manager.gd
extends Node
## 教学步骤枚举(顺序即解锁顺序)
enum TutorialStep {
MOVE = 0, # WASD 移动
CAST = 1, # 首次自动施法(Wand 冷却后自动提示)
PICK_SPELL = 2, # 首次拾取法术卡
OPEN_SHOP = 3, # 首次进入商店
EQUIP_CORE = 4, # 首次装备 Core
KILL_10 = 5, # 击杀 10 个敌人
WAVE_CLEARED = 6, # 通过 Wave 1
RESONANCE_HINT = 7, # 首次触发共鸣(S3 解锁)
COMPLETED = 99, # 引导全部完成
}
## 已完成步骤的持久化集合(存入 ProfileManager
var _completed_steps: PackedInt32Array = PackedInt32Array()
## 当前激活提示步骤(-1 = 无)
var _active_step: int = -1
func _ready() -> void:
# 从 ProfileManager 恢复已完成状态
_completed_steps = ProfileManager.get_int_array(
"tutorial_completed", PackedInt32Array())
# 订阅进度事件
EventBus.subscribe(EventID.PLAYER_SPAWNED, _on_player_spawned)
EventBus.subscribe(EventID.SPELL_CAST, _on_spell_cast)
EventBus.subscribe(EventID.SHOP_OPENED, _on_shop_opened)
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
EventBus.subscribe(EventID.WAVE_CLEARED, _on_wave_cleared)
EventBus.subscribe(EventID.RESONANCE_TRIGGERED, _on_resonance)
```
### 2.2 状态机定义
```
[初始状态]
MOVE ──────────────── 检测:玩家首帧输入 WASD 后标记完成
CAST ──────────────── 检测:EventID.SPELL_CAST 首次触发
PICK_SPELL ─────────── 检测:拾取 loot 掉落的法术卡
OPEN_SHOP ──────────── 检测:Wave 1 通关后自动弹出商店提示
EQUIP_CORE ─────────── 检测:首次从商店装备任意 Core
KILL_10 ────────────── 检测:ENEMY_KILLED 事件累计 10 次
WAVE_CLEARED ───────── 检测:WAVE_CLEAREDwave_num=1
RESONANCE_HINT ─────── 检测:RESONANCE_TRIGGEREDS3 起激活)
COMPLETED ──────────── 持久化到 ProfileManager,停止所有提示
```
- **前向跳跃**:若玩家自行完成某步(无提示出现),步骤自动标记完成,无需显示提示。
- **后向保护**:已完成步骤不会因新 Run 而重置(存 ProfileManager 而非 RunData)。
### 2.3 核心接口
```gdscript
# 查询某步骤是否已完成
func is_completed(step: TutorialStep) -> bool:
return step in _completed_steps
# 手动标记完成(商店系统 / 法杖编辑器调用)
func mark_complete(step: TutorialStep) -> void:
if is_completed(step): return
_completed_steps.append(int(step))
ProfileManager.set_int_array("tutorial_completed", _completed_steps)
_hide_hint()
EventBus.emit(EventID.TUTORIAL_STEP_COMPLETED, step)
# 显示上下文提示(锚定到 UI 节点旁)
func show_hint(step: TutorialStep, anchor: Control,
offset: Vector2 = Vector2.ZERO) -> void:
if is_completed(step): return
_active_step = step
# 委托给 UIManager 渲染气泡提示
UIManager.show_tutorial_hint(
TutorialHintData.build(step), anchor, offset)
func _hide_hint() -> void:
if _active_step == -1: return
UIManager.hide_tutorial_hint()
_active_step = -1
```
---
## 三、上下文提示系统(Contextual Hints
### 3.1 提示数据结构
```gdscript
# resources/tutorial/tutorial_hint_data.gd
class_name TutorialHintData
extends Resource
@export var step: int # TutorialStep 枚举值
@export var icon_key: String # 对应 UIAtlas 中的图标键(如 "wasd_icon"
@export var text_key: String # i18n 键(tr(text_key) 使用)
@export var arrow_dir: int # 0=上, 1=右, 2=下, 3=左(箭头指向目标)
@export var duration: float = 0.0 # 0 = 手动关闭,>0 = 自动消失秒数
static func build(step: TutorialManager.TutorialStep) -> TutorialHintData:
return _HINT_TABLE[step]
# 提示内容表(运行时常量,避免 JSON 热加载)
const _HINT_TABLE: Dictionary = {
0: preload("res://resources/tutorial/hint_move.tres"),
1: preload("res://resources/tutorial/hint_cast.tres"),
2: preload("res://resources/tutorial/hint_pick_spell.tres"),
3: preload("res://resources/tutorial/hint_shop.tres"),
4: preload("res://resources/tutorial/hint_equip_core.tres"),
5: preload("res://resources/tutorial/hint_kill10.tres"),
6: preload("res://resources/tutorial/hint_wave_cleared.tres"),
7: preload("res://resources/tutorial/hint_resonance.tres"),
}
```
### 3.2 提示触发时机
| 步骤 | 触发时机 | 自动消失 | 跳过方式 |
| :--- | :--- | :--- | :--- |
| MOVE | 游戏开始后 1s(玩家还未移动) | 首次移动后 | 任意移动 |
| CAST | 法杖冷却恢复后 2s(玩家未施法) | 首次施法后 | 施法 |
| PICK_SPELL | 首个法术 loot 落地后 3s | 拾取后 | 走到 loot 上 |
| OPEN_SHOP | Wave 1 通关时自动进入商店流程 | 商店打开后 | 自动 |
| EQUIP_CORE | 商店内有 Core 可购买时 | 购买后 | 购买 |
| KILL_10 | 击杀计数达 5 时预显提示 | 计数达 10 | 击杀 |
| WAVE_CLEARED | Wave 1 通关瞬间 | 5s 后 | 点击 |
| RESONANCE_HINT | 首次编辑法杖时检测邻接 | 触发共鸣后 | 触发共鸣 |
### 3.3 提示 UI 节点结构
```
CombatScene
└── UILayer (CanvasLayer z_index=100)
└── TutorialHintContainer # UIManager 管理的专用节点
├── HintBubble (Panel)
│ ├── Icon (TextureRect)
│ └── Label (text = tr(hint.text_key))
└── Arrow (TextureRect, 根据 arrow_dir 旋转)
```
- `TutorialHintContainer``anchor` 节点世界坐标定位(`force_update_transform` 每帧同步)。
- 气泡使用 `AnimationPlayer` 播放 0.2s 淡入动画,消失时 0.15s 淡出。
- 同一时刻最多显示 1 个提示(显示新提示时先隐藏旧提示)。
---
## 四、首 3 分钟玩家流(First 3 Minutes Flow
```
T=0s 游戏载入 → CombatScene 初始化
T=1s [提示] WASD 移动(气泡锚定玩家位置右侧,arrow→左)
T=~5s 玩家完成移动 → MOVE 标记完成 → 提示消失
T=~8s 法杖冷却结束 → [提示] 观察法术已自动施发(施法为自动)
T=~15s 首个敌人被击杀 → loot 掉落 → [提示] 拾取法术卡
T=~30s 玩家拾取法术卡 → PICK_SPELL 标记完成
T=~60s Wave 1 清场 → 自动进入商店
T=商店 [提示] 尝试装备 Core(若商店有 Corearrow→Core 格)
T=~90s 购买/关闭商店 → Wave 2 开始
T=~120s 已掌握基本循环,FTUE 完成度 ~70%
T=Wave 4+ 触发共鸣时 → 最终提示显示
```
---
## 五、关键 EventID 扩展
> 以下 EventID 需追加到 `event_id.gd`(当前最大 ID = 17):
| ID | 常量名 | 负载 | 触发方 | 监听方 |
| :--- | :--- | :--- | :--- | :--- |
| 18 | `TUTORIAL_STEP_COMPLETED` | `step: int` | TutorialManager | UIManager(显示解锁提示)|
| 19 | `SHOP_OPENED` | — | ShopManager | TutorialManager |
| 20 | `RESONANCE_TRIGGERED` | `recipe_id: String` | SpellEvaluator | TutorialManager, VFXManager |
| 21 | `ACHIEVEMENT_UNLOCKED` | `achievement_id: String` | AchievementManager | Steam 插件层 |
> **注意**ID 0 为 `CRASH_DETECTED`CrashReporter 专用,已定义)。
---
## 六、可跳过 & 关闭机制
```gdscript
# settings_manager.gd 新增项
const KEY_TUTORIAL_DISABLED: String = "tutorial_disabled"
# TutorialManager._ready() 中检查
func _ready() -> void:
if ProfileManager.get_bool(SettingsManager.KEY_TUTORIAL_DISABLED, false):
# 将所有步骤标记为已完成,彻底关闭引导
for s in TutorialStep.values():
_completed_steps.append(s)
return
# 正常初始化...
```
设置菜单提供「关闭新手提示」开关,写入 `ProfileManager`(持久化跨 Run)。
**重置入口**`SettingsManager.reset_tutorial()` → 清空 `tutorial_completed` 并重置标志,
下次启动重新触发完整 FTUE 流程(适用于练习账号 / QA 测试)。
---
## 七、文件清单
```
scripts/autoloads/
tutorial_manager.gd # 核心 Autoload(本文档 §2 实现)
resources/tutorial/
hint_move.tres # TutorialHintDataMOVE 步骤)
hint_cast.tres
hint_pick_spell.tres
hint_shop.tres
hint_equip_core.tres
hint_kill10.tres
hint_wave_cleared.tres
hint_resonance.tres
scenes/ui/
tutorial_hint_bubble.tscn # HintBubble + Arrow 节点树
```
**Autoload 注册顺序**`project.godot`):
> `TutorialManager` 在 `EventBus` 和 `ProfileManager` 之后注册,
> 确保 `_ready()` 时可正常读取持久化数据并订阅事件。
---
## 八、与其他系统的接口约定
| 系统 | 接口 | 说明 |
| :--- | :--- | :--- |
| `UIManager` | `show_tutorial_hint(data, anchor, offset)` / `hide_tutorial_hint()` | 渲染和动画委托给 UIManager |
| `ProfileManager` | `get_int_array / set_int_array("tutorial_completed", ...)` | 跨 Run 持久化已完成步骤 |
| `EventBus` | 订阅 ID 1/6/11/18~21 等 | 只读订阅,不 emit 游戏逻辑事件 |
| `SpellEvaluator` | 无直接依赖 | 共鸣触发通过 `RESONANCE_TRIGGERED` 事件解耦 |
| `SettingsManager` | `KEY_TUTORIAL_DISABLED` 常量 | 关闭引导的持久化开关 |