项目:html5-games | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
《06-部署运行》第 5 节已经给出了「新增一款游戏」的标准 SOP(改 1 个文件 + 新增 1 个文件 + 同步 3 处文案),本篇不重复其主干,而是把它深化到坑位细节,并再给出五个同样高频的扩展场景:新增一个分类、新增存档形态、接入真实游戏引擎(以 Phaser 为例)、新增站点页面、亮色主题化。每个场景按「前置条件 → 操作步骤 → 坑位清单 → 验证」组织。所有场景共享同一条纪律:改完跑「提交前五连」(pnpm typecheck && pnpm lint && pnpm test && pnpm build,见《09-测试与质量保障》第 3 节),触碰《05-核心流程》第 6 节定义的危险修改区时加做全范式回归。
前置条件与三步主干照《06-部署运行》第 5 节执行(复制 src/games/snake.vue 作 JS 范式或 src/games/sokoban-game.vue 作 TS 范式 → 写组件 → 在 src/config/games.ts 登记 → 同步三处文案)。以下 10 条坑位是把 79 款游戏踩过的坑提炼成的 checklist,逐条对应源码证据:
| # | 坑位 | 细节与依据 |
|---|---|---|
| 1 | canvas 必须可聚焦 | tabindex="0" + 开始/继续时 focusCanvas(),否则方向键会滚动页面;框架层不会替你做(GameShell 不知道 canvas 存在) |
| 2 | 触屏手势三件套 | canvas 样式加 touch-action: none(70 款已设);滑动用 @touchstart.passive 记起点 + @touchend.prevent 读位移并阻断合成 click(snake.vue 357-387 行范式);24px 死区过滤微抖 |
| 3 | 逻辑 dt 必须 clamp | Math.min(ts - lastTs, 100) 级别(snake/plane-shooter/suika 均如此),否则切后台回来一帧巨大 dt 直接穿模或瞬移 |
| 4 | 清理责任清单 | rAF、setInterval、window 级 addEventListener、PixiJS Application 四类,凡在 onMounted/组件体创建的都必须在 onBeforeUnmount 成对释放;优先走 useCanvasStage/useAnimationLoop 的 registerTargetEvent/startLoop(见《07-模块详解》第 5 节);自管时学 suika(onBeforeUnmount 里 cancelAnimationFrame + removeEventListener 配对) |
| 5 | 音效只用 GameSfx 联合类型 | play('lose') 这类越界名在 JS SFC 里不报错但无声(render switch 无 case,gobang 的实际事故);可用的 16 个名字见《03-接口设计》4.2 |
| 6 | 存档键用完整 id | BEST_KEY = 'h5g:<id>-best',禁止模仿 8 处历史短键(《04-数据模型》2.3);低分更优的游戏自行比较后才写(video-puzzle.vue 288-291 行范式) |
| 7 | id 命名带 -game 后缀 | 惯例 <name>-game.vue(67/79 满足;snake、gobang、tic-tac-toe 等 12 款早期 id 无后缀);id 同时决定路由、文件名、推荐键名,定稿前想清楚;正则 ^[a-z0-9-]+$ 由测试强制 |
| 8 | GameShell 参数即承诺 | ratio 按玩法定(竖版滑块 0.56、横版射击 16/9 或 1.571、正方形 1);accent 建议用分类色或与分类色和谐的色(封面辉光、卡片描边走的是分类色,accent 只管运行页氛围);instructions 是底部一句话提示,写清按键 |
| 9 | 插槽内容用 gs-* 工具类 | gs-chip(HUD)、gs-info(说明)、gs-overlay/gs-card/gs-btn(浮层)、gs-action(顶栏自定义按钮)——见 GameShell.vue 542-729 行;自创类名会出现视觉漂移(GameShell 契约风险,《03-接口设计》第 7 节) |
| 10 | sortOrder 接续最大值 | 当前最大 79,新游戏写 80;乱插数值会把新游戏顶进首页第一屏(默认排序按其升序) |
验证按 06 篇第 4 步的命令与手工清单执行,这里补充两条该清单的原理说明:为什么要「拼错 id 访问 /game/xxx」——验证详情页的「未找到游戏」分支(GameDetailPage.vue 84-96 行)而非靠运气覆盖;为什么要「静音后刷新再开」——验证 readMuted() 的持久化恢复路径(useGameAudio.ts 31-39 行)。
前置确认:先想清楚是否真的需要——分类只应按「玩法主基调」增删,且当前四分类在多处文案中被固化(index.html meta description「涵盖益智、棋牌、动作与休闲」、README、既有文档)。操作步骤:(1)在 src/config/games.ts 的 localGameCategories 追加 { id, name, icon, color, description },color 必须六位 hex(测试 2.5 用例强制);(2)把若干游戏的 category 改为新 id(注意 GameCategory 联合类型会自动放行新值,类型层面无闸门,全靠测试「每分类至少一款」兜底);(3)同步 index.html 与 README 的分类文案;(4)跑五连。坑位有三,全部有前车之鉴:图标连字缺失——material-symbols@0.45.8 存在个别连字缺失,casual 分类的 ms:catching_pokemon 会以字面文本渲染并撑破侧栏(games.ts 68-69 行注释原文记载「已实测」),新图标必须上线前实测渲染;分类色的视觉连贯——分类色被 GameCard 描边、GameShell accent 默认值间接消费、gameCoverRender 辉光三处消费(见《03-接口设计》1.2),选色应与现有四色(#8b5cf6/#f59e0b/#f43f5e/#14b8a6)饱和度与明度协调;搜索与筛选语义——分类是硬边界(《05-核心流程》流程二的细节二),归类错位会造成「搜索词命中但分类过滤不可见」,新分类上线后建议抽查每款的归类。删除分类是逆操作,先清空该分类的游戏才能通过测试。
当一款游戏需要保存比分、统计、多关进度等复合数据时,最高分工具(src/utils/highScore.ts)不再适用,应遵循项目内已有的三种既成范式。范式 A:JSON 结构键(gobang.vue 115-132 行、tic-tac-toe.vue)——键名 h5g:<id>-<后缀>(-score/-stats/-moves),读取时 JSON.parse(localStorage.getItem(key) || '{}') 再逐字段 raw.w || 0 缺省,写入时 try/catch 包裹 JSON.stringify;字段缺省读是硬要求,它让「玩家手工改坏」「旧版本少字段」「未来加字段」三种情况都安全降级。范式 B:映射键(sokoban-game.vue 的 h5g:sokoban-game-moves)——Record<number, number> 存「关卡索引 → 最少步数」,适合多关卡的按关纪录;注意 JSON.parse 的结果要做 typeof 防御后再当对象用。范式 C:参数化键(memory-match-game.vue 42 行 h5g:memory-match-best-${size.value})——同一游戏按模式分键;键空间要封闭(size 取值有限枚举),不要把用户可控字符串拼进键名。通用规则:一律 h5g: 前缀;不要复用 -best 键装非「纪录」语义(《04-数据模型》2.2 的「best 不保证方向」已经是历史包袱,别再增加);值里若可能演进结构,建议顶层带版本号字段(如 { v: 1, ... }),为未来迁移留钩子;若要做真正的「中断续玩」进度存档,用新键 h5g:<id>-save 并序列化整局快照(含种子与状态机),卸载时机选 onBeforeRouteLeave 语义之外的游戏内部事件,恢复入口放 overlay——这是《04-数据模型》第 3 节预告过的扩展方向,目前 79 款均为单局制。
结论先行:可行,且有 pixi.js 先例可循,但建议只在单款游戏试点,不建议批量迁移。可行性论证分四点。其一,依赖隔离机制现成——vite.config.ts 的 manualChunks 已有 pixi-vendor 先例,为 Phaser 加一条 if (id.includes('phaser')) return 'phaser-vendor' 即可;体积上 Phaser 3 全量约 1MB+ 压缩后,比 pixi 的 488K 更重,独立 chunk 保证「不玩该游戏不下载」,首屏不受影响,这是方案成立的前提。其二,组件契约兼容——Phaser 游戏只需在 onMounted 里 new Phaser.Game({ type: Phaser.AUTO, parent: canvas 容器, ... }),onBeforeUnmount 里 game.destroy(true);GameShell 只要求默认插槽里有一个会被撑满的容器元素,Phaser 自建的 canvas 同样满足。其三,竞态保护照抄 pixi 范式——pixi 的 Application.init() 是异步的,pixi-pong.vue 用 destroyed 标志位防「init 完成前卸载」的竞态(《02-页面设计》6 节);Phaser 的构造是同步的,该坑不存在,但场景加载(scene 的 create 回调)是异步的,卸载时仍要防「场景资源回调在 destroy 后触发」。其四,类型可选——Phaser 自带完整 TS 类型,试点游戏可以直接写 lang="ts",顺手为「游戏层 TS 化」提供一个样板(见《13-技术债务与演进建议》E-05 的增量策略)。为什么不建议批量迁移:79 款游戏的原生实现每个 6-20K chunk,Phaser 单款就 1MB+,收益(物理引擎、瓦片地图、动画系统)对休闲小游戏普遍过剩;原生 Canvas 实现已被验证足够且零依赖。试点的合理候选是「确实需要物理引擎或瓦片地图」的玩法(如平台跳跃类的高配版)。接入清单:manualChunks 加分支 → 新建 <id>.vue(lang="ts")→ 登记 config(engine/library 字段照抄 'game'/'showcase',勿自作主张改成 'phaser'——那是死字段,见《13-技术债务与演进建议》E-02)→ 测试断言 79→80 的三处文案同步 → 实机验证 destroy 后重进无泄漏。
路由表只有两条业务路由(src/router/index.ts),新增页面的机制成本低,但有两个设计决策要先做。路由层:在 routes 数组追加 { path: '/records', name: 'Records', component: () => import('@/pages/RecordsPage.vue'), meta: { title: '纪录中心' } }——懒加载与标题守卫自动生效,无需改 App.vue(顶栏显隐由 meta.hideHeader 控制,列表型页面应保持显示顶栏);若页面不希望被搜索/直链,再考虑 redirect 兜底(/:pathMatch(.*)* 已把未知路径送回大厅)。数据层是真正的难点:纪录中心需要「按 id 枚举全部存档键」,而现存 8 处历史短键 + 1 处动态键使「h5g:<id>-best 从 id 推导」不成立(《04-数据模型》2.3 明言「本项目并没有这样的函数」)。推荐方案是双读映射表:在 src/utils/highScore.ts 旁新增 src/utils/recordKeys.ts,导出 bestKeyOf(id): string 内部维护一张「短键豁免表」(8 个历史 id → 实际键),规范 id 走 h5g:${id}-best,动态键(memory-match)单独列出各模式键;读取时全量 localStorage 遍历(for (let i = 0; i < localStorage.length; i++) 取 key 匹配 ^h5g: 前缀)配合该映射展示——不要批量改名旧键(老玩家纪录感知性清零,《04-数据模型》2.3 已做此权衡)。UI 侧复用现有组件(CategoryChips 不适用,用简单表格或卡片网格 + localGamesMap 取标题与分类色)。「随机开玩」式的低成本变体(如「关于页」)则完全无数据层障碍。验证:新页面进大厅/直链双向可达、返回大厅滚动位置不受影响(scrollBehavior 只对 Games 特判)、五连通过。
现状是 dark-first:src/assets/main.css 的 :root 定义 Linear 风格暗色 RGB 三元组令牌(画布 #08090a、品牌靛紫 #7170ff 等),全站(页面、组件、79 款游戏、GameShell、封面绘制)统一经 rgb(var(--color-*-rgb) / alpha) 语法消费(《02-页面设计》第 4 节)——这使主题化的改动面被压缩到令牌层。但要做亮色主题,有三个真实的坑:坑一,游戏内硬编码色。大量游戏在绘制代码里硬编码暗色(suika 背景 #0b1020→#05070f 渐变、tetris 棋盘 #0b1020、plane-shooter 场景 #020617→#0f172a),这些不读 CSS 变量,亮色主题下会变成亮页面上的一块暗色岛;务实的取舍是「游戏舞台保持暗色」(机台屏幕本来就暗,GameShell 舞台自带深色底),只把大厅、顶栏、info 栏做亮色适配。坑二,封面是程序化绘制的位图。drawCover 的暗色基底(#141519→#0b0c0e)与白色标题写死在 canvas 绘制里,亮色主题下封面依然是暗卡——视觉上可接受(类似暗色视频缩略图),但若要彻底适配需给 drawCover 增加主题参数并重建缓存(cacheVersion 机制现成)。坑三,index.html 启动屏。#boot-splash 的颜色是内联 style 的硬编码暗色,主题切换前它先渲染,需要同步处理(或接受启动屏恒暗)。实现路径:令牌不动结构,把 :root 的值抽为 --theme-dark 块、新增 [data-theme='light'] 覆盖块,document.documentElement.dataset.theme 切换并持久化到新的 h5g:theme 键(范式照抄 game-audio-muted);Tailwind v4 的 @theme inline 桥接自动跟随变量值。工作量主要在坑一的视觉验收(79 款游戏的舞台外区域逐一抽查)。
| 你想做的事 | 走哪个场景 | 关键前置阅读 |
|---|---|---|
| 加一款游戏 | 场景一 | 《06-部署运行》第 5 节 + 本篇表 1 + 《03-接口设计》第 2 节 |
| 重组分类 | 场景二 | 《00-项目总览》2.1 + 《03-接口设计》1.2 |
| 保存比分/进度 | 场景三 | 《04-数据模型》全篇 |
| 上引擎/重依赖 | 场景四 | 《07-模块详解》第 1-2 节 + 《10-性能设计与优化》第 1 节 |
| 加页面/入口 | 场景五 | 《02-页面设计》第 1 节 + 《04-数据模型》2.3 |
| 换主题 | 场景六 | 《02-页面设计》第 4 节(令牌体系) |
六个场景的共性收束成一句话:这个项目的扩展面几乎总是「登记一处(config/games.ts 或路由表)+ 落盘一处(组件或页面)+ 文案同步」,架构不给扩展设门槛,但每一处坑位(表 1 的 10 条、各场景的坑)都是 79 款游戏与既有篇章用真实事故换来的,新增代码时应逐条对照而不是依赖事后 review 发现。