原生JS游戏源码拆解:游戏循环、碰撞检测与对象池实战
简介这是一套JavaScript游戏源码合集面向Web前端初学者和游戏开发入门者。压缩包共60个文件包含17个可直接运行的htm页面、16个txt代码说明、6个gif动图素材及css样式、db存档等辅助文件总大小仅78KB。包内收录了字母华容道、打蜜蜂、俄罗斯方块、拼图、推箱子、五子棋、勇闯迷宫、射击、撞击游戏等十余款经典小游戏覆盖DOM操作、事件处理、定时器、Canvas绘图、碰撞检测、得分系统、本地存储等核心知识点。读者既可通过阅读源码理解交互逻辑也能修改参数观察表现变化适合课后练习与二次创作。目前已有868人学习下载资源小巧且结构规整便于快速上手。每个游戏均配有说明文本方便对照学习源码注释清晰目录结构简单尤其适合离线自学场景无论是入门练手还是备课参考都能从中获得启发。1. 一份能直接跑的 JS 游戏源代码从休闲小游戏到核心机制实战如果你搜过“js游戏源代码”大概率见过两类东西一类是粘贴就报错的残缺片段另一类是包装精美但项目结构一团乱麻的源码包。这份资源属于第三类——它是把若干款经典小游戏用纯原生 JavaScript 实现后打包在一起的完整项目打开浏览器就能玩没有一行框架依赖也不用跑构建命令。我拆完第一遍的感受是它不像教学 Demo更像一个能直接上线的小型游戏合集每一款游戏的核心机制碰撞检测、状态机、渲染循环都被单独抽成了可复用模块。适合两类人一是想用原生 JS 练手、看别人怎么组织游戏代码的开发者二是需要快速给网站或活动页加一个小游戏入口的前端工程师——拿过来改改样式和关卡参数就能用。2. 技术选型与整体架构零依赖、无构建、单页面切换2.1 为什么选原生 JS 而不是 Phaser / PixiJS项目里十几款游戏全部基于原生 JavaScript Canvas 2D 实现没有引第三方游戏引擎。这个选择在当下看起来有点“复古”但胜在三个实际好处第一源码包解压后直接双击 index.html 就能跑对初学者极其友好第二所有逻辑都是透明的你想看碰撞检测就打开 collision.js想改游戏速度就调全局配置不会被引擎黑匣子挡住第三部署成本几乎为零丢到任意静态服务器或对象存储即可。我一般会建议如果目标是快速做商业级游戏直接上 Phaser 没问题但如果目标是把游戏开发的基础吃透或者需要一个轻量、可嵌入现有网页的小游戏集合原生 JS 依然是最顺手的选择。这套代码里没有模块化打包器、没有 npm 依赖全靠 script 标签按顺序加载每个人都能一眼看完整个项目的加载链路。2.2 目录结构按功能拆分而非按游戏拆分打开源码包目录结构是这样组织的js-game-source/ ├── index.html // 入口页游戏选择大厅 ├── css/ │ └── style.css // 全局样式 各游戏共享样式 ├── js/ │ ├── core/ │ │ ├── game-loop.js // 游戏循环核心requestAnimationFrame 封装 │ │ ├── input.js // 键盘/鼠标/触摸输入统一处理 │ │ ├── collision.js // 碰撞检测工具集 │ │ ├── state-manager.js// 游戏状态机MENU/RUNNING/PAUSED/OVER │ │ └── utils.js // 随机数、角度换算、性能统计等 │ ├── games/ │ │ ├── snake/ │ │ │ ├── index.js // 游戏入口与配置 │ │ │ ├── snake.js // 贪吃蛇实体逻辑 │ │ │ └── food.js // 食物生成逻辑 │ │ ├── breakout/ │ │ │ ├── index.js │ │ │ ├── paddle.js │ │ │ ├── ball.js │ │ │ └── bricks.js │ │ ├── puzzle/ │ │ │ ├── index.js // 拼图游戏图片切割与拖拽 │ │ │ └── tile.js │ │ ├── 2048/ │ │ │ ├── index.js │ │ │ ├── board.js // 棋盘合并逻辑 │ │ │ └── tile.js │ │ └── plane/ │ │ ├── index.js // 飞机大战入口 │ │ ├── player.js │ │ ├── enemy.js │ │ └── bullet.js │ └── main.js // 游戏注册与切换调度 └── assets/ └── images/ // 拼图用图片、飞机贴图等这种按功能拆分的思路比把每个游戏做成独立目录更值得学——core 目录里是跨游戏复用的底层能力games 目录按游戏业务划分新增一款游戏时只需要在 games/ 下建新目录并在 main.js 里注册不用动核心框架。这套组织方式对中小型项目非常实用。2.3 游戏注册机制main.js 如何调度十几款游戏main.js 是项目的中枢它维护了一个游戏注册表每个游戏实现统一的接口后就能被大厅拉起// js/main.js —— 游戏注册与切换 const gameRegistry {}; function registerGame(id, config) { // config 必须包含 name, init, destroy, update, render 等接口 if (!config.init || typeof config.init ! function) { throw new Error(游戏 [${id}] 缺少 init 方法); } gameRegistry[id] config; // 在大厅菜单中动态生成入口按钮 const menuBtn document.createElement(button); menuBtn.textContent config.name; menuBtn.addEventListener(click, () switchToGame(id)); document.getElementById(game-menu).appendChild(menuBtn); } function switchToGame(id) { // 先销毁上一局 if (currentGame currentGame.destroy) { currentGame.destroy(); } // 从注册表取配置并启动 currentGame gameRegistry[id]; currentGame.init({ canvas: document.getElementById(game-canvas), input: inputManager, done: backToMenu }); }这里的关键是 registerGame 里做了接口校验——init 是必选方法否则直接抛异常。我在自己做的项目里也养成了这个习惯插件或子模块注册时先校验接口避免错误在运行时才暴露。switchToGame 里先销毁再启动的顺序也很重要很多新手写游戏切换时直接在旧实例上改会导致事件监听叠加、计时器残留这里统一走 destroy 再做准备就干净多了。每个游戏的 init 方法拿到的是一个共享 canvas 和输入管理器这样不管切到哪款游戏浏览器只需保留一个画布节点性能开销很可控。2.4 核心游戏循环requestAnimationFrame 的封装细节整个项目的运行基石是 core/game-loop.js它把 requestAnimationFrame 封装成一个可启停的循环并在每帧里按固定顺序执行 update逻辑更新和 render绘制// js/core/game-loop.js —— 游戏循环核心 class GameLoop { constructor() { this.running false; this.rafId null; this.lastTime 0; this.accumulator 0; // 固定时间步长单位毫秒与帧率无关 this.step 1000 / 60; } start(updateFn, renderFn) { this.updateFn updateFn; this.renderFn renderFn; this.running true; this.lastTime performance.now(); this._tick(this.lastTime); } stop() { this.running false; cancelAnimationFrame(this.rafId); } _tick(timestamp) { if (!this.running) return; // 计算距上一帧的真实时间差 let delta timestamp - this.lastTime; this.lastTime timestamp; // delta 过大会导致“螺旋死亡效应”截断在一帧内 if (delta 120) delta 120; this.accumulator delta; // 按固定步长推进游戏逻辑保证逻辑稳定 while (this.accumulator this.step) { this.updateFn(this.step / 1000); // 参数是秒 this.accumulator - this.step; } this.renderFn(timestamp); this.rafId requestAnimationFrame(t this._tick(t)); } }这段代码里有两个值得注意的参数设计一是 step 1000/60代表游戏逻辑的固定时间步长为 60 帧一秒游戏速度以“秒”为单位传入 updateFn意味着即使浏览器掉到 30 帧逻辑也不会变慢——这是比直接“每帧更新一次”更专业的做法二是 delta 被截断在 120 毫秒防止用户切走标签页回来后积累的巨量时间差导致游戏实体瞬间飞出地图。很多血泪经验都来自这里如果直接用 delta 驱动位置变化一旦浏览器背景标签页被挂起后再恢复角色可能已经穿墙了。这里的 accumulator 手法是游戏引擎中常见的固定时间步长方案项目里用手写方式表达得很清晰。3. 核心模块拆解一个游戏从进入游戏到 Game Over 的完整链路3.1 状态管理器MENU / RUNNING / PAUSED / OVER 的切换逻辑state-manager.js 实现了一个极简状态机每一款游戏都依赖它来管理生命周期。游戏状态只有四种但在切换时有一堆隐藏的细节// js/core/state-manager.js —— 游戏状态机 export const GameState { MENU: MENU, // 菜单/待开始 RUNNING: RUNNING, // 运行中 PAUSED: PAUSED, // 暂停 OVER: OVER // 结束 }; export class StateManager { constructor() { this.state GameState.MENU; this.listeners []; } setState(nextState) { const prev this.state; if (prev nextState) return; // 不合法的迁移直接警告不静默通过 if (prev GameState.OVER nextState GameState.PAUSED) { console.warn(游戏已结束不允许进入暂停状态); return; } this.state nextState; // 通知所有订阅者用于 UI 更新或音乐暂停/恢复 this.listeners.forEach(cb cb(nextState, prev)); } isRunning() { return this.state GameState.RUNNING; } }这段代码最有价值的是对非法迁移的拦截——游戏已经 OVER 了还想点暂停按钮这里会直接拦下来并打警告而不是让状态变成夹生的“既结束又暂停”。在游戏开发里状态管理混乱是 bug 的重灾区。我处理类似需求时会再多加一张迁移表把“从哪个状态能跳到哪个状态”做成矩阵但这里用简单的 if 判断足以覆盖四个状态、六条迁移路径不会过度设计。游戏主逻辑每帧开头都会调 isRunning() 做短路判断非运行状态直接跳过 update这样暂停时游戏世界不会继续运转。3.2 碰撞检测实战打砖块中的 AABB 判定打砖块breakout是碰撞检测最密集的游戏球和挡板、球和砖块、球和边界都是矩形相交判断。项目在 collision.js 里封装了基础方法然后在砖块游戏中复用它// js/games/breakout/ball.js —— 球与砖块的碰撞处理 import { rectsOverlap } from ../../core/collision.js; function handleBrickCollision(ball, bricks) { for (let i 0; i bricks.length; i) { const brick bricks[i]; // 已销毁的砖块跳过 if (brick.destroyed) continue; // AABB 矩形相交检测 if (rectsOverlap( ball.x, ball.y, ball.radius * 2, ball.radius * 2, brick.x, brick.y, brick.width, brick.height )) { // 根据碰撞方向反转速度 // 比较球心与砖块中心的偏移决定反转 x 还是 y const overlapX Math.min( ball.x ball.radius - brick.x, brick.x brick.width - ball.x ); const overlapY Math.min( ball.y ball.radius - brick.y, brick.y brick.height - ball.y ); if (overlapX overlapY) { ball.vx -ball.vx; } else { ball.vy -ball.vy; } brick.destroyed true; brick.onDestroy(); // 触发计分和粒子特效 break; // 每帧最多只处理一个砖块避免重复反转 } } }这里的关键参数是 overlapX 和 overlapY——它们表示球渗入砖块的深度谁的深度小就说明球从哪个方向撞进来。这是 2D 碰撞里最经典的“最小分离轴”思想比单纯反转速度要可靠得多。我见过不少实现直接碰撞后就 vx -vx结果球在侧面碰到砖块时速度方向完全不对。代码里还有一个容易忽略的细节处理完一个砖块就 break防止球同时和多个砖块重叠时连续反转两次导致速度方向变回原样。下一帧物理引擎会自然处理新的位置关系这是工程上的务实取舍。3.3 贪吃蛇的移动与自碰撞用数组头尾操作模拟蛇身贪吃蛇的核心数据结构是一个数组蛇头是数组头部蛇身是后续元素。项目里把蛇的移动设计成一个非常优雅的“去尾插头”操作// js/games/snake/snake.js —— 蛇的移动逻辑 class Snake { constructor() { // 蛇身每个节用 {x, y} 表示格子坐标 this.body [ { x: 5, y: 3 }, { x: 4, y: 3 }, { x: 3, y: 3 } ]; this.direction { x: 1, y: 0 }; // 当前移动方向 this.nextDirection { x: 1, y: 0 }; // 缓冲方向 } move(grow false) { // 把缓冲方向应用为实际方向防止一帧内多次变向 this.direction { ...this.nextDirection }; const head this.body[0]; const newHead { x: head.x this.direction.x, y: head.y this.direction.y }; // 插入新头 this.body.unshift(newHead); // 不吃食物就去掉尾巴吃了食物则保持长度 if (!grow) { this.body.pop(); } return newHead; } checkSelfCollision() { const head this.body[0]; // 从第 1 节开始遍历跳过蛇头自身 for (let i 1; i this.body.length; i) { if (this.body[i].x head.x this.body[i].y head.y) { return true; } } return false; } setDirection(dir) { // 不允许 180 度掉头 if (dir.x -this.direction.x dir.y -this.direction.y) return; this.nextDirection dir; } }move 方法里有个常被新人忽略的参数 grow——它决定了本轮是否吃到了食物。grow 为 true 时只插头不删尾蛇就会变长一截。unshift 和 pop 的配合让移动变得意外简洁。setDirection 里的反向检测也很关键用户在快速按两个方向键时如果直接改 this.direction蛇可能在一帧内反向移动直接撞到自己。这里的 nextDirection 缓冲机制保证了方向切换是安全的——你只能在下一帧把方向调整为合法的方向不给瞬时掉头的机会。我在自己写 2D 格子游戏时也沿用了这套思路它让输入响应变得非常“稳”。3.4 2048 的合并算法单行滑动带来的简化2048 的难点在于合并规则同一行中相同数字会合并一次且合并后不再参与二次合并。项目里 2048 的实现没有用复杂的动画过渡而是把整个棋盘抽象成二维数组滑动时按行处理// js/games/2048/board.js —— 单行合并核心 function mergeRow(row) { // 去掉 0压缩非空元素 const tiles row.filter(v v ! 0); const result []; for (let i 0; i tiles.length; i) { // 如果相邻两个值相同合并 if (i 1 tiles.length tiles[i] tiles[i 1]) { result.push(tiles[i] * 2); i; // 跳过一个防止重复合并 } else { result.push(tiles[i]); } } // 末尾补 0 到原行长度 while (result.length row.length) { result.push(0); } return result; } // 左滑每行直接合并 // 右滑每行反转后合并再反转回来 // 上/下滑同理先转置再按行合并再转置回去 function moveLeft(board) { return board.map(row mergeRow(row)); } function moveRight(board) { return board.map(row mergeRow([...row].reverse()).reverse()); }mergeRow 里的 i 是合并一次的关键——两个相邻且相等的数字合并后生成的新数字不再参与本轮合并。比如一行是 [2, 2, 4]合并结果是 [4, 4] 而不是 [8]。很多半成品项目正是在这里翻车。参数说明row 是长度为 4 的数组filter 去零后循环里用相邻比较实现合并。左右滑动的差异只在“反转后合并再反转”上下滑动在真实棋盘上需要先把行列转置处理完再转置回去。整个 2048 的移动逻辑加起来不到 40 行比想象中简单得多核心就是在“合并方向”上做文章。3.5 飞机大战中的对象池与子弹管理飞机大战涉及高频创建和销毁子弹、敌人。项目里在 plane/bullet.js 里用了一个极简对象池避免频繁触发垃圾回收导致掉帧// js/games/plane/bullet.js —— 子弹对象池 class BulletPool { constructor(capacity 50) { this.pool []; this.capacity capacity; this.active []; } acquire(x, y, speed) { // 优先从池中取复用对象 let bullet this.pool.pop(); if (!bullet) { bullet { x: 0, y: 0, speed: 0, active: false }; } bullet.x x; bullet.y y; bullet.speed speed; bullet.active true; this.active.push(bullet); return bullet; } release(bullet) { bullet.active false; // 从活跃列表移除 const idx this.active.indexOf(bullet); if (idx -1) this.active.splice(idx, 1); // 回收到池中容量满则丢弃 if (this.pool.length this.capacity) { this.pool.push(bullet); } } update(dt) { for (let i this.active.length - 1; i 0; i--) { const bullet this.active[i]; bullet.y - bullet.speed * dt; // 超出边界即回收 if (bullet.y -20) { this.release(bullet); } } } }这套对象池的容量默认是 50如果同时活跃的子弹超过 50池子也不会扩容——多出的对象会被丢弃这在子弹游戏中反而是一种保护机制避免极端情况下屏幕上堆满子弹导致性能崩掉。update 里从后往前遍历的原因是在遍历中删除元素不会影响前面的索引这是数组操作的一个经典坑点。用对象池而不是直接 new Bullet在低端移动设备上差别尤其明显如果你要把这个项目嵌入微信小游戏或者 H5 活动页对象池几乎是必须掌握的优化手段。4. 从源码到可玩本地运行、调试与性能观察4.1 打开即玩与本地服务两种方式第一步最简单解压后直接双击 index.html浏览器就会打开游戏大厅所有游戏都能玩耍。因为代码里没有 fetch 本地 JSON、没有 ES Module 跨域加载所有脚本都是传统 script 标签引入所以 file:// 协议下也能正常跑。但如果想让项目更接近真实部署环境或者后续要改代码实现热更新我建议在项目根目录起一个静态服务# 方式一Python 3 自带 python3 -m http.server 8080 # 方式二Node 环境用 npx npx serve -l 8080 # 方式三VS Code 装 Live Server 插件右键 index.html 打开之后浏览器访问 http://localhost:8080 即可。两种方式的区别在于直接双击适合快速体验本地服务适合开发调试——你需要看控制台报错、改代码后自动刷新而且后续如果给游戏加上音频或图片资源加载file:// 协议下容易踩跨域限制。我一般直接从本地服务开始顺手也把浏览器 DevTools 的 Network 面板开着观察资源加载情况。4.2 调试入口全局对象与断点位置源码里在 main.js 末尾暴露了一个全局对象 window.GameDebug把当前活跃游戏实例和核心循环都挂了上去// js/main.js —— 调试入口 window.GameDebug { get activeGame() { return currentGame; }, get loop() { return gameLoop; }, // 手动切换游戏状态 setState(state) { if (currentGame currentGame.stateManager) { currentGame.stateManager.setState(state); } } };这意味着你在浏览器控制台里可以直接输入 GameDebug.activeGame 查看当前游戏实例的所有属性比如蛇的长度、球的位置、砖块剩余数量。这在排查“为什么球穿墙了”“为什么蛇撞了自己没死”这类问题时非常高效。常见做法是在 update 函数入口打一个断点单步跟踪每一帧的数值变化再配合 console.table 打印关键实体数组。比如贪吃蛇里我在 move() 的第一行打条件断点条件是 this.body.length 8这样能快速定位蛇变长后的逻辑异常。4.3 性能观察帧率与主线程耗时原文代码的 utils.js 里带了一个简易 FPS 统计器它会每隔一秒更新一次当前帧率并绘制在 Canvas 右上角。跑每个游戏时可以留意这个数字如果你看到 FPS 稳定在 60说明逻辑和渲染没有明显瓶颈如果掉到 45 以下打开 DevTools 的 Performance 面板录制几秒看是 update 阶段耗时高还是 render 阶段高飞机大战游戏中如果同时存在大量子弹和敌人观察对象池是否生效——如果 active 数量总是接近 capacity说明池容量偏小可以调大。一个常见的心得是 原生 Canvas 2D 游戏在移动端的主要开销往往不是逻辑计算而是大量 fillRect / drawImage 调用。 项目里的 render 函数对不可见区域做了剔除比如砖块只绘制存活者这种剔除逻辑在小游戏里性价比很高——几十行代码就能避免一多半的绘制调用。5. 避坑指南跑这些 JS 游戏源码时最容易踩的五个坑5.1 现象双击打开显示空白页控制台报 “Cannot read properties of undefined”原因直接双击时部分浏览器对本地 file:// 页面有严格限制尤其是当代码里存在 ES Module 的 import/export 语法时。项目核心代码用的是传统 script 标签但如果某个游戏目录下的文件被单独改成了 import 写法整页会因语法报错而白屏。解决不要纠结于双击直接起一个本地静态服务再访问。这是所有 H5 游戏开发的第一步过早使用 file:// 只会浪费时间。5.2 现象贪吃蛇按方向键时蛇自己反向了结果撞死原因在同一个事件循环内连续改变方向且没有做“禁止 180 度掉头”的校验。比如蛇向右移动时你快速按了“上”再按“左”如果代码直接处理每个按键事件方向会被更新为“左”相当于瞬间掉头撞到自己。解决参考项目里 snake.js 的 nextDirection 缓冲机制——所有输入先写入 nextDirection等下一帧才生效同时对非法方向直接 return。当你自己写类似游戏时把 setDirection 的校验逻辑放到输入处理的最前面这是最简单的防线。5.3 现象打砖块的球碰到砖块侧面球竟然穿过去了原因碰撞检测帧率不足。一帧内球移动距离超过砖块厚度时前后两个采样点会分别位于砖块两侧标准的 AABB 矩形相交检测完全失效——这在工程上叫“隧穿效应”。小球速度过快时尤其明显。解决项目中 ball.js 做了速度上限限制最大速度不超过每帧 20px同时碰撞后按最小重叠轴反转方向。如果你要彻底解决隧穿终极做法是“连续碰撞检测”CCD——把移动路径切分成多段逐步检测但大多数休闲游戏限制最大速度就够了这也是项目采取的务实路线。5.4 现象2048 滑动时数字会重复合并比如一行 [2, 2, 2, 2] 变成了 [8] 而不是 [4, 4]原因实现时没有“每轮每格只参与一次合并”的标记。合并过的格子继续参与后面的比较或者循环变量没有跳过合并后的新元素导致连锁合并。解决严格按 mergeRow 的逻辑实现比较时使用索引跳跃——两元素相等并合并后i 跳过下一格。这个规则写清楚后2048 的合并逻辑就不是黑匣子。测试用例建议用 [2, 2, 2, 2] → [4, 4]、[4, 4, 8] → [8, 8] 这种边界数据来验证。5.5 现象切到后台再回来游戏角色已经飞出屏幕或全部撞死原因循环里的 delta 未做截断浏览器在后台标签页时 requestAnimationFrame 停止触发回到前台后第一次 tick 的 timestamp 与 lastTime 差距巨大更新逻辑一次性“追回”了过去的数秒物理系统瞬间崩溃。解决game-loop.js 里已实现 delta 截断到 120 毫秒的逻辑。如果你要基于这个项目做自己的游戏永远不要移除这段逻辑它是最便宜的“防翻车”代码。另一个可选做法是监听 visibilitychange 事件页面隐藏时自动暂停游戏进入 PAUSED 状态回来后恢复 RUNNING——项目里部分游戏已经内置了这个监听你可以看 state-manager 的调用位置来对照实验。6. 二次开发指南把贪吃蛇改成“双人对战”版并加入存档系统最后一个章节我们落到实战改造把贪吃蛇改成双人对战模式并加一个本地存档。这个需求很适合拿来训练对项目结构的理解——它同时涉及输入层双人按键、逻辑层两条蛇、渲染层分屏绘制和数据层localStorage 存档。6.1 双人模式的输入层改造先在 input.js 里扩展支持两组按键映射// js/core/input.js —— 扩展双人按键配置 class InputManager { constructor() { this.keys {}; // 新增一个玩家映射表 this.playerMaps { p1: { up: KeyW, down: KeyS, left: KeyA, right: KeyD }, p2: { up: ArrowUp, down: ArrowDown, left: ArrowLeft, right: ArrowRight } }; window.addEventListener(keydown, e { this.keys[e.code] true; }); window.addEventListener(keyup, e { this.keys[e.code] false; }); } // 查询某位玩家的方向输入 getDirection(player) { const map this.playerMaps[player]; if (this.keys[map.up]) return { x: 0, y: -1 }; if (this.keys[map.down]) return { x: 0, y: 1 }; if (this.keys[map.left]) return { x: -1, y: 0 }; if (this.keys[map.right]) return { x: 1, y: 0 }; return null; } }关键设计是把按键映射集中到一个表格里后续想改键位比如支持 WASD 和 小键盘 8456只需要改这个映射对象。双人对战里最难处理的是“焦点权”——一个键盘输入只能被一个玩家响应这里用按键码把输入职责分割开天然解决了冲突。注意 getDirection 返回 null 表示没有按下方向键实际游戏中要保留蛇原来的移动方向而不是把 direction 置空。6.2 双人对战的蛇管理器新建一个 game-manager 来同时驱动两条蛇并处理互相碰撞// js/games/snake/battle-manager.js —— 双人对战管理 export class BattleManager { constructor(canvasWidth, canvasHeight) { this.snake1 new Snake({ startX: 5, startY: 3, // 左侧出生 initLength: 3, color: #4CAF50 }); this.snake2 new Snake({ startX: 25, startY: 3, // 右侧出生 initLength: 3, color: #FF5722 }); this.width canvasWidth; this.height canvasHeight; this.winner null; } update(dt) { const dir1 inputManager.getDirection(p1); const dir2 inputManager.getDirection(p2); if (dir1) this.snake1.setDirection(dir1); if (dir2) this.snake2.setDirection(dir2); const head1 this.snake1.move(false); const head2 this.snake2.move(false); // 玩法设计一方的头碰到另一方身体即战败 if (this.snake2.checkHitPoint(head1) || this._hitWall(head1)) { this.winner p2; } if (this.snake1.checkHitPoint(head2) || this._hitWall(head2)) { this.winner p1; } // 同时撞墙或互撞判平局 if (this.winner p1 this.winner p2) { this.winner draw; } } }这一层把两条 Snake 实例组装成一场对局碰撞判定从“自己撞自己”扩展到了“撞对方身体”和“撞墙”。如果你要加食物机制只需要在场景里维护一个食物数组任何一方头与食物重叠时调用 snake.grow(true) 并重新生成食物。这就是把原来的单机逻辑往上抽一层的好处——原有的 Snake 类完全不用动新增逻辑全部放在 manager 里。6.3 用 localStorage 做最高分存档最后加一个轻量存档模块记录每位玩家的最高分// js/utils/save.js —— 基于 localStorage 的存档 const SAVE_KEY snake-battle-highscore; export function loadScores() { // 解析失败时返回默认结构不抛异常 try { const raw localStorage.getItem(SAVE_KEY); return raw ? JSON.parse(raw) : { p1: 0, p2: 0 }; } catch (e) { console.warn(存档解析失败使用默认值, e); return { p1: 0, p2: 0 }; } } export function saveScore(player, score) { const scores loadScores(); if (score scores[player]) { scores[player] score; localStorage.setItem(SAVE_KEY, JSON.stringify(scores)); return true; // 返回是否刷新了纪录 } return false; }这段代码展示了两个值得留意的工程习惯一是 JSON.parse 放在 try/catch 里因为 localStorage 里的内容可能被用户或别处的代码写坏一旦坏了整个游戏都进不去二是 saveScore 在写入前先读旧值做比较只有破纪录时才写避免无意义的 localStorage 写操作。浏览器存储配额非常充裕但被塞满时 setItem 会抛异常如果你要长期维护这个项目可以在 catch 里捕获 QuotaExceededError 并提示用户清理浏览器数据。从这以后我每次在这个项目里加新功能都强制自己先走一遍现有代码的调用链——先看 stateManager 怎么流转再确认 input 层是否要扩展最后才动手写业务逻辑。顺序反了十有八九会留下状态残留或者输入冲突的隐患。这个习惯也是我从这个项目里收获最大的一点游戏代码的组织方式比游戏本身更值得反复揣摩。希望这篇拆解能帮你在跑通源码的基础上找到自己的改造切入点。本文还有配套的精品资源点击获取