第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 章有完整版):

  1. Phaser 实例必须用 shallowRef 或 markRaw 持有。用 ref 会让 Vue 的响应式系统深度遍历整个 Phaser 对象图(场景、纹理、WebGL 上下文),轻则卡顿,重则直接抛错。
  2. 游戏容器不要放进 <KeepAlive> 或 <Transition>,离开路由前必须 await unmount()。
  3. 给游戏模块加 HMR 边界:编辑 Phaser 场景时应整页刷新,否则热替换会叠出多个 canvas。
  4. 不要在 computed / watch 里持有 Phaser 的 GameObject,它们不是响应式数据。
  5. 宿主组件最小实现就是 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 是「完成」的定义,其中体积守卫放在构建之后。

下一章从零把项目跑起来,并介绍日常开发最常用的几条命令与调试入口。