本节目标:把「这次更新要下多少」算成确定数字——版本号怎么比、清单怎么差、什么时候走补丁、什么时候只能下整包。
站内那套 127 篇的 Phaser 引擎专书
里,资源是启动时一次性 preload 进来的,讲的是引擎的加载流程。本节不碰加载 API,而是讲可跑的资源版本与差异模型:本地清单与远端清单怎么比、哪些文件要重下、补丁与整包怎么选——都能在本机跑出确定结果。
4.3 资源热更新与版本管理
4.3.1 热更新解决的是什么问题
小游戏发布后要持续更新内容:改一张图、加一个关卡、修一段逻辑。如果每次都让用户下整包,包体一大,用户就不更了。热更新要回答的就是一个问题:本地已经有的东西,哪些不用再下。
答案是一份资源清单:每个文件记录路径、内容哈希、体积。本地一份、远端一份,两份一差,就知道这次要下什么。清单要能被版本号寻址——用户端记录「我停在哪个版本」,服务端据此给出「从那个版本到最新版的差异」。
4.3.2 D 层:本地与远端清单
下面这份是 D 层配置:本地清单(版本 1.3.0)与远端清单(版本 1.4.0),外加一个补丁阈值。两份清单有增、有删、有改、有不变,正好覆盖差异分类的四种情况。
{
"observation": "公开可观察,观察时点 2026-10;版本号与差异清单口径为可配置参数",
"patchThresholdPct": 60,
"local": {
"version": "1.3.0",
"assets": [
{"path": "assets/atlas/merge_tiles.png", "hash": "a1c9f0", "sizeKB": 13},
{"path": "assets/atlas/ui_base.png", "hash": "a2d41b", "sizeKB": 22},
{"path": "assets/atlas/decor.png", "hash": "aa7788", "sizeKB": 560},
{"path": "assets/atlas/old_event.png", "hash": "af0001", "sizeKB": 180},
{"path": "assets/audio/bgm_island.aac", "hash": "a3e7c2", "sizeKB": 1153},
{"path": "assets/audio/sfx_pack.adpcm", "hash": "a4b105", "sizeKB": 48},
{"path": "assets/config/levels.json", "hash": "a5f6a3", "sizeKB": 90},
{"path": "assets/config/items.json", "hash": "a6c2e8", "sizeKB": 12},
{"path": "assets/fonts/ui.woff2", "hash": "a9330f", "sizeKB": 30},
{"path": "js/main.js", "hash": "a7aa44", "sizeKB": 120},
{"path": "js/game.js", "hash": "a8bb55", "sizeKB": 260}
]
},
"remote": {
"version": "1.4.0",
"assets": [
{"path": "assets/atlas/merge_tiles.png", "hash": "b1c9f0", "sizeKB": 15},
{"path": "assets/atlas/ui_base.png", "hash": "a2d41b", "sizeKB": 22},
{"path": "assets/atlas/decor.png", "hash": "aa7788", "sizeKB": 560},
{"path": "assets/atlas/decor_xmas.png", "hash": "b20099", "sizeKB": 240},
{"path": "assets/audio/bgm_island.aac", "hash": "a3e7c2", "sizeKB": 1153},
{"path": "assets/audio/sfx_pack.adpcm", "hash": "b4b105", "sizeKB": 52},
{"path": "assets/config/levels.json", "hash": "b5f6a3", "sizeKB": 104},
{"path": "assets/config/items.json", "hash": "a6c2e8", "sizeKB": 12},
{"path": "assets/fonts/ui.woff2", "hash": "a9330f", "sizeKB": 30},
{"path": "js/main.js", "hash": "a7aa44", "sizeKB": 120},
{"path": "js/game.js", "hash": "b8bb55", "sizeKB": 285}
]
}
}
4.3.3 P 层:差异计算内核
下面这段是 P 层内核。它把版本号按 . 切成整数段做比较(注意是整数比较,不是字符串——1.10 大于 1.9),把清单按路径索引后逐条分类成新增/删除/变更/不变,再算出补丁体积与整包体积,最后按阈值在「差异下载」与「整包更新」之间决策。
// file: core/src/e04/hotupdate.ts
// tier: P
// verify: node --test 'core/test/e04_*.test.ts'
// 《合成小岛》资源热更新内核:版本号整数分段比较 + 清单差异计算 + 补丁/整包决策。
// 全整数运算,禁 enum / namespace / Math.random;哈希按字符串比较,版本段按整数比较。
export interface AssetEntry {
readonly path: string;
readonly hash: string;
readonly sizeKB: number;
}
export interface Manifest {
readonly version: string;
readonly assets: AssetEntry[];
}
export interface HotUpdateConfig {
readonly patchThresholdPct: number;
readonly local: Manifest;
readonly remote: Manifest;
}
export interface DiffResult {
readonly added: string[];
readonly removed: string[];
readonly changed: string[];
readonly unchanged: string[];
}
export interface UpdatePlan {
readonly needed: boolean;
readonly patchKB: number;
readonly fullKB: number;
readonly strategy: "none" | "patch" | "full";
readonly reasons: string[];
}
/** 版本号按 `.` 切成整数段。非法段记为 0,保证比较不抛异常。 */
export function parseVersion(v: string): number[] {
return v.split(".").map((seg) => {
const n = Number.parseInt(seg, 10);
return Number.isNaN(n) ? 0 : n;
});
}
/** 整数分段比较:a>b 返回 1,a<b 返回 -1,相等 0。短版本号按缺位 0 补齐。 */
export function compareVersion(a: string, b: string): number {
const pa = parseVersion(a), pb = parseVersion(b);
const n = Math.max(pa.length, pb.length);
for (let i = 0; i < n; i += 1) {
const x = pa[i] ?? 0, y = pb[i] ?? 0;
if (x > y) return 1;
if (x < y) return -1;
}
return 0;
}
/** 远端是否比本地新(严格大于)。 */
export function isNewer(local: string, remote: string): boolean {
return compareVersion(remote, local) > 0;
}
function indexOf(m: Manifest): Map<string, AssetEntry> {
const map = new Map<string, AssetEntry>();
for (const a of m.assets) map.set(a.path, a);
return map;
}
/** 计算本地→远端的差异清单。分类结果各按路径字典序排列,保证可复现。 */
export function diffManifests(local: Manifest, remote: Manifest): DiffResult {
const lm = indexOf(local), rm = indexOf(remote);
const added: string[] = [], removed: string[] = [], changed: string[] = [], unchanged: string[] = [];
for (const [path, ra] of rm) {
const la = lm.get(path);
if (!la) added.push(path);
else if (la.hash !== ra.hash) changed.push(path);
else unchanged.push(path);
}
for (const path of lm.keys()) if (!rm.has(path)) removed.push(path);
const s = (arr: string[]) => arr.sort();
return { added: s(added), removed: s(removed), changed: s(changed), unchanged: s(unchanged) };
}
/** 补丁体积 = 需要新增或替换的资产体积之和(远端口径)。 */
export function patchKB(diff: DiffResult, remote: Manifest): number {
const rm = indexOf(remote);
let kb = 0;
for (const path of [...diff.added, ...diff.changed]) kb += rm.get(path)?.sizeKB ?? 0;
return kb;
}
/** 整包体积 = 远端全部资产体积之和。 */
export function fullKB(remote: Manifest): number {
let kb = 0;
for (const a of remote.assets) kb += a.sizeKB;
return kb;
}
/** 热更新决策:远端不更新则 none;补丁超过整包的阈值百分比则走整包,否则走补丁。 */
export function planUpdate(cfg: HotUpdateConfig): UpdatePlan {
const { local, remote } = cfg;
const full = fullKB(remote);
if (!isNewer(local.version, remote.version)) {
return { needed: false, patchKB: 0, fullKB: full, strategy: "none",
reasons: [`本地 ${local.version} 不低于远端 ${remote.version}`] };
}
const diff = diffManifests(local, remote);
const patch = patchKB(diff, remote);
const limit = Math.floor((full * cfg.patchThresholdPct) / 100);
const reasons = [
`新增 ${diff.added.length} / 变更 ${diff.changed.length} / 删除 ${diff.removed.length}`,
`补丁 ${patch}KB vs 整包 ${full}KB(阈值 ${cfg.patchThresholdPct}% = ${limit}KB)`,
];
const strategy = patch > limit ? "full" : "patch";
reasons.push(strategy === "full" ? "补丁逼近整包,直接下整包" : "补丁远小于整包,走差异下载");
return { needed: true, patchKB: patch, fullKB: full, strategy, reasons };
}
4.3.4 P 层测试
下面这段是 P 层测试,读上面那份配置,验证版本比较、差异分类、体积核算与决策分支。
// file: core/test/e04_hotupdate.test.ts
// tier: P
// verify: node --test 'core/test/e04_*.test.ts'
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
parseVersion, compareVersion, isNewer, diffManifests, patchKB, fullKB, planUpdate,
} from "../src/e04/hotupdate.ts";
import type { HotUpdateConfig, Manifest } from "../src/e04/hotupdate.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(
readFileSync(join(HERE, "..", "..", "config", "e04_hotupdate.json"), "utf-8"),
) as HotUpdateConfig;
test("版本号按整数分段比较,而非字符串比较", () => {
assert.deepEqual(parseVersion("1.4.0"), [1, 4, 0]);
assert.deepEqual(parseVersion("2.0.1"), [2, 0, 1]);
assert.equal(compareVersion("1.4.0", "1.3.0"), 1);
assert.equal(compareVersion("1.3.0", "1.4.0"), -1);
assert.equal(compareVersion("1.3", "1.3.0"), 0); // 缺位按 0 补齐
assert.equal(compareVersion("1.10.0", "1.9.0"), 1); // 10 > 9(字符串比较会判错)
assert.equal(isNewer("1.3.0", "1.4.0"), true);
assert.equal(isNewer("1.4.0", "1.3.0"), false);
assert.equal(isNewer("1.4.0", "1.4.0"), false);
});
test("差异清单四分类精确,且各按路径字典序", () => {
const diff = diffManifests(cfg.local, cfg.remote);
assert.deepEqual(diff.added, ["assets/atlas/decor_xmas.png"]);
assert.deepEqual(diff.removed, ["assets/atlas/old_event.png"]);
assert.deepEqual(diff.changed, [
"assets/atlas/merge_tiles.png", "assets/audio/sfx_pack.adpcm",
"assets/config/levels.json", "js/game.js",
]);
assert.deepEqual(diff.unchanged, [
"assets/atlas/decor.png", "assets/atlas/ui_base.png",
"assets/audio/bgm_island.aac", "assets/config/items.json",
"assets/fonts/ui.woff2", "js/main.js",
]);
});
test("补丁体积 = 新增 + 变更之和;整包 = 远端全量", () => {
const diff = diffManifests(cfg.local, cfg.remote);
assert.equal(patchKB(diff, cfg.remote), 696); // 240 + (15+52+104+285)
assert.equal(fullKB(cfg.remote), 2593);
});
test("补丁远小于整包时走差异下载", () => {
const plan = planUpdate(cfg);
assert.equal(plan.needed, true);
assert.equal(plan.strategy, "patch");
assert.equal(plan.patchKB, 696);
assert.equal(plan.fullKB, 2593);
assert.equal(plan.reasons.length, 3);
});
test("版本相同则不更新;补丁超过阈值则回退整包", () => {
const same = planUpdate({ ...cfg, remote: { ...cfg.remote, version: cfg.local.version } });
assert.equal(same.needed, false);
assert.equal(same.strategy, "none");
const a = (hash: string): { path: string; hash: string; sizeKB: number } => ({ path: "a.bin", hash, sizeKB: 100 });
const local: Manifest = { version: "1.0.0", assets: [a("h1"), { path: "b.bin", hash: "h2", sizeKB: 100 }] };
const remote: Manifest = { version: "1.1.0", assets: [a("h9"), { path: "b.bin", hash: "h8", sizeKB: 100 }] };
const big = planUpdate({ patchThresholdPct: 60, local, remote });
assert.equal(big.patchKB, 200); // 两项全变
assert.equal(big.fullKB, 200);
assert.equal(big.strategy, "full"); // 200 > floor(200*60/100)=120
});
本机实跑输出:
✔ 版本号按整数分段比较,而非字符串比较 (0.977ms)
✔ 差异清单四分类精确,且各按路径字典序 (0.191916ms)
✔ 补丁体积 = 新增 + 变更之和;整包 = 远端全量 (0.126166ms)
✔ 补丁远小于整包时走差异下载 (0.622667ms)
✔ 版本相同则不更新;补丁超过阈值则回退整包 (0.170625ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
4.3.5 补丁 vs 整包的决策
逐条读这份结果:
- 版本比较是整数比较。
compareVersion("1.10.0", "1.9.0")返回 1——按字符串比会得出"1.10" < "1.9"的错误结论,热更新会判成「不需要更新」。 - 差异分类四类齐全。远端相对本地:新增 1 个(
decor_xmas.png240KB)、删除 1 个(old_event.png)、变更 4 个、不变 6 个。 - 补丁 696KB vs 整包 2593KB。补丁体积是「新增 + 变更」之和(240 + 15 + 52 + 104 + 285)。阈值 60% 对应 1555KB,696 远低于它,所以走差异下载。
- 决策不是拍脑袋。当补丁逼近整包(比如远端几乎全变),下补丁不如直接下整包——测试里用一个两项全变的例子验证了这个回退分支。
需要说明:这里的哈希是占位字符串,真实工程里用内容摘要(如 SHA-256 截断)。本节算的是版本模型的逻辑,不依赖哈希算法本身;换任何哈希,差异分类的结论都一样。
4.3.6 三条可复用判断
- 版本号分段整数比较。字符串比较会在
1.10与1.9之间出错,这是热更新最容易踩的坑。 - 清单是唯一真源。路径 + 哈希 + 体积三样齐全,才能既算「要不要下」也算「要下多少」。
- 补丁要有回退线。差异下载不是永远最优;补丁接近整包时应直接下整包,避免拆包与校验的额外复杂度。
小结
- 热更新要回答的是「本地已有的哪些不用再下」,靠的是一份按版本寻址的资源清单。
- D 层配置给出本地/远端清单与补丁阈值;P 层内核做版本整数比较 + 差异四分类 + 补丁/整包决策。
- 本机实跑:1.3.0 → 1.4.0 新增 1 / 删除 1 / 变更 4 / 不变 6,补丁 696KB、整包 2593KB,走差异下载;5 条测试全过。
- 哈希口径可替换;本节验证的是版本模型的逻辑,与具体摘要算法无关。
资源管线到这里闭环了:图集打包、音频压缩、热更新版本。下一章转入游戏循环——固定步长、确定性与状态机,那是本卷的另一块核心。
阅读导航:上一节:音频格式与体积权衡 · 下一节:固定步长主循环与确定性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。