项目:html5-games | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
前七篇从架构、页面、接口、流程等宏观视角完成了对项目的覆盖,本篇下沉一层,对框架层的每一个模块做逐文件深读:注册表、统一外壳、封面渲染管线、两个音频 composable、canvas/循环基建与存档工具。每个模块给出「职责一句话 → 关键文件与函数清单 → 机制走读」三段式内容;篇末按四分类各挑一款代表游戏做实现走读(益智选俄罗斯方块、棋牌选五子棋、动作选飞机大战、休闲选合成大西瓜),代表选择的理由与已深拆过的贪吃蛇(见《02-页面设计》第 6 节)、2048(同篇)、pixi-pong(同篇)错开,避免重复展开。宏观数据流、加载时序与 GameShell 插槽契约本文不再复述,分别见《01-系统架构》第 2-3 节、《05-核心流程》第 1 节与《03-接口设计》第 3 节。
职责一句话:用一句 import.meta.glob 把「文件存在于 src/games/ 目录」这件事直接变成「游戏可被懒加载」,并提供悬停预取入口。
关键文件与函数清单
| 文件/导出 | 行数规模 | 职责 |
|---|---|---|
src/games/registry.ts gameModules | 20 行文件的核心 4 行 | glob 映射:key 为 /src/games/<id>.vue,value 为动态 import loader |
src/games/registry.ts prefetchGame(id) | 15-20 行 | 预取游戏 chunk 与 GameDetailPage.vue chunk,双 .catch(() => {}) 静默 |
src/config/games.ts localGameFileName(id) | 1098-1100 行 | 返回 ${id}.vue,把「id → 文件名」约定写成函数(当前仅文档语义,运行时未消费) |
机制走读。registry 是全项目最短却最关键的模块,值得逐行体会它的三个设计点。第一,glob 路径写作 @/games/*.vue 而 GameDetailPage 查表时用 /src/games/${id}.vue 前缀——import.meta.glob 返回的 key 统一是以 /src 开头的绝对路径形式,别名写法只影响 Vite 的解析入口不影响产物 key,这两处字符串必须严格一致才能查到 loader(测试 src/config/__tests__/games.test.ts 的文件一一对应断言用的第二个 glob 则写成 /src/games/*.vue,同样依赖该 key 格式)。第二,prefetchGame 同时预取详情页 chunk(void import('@/pages/GameDetailPage.vue').catch(() => {})),因为应用内跳转需要先下载页面骨架再由页面下载游戏 chunk,两次串行下载被 hover 期间的一次预取拉平成「点击即达」;void 前缀显式丢弃 Promise 表明这是 fire-and-forget 语义。第三,预取失败被刻意吞掉而不是重试:预取是纯优化,任何失败都应该留给正式打开路径上的 GameDetailPage 错误兜底(见《01-系统架构》2.2),在预取层做重试只会浪费带宽并放大故障。修改该模块属于《05-核心流程》第 6 节定义的「全站级手术」,需同步跑测试与手工回归。
职责一句话:把 79 款游戏共有的「机台感页面骨架」——氛围背景、顶栏(标题 + HUD + 控制按钮)、按比例最大化的舞台、可折叠玩法说明栏、底部操作提示、浮层容器——做成一个插槽式模板,让游戏只写玩法。
关键文件与函数清单
| 成员 | 位置 | 职责 |
|---|---|---|
props title/ratio/instructions/showRestart/showPause/paused/accent/showInfo | 20-44 行(withDefaults) | 舞台比例、控制按钮显隐、强调色等 8 个可配置项,默认值集中在 withDefaults |
emits restart/togglePause | 46-49 行 | 唯二对上事件,游戏以此为界与外壳通信 |
onFirstGesture() | 57-63 行 | 首个 pointerdown/keydown:unlock() 音频 + bgm.start(props.title) + applyConditions,之后自我注销监听 |
toggleBgm() | 75-81 行 | BGM 开关;开启分支利用「点击本身是手势」直接 start,无需再等 unlock |
accentStyle computed | 84-87 行 | 注入 --gaccent 与 --gratio 两个 CSS 变量,是舞台布局与配色的唯一注入点 |
infoOpen ref | 90 行 | 窄屏(<1024px)info 栏手风琴开关 |
全局工具类 gs-chip/gs-info/gs-overlay/gs-card/gs-btn/gs-action 等 | 542-729 行非 scoped style | 供游戏插槽内容复用的类名约定(以 .gshell 前缀限定作用域) |
机制走读。GameShell 有四个值得细看的实现决策。其一,舞台最大化用容器查询单位而非 JS 计算:.gshell__stagewrap 声明 container-type: size(335 行),.gshell__stage 的宽高写成 min(100cqw, calc(100cqh * var(--gratio))) 与其对偶式(336-337 行),纯 CSS 完成「在保持宽高比的前提下最大化」——任何容器尺寸变化(旋转屏幕、折叠 info 栏)浏览器自动重排,不需要 ResizeObserver 与 JS 布局循环。其二,overlay 挂在 stagewrap 而非 stage 内(160-167 行注释):舞台按 ratio 缩放后对横版游戏可能很扁,浮层若放在有 overflow: hidden 的 stage 内会被裁切,放在 wrap 层则始终居中于可视区域——这是从实际横版游戏中暴露的布局坑。其三,音频联动的责任边界:GameShell 只负责「首个手势解锁 + 以标题为种子起 BGM + 把 muted/paused 同步给 BGM」(73 行 watch),音效播放完全由游戏自己调用 play(),外壳不越权代播;手势监听用 onFirstGesture 自我移除的方式实现「只触发一次」,避免每次按键都走一遍 unlock 分支。其四,工具类的第三层样式:scoped 样式管外壳自身,非 scoped 的 gshell .gs-* 样式(542 行起)管游戏插槽内容,既让 79 款游戏的 HUD chip、结算卡视觉统一,又不用为每个游戏引公共 CSS 文件;代价是类名拼写成了隐式契约(见《03-接口设计》第 7 节风险三)。响应式行为(<1024px 的 info 折叠、HUD 横滑、触屏 44px 按钮)在 435-539 行以三个媒体查询收口。
职责一句话:为零素材项目程序化生成 79 张风格统一的封面——离屏 canvas 绘制 → worker 线程 webp 编码 → objectURL → 模块级缓存,同 id 全局只画一次。
关键文件与函数清单
| 函数/变量 | 文件与位置 | 职责 |
|---|---|---|
coverCache: Map<string,string> | gameCoverRender.ts 23 行 | 模块级缓存:id → objectURL;注释明确警告「写在 <script setup> 里的 const 是每实例的,缓存会失效」 |
pendingRenders: Map | 25-28 行 | 同 id 并发渲染去重:网格卡与精选卡同时挂载同一款时共享一次渲染 |
cacheVersion / coverVersion | 30-31 行 | 字体翻转清缓存时递增,驱动已挂载的 GameCover 实例重绘(GameCover.vue 33 行 watch) |
getEncodeWorker() | 37-59 行 | 惰性创建 worker;onerror 置 workerBroken 并把在途渲染全部转同步兜底 |
interReady() + 字体竞态防护块 | 61-83 行 | 仅当 Inter 字体「不可用→可用」翻转时清缓存重建一次;字体源不可达(无注册字体面)时 check() 恒 true、状态永不翻转,缓存永不重建 |
hash(str) | 85-92 行 | FNV-1a 32 位哈希,决定封面几何符号、辉光位置(与 useGameBgm.ts 的 hashKey 同源同实现) |
drawGlyph(ctx, kind, ...) | 110-243 行 | 8 种几何符号库(同心圆/三角/菱形/准星/九宫格/波浪/闪电/四叶),kind % 8 选取 |
drawCover(ctx, game) | 245-327 行 | 八步绘制:暗色基底→分类色双辉光→细点阵→主符号→分类 pill→底部压暗→自适应字号标题→内描边 |
renderCoverAsync(game, onDone) | 341-370 行 | 主入口:查缓存→并入 pending→主线程绘制(约 2ms)→createImageBitmap 转移到 worker |
settleRender / settleSync | 372-394 行 | worker 回包落缓存并回调;同步兜底重绘 + toDataURL('image/webp', 0.9) |
worker onmessage | coverEncode.worker.ts 11-24 行 | OffscreenCanvas 上 convertToBlob({ type: 'image/webp', quality: 0.9 });编码失败静默(主线程兜底逻辑覆盖不了该 id,属已知边界) |
GameCover.vue load() | 19-33 行 | 组件侧薄封装:先查 getCachedCover,miss 则 renderCoverAsync,跟随 coverVersion 重绘 |
机制走读。这条管线最值得学习的是它对「性能、正确性、环境差异」三件事的处理。性能上,绘制(快,约 2ms)与编码(慢,软件渲染下 45-65ms/张)被拆到两个线程:主线程 drawCover 后用 createImageBitmap 得到可转移的位图,postMessage(..., [bitmap]) 零拷贝移交,worker 在 OffscreenCanvas 上 convertToBlob 编码后把 Blob 传回,主线程 URL.createObjectURL 收尾;注释记载的背景是「一次大厅挂载 28 张即约 1.7 秒主线程长任务,页面切换卡顿元凶」,worker 化后主线程零阻塞。正确性上,缓存是模块级单例 + 在途去重双 Map,覆盖了「同 id 多组件实例并发请求」这一最容易写错的场景;缓存值是 objectURL,页面关闭随文档回收,无需手动 revoke。环境差异上,worker 不可用(构造抛异常或运行中 onerror)走 workerBroken 单向降级到同步编码;字体竞态只认「真实可用性翻转」,避免了旧实现「三重信号一触发就清缓存」在字体被墙环境下的无限重建。封面视觉是确定性的:同一 id 永远得到同一符号、同一辉光位置(rand(seed) 与 rand(seed >> 3)),所以缓存失效重建也不会闪变。分类色从 localGameCategoryMap 读取,游戏自身不参与配色(见《03-接口设计》1.2)。
职责一句话:全部音效与背景音用 WebAudio 振荡器实时合成,零音频素材;两者共享单例 AudioContext,静音状态模块级单例并持久化。
关键文件与函数清单
| 函数 | 文件与位置 | 职责 |
|---|---|---|
ensureCtx() / 导出别名 ensureAudioContext() | useGameAudio.ts 42-56 行 | 单例 AudioContext 创建与 resume(webkitAudioContext 兼容),BGM 模块经导出函数复用 |
tone(ac, opts) | 69-85 行 | 单音基元:振荡器 + 增益包络(8ms 攻起、指数衰减),支持 slideTo 滑音与 delay 延迟 |
noiseBurst(ac, dur, gain) | 87-103 行 | 白噪声爆音基元:随机采样缓冲 + 1400Hz 低通,供爆炸音 |
render(ac, name) | 105-165 行 | 16 种 GameSfx 的 switch 分发表——每种音色是 1-4 个 tone/noiseBurst 的编排,新增音效=加一个 case |
play(name) | 168-177 行 | muted 短路 → ensureCtx → try/catch 包裹 render,音频异常绝不影响游戏逻辑 |
hashKey / mulberry32 / createBgmPlan | useGameBgm.ts 16-123 行 | 纯函数三件套:FNV-1a 哈希、确定性 PRNG、曲谱计划生成(tempo 82-118、三调式、五种和弦走向、32 步三层编制) |
note(ac, out, opts) | 161-173 行 | BGM 单音符:线性攻起 + 指数衰减,与音效 tone 参数尺度不同(gain 0.013-0.055 更轻) |
applyGain() | 175-181 行 | 音量仲裁:正常 0.9 / 暂停压低 0.28 / 静音 0.0001,setTargetAtTime 0.12s 平滑过渡 |
scheduleStep + tick() | 183-207 行 | lookahead 调度器:90ms interval,向未来 0.4s 窗口预排 32 步循环音符,规避 setInterval 抖动 |
start(seedKey) / stop() | 213-271 行 | 建链 master→lowpass(1700Hz)→delay 反馈空间感→destination;stop 清 interval、渐隐 700ms 后 disconnect |
applyConditions(muted, paused) | 289-293 行 | GameShell 把全局静音与游戏暂停态同步进来(暂停压低而非静音是刻意设计) |
机制走读。两个模块共享同一套「单例 + 惰性 + 静默降级」哲学,但分工明确:useGameAudio 是「事件驱动的短音」,useGameBgm 是「时间轴驱动的循环曲」。音效侧最容易被忽视的是 render() 的参数设计——每条 case 都是一份微型乐谱(频率、波形、时值、滑音目标),例如 gameover 用 440→330→220→130 的三连下行三角波表达「失败感」,win 用 C 大调主和弦琶音上行;想改全站某类音效只需改对应 case,这是「音效即代码」的直接收益。BGM 侧的核心难点是 WebAudio 的精确节拍:setInterval 的回调时机有毫秒级抖动,直接在回调里发声会「抢拍」,实现采用业界标准的 lookahead 模式——调度器每 90ms 醒来一次,把「从当前到 0.4 秒后」该响的所有音符按精确的 ac.currentTime 时间戳提前排入音频线程,抖动被 0.4 秒窗口完全吸收。曲子与游戏的绑定用「标题即种子」:bgm.start(props.title) 由 GameShell 在首个手势时调用,createBgmPlan 对同一标题恒定产出同一曲谱(测试 useGameBgm.test.ts 固化),因此「每款游戏有一首专属 BGM」不需要任何存储。调用侧约束见《03-接口设计》第 2 节:游戏只准经 play(GameSfx) 发声,禁止自建 AudioContext。已发现的边角问题(gobang 播放了 GameSfx 之外的 'lose'/'draw' 音效名导致静默无声)记录在《12-FAQ与故障排查》与《13-技术债务与演进建议》。
职责一句话:useCanvasStage / useCanvas / useAnimationLoop 三个 composable 解决 DPR 适配、rAF 循环与 window 级事件清理三件高频杂务;highScore.ts 把 localStorage 存档样板压成两个函数。
关键文件与函数清单
| 模块 | 规模 | 关键成员 |
|---|---|---|
src/composables/useCanvasStage.ts | 145 行 | canvasRef/ctxRef(shallowRef)/size(reactive)、applyCanvasSize()(54-77 行:CSS 尺寸与 dpr 物理尺寸双写 + setTransform)、startLoop/stopLoop(86-95 行:pauseWhenHidden 时 document.hidden 跳帧但保持循环)、registerTargetEvent(97-109 行:注册与 cleanup 配对入栈)、destroy()(onBeforeUnmount 自动调用) |
src/composables/useCanvas.ts | 27 行 | 别名封装:context/startAnimation/stopAnimation/clearCanvas/resizeCanvas;新代码建议直接用 useCanvasStage |
src/composables/useAnimationLoop.ts | 53 行 | 无 canvas 依赖的 startLoop/stopLoop/registerTargetEvent,onUnmounted 统一清理;tetris/plane-shooter 等 DOM 输入游戏只用它 |
src/utils/highScore.ts | 23 行 | readHighScore(key)(`Number(...) |
机制走读。这一组工具的全部价值在于「把 79 款游戏最容易漏的清理责任收走」。registerTargetEvent 返回真正的解绑函数(可提前手动解绑),同时把 cleanup 压入 cleanupTasks 数组,destroy()/onUnmounted 时出栈执行——游戏代码里从此看不到成对的 add/removeEventListener。shallowRef 包 ctxRef 是有意的性能选择:CanvasRenderingContext2D 是重型宿主对象,深度响应式代理会让每次属性访问都过 Proxy。pauseWhenHidden 的实现细节是「跳过 handler 但不 cancel rAF」:标签页切回时循环仍在,只是隐藏期间不执行帧逻辑,避免了重启循环的时序问题。值得注意的现状是:真正使用 useCanvasStage 的游戏只有 4 款(grep 验证),加上 useAnimationLoop 约十余款;多数游戏(如 suika、snake)在 onMounted 里自管 getContext、DPR 与 requestAnimationFrame,重复实现了同一模式——这与《05-核心流程》第 5 节的判断一致,也是《13-技术债务与演进建议》中「基建渗透率低」一项的证据。存档工具方面,它只服务「高分更大」语义(60 款 import 使用),低分更优的游戏自行比较后仍调用同一对函数;键名方向语义与 8 处历史短键偏差见《04-数据模型》2.2/2.3,本篇不重复。
四款代表的共同骨架(GameShell 五插槽、状态机 ref、世界/镜像分离)已在《04-数据模型》第 3 节总结为三条模式,走读聚焦各自玩法层的独有实现。
数据建模:棋盘是 Array(20).fill().map(() => Array(10).fill(null)) 的二维数组(281-283 行 createEmptyBoard),格子直接存颜色字符串而非布尔——渲染时无需再查色表。七种方块定义为「预计算的全部旋转态」而非旋转算法:SHAPES 表(145-253 行)里每个 type 存 rotations 数组,旋转 = 换下标,规避了矩阵旋转的裁剪错误;T/S/Z/J/L 各 2-4 个旋转态,O 只有 1 个。碰撞与消行:isValidPosition(piece, offsetX, offsetY, matrix)(384-395 行)支持传入替换后的矩阵做试探,旋转系统据此实现简易 wall kick——rotatePiece(472-486 行)依次尝试 [0, -1, 1, -2, 2] 五个横向偏移,全部失败才放弃旋转,这是标准 SRS 的极简版。clearLines(406-428 行)自底向上扫描、splice+unshift 压行(y-- 补偿的写法让同帧多消不漏判),得分表 [0,100,300,500,800] × level,每 10 行升级,getDropInterval 用 max(140, 760-(level-1)*60) 封底下落间隔。帧循环:useAnimationLoop({ pauseWhenHidden: true }) + 双累加器——dropAccumulator 驱动逻辑节拍、secondAccumulator 驱动用时统计(526-533 行),这是「渲染帧率与游戏节拍解耦」的标准写法;暂停时 lastTimestamp = 0 使恢复瞬间 dt 归零,防止暂停期间的时间被一次算入。触屏:底部虚拟按键条仅在 @media (hover: none) 显示,startHold/endHold(579-589 行)用 110ms setInterval 实现长按连发并在 pointerup/pointercancel/pointerleave 三处停表;onBeforeUnmount 里补清 holdTimer(609-612 行)——因为 setInterval 是组件体直接创建的,不走 registerTargetEvent,属于工具未覆盖的手工清理。 ghost 落点投影(345-361 行)复用 isValidPosition 循环下探后以 0.18 透明度重绘一遍,无额外状态。
建模与交互:15 路棋盘存 board[x][y](x 列先行,绘制与判定统一用 [dx,dy] 四方向向量 DIRS,279-284 行)。事件上它是「非 rAF 游戏」的典型——没有动画循环,棋盘只在 doMove/undo/hover/resize 时重绘(draw() 主动调用),watch([size.width, size.height])(494-498 行)接住 useCanvasStage 的 ResizeObserver 变化,registerTargetEvent(window, 'resize') 兜底窗口缩放。落子坐标由 toCell(207-216 行)经 getBoundingClientRect 换算后 Math.round((mx-ox())/c) 取最近交叉点,onTap 里三重守卫(状态、AI 回合、占位)。AI 实现(278-359 行)是项目内最完整的启发式:候选点生成器 candidates() 只取已有棋子 5×5 邻域内的空点(首手落天元),评分函数 scoreAt(x, y, who) 对四个方向分别统计「连子数 count + 端点开敞数 open」,查表 lineScore(286-293 行)赋值——成五 1e7、活四 1e6、冲四 1e5、活三 1e4 量级递减;落子价值取 attack + defend × 0.85 + Math.random() × 2,攻守权重 0.85 让 AI「能赢先赢、其次堵截」,随机项避免每局完全复刻。悔棋的状态复原(261-276 行)值得注意:人机模式退两步、双人退一步,currentPlayer 由 history.length % 2 推导而非存储,撤销后清空 winner/winLine 并回 playing——用单一事实源(history 数组)推导派生态,避免快照恢复的遗漏。存档:h5g:gobang-score 存 { w, l, d } JSON(115-132 行),仅人机模式结算时累计(244-255 行);顺带一提 250/253/257 行播放的 'lose'/'draw' 不在 GameSfx 联合类型内,render() 无对应 case,实际无声(详见《13-技术债务与演进建议》E-06)。
固定逻辑分辨率:canvas 物理尺寸在 onMounted 固定为 880×560 × dpr(432-438 行)并 setTransform,此后一切实体坐标都在 880×560 逻辑空间内运算,CSS 把画布拉伸到舞台实际大小(.game-canvas 的 width/height 100%);触屏拖动时 movePlayerTo(407-415 行)反向用 rect.width 把客户坐标归一化回逻辑空间。这与 useCanvasStage 的「跟随容器」策略不同——射击游戏需要恒定坐标系,缩放交给 CSS。实体系统:四类实体全部是普通数组 + 每帧 filter 回收(249-269 行):bullets 出界即删、enemies 出底部即删、explosions 以 life 计数衰减、powerups 下落出界即删;敌机 560ms 一架(spawnTimer),带 wave 相位的正弦横摆(256-260 行)。时间标定:render(time)(371-387 行)把 dt clamp 到 100ms 后换算成 factor = delta / 16.67(以 60fps 为基准的倍率),所有速度项乘 factor——掉帧时游戏等比慢放而不是跳帧穿模。碰撞与道具:hitCircle 圆形碰撞(271-273 行)+ extra 缓冲参数(玩家判定放宽 8px 提升手感);子弹命中用 bullets.filter 内嵌 enemies.find 的 O(n·m) 简单实现,规模下无性能问题;四种道具(火力 L/射速 S/无敌 G/清屏 B)经 usePowerup 分派,受伤后 1800ms 无敌(godUntil = now + 1800)给玩家喘息,drawShip 里以白圈可视化无敌态(317-322 行)。输入:键盘用 e.code 的状态字典(100-107 行,支持 Z/C 旋转朝向改变弹道散射方向,shoot 的 spread 0.18 弧度扇形);触屏 pointerdown 时 setPointerCapture 锁定拖动、拖动点上移 48px 避免手指遮挡机身(414 行)——两个细节都来自真机反馈。445-447 行 watch(muted, m => !m && unlock()) 处理「先静音后取消静音」时 AudioContext 尚未创建的路径。
物理引擎:这是 79 款中唯一自写圆形刚体物理的。常量区(14-24 行)定义 400×600 逻辑空间、墙/地板/警戒线坐标、GRAVITY 0.35、FRICTION 0.985、RESTITUTION 0.2、PHYSICS_ITERATIONS 3。update(dt)(170-284 行)每帧做三件事:半隐式欧拉积分(先更新速度再用新速度位移,f.vy += GRAVITY * dt);三次迭代的约束求解——墙壁反弹与水果间两两碰撞在每次迭代里重复解算,迭代次数是「物理稳定性」与「性能」的折衷(一次迭代堆叠会抖动,过多则浪费);碰撞响应分两支——同等级触发 mergeFruits(新水果取中点、继承平均速度并上抛 -1、双方标记 dead),异级做位置修正(按半径比分配穿透深度)与冲量反射(dot < 0 才施加,防止粘连反复弹)。dt 归一化:dt = min(ts - lastTs, 50) / 16.67(289 行),物理参数全部按 60fps 标定。失败判定不是「越线即死」而是「静止越线」:先算 settled(速度双模 <0.3),只有静止水果顶部超 DANGER_Y 才累计 dangerTimer,超过 80 帧才 finish()(249-265 行)——缓冲设计避免了刚落下时误判;警戒线在绘制层随 dangerTimer 呼吸闪烁(323 行),把系统状态可视化。循环治理:它没有用任何基建 composable,raf 变量 + requestAnimationFrame 自管循环,暂停即 cancelAnimationFrame、恢复时 lastTs = 0 归零 dt(85-96 行);onBeforeUnmount 里 cancelAnimationFrame(raf) 与移除 window keydown 配对(64-66 行)——是「自管循环也能清理干净」的范本,但如前所述,这类样板本可由 useCanvasStage 代管。水果 11 级只从前 5 级随机掉落(MAX_DROP_LEVEL 4,25 行),合成到顶级的 win 音效与全屏绿闪(396-401 行)提供一次性正反馈。
把五个框架模块放回同一条链上看:registry 决定「代码从哪来」,GameShell 决定「页面长什么样」,封面管线决定「列表里是什么样」,音频与画布基建决定「玩起来共享什么」,存档工具决定「留下什么」。它们的共同特征是:小(最大的 GameShell 也就 729 行含样式)、无状态或模块级单例、对游戏层零侵入、全部有静默降级路径。理解本篇之后,接手者应当能对任何一款 src/games/*.vue 做「先找状态机 ref、再找世界变量、后查插槽用法」的三步定位法,并在修改框架层前意识到《05-核心流程》第 6 节的影响面警示。下一篇转向整个项目的安全面。