
简介一个基于 JavaScript 的简易版《宝石迷阵》游戏源码包面向前端开发与游戏制作初学者用来演示网页游戏中规则设计、交互控制、动画效果与碰撞检测等基础逻辑的实现也适合作为课程设计或趣味项目参考。资源共 20 个文件核心代码由 3 个 js、1 个 css 和 1 个 html 构成另外包含 12 张 gif 动图与 3 张 png 图片分别用于角色/道具动画和界面贴图压缩包整体仅 35KB结构很精简。目前已有 76 人学习下载。通过阅读源码可以发现游戏地图用二维数组存储借助 DOM 操作和键盘事件监听控制角色移动用 requestAnimationFrame 维护主循环并实现了石头的滚动、宝石收集、洞口判定等典型玩法逻辑还涉及音效与暂停/重开等交互同时各个资源的命名和目录组织清晰方便对照理解适合作为 JavaScript 游戏开发的入门练习和二次改造底子。1. Simple Boulder Dash 不是小游戏素材是一套可运行的网格游戏骨架第一次拆这个资源包的时候我以为它只是又一个复刻贪吃蛇的 Demo。打开源码才发现Simple Boulder Dash 是一份用 JavaScript 写完整状态机的网格游戏骨架地图、重力、碰撞、渲染全部自管不依赖框架。它解决的是个很具体的问题你想要一个能跑、规则完整的游戏作为学习底板或课程设计基础又不想从事件循环和 Canvas 坐标换算开始写。适合两类人一类是刚能写 DOM 游戏、想往网格寻路和规则引擎方向走的新手另一类是需要几天内交付一个可演示小游戏的从业者。表面看游戏核心是“采矿”工程上真正值钱的是地图数据结构和 tick 规则引擎。2. 关卡从文本到内存地图字符表与二维数组的取舍Boulder Dash 的地图是标准的网格离散模型所有角色都活在同一个二维格子里。这决定了代码组织方式关卡文本负责“人能看懂”二维数组负责“引擎能跑”。这一章把地图数据的加载链路走通后面所有重力、推石头、碰撞判断都在这个数据结构上做。2.1 关卡文本的符号约定常见做法是每个关卡就是一个字符串数组每一行对应地图一行。示例长这样const LEVEL_1 [ ################, #..............#, #.O..D.......O.#, #..D..X...D....#, #..............#, #P.........#...#, ################ ];符号和实体的对应关系是一张约定表在这类源码包里通常写在文件顶部注释里。拆包时建议先把它抄出来后续所有逻辑都要引用它符号含义碰撞表现#墙不可通过不参与重力.土可被挖掉挖后变空O石头可被推动受重力D钻石受重力收集计数P玩家出生点单例加载时记录坐标X出口初始锁闭钻石达标后开放空格空地可通行可接受落体为什么用字符串而不是 JSON 数组做关卡格式因为人能直接读懂文本 diff 也方便配合 Git 管理关卡改起来效率很高。二进制格式容量小但对一个 HTML 游戏来说可读性差调试时还得专门写工具解码不值得。2.2 字符转 Tile 对象的 loadLevel 实现加载逻辑的核心是把每一行字符串切开把字符逐个映射成内存里的格子对象。const TILE { EMPTY: 0, WALL: 1, DIRT: 2, ROCK: 3, DIAMOND: 4, EXIT: 5, PLAYER: 6 }; function loadLevel(levelStrings, rows, cols) { const grid []; for (let y 0; y rows; y) { const row []; for (let x 0; x cols; x) { const ch (levelStrings[y] || )[x] || ; const type ch # ? TILE.WALL : ch . ? TILE.DIRT : ch O ? TILE.ROCK : ch D ? TILE.DIAMOND : ch X ? TILE.EXIT : ch P ? TILE.PLAYER : TILE.EMPTY; row.push({ type, x, y, falling: false, // 是否处于下落状态 fallDelay: 0 // 剩余下落延迟单位毫秒 }); } grid.push(row); } return grid; }每个格子里存的是对象而不是数值这是一个值得坚持的做法。后续你会在石头上挂“正在下落”状态在出口上挂“是否开放”状态把这些运行时字段塞进同一个对象里最省事省得再造一套平行数组。(levelStrings[y] || )这层保护是为了处理行长度不一致关卡文本某行少了字符时用|| 把空位补成空格保证每行都能得到cols个格子不会因为undefined导致后续渲染崩掉。加载完成之后推荐立刻做一遍预处理遍历一次地图记录玩家出生坐标、钻石总数量、出口坐标。这些数据是规则引擎的全局基准后面每帧都要用。2.3 固定行列数比自动推断更稳有些版本会用levelStrings.length推行数、用最长一行推列数省去传参。看起来方便实际是坑。关卡一旦做成不规则阶梯状地图右边缘会飘石头下落时拿到的目标格坐标可能直接越界。拆包后我一般会把行列数作为显式参数传给loadLevel固定地图边界。这样写还有一个好处后续做存档序列化时行数列数直接作为元数据写入反序列化不依赖对字符串的猜测。3. 重力、推石头与连锁塌方用固定 tick 驱动规则引擎Boulder Dash 最核心的机制不在渲染在于“每一个离散时间片里哪些格子发生了变化”。这一章讲透规则引擎的骨架重力扫描、碰撞判定、出口状态全部围绕一个update函数展开。3.1 为什么不用实时物理引擎Boulder Dash 的规则本质是离散状态变化玩家每次移动一格石头每次下落一格。如果你用实时物理引擎帧率一波动石头速度就不稳定碰撞也要单独调参数复杂度会失控。常见做法是让所有规则都挂在固定 tick 上也就是每个固定时间间隔跑一次update每次只允许格子移动一步。这样重力和推石头的规则就变成了“在邻居格子上交换位置”不是算力和速度而是纯状态判断。我在复现时把规则引擎和渲染彻底解耦update固定步长跑逻辑requestAnimationFrame负责渲染。这样 60Hz 显示器和 120Hz 显示器上游戏物理速度完全一致。3.2 从底向上扫描的重力实现重力是这张地图里最容易写错的部分。先看代码function applyGravity(grid, rows, cols, delta) { // 从倒数第二行向上扫保证每个格子每帧只处理一次 for (let y rows - 2; y 0; y--) { for (let x 0; x cols; x) { const tile grid[y][x]; if (tile.type ! TILE.ROCK tile.type ! TILE.DIAMOND) { continue; } const below grid[y 1][x]; if (below.type TILE.EMPTY) { tile.fallDelay - delta; if (tile.fallDelay 0) { // 交换当前格与下方格 grid[y 1][x] tile; grid[y][x] { type: TILE.EMPTY, x, y, falling: false, fallDelay: 0 }; tile.y 1; tile.fallDelay 100; // 每 100ms 落一格 } } else if (tile.type TILE.ROCK below.type TILE.DIRT) { // 石头压碎下方泥土然后继续下落 below.type TILE.EMPTY; grid[y 1][x] tile; grid[y][x] { type: TILE.EMPTY, x, y, falling: false, fallDelay: 0 }; tile.y 1; tile.fallDelay 100; } } } }为什么必须从底向上扫描因为下落本质是“相邻格交换”如果从上往下扫某个石头落到(y1, x)之后同一帧后续扫描可能又把它当作新位置的落体导致一个 tick 里连掉两格直接穿透下面的墙或者钻石。从底向上保证每个格子最多被处理一次落石行为可控。fallDelay是个毫秒计数器每次update时由游戏循环传入delta累减。调小到 50 石头掉得很快调大到 200 石头慢得像飘。注意水平移动和重力联结如果一个石头正在下落玩家此时推它应把fallDelay重置否则会出现“石头被推了一格但竖直方向还在累计下落”的幽灵移动。3.3 推石头和移动的判定顺序玩家移动要检查三类格子空地和出口可走土可以被挖开石头需要多检查一格才能决定能不能推。function tryMove(player, grid, dx, dy, cols, rows) { const tx player.x dx; const ty player.y dy; if (tx 0 || ty 0 || tx cols || ty rows) { return false; // 越界直接拒绝 } const target grid[ty][tx]; // 空地或开放的出口直接走 if (target.type TILE.EMPTY || (target.type TILE.EXIT target.open)) { movePlayer(player, grid, tx, ty); return true; } // 土挖掉变成空地 if (target.type TILE.DIRT) { grid[ty][tx].type TILE.EMPTY; movePlayer(player, grid, tx, ty); return true; } // 石头判断能不能推 if (target.type TILE.ROCK) { const bx tx dx; const by ty dy; if (bx 0 || by 0 || bx cols || by rows) { return false; } const behind grid[by][bx]; // 后面必须完全是空才能推 if (behind.type TILE.EMPTY) { grid[by][bx] target; grid[ty][tx] { type: TILE.EMPTY, x: tx, y: ty, falling: false, fallDelay: 0 }; movePlayer(player, grid, tx, ty); return true; } } return false; }推石头的第三个条件最容易漏只检查了玩家和石头两个格子没检查石头后面一格。如果后面是墙石头会被推进墙里地图直接焊死如果后面是出口出口会被堵住这局再也通不了关。我一般把“石头后面只能是空”作为硬性条件不额外放开到出口。移动的顺序也要注意先判定目标格再移动玩家如果目标是石头先换石头位置再移动玩家。反过来会导致玩家站进石头原来的格子后重力判断把玩家当普通格子交换出现诡异的瞬移。3.4 钻石计数与出口状态切换出口是否开放依赖钻石收集量。不要每帧去扫描全图数钻石而是维护一个全局计数器在玩家移动后检查当前格子是否是钻石function collectDiamond(grid, player, state) { const tile grid[player.y][player.x]; if (tile.type TILE.DIAMOND) { tile.type TILE.EMPTY; // 钻石被拾取格子变空 state.collected 1; if (state.collected state.total) { // 出口开放 const exit state.exitPos; grid[exit.y][exit.x].open true; } } }收集动作要放在tryMove成功之后并且要放在重力更新之前。原因是玩家移动和重力共用同一张grid如果先重力后拾取某些情况下钻石会先落进玩家格子被归为“坠落击杀”而不是被拾取体验上就是“明明撞到了钻石却死了”。这里也顺带解释一个设计边界这份资源的核心版本没有做敌人和炸弹但 tick 引擎预留了扩展位。敌人移动本质上也是每一 tick 走一格寻路用简单的四方向随机或朝向玩家的贪心算法炸弹则是一个带倒计时的实体在多个 tick 后把周围格子改成空。这些都不需要引入物理引擎沿用同一个固定步长循环就行。4. 渲染循环与操作手感Canvas 双缓冲、固定步长和输入状态机规则引擎跑通之后游戏能不能“玩”取决于渲染和输入。这一章处理三个问题如何让物理速度和帧率解耦如何把网格画清楚不模糊以及如何让键盘操作不丢帧不滑步。4.1 requestAnimationFrame accumulator 固定步长最省事的写法是setInterval(() update(), 60)但浏览器标签页切换回来后setInterval会堆积回调导致玩家画面瞬间跳一大段。常见做法是固定步长加 accumulatorlet lastTime 0; const STEP 60; // 逻辑步长每 60ms 跑一次 update let accumulator 0; function loop(timestamp) { const delta Math.min(timestamp - lastTime, 200); // 防止切标签页回来 delta 爆炸 lastTime timestamp; accumulator delta; while (accumulator STEP) { update(STEP); // 规则引擎每次只走一格 accumulator - STEP; } render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);STEP的单位是毫秒它决定了游戏整体节奏。拆包默认值如果是 60ms手感属于慢速推箱想更刺激可以改成 4050ms。这里的技巧是逻辑步长和渲染完全解耦update里所有时间相关的累减都用传入的delta只有render跟随着屏幕刷新率。这样 120Hz 显示器上玩家不会跑得比 60Hz 快一倍物理世界始终按固定时间轴推进。4.2 Canvas 网格绘制与高分屏适配Canvas 绘制网格第一要注意的不是颜色而是物理像素。直接写canvas.width cols * cell在 Retina 屏上会糊成一片。常见做法是乘上devicePixelRatioconst DPR window.devicePixelRatio || 1; canvas.width cols * CELL * DPR; canvas.height rows * CELL * DPR; canvas.style.width cols * CELL px; canvas.style.height rows * CELL px; ctx.setTransform(DPR, 0, 0, DPR, 0, 0);绘制每个格子时按格子坐标乘以CELL换算const TILE_COLORS { [TILE.EMPTY]: #1a1a1a, [TILE.WALL]: #4a4a4a, [TILE.DIRT]: #8a5a2a, [TILE.ROCK]: #aaaaaa, [TILE.DIAMOND]: #55ddff, [TILE.EXIT]: #ffcc55, [TILE.PLAYER]: #ff4444 }; function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let y 0; y rows; y) { for (let x 0; x cols; x) { const tile grid[y][x]; ctx.fillStyle TILE_COLORS[tile.type]; ctx.fillRect(x * CELL, y * CELL, CELL - 1, CELL - 1); } } }我一般把颜色表单独抽出来维护而不是在绘制函数里到处写#ffcc55。小地图全量重绘性能完全够30×20 格一帧才 600 次fillRect浏览器扛得住。等你以后扩展大地图再按“脏矩形”思路只重绘变化的格子现在不要提前优化。4.3 输入状态机从 keydown 到持续按键直接监听keydown去触发移动会踩两个坑一是按住方向键时系统的自动重复间隔不稳定导致移动忽快忽慢二是同时按两个方向键时后一个会覆盖前一个。常见做法是把按键状态维护成集合每帧读取一次const keys {}; window.addEventListener(keydown, (e) { keys[e.code] true; if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space].includes(e.code)) { e.preventDefault(); // 阻止方向键滚动页面 } }); window.addEventListener(keyup, (e) { keys[e.code] false; }); function handleInput(state) { let dx 0; let dy 0; if (keys[ArrowLeft] || keys[KeyA]) dx -1; if (keys[ArrowRight] || keys[KeyD]) dx 1; if (keys[ArrowUp] || keys[KeyW]) dy -1; if (keys[ArrowDown] || keys[KeyS]) dy 1; if (dx || dy) { tryMove(state.player, grid, dx, dy, cols, rows); } }写输入状态机时要注意“方向优先级”问题玩家同时按左和上代码会先执行水平移动再执行垂直移动实际结果是一帧内斜向移动了两格违背“每次只能走一格”的设计。我一般会在tryMove成功后就从handleInput返回保证一帧内至多移动一次。如果追求更细腻的手感可以维护一个“最后按下的方向”变量每帧只取它作为唯一移动方向。5. 避坑笔记五条新手必踩的本地运行问题这一章记录我在复现和改动这个资源时真实踩过的坑每一条都是现象、原因、解决三段式。新手照着改能省几个小时老手也能当快速排查清单用。5.1 石头在竖直下落时穿透钻石现象竖直通道里一块石头从上方掉下来一帧之后直接出现在钻石下方而不是压在钻石上面。钻石像被“无视”了。原因下落扫描方向搞反了或者某个格子在一帧内连续执行了两次换位。如果从上往下扫(y, x)的石头换到(y1, x)后循环后续可能又遍历到这个新位置再次让它下落一把掉到底。解决applyGravity的循环严格从rows - 2向上走到0只要方向对配合fallDelay的毫秒累减一个update内最多交换一次相邻格。检查时先在控制台打印每帧石头 y 坐标看是否出现“一跳多格”。5.2 石头往墙里推地图焊死现象玩家推动石头石头直接卡进墙或出口格子里不能再推回来出口也被堵死。原因推石头的判定只检查了玩家和目标格两格没有检查石头后面那一格。后面是墙时真实物理应该是“推不动”但代码却执行了交换把石头塞进墙体。解决在推广分支里加上石头后方格子的边界和类型检查只有behind.type TILE.EMPTY才允许交换。排查时给调试面板加一个“显示不可达格子”的开关把石头周围的非空格高亮一眼就能看出哪里有非预期碰撞。5.3 高刷屏下游戏速度翻倍现象同一个关卡文件在高刷新率显示器上打开玩家跑得比普通屏快一倍规则引擎节奏完全不对。原因update直接绑定在requestAnimationFrame回调里执行rAF 在 120Hz 屏上一秒触发 120 次update也跟着跑 120 次逻辑步长没有固定速度等于被屏幕帧率绑架。解决把update和render解耦用固定的STEP步长加accumulator累积。逻辑速度只跟STEP有关和显示器刷新率无关。这个改动对任何网格小游戏都是通用方案建议直接养成习惯。5.4 Canvas 在 Retina 屏上模糊现象游戏窗口在笔记本上打开文字和方块边缘发虚截图看像蒙了一层雾。原因canvas.width用的是 CSS 逻辑像素屏幕物理像素密度更高浏览器拉伸 canvas 时进行插值模糊。解决创建 canvas 时把宽高乘上window.devicePixelRatio || 1然后用ctx.setTransform(DPR, 0, 0, DPR, 0, 0)把坐标系缩放回来。这样绘制代码里的格子坐标不用改画面却清晰了。注意改canvas.width会清空画布resize 事件触发时要立刻触发一次重绘。5.5 玩家站在出口格子却进不去现象钻石数量集满出口看起来已经开放玩家走过去却没有触发通关像是撞到透明墙。原因出口的碰撞逻辑写成了“和墙一样不可通过”或者出口只是改了渲染颜色没有改格子碰撞类型。代码里可能存在两个地方分别维护出口状态一处改了一处没改。解决统一在loadLevel预处理时给出口格加open: false字段钻石集满时置为truetryMove里把“出口且 open”和空地并列处理。通关判定不要放在碰撞里而是玩家移动成功后检查坐标是否落在出口再进入下一关。6. 进阶把 grid 序列化回文本做一个运行中可编辑的关卡规则引擎跑通以后最值得加的功能不是特效而是“地图能存能改”。Boulder Dash 的关卡本质是一张文本表存档最稳的方案也仍然是文本。serialize函数把运行中的二维数组反向映射回字符串玩家退出时把字符串写进localStorage下次进入直接复用function serialize(grid, rows, cols) { const lines []; for (let y 0; y rows; y) { let line ; for (let x 0; x cols; x) { const t grid[y][x]; line t.type TILE.WALL ? # : t.type TILE.DIRT ? . : t.type TILE.ROCK ? O : t.type TILE.DIAMOND ? D : t.type TILE.PLAYER ? P : t.type TILE.EXIT ? X : ; } lines.push(line); } return lines.join(\n); } // 保存 localStorage.setItem(boulder-save, serialize(grid, rows, cols)); // 读取 const saved localStorage.getItem(boulder-save); if (saved) { grid loadLevel(saved.split(\n), rows, cols); }反序列化直接复用第 2 章的loadLevel只是输入从常量换成localStorage里的文本。这个方案的优点是把“存档”和“关卡编辑”合二为一存档文件和关卡文件是同一种格式你可以手工改几行再放回去验证。在此基础上还能直接在浏览器里做一个简单关卡编辑器给 canvas 加mousedown监听点击坐标除以格宽换算成格子索引把当前选中的笔刷写入格子canvas.addEventListener(mousedown, (e) { const x Math.floor(e.offsetX / CELL); const y Math.floor(e.offsetY / CELL); if (grid[y] grid[y][x]) { grid[y][x].type currentBrush; // currentBrush 来自工具栏 render(); } });配合serialize输出到页面上一个textarea就完成了“画图 → 复制文本 → 粘贴回关卡常量”的完整闭环。我甚至见过有人用这种方式反推重做整个地图设计流程效率比改纯文本快很多。以前我做网格小游戏存档格式总是写成嵌套 JSON结构复杂、字段对不齐还会丢进度。后来把所有地图数据焊死在“文本 ⇄ 数组”这个循环里代码量少了一半调试和手工改地图都方便连版本管理都变得更干净。从那以后我每次做这种网格原型都强制先跑通序列化再加玩法逻辑先保证“关掉页面还能回来”再谈好看不好看。希望帮到你。本文还有配套的精品资源点击获取