1.1 环境与仓库骨架
这一卷要做的事有点反常:先不用引擎,从零手写一个能跑的 2D 运行时 mini2d,再用 Cocos Creator 3.8 把同一款游戏做一遍。 所以我们要同时准备两套东西——一套是 mini2d 自己的 TypeScript 工程(Node + vitest + vite + Playwright),另一套是把 Cocos 引擎的类型声明接进来,好让书里的 Cocos 脚本片段也能被类型检查器验一遍。
本节把《方块冲刺》推进到「环境就绪」这一步:装好 Node 26.5 与全套工具链,跑通
vitest与vite,理解仓库目录的划分,并把@cocos/creator-types@3.8.8通过tsconfig的types字段接通。本节不含游戏逻辑,但它是后面 17 章的地基。
与站内 game/ 下那 1153 篇的关系:那些文章讲的是「某个引擎怎么做」,本节以及后面每一节讲的,是一行一行写出来的可运行代码 + 真跑过的测试。
1.1.1 三层可运行性口径
在动手之前,先把这套书的验证边界说清楚。否则你很容易误以为「书上所有代码都能一键跑起来」——事实并非如此,而且这个差异本身是本书最诚实的地方。
| 层 | 内容 | 能否真跑 | 怎么验证 |
|---|---|---|---|
| L1 | mini2d 从零实现(纯 TypeScript,零 cc 依赖) | 能真跑 | Node + vitest 单测、Playwright 无头截图 |
| L2 | Cocos 组件脚本(import { X } from 'cc') | 能真做类型检查 | tsc --noEmit 对 @cocos/creator-types@3.8.8 检查 |
| L3 | Cocos 工程文件(.scene / .prefab / .meta) | 不能命令行复现 | 结构校验;正文会显式标注「需编辑器」 |
为什么这么分?因为 Cocos Creator 是一个 GUI 编辑器,它的引擎运行时不在 npm 上,命令行里既装不了也跑不起来。于是本书采取的策略是:
- 能跑的部分做到极致:L1 全部真跑、真测、真截图。
- 不能跑的部分如实标注:L3 一律写明「本节需 Cocos Creator 3.8 编辑器,无法命令行复现」。
- 夹在中间的 Cocos 脚本:用真实引擎类型声明做
tsc --noEmit,保证每个 API 名、每个参数类型都对得上 3.8.8。
你看到的每一段 mini2d 代码,都是在 Node 里跑过、在浏览器里画过、被单测断言过的。
1.1.2 版本口径
版本不是随便选的,全部锁定,且要能互相兼容:
| 项 | 版本 | 用途 |
|---|---|---|
| Node.js | 26.5.0 | 直接运行 TypeScript(只做类型剥离) |
| TypeScript | 7.0.2 | 类型检查与编辑器支持 |
| vitest | 5.0.3 | L1 逻辑单测与假时钟 |
| Vite | 8.3.4 | 打包成单文件 bundle |
| Playwright | 1.59.1 | 无头浏览器截图回归 |
| @cocos/creator-types | 3.8.8 | 仅类型声明,供 L2 检查 |
Node 26 有一个关键特性:它能直接运行 .ts 文件,但只做「类型剥离」,不做语法转换。 这意味着 enum、namespace、构造函数参数属性这类「需要生成运行时代码」的写法不能用——它们会被 Node 直接报错。本书一律用 const 对象 + as const 代替 enum,用模块导出代替 namespace。这不是限制,而是一种更贴近现代 TS 的写法。
1.1.3 仓库骨架
先建目录。mini2d 的源码全部放在 src/ 下,按职责分文件夹:
mini2d/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── vite.config.ts
├── index.html
└── src/
├── core/ # 帧循环、时钟、事件总线
│ ├── clock.ts
│ └── loop.ts
├── math/ # Vec2、Mat3 等纯数学
│ └── mat3.ts
├── render/ # Canvas2D 绘制、精灵、图集
├── camera/ # 相机与视口
├── input/ # 指针、手势、输入缓冲
├── game.ts # 贯穿项目的 Game 类
└── index.ts # 对外统一出口
三条约定,后面每一章都会遵守:
src/core/不依赖任何浏览器 API。 时钟要能被换成假时钟,循环要能在 Node 里手推,所以这一层只接受注入进来的时间源。src/math/是纯函数。 没有类实例状态,方便单测直接断言数值。src/render/是唯一碰CanvasRenderingContext2D的地方。 逻辑层不认识 canvas,这样第 16 章才能用「假渲染器」录下绘制指令做回归。
1.1.4 package.json
{
"name": "mini2d",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cocos/creator-types": "^3.8.8",
"@playwright/test": "^1.59.1",
"typescript": "^7.0.2",
"vite": "^8.3.4",
"vitest": "^5.0.3"
}
}
注意 "type": "module"——整个工程走 ESM。Node 26 运行 .ts 时也按 ESM 解析,import 的路径要带扩展名或由 Vite/vitest 解析。为了让同一份源码既能被 Node 直接跑、又能被 Vite 打包,本书统一用不带扩展名的相对导入(如 from '../core/clock'),由工具链负责解析;只有 tsc 的类型检查不看这个。
1.1.5 接通 Cocos 类型:tsconfig 的 types 字段
这是本节最容易被忽略、却最重要的一步。我们想把 import { Component } from 'cc' 也纳入类型检查,就得让 TypeScript 知道 cc 这个模块存在。
@cocos/creator-types@3.8.8 这个包的 engine.d.ts 只有一行:
/// <reference path="./engine/cc.d.ts"/>
它把 3.3 MB 的 engine/cc.d.ts(真实的 Cocos Creator 3.8.8 引擎 API)引了进来。我们只要在 tsconfig.json 里把它列进 types 数组,TypeScript 就会自动加载它:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"types": ["@cocos/creator-types/engine"]
},
"include": ["src", "*.ts"]
}
逐条说明:
types: ["@cocos/creator-types/engine"]是关键。它的语义是「加载node_modules/@cocos/creator-types/engine.d.ts这个类型入口」,于是cc.d.ts里的declare module "cc"生效,from 'cc'不再是「找不到模块」。skipLibCheck: true必须开。cc.d.ts有 3.3 MB,内部大量使用交叉类型与条件类型,逐行检查既慢又容易报无关噪声;我们只关心「自己写的代码是否对得上 API 签名」。experimentalDecorators: true是 Cocos 组件脚本的前提——@ccclass是实验性装饰器语法。noEmit: true因为类型剥离由 Node/Vite 负责,tsc只当检查器。
装好之后,验证一下。下面这段是 L2 片段,它不参与 mini2d 的运行,只用来确认类型通道是通的:
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
@ccclass('HelloTypeCheck')
export class HelloTypeCheck extends Component {
onLoad(): void {
console.log('cc 类型已接通,节点名:', this.node.name);
}
}
跑 npm run typecheck,如果没有报错,就说明 _decorator、Component、this.node 这些符号都真实存在于 3.8.8 的声明里。本书每一个 cc 符号落笔前都做过这一步核对——凭记忆写 API 名是这类书稿最高频的错误来源。
1.1.6 第一个冒烟测试:vitest
mini2d 的核心价值是「可测」。第 1.3 节的固定步长循环、第 4 章的时钟,全都靠假时钟在 Node 里复现。先把测试跑起来。
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});
environment: 'node' 是刻意的:src/core/ 与 src/math/ 不碰 DOM,测试就该在纯 Node 环境里跑,快且没有浏览器噪声。需要 DOM 的渲染测试会单独用一个 Playwright 工程,那是第 16 章的事。
写一个最小冒烟测试,先证明「测试能跑、断言能过」:
import { describe, expect, it } from 'vitest';
describe('冒烟', () => {
it('数学是可信的', () => {
expect(1 + 1).toBe(2);
});
});
npm run test
看到一行 1 passed 就够了。这个测试很傻,但它是后面所有断言的地基——先确认工具有输出,再往里填真东西。
1.1.7 在浏览器里跑起来:vite
逻辑测试走 Node,画面要进浏览器。vite.config.ts 里把产物配成单文件,方便 Playwright 用 file:// 直接载入:
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
build: {
outDir: 'dist',
target: 'es2022',
assetsInlineLimit: 100_000_000,
cssCodeSplit: false,
codeSplitting: false,
},
});
assetsInlineLimit 调到极大 + 关掉代码分割,是为了把 JS、CSS、图片全部内联进一个 index.html——无头浏览器载入一个本地文件即可截图,不用起服务器。base: './' 保证用相对路径引用资源,file:// 下才不会 404。
页面骨架 index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>方块冲刺 · mini2d</title>
<style>
html, body { margin: 0; height: 100%; background: #111; }
canvas { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<script type="module" src="/src/index.ts"></script>
</body>
</html>
npm run dev 会起一个开发服务器;npm run build 产出 dist/index.html。两条路都通,说明环境闭环了。
1.1.8 动手自检清单
环境这东西,最怕「看起来装好了」。逐条打勾再往下走:
-
node --version输出v26.5.0 -
npx tsc --version输出Version 7.0.2 -
npm run test输出1 passed -
npm run typecheck不报错(说明cc类型已接通) -
npm run dev能打开页面,npm run build能产出dist/index.html - 目录里存在
src/core/、src/math/、src/render/、src/input/、src/game.ts
如果第 4 条报「找不到模块 cc」,九成是 types 数组写错了——检查包名是否是 @cocos/creator-types/engine(带 /engine 后缀),而不是 @cocos/creator-types。
1.1.9 这一节我们没做什么
特意说明一下本节没有涉及的东西,免得你误以为漏了:没写任何游戏逻辑,没碰 canvas 绘制,没引入物理或碰撞。本节唯一的目标是让工具链闭环——能跑测试、能打包、能类型检查。真正的第一行游戏代码在 1.3 节,那时我们会在 src/core/loop.ts 里写下 mini2d 的第一个 requestAnimationFrame 主循环。
环境就绪之后,下一个问题不是「怎么写循环」,而是「循环里那个 dt 到底是什么、坐标从哪里量、y 轴朝哪边」。这些约定如果一开始就想错,后面每章都要还债。下一节我们把「一帧」这件事的心智模型彻底讲清楚:离散时间、坐标原点、y 轴方向,以及屏幕坐标与世界坐标为什么是两套数。
阅读导航:上一节:目录 · 下一节:1.2 游戏的心智模型:帧、时间、坐标系 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。