项目:html5-games | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇是可复制粘贴的代码示例集:把散落在 79 款游戏与框架层里的数据契约,浓缩为四组拿来即用的模板——元数据登记对象、GameShell 插槽用法、存档读写调用、游戏组件标准骨架。契约的完整字段表与语义分析见《03-接口设计》与《04-数据模型》,本篇不重复字段论述,只保证每段示例与当前源码逐字对齐(字段名、键名、默认值均来自源码),并在示例后给出「这段示例的硬约束」注记。所有示例均为 TypeScript/Vue 代码与 localStorage 键约定——本项目没有 HTTP 接口(无后端,见《00-项目总览》第 1 节),「接口」一词在本项目语境下永远指模块导出、组件契约与存储键三类。
先说这套示例集的使用方法与三个通则,它们比任何单段代码都重要。通则一:示例的权威度以源码为准,以测试为界——每段示例都标注了源文件路径与行号,若未来源码与本篇漂移(例如 E-02 的死字段被清理后 engine/library 从接口消失),以 games.test.ts 的断言为准绳回头修订本篇,而不是反向照抄文档。通则二:示例之间不是并列关系而是同心圆——第 1 节的登记对象决定「游戏被发现」,第 2 节的插槽决定「游戏被装扮」,第 4 节的骨架把两者组装成「游戏本身」,第 3 节的存档决定「游戏留下什么」;接手者第一次写新游戏时应按 4→2→1→3 的顺序消费(先骨架立起来,再填插槽,再登记,最后补存档),而老手做增量修改时按索引直取。通则三:每个示例都带反例边界——本项目契约的特点是「宽进严出」:JS 层可以写出违反契约的代码且编译器不报错(gobang 越界音效即实证),所以每节的硬约束注记不是风格建议,而是「违反后没有机制拦你」的悬崖护栏,务必当作编译错误来对待。
新增一款游戏的登记写法(加入 src/config/games.ts 的 localGames 数组)。示例取自真实条目 connect-pipe-game(sortOrder 0)与 gobang(sortOrder 2)的原文,字段顺序与接口定义(games.ts 第 16-31 行)一致:
import type { LocalGame } from '@/config/games' // 实际登记处就在该文件内,此 import 仅为标注类型
// ── 标准条目:全部 10 个字段 ──
const demoEntry: LocalGame = {
id: 'bubble-pop-game', // ① 全局唯一;必须匹配 /^[a-z0-9-]+$/(测试强制)
// 同时决定:组件文件名 src/games/<id>.vue、路由 /game/<id>、
// 推荐存档键 h5g:<id>-best —— 定稿前想清楚
category: 'casual', // ② 四选一:'puzzle' | 'board' | 'action' | 'casual'
engine: 'game', // ③ ⛔ 死字段:固定照抄 'game'。注释声称「供详情页 VisualDemo
// 类型消费」,但该类型已不存在,全仓库无运行时读取
// (《13-技术债务与演进建议》E-02 裁定待清理)
library: 'showcase', // ④ ⛔ 死字段:同上,固定照抄 'showcase'
title: '泡泡消除', // ⑤ 展示名;同时是程序化 BGM 的作曲种子与封面标题文字
description: '发射泡泡与同色球消融,三个同色即爆,清空全场过关。', // ⑥ 参与大厅搜索匹配
tags: ['GitHub 示例'], // ⑦ ≥1 项(测试强制非空);参与大厅搜索匹配
group: 'GitHub 示例集成', // ⑧ 当前取值 5 种:核心精选 / GitHub 示例集成 /
// PixiJS 精选 / 专题灵感 / Canvas Test 整理;
// '核心精选' 有特判(首页横带 + 卡片角标,要求 ≥4 款)
difficulty: '', // ⑨ ⛔ 死字段:79 款全部为 '',UI 无任何读取
// (《13-技术债务与演进建议》E-03 裁定:无产品需求则删)
sortOrder: 80, // ⑩ 全表唯一(测试强制无重复);当前最大 79,新游戏接 80。
// 乱插数值会把新游戏顶进首页第一屏(默认排序按其升序)
}配套的派生索引不需要动手——localGamesMap(id → 元数据,详情页校验用,games.ts 第 1096 行)与 localGameCategoryMap(分类色查询,第 76 行、紧随分类定义构建)在 games.ts 内随数组自动生成,localGameFileName(id)(第 1098-1100 行)同样已存在但当前无运行时消费(与 ③④ 同批的死代码,见 E-02)。
这段示例的硬约束:三个死字段(③④⑨)当前的正确姿势是照抄——不要自作主张改成 'bubble' 之类的「有意义」值(《11-扩展开发指南》场景四 明确告诫),它们是待清理的历史仪式,清理将按 E-02/E-03 的方案一次到位;在此之前的任何「创造性填写」都会让未来的一次性清理脚本多一个分支。登记完成后必须跑 pnpm test:games.test.ts 断言总数(79,加一款变 80 后需同步三处文案——README、index.html meta、package.json description,见《06-部署运行》第 5 节第 3 步)、id 格式、sortOrder 唯一、文件一一对应等不变量(8 个用例,拆解见《09-测试与质量保障》2.1)。
关于字段填写还有三处「不在断言里、但错了会疼」的语义细节值得单独说透。id 的选择是一次性的:它同时锁定文件名、路由与推荐存档键,上线后再改 id 意味着组件文件、config、(若有)存档键三处联动外加老玩家路由书签失效——本项目对此没有任何迁移机制,所以「定稿前想清楚」不是客套。title 是音频与封面的种子:程序化 BGM 以 title 为种子(bgm.start(props.title))、封面标题直接绘制 title,改 title 会改变 BGM 曲子(同一 id 曲子变调)并使封面缓存重建——功能上都自愈,但玩家感知层面「曲子换了」,所以 title 属于半稳定字段,调整前想清楚。description 与 tags 是搜索面:大厅关键词匹配只查 title/description/tags 三字段(《05-核心流程》流程二),把玩家会用到的同义词(如「贪吃蛇」「蛇」)放进 description 或 tags,比事后改文案更能提升可发现性;测试要求 tags 非空但不管质量,质量靠自觉。
五插槽 + 两事件 + 八 props 的完整消费写法,逐字取自 src/games/snake.vue 第 1-74 行(本项目最标准的插槽范本,79 款游戏均可对照):
<template>
<GameShell
title="贪吃蛇"
:ratio="1"
:show-restart="true"
:show-pause="state !== 'idle' && state !== 'over'"
:paused="state === 'paused'"
accent="#34d399"
instructions="方向键 / WASD 或手指滑动转向 · 空格 开始·暂停·重来"
@restart="reset"
@toggle-pause="togglePause"
>
<!-- 插槽一 hud:顶栏中部数据区,惯例用 gs-chip -->
<template #hud>
<span class="gs-chip">分数 <b>{{ score }}</b></span>
<span class="gs-chip">最高 <b>{{ best }}</b></span>
<span class="gs-chip gs-chip--accent">等级 <b>{{ level }}</b></span>
</template>
<!-- 插槽二(默认):舞台内容,canvas 或 DOM 均可 -->
<canvas
ref="canvasRef"
class="game-canvas"
tabindex="0"
@touchstart.passive="onTouchStart"
@touchend.prevent="onTouchEnd"
/>
<!-- 插槽三 info:右侧玩法说明栏(<1024px 折叠为手风琴),惯例用 gs-info -->
<template #info>
<div class="gs-info">
<h3>玩法</h3>
<p>吃普通食物 +10 分;每吃 5 个升级一次,速度更快。随机出现限时金果,吃到 +50。</p>
<h3>操作</h3>
<ul>
<li><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd>、<kbd>WASD</kbd> 或手指滑动转向</li>
<li><kbd>空格</kbd> 开始 / 暂停 / 重新开始</li>
</ul>
<h3>提示</h3>
<p>撞墙或咬到自己即结束。贴着边缘绕圈更稳。</p>
</div>
</template>
<!-- 插槽四 overlay:舞台浮层,按状态切换;惯例 gs-overlay / gs-card / gs-btn -->
<template #overlay>
<div v-if="state === 'idle'" class="gs-overlay">
<div class="gs-card">
<div class="gs-emoji">🐍</div>
<h2 class="gs-title">贪吃蛇</h2>
<p class="gs-sub">经典街机,越吃越快</p>
<button class="gs-btn" type="button" @click="start">开始游戏</button>
</div>
</div>
<div v-else-if="state === 'paused'" class="gs-overlay">
<div class="gs-card">
<h2 class="gs-title">已暂停</h2>
<button class="gs-btn" type="button" @click="resume">继续</button>
</div>
</div>
<div v-else-if="state === 'over'" class="gs-overlay">
<div class="gs-card">
<h2 class="gs-title gs-title--lose">游戏结束</h2>
<p class="gs-score">本局 <b>{{ score }}</b> 分</p>
<p class="gs-sub">{{ newRecord ? '🎉 新纪录!' : `最高纪录 ${best} 分` }}</p>
<button class="gs-btn" type="button" @click="reset">再来一局</button>
</div>
</div>
</template>
<!-- 插槽五 actions:顶栏自定义按钮,位于 BGM/静音/暂停/重开之前(本游戏未用) -->
</GameShell>
</template>这段示例的硬约束:其一,工具类名(gs-chip/gs-info/gs-overlay/gs-card/gs-btn/gs-title--lose 等)是 GameShell.vue 第 542-729 行非 scoped 样式定义的隐式契约,自创类名会出现视觉漂移(《03-接口设计》第 7 节风险三),类名清单见《03-接口设计》第 3 节。其二,paused 是受控 prop:暂停的事实源是游戏的 state ref,外壳据此渲染按钮态并联动 BGM 压低——不要在外壳外再造一个暂停按钮 unless 走 actions 插槽。其三,canvas 的 tabindex="0" 与 touch-action: none(样式见第 4 节骨架)不可省略,否则方向键滚动页面、触屏滑动触发浏览器手势(《11-扩展开发指南》坑位 1/2)。其四,ratio 按玩法定:竖版滑块 0.56、横版射击 16/9 或 1.571、正方形 1——它经 CSS 变量 --gratio 驱动舞台最大化(机制见《18-代码走读实录》第 2 节)。
插槽用法的两个进阶问题在此一并回答。哪些插槽可以不填?五个插槽全部可选(除默认舞台外),外壳对缺省插槽的处理是「容器也不渲染」:不填 info 时右侧说明栏整体消失(hasInfo 的 !!slots.info 判断)、instructions 为空串时底部提示不渲染——所以极简游戏(如双人对弈类)可以只写默认插槽 + overlay,页面依然体面;反过来,hud 不填则顶栏只剩标题与控制按钮,视觉重心全在舞台。actions 插槽什么时候用?它位于 BGM/静音/暂停/重开四个内置按钮之前,用于游戏专属的全局动作——典型如双人游戏的「换边」、卡牌游戏的「重新发牌」、贪吃蛇这类把暂停做成内联语义的游戏则完全不用它。自定义按钮的样式直接复用 gs-action 类(36px 图标按钮,触屏下自动放大到 44px),与内置按钮视觉无缝;这是五插槽里唯一「不填也看不出缺」的插槽,用与不用都不影响契约履行。
60 款游戏的标准写法,取自 src/games/snake.vue(第 86、95、165-173 行):
import { readHighScore, writeHighScore } from '@/utils/highScore'
const BEST_KEY = 'h5g:snake-best' // 惯例:h5g:<完整id>-best;禁止模仿历史短键(见 3.5)
const best = ref(readHighScore(BEST_KEY)) // 挂载时读:缺失/非法/异常一律得 0
function gameOver() {
state.value = 'over'
if (score.value > best.value) { // 严格大于:追平不写入(全站惯例)
best.value = score.value
newRecord.value = true
writeHighScore(BEST_KEY, best.value) // 内部 String(value) 化,try/catch 静默
}
play('gameover')
}src/utils/highScore.ts 的两个函数全文即契约——readHighScore(key) 是 Number(localStorage.getItem(key)) || 0 的 try/catch 包装;writeHighScore(key, value) 是 setItem(key, String(value)) 的静默包装。「localStorage 不可用时仅内存生效」的语义由这层工具统一保证。
工具只适配「高分更大」,低分更优游戏自行比较后调用同一对函数。取自 src/games/video-puzzle.vue 第 285-293 行:
function win() {
status.value = 'won'
stopTimer()
if (!bestMoves.value || moves.value < bestMoves.value) { // 反向比较:更少步数才写
bestMoves.value = moves.value
newRecord.value = true
writeHighScore(BEST_KEY, moves.value) // 键值里存的是「最优步数」
}
play('win')
}注意键名中的 best 不保证方向(h5g:video-puzzle-best 存的是步数)——读代码时以各游戏比较逻辑为准(《04-数据模型》2.2 的既成包袱)。
复合数据不经过 highScore 工具,游戏内联读写。两段原文:src/games/gobang.vue 第 115-132 行(胜负战绩)与 src/games/wordle-game.vue 第 51-60 行的统计(键 h5g:wordle-game-stats,值 { played, won, streak, maxStreak, guesses: number[6] }):
// gobang.vue —— 五子棋战绩:h5g:gobang-score 存 { w, l, d }
const SCORE_KEY = 'h5g:gobang-score'
function readRec() {
try {
const raw = JSON.parse(localStorage.getItem(SCORE_KEY) || '{}')
return { w: raw.w || 0, l: raw.l || 0, d: raw.d || 0 } // 逐字段缺省:改坏/少字段/未来加字段都安全
} catch {
return { w: 0, l: 0, d: 0 }
}
}
function saveRec() {
try {
localStorage.setItem(SCORE_KEY, JSON.stringify({ w: wins.value, l: losses.value, d: draws.value }))
} catch {
/* ignore */
}
}
// sokoban-game.vue —— 映射键:h5g:sokoban-game-moves 存 Record<关卡索引, 最少步数>
const raw = JSON.parse(localStorage.getItem('h5g:sokoban-game-moves') || '{}')
// 使用前对解析结果做 typeof 防御后再当对象用(《11-扩展开发指南》场景三范式 B)多模式分键的参数化键范式见 src/games/memory-match-game.vue 第 40-43 行:bestKey() 返回 `h5g:memory-match-best-${size.value}`(4×4 与 4×6 各一个键)——键空间必须封闭枚举,不得把用户可控字符串拼进键名。
一律 h5g: 前缀;读写全 try/catch(游戏逻辑绝不因存储失败中断);新游戏键名必须用完整 id(h5g:<id>-best),历史短键不迁移也不模仿;复合值若未来会演进结构,顶层带版本字段(如 { v: 1, ... })留迁移钩子;真正的「中断续玩」进度存档属产品边界变更,用新键 h5g:<id>-save 序列化整局快照,勿复用 best 键(《04-数据模型》第 3 节、《11-扩展开发指南》场景三)。
四个范式的适用边界用一句话各自收束,选型时对照即可。范式一(工具函数)适配「单一数值 + 单向比较」的最高分场景,是 60/79 款的默认选择;范式二(低分更优)只是范式一的比较方向翻转,键与工具完全复用,但要接受「best 键值方向由游戏自定」的历史语义(《04-数据模型》2.2);范式三(JSON 结构键)适配「多维度统计/战绩」,核心纪律是逐字段缺省读——它让玩家手工改坏、旧版本少字段、未来加字段三种情况都安全降级,省掉任何迁移代码;范式四(参数化键)适配「同游戏多模式分档」,唯一额外要求是键空间封闭(枚举值拼键),绝不把用户可控字符串拼进键名。四者共用的底层语义是「localStorage 属于玩家领地」——《08-安全与权限设计》第 4 节的信任边界分析解释了为什么项目从不校验、只做类型兜底。
8 款游戏的存档键短于 id(如 snake 的键是 h5g:snake-best 而非 h5g:snake-game-best,完整清单见《04-数据模型》2.3),因此「从 id 推导键名」必须经豁免表。以下为《11-扩展开发指南》场景五与《13-技术债务与演进建议》E-04 规定的 bestKeyOf 双读方案参考实现(当前源码中尚不存在该函数,此为落地的标准写法):
// src/utils/recordKeys.ts(建议落位;与 highScore.ts 同目录)
/** 历史短键豁免表:id → 实际存档键(8 项,源自 04 篇 2.3 的 grep 清单) */
const SHORT_KEY_EXEMPTS = new Map<string, string>([
['snake', 'h5g:snake-best'],
['fifteen-puzzle', 'h5g:fifteen-puzzle-best'],
['shot-ball-physics', 'h5g:shot-ball-physics-best'],
['special-look-def-color', 'h5g:special-look-def-color-best'],
['special-man-down100', 'h5g:special-man-down100-best'],
['whack-a-mole-game', 'h5g:whack-a-mole-best'], // 唯一一处「键比 id 还短」的真漂移
['video-puzzle', 'h5g:video-puzzle-best'],
['pixi-invaders', 'h5g:pixi-invaders-best'],
])
/** id → 最高分键:规范 id 直出,历史短键查表。
* 注意 memory-match-game 的参数化键不适用本函数(按模式分键,需单独列举)。 */
export function bestKeyOf(id: string): string {
return SHORT_KEY_EXEMPTS.get(id) ?? `h5g:${id}-best`
}
/** 双读:兼容「先写规范键、旧数据在短键」的过渡期(本项目当前数据只在单侧,
* 双读是给未来做迁移缓冲时的保险;两侧都 miss 返回 0) */
export function readBestDual(id: string): number {
const canonical = readHighScore(`h5g:${id}-best`)
if (canonical > 0) return canonical
const legacy = SHORT_KEY_EXEMPTS.get(id)
return legacy ? readHighScore(legacy) : 0
}配套纪律:禁止批量改名旧键(老玩家纪录感知性清零,《04-数据模型》2.3 已裁定不迁移);豁免表应配扫描测试(遍历 localGamesMap 断言每个 id 的 bestKeyOf 结果与磁盘上游戏内常量一致),防止新增游戏误入短键(《09-测试与质量保障》第 4 节 P0 的键名契约测试)。
把前四节串成一个完整最小游戏骨架(JS SFC 范式,取自 snake.vue 的真实结构,逻辑压缩但每个钩子与清理点保真):
<template>
<GameShell title="贪吃蛇" :ratio="1" :show-pause="state === 'running' || state === 'paused'"
:paused="state === 'paused'" accent="#34d399" instructions="方向键转向 · 空格 开始/暂停"
@restart="reset" @toggle-pause="togglePause">
<template #hud>
<span class="gs-chip">分数 <b>{{ score }}</b></span>
<span class="gs-chip">最高 <b>{{ best }}</b></span>
</template>
<canvas ref="canvasRef" class="game-canvas" tabindex="0"
@touchstart.passive="onTouchStart" @touchend.prevent="onTouchEnd" />
<template #overlay> <!-- idle/paused/over 三态浮层,完整版见第 2 节 --> </template>
</GameShell>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import GameShell from '@/components/games/GameShell.vue'
import { useCanvasStage } from '@/composables/useCanvas'
import { useGameAudio } from '@/composables/useGameAudio'
import { readHighScore, writeHighScore } from '@/utils/highScore'
const { play, unlock } = useGameAudio()
const BEST_KEY = 'h5g:snake-best'
// ① 基建:canvas 适配 + rAF 循环 + 事件清理三合一
const { canvasRef, ctxRef, size, startLoop, stopLoop, registerTargetEvent } =
useCanvasStage({ pauseWhenHidden: true }) // 切后台跳帧防大步进
// ② 状态机(响应式 UI 镜像)
const state = ref('idle') // idle | running | paused | over
const score = ref(0)
const best = ref(readHighScore(BEST_KEY))
// ③ 世界数据(非响应式:高频可变量放普通变量,避免每帧触发依赖收集)
let snake = []
let dir = { x: 1, y: 0 }
let nextDir = { x: 1, y: 0 }
let acc = 0 // 时间累加器:渲染帧率与游戏节拍解耦
let last = 0
function step() { /* 逻辑 tick:吃食/碰撞/结算 */ }
function update(dt) {
if (state.value !== 'running') return
acc += dt
while (acc >= 150) { acc -= 150; step() } // 150ms 一拍,speedMs() 随等级加快
}
function draw() {
const ctx = ctxRef.value
if (!ctx) return
/* 用 size.width/height(CSS 逻辑坐标)全量重绘 */
}
function loop(ts) {
const dt = last ? ts - last : 0
last = ts
update(Math.min(dt, 100)) // ④ dt 必须 clamp:切后台回来一帧巨大 dt 会穿模
draw()
}
function reset() {
score.value = 0; acc = 0; last = 0
/* placeSnake(); spawnFood(); */
state.value = 'running'
canvasRef.value?.focus() // ⑤ canvas 可聚焦:方向键不再滚动页面
play('start')
}
function togglePause() {
if (state.value === 'running') { state.value = 'paused'; last = 0 }
else if (state.value === 'paused') { state.value = 'running'; last = 0 }
}
onMounted(() => {
registerTargetEvent(window, 'keydown', onKey) // ⑥ 注册即配对清理,卸载自动释放
startLoop(loop)
})
onBeforeUnmount(() => stopLoop())
// ⑦ 静音偏好变化时解锁音频(先静音后取消静音的场景)
import { watch } from 'vue'
watch(useGameAudio().muted, (m) => { if (!m) unlock() })
</script>
<style scoped>
.game-canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
touch-action: none; /* ⑧ 触屏滑动不被浏览器当滚动手势 */
}
</style>(注:末段 watch 的 import 位置在真实源码中位于 script 顶部第 77 行,此处为教学聚合;真实写法以 src/games/snake.vue 为准。)
这段骨架的硬约束清单(与《11-扩展开发指南》表 1 十条坑位一一对应,此处列代码层最易漏的五条):① 优先用 useCanvasStage/useAnimationLoop 而不是自管 rAF——前者把 DPR 适配、ResizeObserver、事件清理责任收走(当前渗透率仅 4 款,见《13-技术债务与演进建议》E-09,新游戏应向基建收敛而非延续自管);② 所有自建的 setInterval/window 监听器必须在 onBeforeUnmount 成对释放(基建不覆盖组件体直创的定时器,tetris 的 holdTimer 即手工清理实例);③ dt clamp 到 100ms 量级是生存性代码,不是优化;④ 音效只能经 play(<GameSfx>),越界音效名在 JS SFC 里静默无声(gobang 事故,见《13-技术债务与演进建议》E-06);⑤ 新游戏若用 TS,把 <script setup> 写成 <script setup lang="ts"> 即进入框架层的 strict 检查(《16-决策记录ADR》ADR-003 的增量策略:新游戏必须 TS)。
最后把骨架背后的生命周期时序串成一条线,供排障时对照。挂载序:onMounted 里依次做三件事——注册 window 级键盘(经 registerTargetEvent,注册即入清理栈)、startLoop 启动 rAF、(如需)读最高分填充 HUD;此时游戏停在 idle 态,overlay 展示开始卡,循环已在跑但 update 被状态守卫短路,绘制函数仍执行(静态棋盘可见)。启动序:用户点击「开始游戏」→ start() 先 unlock()(手势内创建 AudioContext,GameShell 的手势监听同时起 BGM)→ reset() 归零状态并置 running → focusCanvas() 抢键盘焦点。运行序:每帧 loop(ts) 算 dt(clamp)→ update 按累加器节拍推进逻辑 → draw 全量重绘。暂停序:外壳 togglePause 事件或空格键置 paused,last 必须归零——否则恢复瞬间会吃到「暂停期间流逝的时间」作为一帧巨大 dt(clamp 兜住了后果,但归零才是正解,tetris 的双累加器同款处理见《07-模块详解》6.1)。结算序:死亡/胜利时置 over → 比较并写最高分 → play('gameover'|'win') → overlay 切结算卡。卸载序:onBeforeUnmount 里 stopLoop(或自管者 cancelAnimationFrame),监听器由清理栈统一释放,pixi 游戏额外 app.destroy(true)。任何一款游戏的「行为怪异」都可以拿这条线逐站核对——七站里最常见的故障站是暂停序(忘归零 last)与卸载序(漏 pixi destroy)。骨架的逐行机制解读(状态机三模式、世界与镜像分离、输入缓冲)见《04-数据模型》第 3 节;跑通后的验证步骤见《06-部署运行》第 5 节第 4 步。