# 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` 常量 | 关闭引导的持久化开关 |