项目:html5-games | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
本项目没有 HTTP API,全部「接口」都是 TypeScript 模块导出 + Vue 组件契约 + localStorage 键约定三类。其中最重要的是「游戏-框架接口契约」:一款游戏组件只要满足第 1、2 节的两份契约(元数据登记 + 组件自包含规范),就能被大厅发现、被运行页加载、被外壳统一装扮——这也是 06 篇「新增一款游戏 SOP」的技术基础。
src/config/games.ts)export type GameCategory = 'puzzle' | 'board' | 'action' | 'casual'
export interface LocalGame {
id: string // 全局唯一,同时是文件名与路由参数:必须匹配 /^[a-z0-9-]+$/
category: GameCategory // 四分类之一
engine: string // 固定传 'game'(历史遗留字段,供详情页 VisualDemo 类型消费)
library: string // 固定传 'showcase'(同上)
title: string // 展示名(也是程序化 BGM 的作曲种子)
description: string // 一句话玩法描述(搜索范围包含此字段)
tags: string[] // 标签数组(参与搜索;测试要求至少 1 项)
group: string // 来源分组:核心精选 / GitHub 示例集成 / Canvas Test 整理 / PixiJS 精选 / 专题灵感
difficulty: string // 难度(当前 79 款全部为空字符串 '',UI 未消费)
sortOrder: number // 默认排序键(全表唯一,测试断言无重复)
}配套导出与使用关系如下表。理解这张表的关键是分清「谁读谁写」:localGames 只被列表页消费(筛选管道的数据源);localGamesMap 只被详情页消费(路由参数校验);分类两个结构被卡片、封面、外壳三处消费(分类色透传)。写入方只有一个——维护者本人,而且只在新增/删除游戏时变更。
| 导出名 | 类型 | 用途 |
|---|---|---|
localGames | LocalGame[] | 79 条元数据(顺序无关,展示排序看 sortOrder) |
localGamesMap | Map<string, LocalGame> | id → 元数据,GameDetailPage 用它校验路由参数 |
localGameCategories | GameCategoryMeta[] | 四分类元数据:{ id, name, icon, color, description },顺序即侧栏展示顺序 |
localGameCategoryMap | Map<GameCategory, GameCategoryMeta> | 分类色快速查询(GameCard/GameCover 用) |
localGameFileName(id) | (id: string) => string | 返回 `${id}.vue`,建立 id 与文件名的约定 |
约束由测试固化(src/config/__tests__/games.test.ts):总长度必须为 79;id 无重复且匹配 ^[a-z0-9-]+$;sortOrder 无重复;title/description/tags 非空;category 必须合法;每个 id 必须存在同名 .vue 文件且无孤儿文件(用第二个 import.meta.glob('/src/games/*.vue') 双向比对);「核心精选」分组至少 4 款(HallHero 精选横带满排)。此前标注 ⚠️ 待确认的 engine/library 已由《13-技术债务与演进建议》E-02 经全仓库 grep 复核正式判定为死字段(消费方 VisualDemo 类型已不在本仓库,无任何运行时读取),待与 difficulty、localGameFileName 一并清理;清理前新增游戏照抄 'game'/'showcase' 即可。
GameCategoryMeta.color 是六位 hex(测试断言 /^#[0-9a-fA-F]{6}$/):puzzle #8b5cf6、board #f59e0b、action #f43f5e、casual #14b8a6。该色被三处消费:GameCard 的 --cat-color(hover 边框/辉光)、GameShell 的 accent(若游戏未显式传 accent,可从分类色取)、gameCoverRender 的封面辉光。新增游戏不定义颜色,颜色属于分类。
src/games/<id>.vue)游戏组件没有显式的 TypeScript interface,其契约由「加载机制 + 统一实践」构成,79 款均已满足(唯一已知违例是五子棋的越界音效名——JS 层可写出 GameSfx 之外的音效名且静默无声,见 4.2 与《13-技术债务与演进建议》E-06):
| 契约项 | 要求 | 违反后果 |
|---|---|---|
| 文件位置与命名 | src/games/<id>.vue,id 与 config 完全一致 | CI 测试红(配置/文件一一对应断言) |
| 默认导出 | 标准 SFC 默认导出(defineAsyncComponent 依赖 mod.default) | 运行页无法挂载 |
| 自包含 | 不 import 其他游戏、不感知路由/URL | 破坏「单款游戏可独立增删」性质 |
| 统一外壳 | 模板根为 <GameShell>(79/79 遵守) | 页面骨架失控、无法统一返回/暂停/音频 |
| 资源清理 | 在 onBeforeUnmount/onUnmounted 中停止 rAF、移除 window 级监听、销毁 PixiJS Application | 内存泄漏、后台耗电 |
| 输入三通道 | 键盘 + 鼠标 + 触屏至少支持两种;canvas 需设 touch-action: none(70 款已设) | 移动端不可玩 |
| 音效 | 经 useGameAudio().play(<GameSfx>),禁止自建 AudioContext | 绕过全局静音开关;越界音效名则静默无声(JS 层无编译期拦截,gobang 事故见《13-技术债务与演进建议》E-06) |
这套隐式契约的设计逻辑值得展开说明。项目刻意不为游戏定义 TypeScript 接口,而是把约束放在三个更可靠的位置:其一,「文件即注册」的 glob 机制让组件的存在本身就是接口的一部分,任何接口检查都不如 games.test.ts 的文件一一对应断言来得直接;其二,GameShell 以插槽形式吸收了所有页面级差异,游戏作者不需要关心顶栏、返回、音频这些横切关注点,自然也就没有破坏它们的入口;其三,运行页的 onErrorCaptured 兜底把「最坏情况」从白屏降级为带重试的错误卡,容忍了 74 款 JS SFC 无法静态验证的现实。换句话说,这是一套靠约定、测试与运行时隔离共同兜住的软契约——接手者新增游戏时不必寻找接口定义,照着现有 79 款的样子写即可。
契约中两条最容易被忽视的条目再强调一次。资源清理:游戏内凡是在 onMounted/组件体里向 window 注册的监听器、启动的定时器与 rAF 循环,都必须在卸载钩子里成对释放;推荐统一走 useCanvasStage/useAnimationLoop 的 registerTargetEvent 与 startLoop/stopLoop,它们在内部维护清理任务数组并于 onUnmounted 自动执行,可以从根上避免遗漏。pixi 游戏额外有竞态要求:Application.init() 是异步的,若组件在 init 完成前被卸载,直接 destroy 会报错,因此 pixi-pong.vue 维护了 destroyed 标志位(init 完成后检查,已卸载则立即销毁并跳过挂载)。自包含:游戏之间严禁互相 import,也不允许读取 route 或操作 history——曾有价值的反面例证是「返回大厅」能力:它被收拢在 GameDetailPage 的悬浮按钮中,79 款游戏没有一款自己实现返回,这正是自包含约束带来的结构红利。
推荐生命周期骨架(摘自 src/games/snake.vue,可作模板):
<template>
<GameShell title="贪吃蛇" :ratio="1" :show-pause="…" :paused="…"
accent="#34d399" instructions="…"
@restart="reset" @toggle-pause="togglePause">
<template #hud>…</template>
<canvas ref="canvasRef" class="game-canvas" tabindex="0"
@touchstart.passive="onTouchStart" @touchend.prevent="onTouchEnd" />
<template #info>…</template>
<template #overlay>…</template>
</GameShell>
</template>
<script setup>
import { useCanvasStage } from '@/composables/useCanvas'
import GameShell from '@/components/games/GameShell.vue'
import { useGameAudio } from '@/composables/useGameAudio'
import { readHighScore, writeHighScore } from '@/utils/highScore'
const { play, unlock } = useGameAudio()
const BEST_KEY = 'h5g:snake-best'
const { canvasRef, ctxRef, size, startLoop, stopLoop, registerTargetEvent } =
useCanvasStage({ pauseWhenHidden: true })
onMounted(() => { registerTargetEvent(window, 'keydown', onKey); startLoop(loop) })
onBeforeUnmount(() => stopLoop())
</script>要点:canvas tabindex="0" 使 canvas 可聚焦(focusCanvas() 在开始/继续时调用,避免方向键滚动页面);@touchstart.passive + @touchend.prevent 的组合让滑动不被浏览器当滚动手势;键盘监听统一经 registerTargetEvent(window, 'keydown', …) 注册以获得自动清理。
src/components/games/GameShell.vue)| 类别 | 成员 | 类型/默认 | 说明 |
|---|---|---|---|
| props | title | string,必填 | 顶栏标题 |
| props | ratio | number,默认 1 | 舞台宽高比(宽/高),经 --gratio + 容器查询最大化铺满 |
| props | instructions | string,默认 '' | 底部一句话操作提示(空则不渲染页脚) |
| props | showRestart / showPause / paused | boolean,默认 true/false/false | 控制按钮显隐与受控暂停态 |
| props | accent | string,默认主色 | 强调色(CSS 颜色),注入 --gaccent |
| props | showInfo | boolean,默认 true | 是否渲染右侧 info 栏 |
| slots | 默认 | — | 舞台内容(canvas / DOM 棋盘) |
| slots | hud | — | 顶栏中部数据区(惯例用 gs-chip 类名) |
| slots | actions | — | 顶栏自定义控制按钮(在 BGM/静音/暂停/重开之前) |
| slots | info | — | 玩法说明侧栏(<1024px 折叠为手风琴) |
| slots | overlay | — | 舞台浮层(开始/暂停/结束卡;惯例 gs-overlay/gs-card/gs-btn 类名) |
| emits | restart | — | 点击重开按钮 |
| emits | togglePause | — | 点击暂停/继续按钮 |
GameShell 内部自动承担:首个用户手势解锁 AudioContext 并以 title 为种子启动 BGM;muted/paused 变化联动 BGM 音量;BGM 与音效两个独立开关按钮。
src/games/registry.ts)export const gameModules: Record<string, () => Promise<{ default: Component }>>
// key 形如 '/src/games/snake.vue'
export function prefetchGame(id: string): void // 预取游戏 chunk + 详情页 chunk,失败静默src/composables/useGameAudio.ts)export function useGameAudio(): {
muted: Readonly<Ref<boolean>> // 全局共享(模块顶层单例)
play: (name: GameSfx) => void // 16 种音色之一;静音时 no-op;异常吞掉
unlock: () => void // 手势内调用:创建/resume AudioContext
setMuted: (v: boolean) => void // 持久化到 'game-audio-muted'
toggleMute: () => void
}
export function ensureAudioContext(): AudioContext | null // 供 BGM 复用单例
export type GameSfx = 'click' | 'start' | 'select' | 'score' | 'eat' | 'coin'
| 'hit' | 'block' | 'shoot' | 'explode' | 'pop' | 'move' | 'jump'
| 'gameover' | 'win' | 'error'注意:越界音效名在 JS SFC 中没有编译期拦截——render() 的 switch 无对应 case 时静默无声。已发生的实例是 gobang.vue 250/253/257 行播放的 'lose'/'draw'(不在上述 16 个名字内,人机模式输棋/和棋与双人模式平局时无声),登记为《13-技术债务与演进建议》E-06,配套的扫描式测试建议见《09-测试与质量保障》第 4 节 P0。
src/composables/useGameBgm.ts)export function useGameBgm(): {
enabled: Readonly<Ref<boolean>> // !bgmMuted
start: (seedKey: string) => void // 种子=游戏标题;重复调用只调音量
stop: () => void // 清调度器 + 渐隐断开节点
toggle: () => void // 持久化到 'h5g:bgm-muted'
applyConditions: (muted: boolean, paused: boolean) => void // 静音哑音/暂停压低到 0.28
}
// 可单测纯函数(已有测试文件 useGameBgm.test.ts):
export function hashKey(str: string): number // FNV-1a
export function mulberry32(seed: number): () => number // 确定性 PRNG
export function createBgmPlan(seedKey: string): BgmPlan // 曲谱计划(tempo/调式/和声/32步音符)src/composables/useCanvasStage.ts 与 useCanvas.ts)这一组 composable 是 Canvas 类游戏最常用的底座,解决四件事:取上下文(canvasRef 绑模板、ctxRef 持 2D 上下文,用 shallowRef 避免对大对象的深度响应式代理);高分屏适配(applyCanvasSize 同时写 CSS 尺寸与乘以 dpr 的物理尺寸,并 setTransform 让绘制代码继续使用逻辑坐标);自适应(ResizeObserver 观察画布父元素,容器变化即重算);循环与事件治理(startLoop/stopLoop 管理 rAF,pauseWhenHidden 在标签页隐藏时跳过帧回调省电;registerTargetEvent 把事件注册与清理配对,组件卸载时统一释放)。useCanvas.ts 是它的旧别名封装,导出 context/startAnimation/stopAnimation/clearCanvas/resizeCanvas 等更口语化的名字,两个入口指向同一实现,新代码建议直接用 useCanvasStage。
useCanvasStage(options?: {
autoResize?: boolean // 默认 true,ResizeObserver 观察父元素
dprCap?: number // 默认 2,devicePixelRatio 上限
pauseWhenHidden?: boolean // 默认 false,document.hidden 时跳过帧回调
clearOnResize?: boolean // 默认 false
}) => {
canvasRef: Ref<HTMLCanvasElement | null> // 模板中 <canvas ref="canvasRef">
ctxRef: ShallowRef<CanvasRenderingContext2D | null>
size: { width, height, pixelWidth, pixelHeight, dpr } // reactive
startLoop(handler: (timestamp: number) => void): void
stopLoop(): void
registerTargetEvent(target | (() => target), type, listener, options?): () => void
resize(): void // 手动重算尺寸
destroy(): void
}
// useCanvas.ts 是别名封装:{ canvasRef, context, startAnimation, stopAnimation, clearCanvas, resizeCanvas }useAnimationLoop.ts 提供 startLoop/stopLoop/registerTargetEvent 三件套(无 canvas 依赖),DOM 游戏常用它仅为获取监听器自动清理。
useListFilter<T extends ListFilterState>(options: {
initial: T; debounce?: number /*300*/; syncUrl?: boolean /*true*/
urlKeys?: Partial<Record<keyof T, string>>
}) => { filters: T; debouncedFilters: Readonly<Ref<T>>; reset(): void; clearAll(): void }
usePagination(options: { total: Ref<number>; pageSize? /*24*/; initialPage? /*1*/
resetOnTotalChange?: boolean /*true*/ })
=> { page; pageSize; totalPages; setPage(n); setPageSize(n); reset() }
useListShortcuts<T extends { id: string }>(options: {
items: Ref<T[]>; onOpen: (item: T) => void
searchRef?: Ref<{ focus(): void } | null>; activeIndex?: Ref<number>
}) => { activeIndex: Ref<number> } // '/' 聚焦搜索,j/k/↑/↓ 移动,Enter 打开,Esc 取消
isTypingTarget(e: KeyboardEvent): boolean // utils/keyboard.ts,输入焦点在表单元素时 trueuseListFilter 是大厅体验的中枢。它同时维护两份状态是有意为之:filters 保证搜索框逐字符即时回显(受控输入不被防抖卡顿),debouncedFilters 保证过滤计算与 URL 回写以 300ms 节流执行(79 条全量过滤虽便宜,但 URL 的 replaceState 不值得逐键触发)。resolveInitial 在组合式函数首次执行时同步读 window.location.search,因此深链恢复发生在组件挂载前,不会出现「先显示全部再跳到筛选结果」的闪烁。reset 与 clearAll 的差别在于后者会额外清空搜索词——对应大厅空态里「清除筛选」按钮的语义。
usePagination 的所有页码变更都经 setPage 的 clamp(1 到 totalPages),组件层因此永远拿不到越界页码;resetOnTotalChange 在本项目被显式关掉(GamesPage 传 false),因为「筛选变化重置页码」由页面自己的 watch 负责,避免双重重置时序耦合。useListShortcuts 则是键盘可访问性的实现:它只在 isTypingTarget 为假(焦点不在输入类元素)时拦截按键,activeIndex 通过 props 透传给 GameCard 渲染高亮,滚动跟随用 data-card-index 属性反查 DOM 后 scrollIntoView,全程不依赖组件 ref 链。
src/router/index.ts)export function rememberLobbyScroll(y: number): void // 供大厅 onBeforeRouteLeave 记录滚动位置
export default router // beforeEach 设标题 / scrollBehavior 恢复 / onError 自愈读写一律 try/catch 静默降级(隐私模式、配额满、SSR 环境都不抛错)。核心键如下(-best 家族、复合键与动态键的完整清单见《04-数据模型》2.1 与《15-接口全量参考》第 4 节):
| 键 | 写入方 | 值格式 | 语义 |
|---|---|---|---|
h5g:<游戏id>-best | 各游戏经 readHighScore/writeHighScore | 十进制数字字符串 | 该游戏最高分(「高分更大」语义;计时/步数类低分更优的游戏自行比较后写入) |
h5g:gobang-score | gobang.vue(五子棋) | JSON { w, l, d } | 五子棋人机模式累计战绩:胜/负/平(仅人机模式结算时写入;已回源核实,见 04 篇 2.1) |
game-audio-muted | useGameAudio | '1' / '0' | 全局音效静音开关 |
h5g:bgm-muted | useGameBgm | '1' / '0' | 背景音开关(enabled = 值 !== '1') |
readHighScore(key): number——值缺失/不可解析返回 0;writeHighScore(key, value): void——写入字符串形式。60/79 款游戏使用了这对工具(其余如双人对弈类无最高分概念)。
vite.config.ts 对开发者的承诺)| 配置项 | 值 | 对接手者的意义 |
|---|---|---|
resolve.alias['@'] | src/ | 所有跨目录 import 写 @/… |
| dev server | 127.0.0.1:5556 | 与 pnpm dev 的 --port 5556 一致 |
manualChunks | vendor(vue 全家桶)/ vueuse-vendor / pixi-vendor | 新增第三方重依赖时考虑是否需要独立 chunk;游戏内 import 'pixi.js' 会自动落入 pixi-vendor,无需游戏关心 |
chunkFileNames | vendor 前缀区分、其余 assets/[name]-[hash].js | 每款游戏 chunk 即 assets/<id>-<hash>.js,可用于排查加载问题 |
build.target | esnext + minify esbuild | 面向现代浏览器,不做 legacy 降级 |
综上,接入一款新游戏需要触碰的接口面只有三处:config/games.ts 追加一条 LocalGame、新建 games/<id>.vue、(如需最高分)约定 h5g:<id>-best 键——其余接口全部是「可选用」的既有工具。
从维护者视角对这套接口做一次坦率评审。优点有三:接口面极小且高度稳定,两份契约(元数据 + 组件规范)五年内大概率不需要变更;错误被分层兜住(构建期测试、运行期捕获、路由级自愈),接口误用很少演变成站点级故障;工具函数全部无状态或单例化,没有隐式调用顺序要求,即取即用。已知风险有四,接手者应有预期:其一,LocalGame 的 engine/library 是死字段,真正消费它们的主项目类型已不在本仓库,未来清理时应连同详情页遗留注释一起处理;其二,游戏层的 JS SFC 不受 vue-tsc 与大部分 TS 规则约束,接口违约(比如忘记清理监听器)只能靠 review 与运行时现象发现;其三,GameShell 的插槽语义靠惯例维系(gs-chip、gs-overlay、gs-card 等类名约定),没有样式层面的强制,新增游戏若自创类名会出现视觉漂移;其四,存储键存在 8 处历史短键偏差(04 篇 2.3),任何「按 id 拼键」的工具函数都要意识到 id 与键名不是恒等映射。这些风险都有明确的边界与兜底,不构成阻塞,但在做批量重构前应通读本篇与 04 篇。