关于资源按需加载和预加载

修改于07/04113 浏览开发心得
SKILL.md
---
name: 通用-加载门禁
description: UrhoX 游戏加载门禁与资源预热方案。 当用户提到首次进入游戏界面元素逐个出现、文字/字体晚显示、图片懒加载、进入战场卡顿、加载页、Loading、预加载资源、资源下载提示、首屏黑屏/空白/闪烁、只在首次启动显示加载页时,必须使用此 skill。适用于 raw NanoVG 项目,也适用于主菜单/战场/商城等需要切场景前资源 ready 的项目。
---
# 通用加载门禁 Skill
目标:不要让正式 UI 暴露半加载状态。资源未 ready 时只显示 LoadingGate;资源 ready 后再切入主菜单/战场。
适用:UrhoX Lua 项目(raw NanoVG 或 urhox-libs/UI)。尤其是自定义 `Theme.lua`、`nvgCreateImage` 懒加载、`nvgCreateFont` 字体注册、中文字体/图片首次显示延迟。
---
## ⚠️ 首先检查:resources.json 预下载配置(首选方案)
### 默认策略(按引擎文档)
除非用户明确说要「边下边玩」「DWP」「按需下载」「启动更快、资源运行时再下」,否则默认使用**全部预下载**:
```jsonc
{
  "preload_groups": ["default"],
  "groups": { "default": ["**"] }
}
```
判断和修改必须以引擎文档为准:`engine-docs/recipes/preload-and-build-refs.md` 与 `engine-docs/recipes/download-while-playing.md`。不要只依赖 skill 旧内容。
- `preload_groups: ["default"]`:启动前预下载 default 组,进入 Lua 时字体/图片等资源已就绪
- `preload_groups: []`:全部资源由引擎 DWP 按需下载,首次使用字体/图片时可能出现下载/占位/热替换
**绝大多数"进入游戏才开始下载"的问题,根因是 `.project/resources.json` 的 `preload_groups` 为空。若用户没有主动要求按需下载,直接改为全预下载。**
### 诊断步骤
1. 打开 `.project/resources.json`
2. 检查 `preload_groups` 字段
```jsonc
// ❌ 错误:启动时不预下载,资源在运行时按需下载(边玩边下)
{
  "preload_groups": [],
  "groups": { "default": ["**"] }
}
// ✅ 正确:所有构建资源在启动前预下载完毕(引擎自带 loading 界面)
{
  "preload_groups": ["default"],
  "groups": { "default": ["**"] }
}
```
### 原理
| 配置 | 效果 |
|------|------|
| `preload_groups: []` | 资源在代码首次引用时才下载(DWP 边玩边下),导致 UI/字体延迟 |
| `preload_groups: ["default"]` | 引擎启动前自动下载所有资源到本地,Lua 脚本执行时资源已就绪 |
### 修复后的效果
- 引擎层面会显示自带的 loading 界面完成预下载
- 进入 Lua `Start()` 时,字体、图片等资源已在本地缓存
- `UI.Init()` / `nvgCreateFont()` 执行时**无需等待网络**
- 第一帧即可完整渲染 UI,无半加载状态
### 何时需要 preload_groups
| 场景 | 推荐配置 |
|------|---------|
| 未明确要求按需下载 / 希望 UI 字体首帧完整 | `preload_groups: ["default"]` |
| 资源总量较小(< 20MB),希望首帧即完整 | `preload_groups: ["default"]` |
| 用户明确要求边下边玩、按需下载、启动最快 | `preload_groups: []` + 必要时自建 LoadingGate |
| 部分资源首包预下载,部分按需 | 拆分多个 group,只预下载关键组 |
**如果修改 `preload_groups` 后问题解决,则不需要自建 LoadingGate。** 以下内容仅在资源量大、需要自定义加载体验时使用。
---
## 先判断问题类型(仅大资源项目)
如果 `preload_groups` 已正确配置但仍有以下症状,才需要自建 LoadingGate:
常见症状:
- 首次进入主菜单,按钮/立绘/背景逐个出现
- 加载页文字也要等一会儿才显示
- 进入战场首帧卡顿,背景或单位图晚出现
- 切界面时出现 fallback 色块,再变成图片
- 中文文字第一次显示慢,后面正常
根因通常是:
- `nvgCreateImage` 在渲染函数里首次调用,边画边加载图片
- `nvgCreateFont` 只注册字体,首次 `nvgText` 才生成字形 atlas
- 中文字体大,首次解析和 glyph 栅格化慢
- 场景切换时直接进入目标状态,没有加载门禁
---
## 推荐架构:LoadingGate(仅大资源项目需要)
新增模块:
```text
scripts/Core/LoadingGate.lua
```
职责:
- 统一管理加载页状态
- 分帧执行预加载任务
- 渲染统一 loading 页面
- 资源任务完成后再执行 `onDone`
- 可选:只首次启动出现一次
不要把 loading 逻辑散落在 `FlowControl.lua`、`Renderer.lua`、各 Renderer 里。
---
## LoadingGate 模块模板
```lua
-- scripts/Core/LoadingGate.lua
local Config = require("Config")
local Theme = require("Theme")
local GS = require("Core.GameState")
local LoadingGate = {}
local DEFAULT_MIN_TIME = 5.0
function LoadingGate.Start(title, tasks, onDone, minTime)
    GS.loading = {
        title = title or "加载中",
        timer = 0,
        minTime = minTime or DEFAULT_MIN_TIME,
        tasks = tasks or {},
        taskIndex = 1,
        ready = false,
        onDone = onDone,
    }
    GS.gameState = Config.State.LOADING
end
function LoadingGate.Update(dt)
    local loading = GS.loading
    if not loading then return end
    loading.timer = (loading.timer or 0) + dt
    if not loading.ready then
        local tasks = loading.tasks or {}
        local idx = loading.taskIndex or 1
        local task = tasks[idx]
        if task then
            if task() then loading.taskIndex = idx + 1 end
        else
            loading.ready = true
        end
    end
    if loading.ready and loading.timer >= (loading.minTime or 0) then
        local onDone = loading.onDone
        GS.loading = nil
        if onDone then onDone() end
    end
end
function LoadingGate.Render(vg, screenW, screenH)
    local loading = GS.loading
    local title = (loading and loading.title) or "加载中"
    local t = GS.GetEngineTime()
    nvgBeginPath(vg)
    nvgRect(vg, 0, 0, screenW, screenH)
    local bg = nvgLinearGradient(vg, 0, 0, 0, screenH,
        nvgRGBA(18, 22, 38, 255), nvgRGBA(42, 54, 92, 255))
    nvgFillPaint(vg, bg)
    nvgFill(vg)
    local cx, cy = screenW / 2, screenH / 2
    Theme.DrawPanel(vg, cx - 260, cy - 90, 520, 180, { fill = {255, 255, 255, 225} })
    nvgFontFace(vg, Theme.FONT_BOLD)
    Theme.fontSize(vg, 30)
    nvgFillColor(vg, Theme.RGBA(Theme.C.text))
    nvgTextAlign(vg, NVG_ALIGN_CENTER + NVG_ALIGN_MIDDLE)
    nvgText(vg, cx, cy - 34, title)
    nvgFontFace(vg, Theme.FONT_BODY)
    Theme.fontSize(vg, 18)
    nvgFillColor(vg, Theme.RGBA(Theme.C.textSec))
    local dots = string.rep(".", math.floor(t * 4) % 4)
    nvgText(vg, cx, cy + 8, "正在下载资源中" .. dots)
    local barW, barH = 300, 10
    local bx, by = cx - barW / 2, cy + 48
    nvgBeginPath(vg)
    nvgRect(vg, bx, by, barW, barH)
    nvgFillColor(vg, nvgRGBA(0, 0, 0, 50))
    nvgFill(vg)
    local pulse = 0.35 + 0.65 * (0.5 + 0.5 * math.sin(t * 5))
    nvgBeginPath(vg)
    nvgRect(vg, bx, by, barW * pulse, barH)
    nvgFillColor(vg, Theme.RGBA(Theme.C.primary))
    nvgFill(vg)
end
return LoadingGate
```
---
## 接入步骤
### 1. 配置状态
在 `Config.State` 增加:
```lua
LOADING = "LOADING"
```
### 2. 主循环 Update
在 `main.lua` 的 `HandleUpdate` 里,其他状态派发前:
```lua
if GS.gameState == Config.State.LOADING then
    LoadingGate.Update(dt)
    return
end
```
### 3. NanoVG 渲染
在主渲染入口:
```lua
elseif GS.gameState == Config.State.LOADING then
    LoadingGate.Render(vg, screenW, screenH)
```
注意:NanoVG 必须在 `NanoVGRender` 事件里绘制。
---
## 字体预热
文字晚显示时,加载门禁要加入字体预热任务:
```lua
local function PreheatFont(vg)
    nvgFontFace(vg, Theme.FONT_BOLD)
    Theme.fontSize(vg, 24)
    nvgFillColor(vg, nvgRGBA(255, 255, 255, 1))
    nvgText(vg, -9999, -9999, "正在下载资源中进入主菜单战斗商城排行榜胜利失败")
    return true
end
```
---
## 检查清单
**首选方案(resources.json 预下载):**
- [ ] 默认检查 `.project/resources.json` 的 `preload_groups` 包含 `"default"`;只有用户明确要求按需下载时才允许为空
- [ ] 构建后测试:进入游戏时 UI/字体无延迟
**自建 LoadingGate(仅大资源项目):**
- [ ] `LOADING` 状态存在
- [ ] `LoadingGate.Update(dt)` 在主 Update 中执行
- [ ] `LoadingGate.Render(...)` 在 NanoVGRender 中执行
- [ ] 主菜单资源不再在首帧正式 UI 中首次加载
- [ ] 如果只首次显示,`loadingShown` 已持久化
- [ ] 改代码后运行 LSP 诊断和 UrhoX build
---
## 反模式
不要:
- 忘记检查 `resources.json` 的 `preload_groups` 配置就直接写 LoadingGate。
- 在 `preload_groups` 已生效的情况下还额外写 LoadingGate(多此一举)。
- 只 `Wait 1 frame` 后切场景。
- 在正式 UI 的 `Render()` 中首次加载大量图片。
- 每帧调用 `nvgCreateFont`。字体注册只在 Start 做一次。
- 只用固定 5 秒替代 ready 判断。
- 用 loading 遮盖所有界面切换。
TapTap
4
1
2