本节目标:把「模块怎么分、谁依赖谁」从口头约定变成一份可校验的配置表,并让依赖图的无环性由断言守住。
站内有一组 24 篇的小游戏创作平台专题 ,讲的是做一个小游戏创作平台——SaaS 创业口径:商业计划、融资节奏、编辑器架构、开发者生态。本章和它不重叠:那边关心「怎么搭一个让别做游戏的平台」,本章关心「用引擎做一个项目的工程骨架与构建管线」,主角是《合成小岛》这一个项目本身。
上一章把引擎选型收敛到了「自研还是用引擎、用哪个引擎」。选型定下来之后,很多人的第一反应是打开编辑器拖一个场景出来。这一步先等等——在写第一行玩法之前,工程骨架的模块边界必须先钉死。边界定得晚,代码会先长成一团,之后每次改动都要先解一次「谁依赖谁」的纠缠,返工成本远高于一开始花半天画这张表。
2.1 项目结构与模块边界
2.1.1 小游戏的边界为什么比 App 更要紧
大项目的模块边界可以靠「先写后拆」慢慢收敛:代码库够大,工具够多,重构有空间。小游戏没有这个余地——有三个约束把边界问题提前了:
- 包体预算压着首屏。哪些模块进主包、哪些进分包,直接由「谁在启动时必须就位」决定(这是本章 2.2 与 2.3 的话题)。边界不清,就没法判断一个模块能不能被推迟加载。
- 一份逻辑要跑两种宿主。同一套逻辑内核既要跑真机(wx / tt),又要跑浏览器原型与无头测试。只有把「平台无关」和「平台相关」划开,逻辑内核才能被两种宿主复用。
- 小团队没有专职架构师。边界不写下来,就只存在于某个人的脑子里;人员一换,约定就散了。所以本节的做法是:先给出一张模块表,再用代码把它校验起来。
2.1.2 一张模块表:职责、层与依赖方向(D 层)
下面这份是 D 层配置:十个模块,每个模块记下它属于哪一层、依赖哪些模块、大致体积与行数。层号越小越底层;依赖只能指向更低的层,这条规则是所有后续校验的判据。
{
"project": "merge-island",
"rule": "依赖只能指向 layer 更小的模块(向下依赖);同层或向上依赖一律判违规",
"modules": [
{"name": "core", "layer": 0, "deps": [], "sizeKB": 96, "lines": 2140, "role": "平台无关逻辑内核:合成规则、关卡判定、经济数值"},
{"name": "platform", "layer": 0, "deps": [], "sizeKB": 64, "lines": 1180, "role": "平台 API 抽象与宿主适配(wx / tt / 浏览器统一接口)"},
{"name": "data", "layer": 1, "deps": ["core"], "sizeKB": 48, "lines": 760, "role": "配置表与存档结构:版本迁移、默认值、校验"},
{"name": "assets", "layer": 1, "deps": ["platform"], "sizeKB": 88, "lines": 1520, "role": "资源加载与分包调度:按需拉取、缓存、失败重试"},
{"name": "render", "layer": 1, "deps": ["platform"], "sizeKB": 112, "lines": 2010, "role": "渲染抽象:精灵、图集、批处理接口,不含管线内核"},
{"name": "input", "layer": 1, "deps": ["platform"], "sizeKB": 56, "lines": 980, "role": "输入抽象:触控点、拖拽、滑动,统一坐标与手势"},
{"name": "audio", "layer": 2, "deps": ["platform", "assets"], "sizeKB": 40, "lines": 640, "role": "音频播放与音效池,依赖资源加载拿音频文件"},
{"name": "ui", "layer": 2, "deps": ["render", "input", "core"], "sizeKB": 132, "lines": 2480, "role": "界面层级、弹窗、滚动与提示,只读核心逻辑状态"},
{"name": "scenes", "layer": 3, "deps": ["core", "render", "assets", "audio", "ui", "data"], "sizeKB": 168, "lines": 3120, "role": "玩法场景编排:合成台、岛屿、关卡入口的组装"},
{"name": "boot", "layer": 4, "deps": ["scenes", "ui", "platform"], "sizeKB": 36, "lines": 520, "role": "启动装配:注册场景、初始化宿主、决定首屏路由"}
]
}
这张表里有三个值得单独说的决定:
core与platform都在第 0 层,但互不依赖。core是纯逻辑(合成规则、数值),它不 import 任何平台 API;platform是宿主适配层,把wx/tt/ 浏览器的差异抹平成统一接口。两层都在最底,方向由data、render这类第 1 层模块往下引。这样core可以单独进无头测试,不需要模拟平台。render只做抽象,不碰渲染管线内核。它对外暴露的是「画一个精灵」「提交一批」这类接口,具体是 Canvas 2D 还是 WebGL 由platform之下的实现决定(渲染环境本身是第 3 章的话题)。把它放在第 1 层,是为了让ui、scenes都能依赖它,而不是各自去摸底层。boot在最上层,只做装配。它的职责是注册场景、初始化宿主、决定首屏路由,不写任何玩法逻辑。所有依赖都向下收敛到scenes,boot本身是「薄」的。
2.1.3 依赖图与无环校验(P 层)
表只是声明,声明可能写错:可能漏了一个依赖、可能无意中造出一个环、可能让某个模块依赖了同层甚至更高层的模块。下面这段是 P 层内核,它读上面那份配置,做三件事——拓扑排序、找环、检查层违规——把「声明」变成「可断言的事实」。
// file: core/src/e02/graph.ts
// tier: P
// verify: node --test 'core/test/e02_*.test.ts'
//
// 模块依赖图:解析模块表,做拓扑排序、无环校验,并检查「依赖只能指向更低 layer」。
// 全整数运算,无 Math.random()。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface ModuleSpec {
readonly name: string;
readonly layer: number;
readonly deps: readonly string[];
readonly sizeKB: number;
readonly lines: number;
readonly role: string;
}
export interface LayerViolation {
readonly from: string;
readonly to: string;
readonly fromLayer: number;
readonly toLayer: number;
}
export interface UnknownDep {
readonly from: string;
readonly to: string;
}
export interface GraphResult {
readonly order: string[];
readonly cyclic: boolean;
readonly layerViolations: LayerViolation[];
readonly unknownDeps: UnknownDep[];
readonly totalKB: number;
readonly layerCount: number;
}
export function buildIndex(mods: readonly ModuleSpec[]): Map<string, ModuleSpec> {
const idx = new Map<string, ModuleSpec>();
for (const m of mods) idx.set(m.name, m);
return idx;
}
/** Kahn 拓扑排序:边 d -> m 表示 d 必须先于 m。就绪集按名字排序,保证确定性。 */
export function topoSort(mods: readonly ModuleSpec[]): { order: string[]; cyclic: boolean } {
const idx = buildIndex(mods);
const indeg = new Map<string, number>();
const adj = new Map<string, string[]>();
for (const m of mods) {
indeg.set(m.name, 0);
adj.set(m.name, []);
}
for (const m of mods) {
for (const d of m.deps) {
if (!idx.has(d)) continue;
adj.get(d)!.push(m.name);
indeg.set(m.name, indeg.get(m.name)! + 1);
}
}
const ready = mods
.filter((m) => indeg.get(m.name) === 0)
.map((m) => m.name)
.sort();
const order: string[] = [];
while (ready.length > 0) {
const n = ready.shift()!;
order.push(n);
for (const nx of adj.get(n)!.sort()) {
indeg.set(nx, indeg.get(nx)! - 1);
if (indeg.get(nx) === 0) {
ready.push(nx);
ready.sort();
}
}
}
return { order, cyclic: order.length !== mods.length };
}
/** 深搜找一条环(若存在),返回环上的节点序列;无环返回空数组。 */
export function findCycle(mods: readonly ModuleSpec[]): string[] {
const idx = buildIndex(mods);
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const m of mods) color.set(m.name, WHITE);
const stack: string[] = [];
let found: string[] = [];
const visit = (name: string): boolean => {
color.set(name, GRAY);
stack.push(name);
const m = idx.get(name);
for (const d of m ? m.deps : []) {
if (!idx.has(d)) continue;
if (color.get(d) === GRAY) {
const at = stack.indexOf(d);
found = stack.slice(at).concat(d);
return true;
}
if (color.get(d) === WHITE && visit(d)) return true;
}
stack.pop();
color.set(name, BLACK);
return false;
};
for (const m of mods) {
if (color.get(m.name) === WHITE && visit(m.name)) break;
}
return found;
}
export function checkLayers(mods: readonly ModuleSpec[]): LayerViolation[] {
const idx = buildIndex(mods);
const out: LayerViolation[] = [];
for (const m of mods) {
for (const d of m.deps) {
const t = idx.get(d);
if (!t) continue;
if (t.layer >= m.layer) {
out.push({ from: m.name, to: d, fromLayer: m.layer, toLayer: t.layer });
}
}
}
return out;
}
export function checkUnknownDeps(mods: readonly ModuleSpec[]): UnknownDep[] {
const idx = buildIndex(mods);
const out: UnknownDep[] = [];
for (const m of mods) {
for (const d of m.deps) {
if (!idx.has(d)) out.push({ from: m.name, to: d });
}
}
return out;
}
export function analyze(mods: readonly ModuleSpec[]): GraphResult {
const { order, cyclic } = topoSort(mods);
const layers = new Set<number>();
let totalKB = 0;
for (const m of mods) {
layers.add(m.layer);
totalKB += m.sizeKB;
}
return {
order,
cyclic,
layerViolations: checkLayers(mods),
unknownDeps: checkUnknownDeps(mods),
totalKB,
layerCount: layers.size,
};
}
拓扑排序用 Kahn 算法,就绪集每次取字典序最小的名字,所以同一个模块表永远得到同一个序——这一点对「构建可复现」很重要。找环用三色深搜(白 / 灰 / 黑),一旦在灰节点上遇到回边就把环截出来。下面这段是 P 层测试,把真实模块表与几个构造出来的坏表都跑一遍。
// file: core/test/e02_graph.test.ts
// tier: P
// verify: node --test 'core/test/e02_*.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 { analyze, topoSort, findCycle, checkLayers, checkUnknownDeps, buildIndex } from "../src/e02/graph.ts";
import type { ModuleSpec } from "../src/e02/graph.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(
readFileSync(join(HERE, "..", "..", "config", "e02_modules.json"), "utf-8"),
) as { modules: ModuleSpec[] };
const mods = cfg.modules;
test("真实模块表:10 个模块、840KB、无环、无层违规、无未知依赖", () => {
const r = analyze(mods);
assert.equal(mods.length, 10);
assert.equal(r.totalKB, 840);
assert.equal(r.cyclic, false);
assert.deepEqual(r.layerViolations, []);
assert.deepEqual(r.unknownDeps, []);
assert.equal(r.layerCount, 5);
});
test("拓扑序逐位固定,且每个模块都排在其依赖之后", () => {
const { order } = topoSort(mods);
assert.deepEqual(order, [
"core", "data", "platform", "assets", "audio",
"input", "render", "ui", "scenes", "boot",
]);
const idx = buildIndex(mods);
const pos = new Map(order.map((n, i) => [n, i]));
for (const m of mods) {
for (const d of m.deps) {
if (idx.has(d)) assert.ok(pos.get(d)! < pos.get(m.name)!);
}
}
});
test("层违规可被检出:同层与向上依赖各一条", () => {
const same: ModuleSpec[] = [
{ name: "a", layer: 1, deps: ["b"], sizeKB: 1, lines: 1, role: "" },
{ name: "b", layer: 1, deps: [], sizeKB: 1, lines: 1, role: "" },
];
const up: ModuleSpec[] = [
{ name: "lo", layer: 0, deps: ["hi"], sizeKB: 1, lines: 1, role: "" },
{ name: "hi", layer: 2, deps: [], sizeKB: 1, lines: 1, role: "" },
];
assert.deepEqual(checkLayers(same).map((v) => [v.from, v.to]), [["a", "b"]]);
assert.equal(checkLayers(up).length, 1);
});
test("环可被检出:a→b→c→a", () => {
const bad: ModuleSpec[] = [
{ name: "a", layer: 3, deps: ["b"], sizeKB: 1, lines: 1, role: "" },
{ name: "b", layer: 2, deps: ["c"], sizeKB: 1, lines: 1, role: "" },
{ name: "c", layer: 1, deps: ["a"], sizeKB: 1, lines: 1, role: "" },
];
assert.equal(topoSort(bad).cyclic, true);
const cyc = findCycle(bad);
assert.ok(cyc.length >= 3 && cyc[0] === cyc[cyc.length - 1]);
assert.deepEqual(findCycle(mods), []);
});
test("未知依赖被单独列出,不参与拓扑排序", () => {
const bad: ModuleSpec[] = [{ name: "x", layer: 1, deps: ["ghost"], sizeKB: 1, lines: 1, role: "" }];
assert.deepEqual(checkUnknownDeps(bad), [{ from: "x", to: "ghost" }]);
assert.equal(topoSort(bad).cyclic, false);
});
本机实跑输出(node --test 'core/test/e02_*.test.ts'):
✔ 真实模块表:10 个模块、840KB、无环、无层违规、无未知依赖
✔ 拓扑序逐位固定,且每个模块都排在其依赖之后
✔ 层违规可被检出:同层与向上依赖各一条
✔ 环可被检出:a→b→c→a
✔ 未知依赖被单独列出,不参与拓扑排序
ℹ tests 5
ℹ pass 5
ℹ fail 0
逐条读这份输出:
- 真实模块表无环,拓扑序固定为
core, data, platform, assets, audio, input, render, ui, scenes, boot。注意platform排在data之后——因为就绪集按字典序取,data先于platform。这个序不唯一,但同一个表永远得到同一个序,所以它可以被断言。 - 真实模块表无层违规。所有依赖都严格向下:
data(1)→core(0)、audio(2)→assets(1)、boot(4)→scenes(3),无一例外。 - 坏表都能被抓出来。同层依赖、向上依赖各返回一条违规;
a→b→c→a被三色深搜截成一个环;未知依赖ghost被单独列出且不影响拓扑排序。 - 总代码体积 840KB,十层合并成 5 个 layer 值。这个数字会在 2.3 的构建产物核算里再次出现。
2.1.4 边界怎么定:三条规则
- 先分「平台无关」与「平台相关」,再谈别的。
core不 import 任何平台 API,platform把差异抹平。这一刀切对了,逻辑内核才能同时进真机、浏览器原型与无头测试。 - 依赖方向由层号唯一确定,且只能向下。层号是单一的排序键,校验脚本只认这一条。规则越少,越守得住;「有时可以横向依赖」这种例外一旦开口,边界就散了。
- 让声明可执行。表写成 JSON,校验写成断言,跑在
node --test里。边界不是文档里的一句话,而是一旦写错就红的一条测试。
小结
- 引擎选型之后,第一件事是把模块边界钉死;小游戏没有「先写后拆」的余地。
- D 层配置把十个模块的层号、依赖、体积写成唯一真源;P 层内核做拓扑排序、无环校验与「依赖只能向下」的检查。
- 本机实跑:真实模块表无环、无层违规、无未知依赖,拓扑序固定,总代码 840KB;三类坏表都被断言抓出。与站内小游戏创作平台专题 的分工:那边是搭平台,这边是做项目——本节管的是一个项目自己的工程骨架。
边界定下来之后,下一个问题是:哪些模块与资源必须进主包、哪些可以推迟到分包加载。这是下一节的事。
阅读导航:上一节:自研 vs 引擎:什么时候值得自己写 · 下一节:分包与资源加载管线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。