285 lines
10 KiB
Markdown
285 lines
10 KiB
Markdown
# 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_CLEARED(wave_num=1)
|
||
│
|
||
▼
|
||
RESONANCE_HINT ─────── 检测:RESONANCE_TRIGGERED(S3 起激活)
|
||
│
|
||
▼
|
||
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(若商店有 Core,arrow→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 # TutorialHintData(MOVE 步骤)
|
||
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` 常量 | 关闭引导的持久化开关 |
|