第02章 - 技术栈与工程结构
2.1 版本基线
CHAGS 使用 pnpm workspace 管理多包,所有版本以各 package.json 为准。写作本文时锁定的基线如下(^ 表示允许小版本升级):
| 包 | 版本 | 作用 |
|---|---|---|
| phaser | ^4.2.1 | 游戏引擎 |
| vue | ^3.5.44 | 应用外壳 |
| pinia | ^4.0.3 | 外壳状态管理 |
| @vitejs/plugin-vue | ^6.0.10 | SFC 编译 |
| vite | ^8.3.4 | 构建(Rolldown 内核) |
| typescript | ~5.9.3 | ⚠️ 刻意锁死,原因见 2.2 |
| vue-tsc | ^3.3.13 | .vue 单文件组件的类型检查 |
| tailwindcss / @tailwindcss/vite | ^4.3.3 | 外壳样式 |
| zod | ^4.6.7 | 内容与关卡脚本的 schema 校验 |
| eslint | ^10.12.0 | flat config |
| typescript-eslint | ^8.71.2 | TS 规则 |
| eslint-plugin-vue | ^10.11.1 | Vue 规则 |
| vitest | ^5.0.4 | 单元测试 |
| @playwright/test | ^1.64.0 | 端到端测试 |
| tsx | ^4.23.15 | 直接运行内容校验 CLI |
| pnpm / node | 11.x / 24.x | 包管理器与运行时 |
桌面壳与移动壳另有两套工具链(仅打包时用到):Tauri 2(Rust)与 Capacitor 8(Gradle + Android SDK),详见第 14 章。
2.2 两个「锁版本」背后的实测原因
TypeScript 锁在 5.9.3,而不是最新的 7.x
TypeScript 7(Go 原生编译器)移除了 ./lib/tsc 子路径导出,而 vue-tsc 依赖它。实测 typescript@7.0.2 + vue-tsc@3.3.13 会直接报错:
ERR_PACKAGE_PATH_NOT_EXPORTED: './lib/tsc' is not defined by "exports"
并且查询 dist-tags 确认当时不存在支持 TS 7 的 vue-tsc 版本。换回 typescript@5.9.3 后同一份代码立即正常工作。5.9.3 同时满足 vue-tsc >= 5.0.0、typescript-eslint < 6.1.0、pinia >= 5.6.0 三方 peer 范围,是当前唯一「全绿」的版本。
代价是暂时享受不到原生编译器约一个量级的类型检查提速;等 vue-tsc 跟进后升级只是改一行版本号,代码零改动。
安卓打包要 JDK 17–24,绕开 JDK 25
Gradle 8.14 只支持到 JDK 24,用 JDK 25 会报 Unsupported class file major version 69。仓库里的 scripts/build-apk.mjs 会自动在常见安装目录里找一个 17–24 的 JDK(也可用环境变量 CHAGS_JDK 指定),不需要手动切换 JAVA_HOME。这是「本机环境差异」这一类问题的典型处置方式:把判断写进脚本,而不是写进文档让人记。
2.3 为什么是 Phaser 4 + Vue 3.5 的组合
| 方案 | 合集适配度 | 优点 | 缺点 | 结论 |
|---|---|---|---|---|
| Phaser 4 | ★★★★★ | 纯代码/TS、MIT、生态成熟、包可控 | 无编辑器,UI 要自己写 | ✅ 主选 |
| Phaser 3 | ★★★★☆ | 教程最多 | 已是上一代 | 备选 |
| Cocos Creator | ★★★☆ | 小游戏一键发布 | 编辑器资产 AI/CI 不友好,包体大 | 备选 |
| PixiJS | ★★☆ | 渲染性能好 | 只是渲染器,游戏框架要从零造 | ✗ |
| Godot / Unity Web | ★★ / ★ | 开发体验好 | Web 导出体积大、移动端 H5 兼容差 | ✗ |
| 纯 DOM/CSS | ★★★(仅问答类) | 最轻、可 SEO | 复杂交互做不动 | 仅用于图鉴类页面 |
选 Phaser 4 的核心原因与 1.2 节的产品约束一致:代码即资产。场景、关卡、动画全部是 TypeScript 文本,可 diff、可 review、可被单测与端到端测试覆盖。
而「外壳用 Vue、游戏用 Phaser」的双栈分工则是基于职责:大厅、图鉴、设置是 UI 密集型页面,用 SFC 写得快;游戏内是 Canvas 世界,交给 Phaser。关键约束是 Phaser 绝不进外壳主包:
- Phaser 4.2.1 完整版未压缩 1,375,976 字节,gzip 后 357,778 字节(约 350 KB);
- 外壳主包(大厅 + 图鉴 + 路由 + 状态)实测约 80 KB gzip 量级,不含 Phaser;
- 每个游戏的业务代码只有几 KB,Phaser 是共享 chunk,只在进入游戏时加载一次。
Vue × Phaser 集成的五条踩坑清单
这段是血泪经验,直接写进代码规范(第 16 章有完整版):
- Phaser 实例必须用
shallowRef或markRaw持有。用ref会让 Vue 的响应式系统深度遍历整个 Phaser 对象图(场景、纹理、WebGL 上下文),轻则卡顿,重则直接抛错。 - 游戏容器不要放进
<KeepAlive>或<Transition>,离开路由前必须await unmount()。 - 给游戏模块加 HMR 边界:编辑 Phaser 场景时应整页刷新,否则热替换会叠出多个 canvas。
- 不要在
computed/watch里持有 Phaser 的 GameObject,它们不是响应式数据。 - 宿主组件最小实现就是
defineProps+shallowRef+onMounted/onBeforeUnmount。
2.4 五层架构
┌──────────────────────────────────────────────────────────────┐
│ 外壳 apps/site(Vue 3.5 SFC) │
│ 典故馆 · 我的图鉴 · 游戏宿主 │
│ 状态:Pinia ── 路由:hash composable ── 样式:Tailwind │
└────────────────┬─────────────────────────────────────────────┘
│ 动态 import(代码分割,Phaser 只在这里出现)
▼
┌──────────────────────────────────────────────────────────────┐
│ 游戏包 games/<id> ← 一个典故 = 一个包(可独立开发/测试) │
│ scenes 编排 logic/(纯 TS:规则,可单测) │
└────────────────┬─────────────────────────────────────────────┘
│ 只依赖下面这些,绝不反向依赖外壳
▼
┌────────────┬────────────┬────────────┬───────────────────────┐
│ game-kit │ templates │ content │ phaser-kit │
│ 契约/事件 │ 玩法模板库 │ 典故数据 │ 引擎启动/主题/绘制 │
└────────────┴────────────┴─────┬──────┴───────────────────────┘
│
┌──────────────┴───────────────┐
│ progress(存档/复习) │
│ platform(存储/音频/生命周期) │
│ i18n(界面文案) │
└──────────────────────────────┘
包之间的实际依赖方向(package.json 里的 workspace:* 依赖)如下:
content → zod
game-kit → content
i18n → content
progress → game-kit
platform → game-kit + progress
phaser-kit → game-kit + i18n(+ phaser)
templates → content + game-kit + phaser-kit + i18n(+ phaser + zod)
games/* → content + game-kit + phaser-kit + templates(+ phaser)
apps/site → 上面所有包 + pinia + vue + 全部 games/*
两条重要事实:
game-kit与content不含 Phaser 依赖,所以外壳可以安全地 import 它们(例如大厅读取典故列表、进度记录星星)。- 依赖方向永远向下:游戏包不认识外壳,模板不认识游戏包。新增一个典故不需要修改任何公共包。
2.5 pnpm workspace 的组织方式
pnpm-workspace.yaml 只有三行目录声明:
packages:
- 'apps/*'
- 'packages/*'
- 'games/*'
每个包都是 private 包,通过 exports 直连源码,不设构建步骤、不配路径别名:
{
"name": "@chags/game-pangu",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@chags/content": "workspace:*",
"@chags/game-kit": "workspace:*",
"@chags/phaser-kit": "workspace:*",
"@chags/templates": "workspace:*",
"phaser": "^4.2.1"
}
}
这种「just-in-time package」的好处是改一处全仓即时生效:改 packages/templates 里的一个判定函数,Vite 开发服务器立刻热更新到游戏里,不需要 pnpm build 任何中间产物。
packages/templates 还导出了若干子路径,让游戏包可以只引用需要的那层(尤其是纯逻辑层,用于单测):
"exports": {
".": "./src/index.ts",
"./layout": "./src/layout.ts",
"./schemas": "./src/schemas.ts",
"./session": "./src/session.ts",
"./substitution.logic": "./src/substitution.logic.ts",
"./place.logic": "./src/place.logic.ts",
"./tap.logic": "./src/tap.logic.ts",
"./aim.logic": "./src/aim.logic.ts"
}
为什么一上来就用 workspace 而不是单应用:合集的目标形态是「30+ 个游戏包」,边界越早划清越省事。局部代价是结构略重,但没有引入 Turborepo / Nx —— 根部的 pnpm script 已经够用,避免过度工程。
2.6 目录结构逐项说明
CHAGS/
├─ apps/
│ ├─ site/ # Vue 3.5 外壳(唯一的产品代码)
│ │ └─ src/
│ │ ├─ main.ts # createApp + Pinia
│ │ ├─ App.vue # 顶栏 + 路由出口 + 音频解锁 + 返回键
│ │ ├─ router.ts # 20 行 hash 路由
│ │ ├─ registry.ts # 典故 id → 懒加载游戏包(新增典故只加一行)
│ │ ├─ shell/
│ │ │ ├─ createGameContext.ts # GameContext 组装 + ?autoplay 调试开关
│ │ │ ├─ platform.ts # createWebPlatform() 单例
│ │ │ ├─ progress.ts # ProgressManager 单例
│ │ │ ├─ debug.ts # window.__chags 调试桥
│ │ │ └─ backButton.ts # 安卓返回键处理
│ │ ├─ stores/{locale,progress}.ts
│ │ └─ views/{HallView,AlbumView,PlayView}.vue
│ ├─ desktop/ # Tauri 2 壳(src-tauri/ 里 20 行 Rust)
│ └─ mobile/ # Capacitor 8 壳(android/ 工程入库)
├─ packages/
│ ├─ game-kit/src/contract.ts # 全仓最重要的一个文件:契约
│ ├─ content/src/
│ │ ├─ schema.ts # AllusionSchema(zod)
│ │ ├─ allusions/*.json # 十二份典故内容(中文为基准)
│ │ ├─ allusions/index.ts # 构建期 zod 解析 + 常用查询
│ │ ├─ i18n/en/*.json # 英文翻译补丁(结构镜像)
│ │ └─ localize.ts # mergeLocalized / validatePatch
│ ├─ templates/src/
│ │ ├─ story.ts / substitution.ts / tap.ts / place.ts / aim.ts
│ │ ├─ sort.ts / trace.ts / wait.ts / quiz.ts / reward.ts
│ │ ├─ *.logic.ts # 对应的纯逻辑(可单测)
│ │ ├─ schemas.ts # 各模板的关卡脚本 schema
│ │ ├─ layout.ts # 750×1334 设计坐标常量(e2e 直接复用)
│ │ ├─ session.ts # 一局状态 + 星星规则
│ │ ├─ defineGame.ts # 故事 + 玩法 + 典故卡的统一组装
│ │ └─ cli/check-content.ts # content:check 入口
│ ├─ phaser-kit/src/
│ │ ├─ createGame.ts # Phaser.Game 启动器
│ │ ├─ baseScene.ts # BaseScene:按钮/提示/小手/翻译
│ │ ├─ theme.ts # 配色、字体栈、文字样式
│ │ ├─ cartoon.ts # 圆润卡通绘制原语
│ │ └─ drag.ts # 按距离判定的拖拽控制器
│ ├─ platform/src/ # web.ts(IndexedDB/音频/TTS)+ runtime.ts(壳探测)
│ ├─ progress/src/ # manager.ts(存档)+ review.ts(复习调度)
│ └─ i18n/src/ # messages.ts(界面文案)+ index.ts(取词器)
├─ games/<id>/src/
│ ├─ manifest.ts # GameManifest(id/title/模板/技能/年代)
│ ├─ logic.ts # 从内容里取本典故的关卡脚本并解析
│ ├─ art.ts # 这个典故的全部几何绘制
│ ├─ index.ts # defineGame(...):拼接本典故的场景
│ ├─ logic.test.ts # 关卡数据单测
│ └─ scenes/ # (可选)游戏专属场景
├─ e2e/ # Playwright:冒烟 + 真实操作 + 截图 + 巡检
├─ scripts/ # check-size.mjs / build-apk.mjs / make-icons.py
└─ docs/ # 技术方案、典故方案、打包方案(双语各一份)
2.7 根目录脚本一览
根 package.json 是所有开发者接触的总入口:
| 脚本 | 命令 | 说明 |
|---|---|---|
pnpm dev |
pnpm --filter @chags/site dev |
开发服务器 127.0.0.1:5183(strictPort) |
pnpm build |
pnpm --filter @chags/site build |
站点产物输出到 apps/site/dist |
pnpm preview |
vite preview |
本地预览生产产物 |
pnpm typecheck |
vue-tsc --noEmit -p tsconfig.json |
全仓类型检查(含 .vue) |
pnpm lint |
eslint . |
flat config + 逻辑层禁令 |
pnpm test |
vitest run |
23 个测试文件、156 项单测 |
pnpm content:check |
tsx packages/templates/src/cli/check-content.ts |
内容与翻译校验 |
pnpm verify |
六连跑 | typecheck → lint → test → content:check → build → size |
pnpm e2e |
playwright test |
开发服务器上的端到端 |
pnpm e2e:dist |
预览配置 | 针对生产构建产物再跑一遍(验证 lazy chunk) |
pnpm audit:i18n |
巡检配置 | 中英双语 34 张画面截图(产物在 .scratch/,不入库) |
pnpm size |
node scripts/check-size.mjs |
体积预算守卫 |
pnpm dist:win |
站点构建 + Tauri | Windows 便携 exe |
pnpm dist:apk |
站点构建 + Capacitor | 安卓 apk |
pnpm icons |
python scripts/make-icons.py |
用 Pillow 生成两套壳的图标 |
注意 verify 里 size 在最后:体积守卫读的是刚构建出来的 apps/site/dist,所以顺序不能换。
2.8 一份 tsconfig 管全仓
仓库只有一个根 tsconfig.json,把 packages、games、apps/site、e2e、根配置全部纳入 include,关键开关:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitOverride": true,
"noEmit": true,
"types": ["vite/client", "node"]
},
"include": [
"packages/*/src/**/*.ts",
"games/*/src/**/*.ts",
"apps/site/src/**/*.ts",
"apps/site/src/**/*.vue",
"apps/mobile/*.ts",
"e2e/**/*.ts",
"*.config.ts"
],
"exclude": ["**/node_modules", "**/dist", "apps/mobile/android", "apps/desktop/src-tauri"]
}
几处取舍说明:
verbatimModuleSyntax强制import type显式标注类型导入,配合isolatedModules让每个文件都能被独立转译(Vite 的底层要求);noEmit: true—— 类型检查与产物构建彻底分离:vue-tsc只查类型,出产物是 Vite 的事;- 原生壳的
android/与src-tauri/被排除,它们不是 TypeScript 工程的管辖范围; moduleResolution: bundler让所有@chags/*的 exports 子路径(如@chags/templates/schemas)按 Vite 的解析规则工作。
同样地,Vitest 与 Playwright 都只有一份根配置,测试发现的规则见第 13 章。
2.9 小结
- 技术栈的核心是「Phaser 4 管游戏 + Vue 3.5 管外壳」,Phaser 被严格隔离在游戏与引擎包里,外壳首屏不为它付体积;
- 工程用 pnpm workspace 划出
apps / packages / games三层,包与包之间exports直连源码,改一处全仓即时生效; - 两个版本锁定(TS 5.9.3、JDK 17–24)都来自实测,不是保守,而是当前唯一全绿的组合;
- 根脚本
pnpm verify是「完成」的定义,其中体积守卫放在构建之后。
下一章从零把项目跑起来,并介绍日常开发最常用的几条命令与调试入口。