上午我们让地形自己接缝,关卡画得飞快。可一旦关卡变多、贴图变重,点「下一关」的那一下就会露馅:画面定住两三秒,鼠标转圈,玩家以为游戏崩了——然后 Alt+F4。load() 是同步的,它会把主线程按在地上摩擦。Godot 的 ResourceLoader 线程加载三件套就是治这个的:读盘在后台线程跑,主线程继续画你的加载界面。这篇把 API 的状态机、进度条、转场时序一次讲透。

今日速览

  • load() / ResourceLoader.load() 会阻塞主线程。资源越大、平台越慢(Steam Deck 的 SD 卡、机械硬盘)卡得越久,表现就是「假死」。
  • 异步加载是三件套 + 一个轮询:load_threaded_request() 下单 → load_threaded_get_status() 每帧问进度 → load_threaded_get() 取货。
  • 进度值放在一个单元素数组里传进去:var p := []; status = ResourceLoader.load_threaded_get_status(path, p),之后读 p[0](0.0~1.0)。
  • load_threaded_get() 在没加载完时依然会阻塞。所以必须自己看状态,不能盲取;否则等于白用异步。
  • 切场景用 get_tree().change_scene_to_packed(packed)。切之前要先让转场遮罩真的画出来,否则黑屏还没铺上,世界就换了。

核心学习内容

一、为什么要用线程加载:一帧的预算是 16.6 毫秒

60 帧的游戏,一帧只有 16.6ms。同步加载一个 30MB 的关卡场景,可能要 3001500ms——相当于**连续丢掉 2090 帧**。玩家看到的不是「正在加载」,而是「卡住了」。

ResourceLoader 的做法是把解析、解码、依赖收集丢到后台线程,主线程只在每帧花几微秒问一句「好了没」。这就是为什么加载界面能动、进度条能涨、BGM 不断。

二、三件套的状态机

const LEVEL_PATH := "res://levels/level_02.tscn"

var _progress: Array = []

func start_loading(path: String) -> void:
    _progress = []
    var err := ResourceLoader.load_threaded_request(path, "", true)
    if err != OK:
        push_error("加载请求失败: %s (%d)" % [path, err])

func _process(_delta: float) -> void:
    var status := ResourceLoader.load_threaded_get_status(LEVEL_PATH, _progress)
    match status:
        ResourceLoader.THREAD_LOAD_IN_PROGRESS:
            progress_bar.value = _progress[0] * 100.0
        ResourceLoader.THREAD_LOAD_LOADED:
            var packed: PackedScene = ResourceLoader.load_threaded_get(LEVEL_PATH)
            get_tree().change_scene_to_packed(packed)
            set_process(false)
        ResourceLoader.THREAD_LOAD_FAILED, ResourceLoader.THREAD_LOAD_INVALID_RESOURCE:
            show_error("关卡读取失败,请重试")
            set_process(false)

三个细节值得记:

  1. use_sub_threads = true(第三个参数):允许再开子线程并行处理,导入型资源(贴图、网格)通常更快。但注意它只是「通常更快」,不是必然;对脚本多的场景收益有限。先在 Steam Deck 这种弱机上量一遍再决定。
  2. 状态只有四种:INVALID_RESOURCE(路径不对/资源类型不符)、IN_PROGRESS、FAILED、LOADED。别只判断「不是 IN_PROGRESS 就当成功」——失败也会退出 IN_PROGRESS,那样你会拿到 null 然后崩在 instantiate()。
  3. 取货后立刻 set_process(false),否则同一帧内可能重复触发 change_scene_to_packed,切两次场景,出现「关卡加载了但玩家在两个世界之间」的经典 bug。

三、转场时序:为什么你的黑屏总是慢半拍

最常见的写法错误是:按下开始 → 立刻 change_scene_to_packed() → 新场景 _ready() 里淡入。结果加载那 1 秒里玩家盯着上一关的静止画面,非常廉价。

正确顺序是先盖被子,再换床单:

func go_to_level(path: String) -> void:
    fade_rect.modulate.a = 0.0
    var tw := create_tween()
    tw.tween_property(fade_rect, "modulate:a", 1.0, 0.25)
    await tw.finished                     # 等遮罩真的不透明
    await get_tree().process_frame        # 再等一帧,确保它被绘制
    start_loading(path)                   # 此时才开始后台读盘

关键就是那句 await get_tree().process_frame。await tw.finished 只保证属性动画播完,不保证这一帧已经渲染到屏幕上;少等这一帧,玩家会看到旧场景多闪一下。一个 process_frame 的成本接近零,换来的是「专业感」。

四、进阶:在玩家还在玩的时候预热下一关

如果关卡切换是可预期的(走到关卡尽头的门、Boss 血量低于 20% 触发过场),别等玩家点了才加载。在进入触发区时就开始 load_threaded_request(),等真正要切的时候状态早就是 LOADED,load_threaded_get() 瞬间返回,转场可以做成一气呵成的 0.2 秒。

代价是内存:LOADED 的 PackedScene 会一直驻留。所以预热只对「下一关」做,并且拿到 PackedScene 后不要在切换前 instantiate,只持有引用;切换完成、旧场景 queue_free() 之后,让引用自然释放。

顺带一句:加载界面上放**随机小贴士(tips)**是最便宜的「把等待变成内容」的手法——把操作技巧、世界观碎片轮流显示,玩家甚至会觉得加载有点意思。进度条如果涨得太快(本地 SSD 常见),可以做「缓动跟随」而不是直接赋值,视觉上更稳。

🎯 DeepSeek 提示词

提示词 1:生成完整的异步加载 + 转场系统

