2.1 项目结构与模块边界

引擎选型定下来之后,第一件事不是写玩法,而是把工程骨架的模块边界钉死。本节先把《合成小岛》的十个模块写成一份带层级与依赖方向的配置表,再用一段 P 层 TypeScript 内核做拓扑排序、无环校验与「依赖只能向下」的检查,让模块边界不靠口头约定,而靠一条跑得起来的断言守住。

本节目标:把「模块怎么分、谁依赖谁」从口头约定变成一份可校验的配置表,并让依赖图的无环性由断言守住。

站内有一组 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 边界怎么定:三条规则

  1. 先分「平台无关」与「平台相关」,再谈别的。core 不 import 任何平台 API,platform 把差异抹平。这一刀切对了,逻辑内核才能同时进真机、浏览器原型与无头测试。
  2. 依赖方向由层号唯一确定,且只能向下。层号是单一的排序键,校验脚本只认这一条。规则越少,越守得住;「有时可以横向依赖」这种例外一旦开口,边界就散了。
  3. 让声明可执行。表写成 JSON,校验写成断言,跑在 node --test 里。边界不是文档里的一句话,而是一旦写错就红的一条测试。

小结

  • 引擎选型之后,第一件事是把模块边界钉死;小游戏没有「先写后拆」的余地。
  • D 层配置把十个模块的层号、依赖、体积写成唯一真源;P 层内核做拓扑排序、无环校验与「依赖只能向下」的检查。
  • 本机实跑:真实模块表无环、无层违规、无未知依赖,拓扑序固定,总代码 840KB;三类坏表都被断言抓出。与站内小游戏创作平台专题 的分工:那边是搭平台,这边是做项目——本节管的是一个项目自己的工程骨架。

边界定下来之后,下一个问题是:哪些模块与资源必须进主包、哪些可以推迟到分包加载。这是下一节的事。

阅读导航:上一节:自研 vs 引擎:什么时候值得自己写 · 下一节:分包与资源加载管线 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制