适用场景:新项目起手,一次把「加载界面 + 淡入淡出 + 进度条 + 失败重试」搭好。

你是资深 Godot 4 开发者,熟悉 ResourceLoader 的线程加载 API。

任务:为我的 Godot 4.x 2D 游戏写一套「场景异步加载 + 转场」系统。

需求:
1. 一个 LoadingScreen 场景(CanvasLayer + ColorRect 遮罩 + ProgressBar + 随机 Tips 标签)。
2. 对外只暴露一个方法 go_to_scene(path: String),内部按顺序执行:
   淡入遮罩(0.25s) → await 一帧 → load_threaded_request → 每帧轮询状态更新进度
   → 成功后 load_threaded_get + get_tree().change_scene_to_packed → 淡出遮罩。
3. 必须在 _process 里轮询 load_threaded_get_status,并把进度数组第 0 项映射到 0-100。
4. 处理全部四种状态:INVALID_RESOURCE / IN_PROGRESS / FAILED / LOADED,
   FAILED 时显示重试按钮,点击后重新请求,不要直接切场景。
5. 防止重复触发:切换开始后用一个 bool 或 set_process(false) 锁住。
6. 进度条用 Tween 缓动跟随真实进度,不要瞬时跳变。
7. 提供一个 preload_level(path) 方法,供「提前预热下一关」使用。

输出格式:
- 先给文件树(每个 .tscn / .gd 的职责一句话)
- 再给完整 GDScript 代码,带中文注释
- 最后列出「我需要在编辑器里手动做的事」清单(节点结构、信号连接、导出变量)

约束:
- 只用 Godot 4 内置 API,不引入插件
- 不使用 await load() 这类阻塞写法
- 所有 await 的位置加注释说明为什么在这里等

提示词 2:审查我的场景切换代码

适用场景:加载偶尔卡一下、偶尔黑屏闪、偶尔进度条不动,自己看不出问题。

你是 Godot 4 代码审查员,专长是异步加载与场景生命周期。

下面是我的场景切换代码。请只从「运行时 bug」角度审查,不要重写风格。

【我的代码】
<<<在这里粘贴脚本>>>

【运行环境】Godot 4.x / Windows + Steam Deck / 目标 60fps

请按以下清单逐条检查,每条给出「有问题 / 没问题 + 依据 + 修复代码」:
1. 是否存在会在主线程阻塞的调用(load、await 阻塞型 API、instantiate 大场景)?
2. load_threaded_get 是否可能在状态未到 LOADED 时被调用?
3. 是否有重复触发 change_scene_to_packed 的路径(信号重复连接、_process 未停)?
4. 切场景前是否等待了至少一帧让遮罩真正绘制?
5. FAILED 分支是否被当成成功处理?是否会导致 instantiate(null) 崩溃?
6. 旧场景是否正确释放?是否有信号/定时器/资源引用导致内存不释放?
7. Tween 是否在场景切换后被销毁但仍有回调?是否需要用 bind_node 或检查 is_instance_valid?

输出格式:表格(问题 | 严重度 高/中/低 | 依据 | 修复片段),最后给一段 5 行以内的「最该先改的三件事」。

提示词 3:设计加载与转场体验(非代码)

适用场景:加载时间降不下去了,与其硬优化,不如把等待设计成体验。

你是游戏 UX 设计师,擅长「把技术限制变成情绪体验」。

背景:我的 2D 动作游戏关卡切换平均需要 1.5~4 秒(Steam Deck 上偏慢)。

任务:为这段等待设计体验方案。

请输出:
1. 分档策略:<0.3s / 0.3-1s / 1-3s / >3s 四档各用什么处理(是否显示界面、是否可跳过动画)。
2. 转场语言:给出 3 种契合「像素风 + 暗黑奇幻」的转场方案,说明画面构成、时长、用 Godot 怎么实现(Tween/Shader/动画)。
3. 加载界面内容:进度条的三种视觉表达(真进度、缓动进度、章节式分段),各自适合什么游戏。
4. Tips 文案:写 8 条不超过 20 字的提示,混合操作技巧与世界观碎片,不要教程口吻。
5. 反模式清单:列出 5 个「独立游戏加载界面常见错误」,并说明为什么玩家会反感。

约束:不提「优化加载速度」这类工程建议,只做体验设计;所有方案要能在 Godot 4 里用内置功能实现。

学习心得

「用 DeepSeek 做一款 Steam 游戏」,最容易被忽略的成本不是写代码,而是玩家对「不流畅」的容忍度。Steam 上差评里常见的一句是「优化差」,很多时候不是帧率低,而是读盘卡顿——这种问题在开发者自己的高配机器上几乎看不出来,在 Steam Deck 和老笔记本上却极其刺眼。

所以今天这套东西的价值是:它把一个「看不见的技术债」变成了 30 行代码就能还掉的债。而且这三条提示词的分工很有意思——第一条让 DeepSeek 生成骨架,第二条让它当审查员(审查比生成更能抓到坑,因为它不需要猜你的意图,只需要对照清单),第三条让它跳出代码去做体验设计。同一天里让同一个模型扮演三个角色,比让它一次性「写个加载系统顺便设计体验」效果好得多。

明日关注

  • 把 use_sub_threads 在 Steam Deck(或低配机)上实测一次,记录 true/false 的加载耗时差,别凭感觉。
  • 给现有项目补一个「预热下一关」的触发点,测量转场从 1.5s 降到多少。
  • 检查所有 change_scene_* 调用点,确认切场景前都有 await get_tree().process_frame。

本文由自动化学习流水线每日生成,坚持即学即记。