ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用原生JS和Canvas还原Boulder Dash:地图、碰撞与重力全攻略

用原生JS和Canvas还原Boulder Dash:地图、碰撞与重力全攻略 简介一份基于JavaScript实现的简约版《宝石迷阵》Boulder Dash游戏源码包适合想要入门网页游戏开发的初学者学习。项目通过经典挖矿收集玩法完整展示了DOM操作、事件监听、requestAnimationFrame游戏循环、二维数组地图管理等基础技术可作为从零搭建小游戏的范例。压缩包共20个文件体积仅35KB结构清晰3个js文件分别负责游戏逻辑、关卡地图与统计调试1个html为运行入口1个css负责界面样式另有12个gif和3个png提供角色、宝石及动画素材非常适合直接阅读和修改。目前已有76人学习下载可用作前端进阶的小型实战项目。通过分析源码读者可以理解游戏循环如何驱动状态更新、碰撞检测如何判断角色与障碍物交互以及如何用数组表示地图并动态渲染画面为日后编写复杂游戏打下基础。1. 做 js 游戏不一定非上引擎这个 Boulder Dash 项目把经典玩法全跑了起来很多人以为做 js 游戏就得用 Phaser、Three.js 这类引擎但实际上像 Boulder Dash 这种基于格子地图的经典解谜游戏用原生 JavaScript 加 Canvas 就能做得很完整。Simple Boulder Dash Game 这个 JS 游戏资源把 1984 年街机版的核心理念搬进了浏览器玩家扮演挖掘工在地底洞穴里挖穿泥土、收集宝石同时躲开落石和敌人凑齐目标数量后找到出口过关。它帮你省掉的不只是造轮子的时间更是把「地图、实体、碰撞、胜负判定」这一整套解谜游戏的骨架用最直白的代码摆在了你面前。适合两类人一是想完整走一遍游戏开发流程的前端学习者二是想研究格子解谜关卡逻辑、直接拿现成模块开改的独立开发者。项目不大逻辑集中在几个核心类里拆起来非常顺手。2. 核心机制拆解地图、移动、落石与胜负判定2.1 地图的数据结构二维数组与图块编号Boulder Dash 的地图天然是网格用二维数组保存是成本最低的做法。每个格子用一个整数代表一种物件而不是存对象引用原因很实际整数数组可以一键 JSON 序列化、保存关卡进度写关卡编辑器时直接改数字就能排版调试时打印出来的也是一张一眼能看懂的矩阵。const TILE { EMPTY: 0, // 空位玩家和石块可以进入 DIRT: 1, // 泥土可挖穿挖开变成 EMPTY ROCK: 2, // 岩石受重力影响可左右推动 DIAMOND: 3, // 宝石受重力影响收集目标 WALL: 4, // 墙壁不可挖掘不可破坏 EXIT: 5, // 出口收集足够宝石后开放 PLAYER: 6, // 玩家位置 ENEMY: 7, // 敌人碰到即死 }; // 一个 8x8 的最小测试关卡 const LEVEL_MAP [ [4, 4, 4, 4, 4, 4, 4, 4], [4, 1, 1, 2, 3, 1, 1, 4], [4, 1, 6, 1, 1, 3, 1, 4], [4, 2, 1, 1, 2, 1, 1, 4], [4, 1, 3, 1, 1, 1, 2, 4], [4, 1, 1, 1, 3, 1, 1, 4], [4, 4, 4, 4, 4, 4, 4, 4], ];TILE 枚举里我特意把注释写全因为后续所有碰撞逻辑都在跟这些数字打交道。玩家初始在[2][1]第 2 行第 1 列周围一圈用 4 围死保证测试时玩家不会掉出地图。这里有个很多人新手期会踩的小细节二维数组的行列顺序是grid[row][col]先行后列如果你写成grid[x][y]画图的时候会跟直觉差一整圈排查半天看不出来。2.2 玩家移动与碰撞判定先算后画不碰边界移动是回合制和实时制结合的一种写法主循环里不断检测按键状态一旦有方向输入就调用移动函数。移动函数的核心不是「把玩家挪过去」而是「先判断目标位置能不能进」。movePlayer(dx, dy) { const nx this.player.x dx; const ny this.player.y dy; // 1. 边界检查必须先做数组范围判断否则访问到 undefined if (ny 0 || ny this.rows || nx 0 || nx this.cols) { return false; } const target this.grid[ny][nx]; // 2. 空位直接走 if (target TILE.EMPTY) { this.grid[this.player.y][this.player.x] TILE.EMPTY; this.grid[ny][nx] TILE.PLAYER; this.player.x nx; this.player.y ny; return true; } // 3. 泥土挖开走进去 if (target TILE.DIRT) { this.grid[this.player.y][this.player.x] TILE.EMPTY; this.grid[ny][nx] TILE.PLAYER; this.player.x nx; this.player.y ny; return true; } // 4. 宝石收集 if (target TILE.DIAMOND) { this.collected; this.uiUpdate(); // 更新 HUD 显示 this.grid[this.player.y][this.player.x] TILE.EMPTY; this.grid[ny][nx] TILE.PLAYER; this.player.x nx; this.player.y ny; return true; } return false; // 墙、岩石、出口默认不可走 }这段逻辑的顺序是有讲究的先做边界判断再去读grid[ny][nx]。如果把顺序反过来玩家走到地图边缘时grid[-1][x]返回undefined后续所有条件都会静默失败表现出来就是「角色到了边缘就消失」。宝石这块单独写是因为它同时涉及三件事更新计数、更新界面、移动玩家任何一个漏掉都会让玩家感觉「宝石吃了没反应」。2.3 落石重力为什么从底部往上扫描Boulder Dash 最核心的物理就是重力——岩石和宝石下面空了就会往下掉。实现重力有个经典陷阱遍历顺序错了石块会「瞬移」甚至穿透。updateGravity() { // 从倒数第二行往上扫最后一行是地板不需要检查 for (let y this.rows - 2; y 0; y--) { for (let x 0; x this.cols; x) { const tile this.grid[y][x]; // 只有岩石和宝石受重力影响 if (tile ! TILE.ROCK tile ! TILE.DIAMOND) continue; const below this.grid[y 1][x]; if (below TILE.EMPTY) { // 正常下落把当前格子清空下面格子变成石块/宝石 this.grid[y 1][x] tile; this.grid[y][x] TILE.EMPTY; } else if (below TILE.PLAYER) { // 石头砸中玩家游戏结束 this.gameOver(squashed); } } } }关键在于y的遍历方向是从下往上。假设一个石块在第 2 行第 3 行是空的第 4 行也是空的。如果你从上往下扫处理第 2 行时发现第 3 行空把石块挪到第 3 行等循环跑到第 3 行时发现下面第 4 行还是空于是同一帧里这个石块又掉了一层。看起来没问题但只要下面有玩家或者另一个石块这个「二次下落」可能直接跳过碰撞判定造成石头穿模。从底往上扫先处理最下面的石块上面的石块移动到已处理过的行时不会再被重复扫描一帧最多掉一格物理规则稳定可控。2.4 胜负判定与出口开放收集足够宝石才能过关经典 Boulder Dash 里出口是隐藏的只有收集够指定数量宝石后才会开放。这个资源实现时把逻辑做成了两种模式的切换未达标时 EXIT 格子在碰撞判定里等于墙达标后变成可通行。checkWin() { const playerTile this.grid[this.player.y][this.player.x]; if (playerTile TILE.EXIT this.collected this.required) { this.status win; this.stopLoop(); this.showWinScreen(); } } // 在 movePlayer 里EXIT 的判定需要动态处理 isExitPassable() { return this.collected this.required; }required是每关的目标宝石数放在配置对象里单独管理。这个资源把「出口是否可走」做成了一个独立方法isExitPassable()而不是在movePlayer里写死target TILE.EXIT this.collected this.required这样后续做多关卡时每关只需要改required不用动碰撞逻辑。3. 从零组装一个可玩版本文件结构、主循环与键盘输入3.1 文件结构与入口别把代码全塞进一个 HTML 文件我见过不少新手做的 JS 游戏所有逻辑堆在一个index.html的script标签里前期跑得通一旦要加敌人、加关卡改一处坏三处。这个资源的做法是把职责拆开按模块放文件。simple-boulder-dash/ ├── index.html ├── css/ │ └── style.css └── js/ ├── config.js // TILE 枚举、关卡地图、全局参数 ├── game.js // Game 类核心逻辑 ├── input.js // 键盘事件与按键状态 └── main.js // 入口初始化 主循环!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleSimple Boulder Dash/title style body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #1e1e1e; } canvas { image-rendering: pixelated; /* 保持像素风清晰 */ background: #000; } /style /head body canvas idgameCanvas width480 height480/canvas script srcjs/config.js defer/script script srcjs/game.js defer/script script srcjs/input.js defer/script script srcjs/main.js defer/script /body /htmldefer属性保证脚本按顺序在文档解析完成后执行这样config.js里的TILE和LEVEL_MAP一定在其他文件加载前就绪。Canvas 的width和height是像素分辨率我这里先写死 480×480后续在主循环里会根据实际关卡尺寸动态调整——因为每个关卡的cols和rows不同固定画布大小会让地图拉伸变形。3.2 Game 类把状态、更新、渲染三条线分开Game类是整套代码的骨架。我拆它的标准很简单状态只管「现在是什么情况」更新只管「逻辑怎么变」渲染只管「画面怎么画」。三条线混在一起的话后期加一个特效都会担心破坏判定逻辑。class Game { constructor(levelMap) { this.rows levelMap.length; this.cols levelMap[0].length; // 深拷贝地图避免修改原关卡数据 this.grid levelMap.map(row [...row]); this.player this.findPlayer(); this.collected 0; this.required 18; // 需要收集的宝石数量后续从配置读取 this.status playing; // playing | win | dead this.enemies this.findEnemies(); } update(dt) { if (this.status ! playing) return; this.updateGravity(); this.updateEnemies(dt); this.checkGameOver(); this.checkWin(); } render(ctx) { ctx.clearRect(0, 0, this.cols * TILE_SIZE, this.rows * TILE_SIZE); for (let y 0; y this.rows; y) { for (let x 0; x this.cols; x) { this.drawTile(ctx, x, y, TILE_SIZE); } } this.drawHUD(ctx); } }构造函数里levelMap.map(row [...row])是很多人会漏的一步。如果直接this.grid levelMap游戏里所有对grid的修改都会写回到原始关卡数组重启关卡时地图已经被挖得乱七八糟。findPlayer()和findEnemies()是在grid里扫描TILE.PLAYER和TILE.ENEMY把位置记录到独立变量里这样更新时不用每次都全图搜索玩家在哪。update(dt)接收dt参数是上一帧到这一帧的时间差。所有与时间相关的逻辑——敌人移动、游戏计时——都基于dt计算而不是「每帧固定走一步」。3.3 键盘输入用状态映射代替事件驱动移动新手最容易犯的错是直接在keydown事件里调用movePlayer。这样做的直接后果是按住方向键时系统自动重复触发keydown每秒约 30 次角色会加速甚至瞬移。正确做法是维护一张「按键状态表」主循环里每帧读取这张表。// input.js const keys {}; document.addEventListener(keydown, (e) { // 阻止方向键滚动页面这是 JS 游戏里最常见的坑之一 if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight].includes(e.key)) { e.preventDefault(); } keys[e.key] true; }); document.addEventListener(keyup, (e) { keys[e.key] false; }); function getDirection() { let dx 0, dy 0; if (keys[ArrowLeft] || keys[a]) dx -1; if (keys[ArrowRight] || keys[d]) dx 1; if (keys[ArrowUp] || keys[w]) dy -1; if (keys[ArrowDown] || keys[s]) dy 1; return { dx, dy }; }主循环里这样消费输入// main.js let lastTime 0; function gameLoop(timestamp) { const dt Math.min(timestamp - lastTime, 50); // 防止切后台后 dt 过大 lastTime timestamp; const { dx, dy } getDirection(); if (dx ! 0 || dy ! 0) { game.movePlayer(dx, dy); } game.update(dt); game.render(ctx); requestAnimationFrame(gameLoop); } const game new Game(LEVEL_MAP); requestAnimationFrame(gameLoop);Math.min(timestamp - lastTime, 50)这行是保底。用户切换到其他标签页再切回来时timestamp会突然跳一大截如果直接把整个 dt 交给update()游戏里所有基于时间的逻辑会瞬间「追帧」最典型的表现是敌人瞬移。限制最大 50ms 后逻辑最多按 50ms 处理剩下的直接丢弃游戏体验不会崩。4. 关卡设计与敌人 AI让地图不只是静态数据4.1 从测试关卡到完整关卡布局比例是关键测试关卡用 8×8正式关卡至少 12×12 才有解谜感。数字矩阵直接搬进代码虽然简陋但胜在直观。下面是一个 12×12 的示例关卡周围一圈是墙内部按「泥土为主、宝石分散、岩石压顶」的经典思路摆放。const LEVEL_MAP [ [4,4,4,4,4,4,4,4,4,4,4,4], [4,1,1,2,3,1,1,2,1,3,1,4], [4,1,6,1,1,1,1,1,1,1,1,4], [4,2,1,1,2,3,1,2,1,1,1,4], [4,1,1,3,1,1,1,1,3,1,2,4], [4,1,2,1,1,1,2,1,1,1,1,4], [4,1,1,1,1,3,1,1,2,3,1,4], [4,3,1,2,1,1,1,1,1,1,1,4], [4,1,1,1,3,1,2,1,1,2,1,4], [4,2,1,1,1,1,1,1,3,1,1,4], [4,1,3,1,2,1,3,1,1,1,5,4], [4,4,4,4,4,4,4,4,4,4,4,4], ];这个关卡的数值设计遵循几个原则宝石数量在 1820 个目标收集数required设为 18意味着玩家不需要全收集留出错峰操作的余地岩石数量控制在 12 个左右超过 15 个会频繁砸死玩家挫败感太强敌人只放 2 个。泥土密度大约在 60%70% 之间太密了挖得累太稀了重力机制体现不出来。参数推荐值说明地图尺寸12×12 到 16×16小于 12 没有解谜空间大于 16 地图文件难维护宝石总数目标收集数 × 1.2 以上留出容错卡关时可以绕路岩石数量格子总数的 8% 左右太多导致每一步都危险敌人数量13 个超过 3 个在窄通道里无解泥土密度60%70%低于 50% 时重力事件太少玩法趋近于纯收集4.2 敌人简单 AI随机游走与概率追踪完全随机游走的敌人没有压迫感但一直追着玩家跑又会让新手没法玩。这个资源采用「基础随机 概率追踪」的混合策略每帧有 35% 概率朝玩家方向移动其余 65% 随机选可行方向。参数可以根据关卡难度调越靠后的关追踪概率越高。updateEnemies(dt) { const dirs [ { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }, ]; for (const enemy of this.enemies) { const { x, y } enemy; let moveDir null; // 35% 概率追踪玩家 if (Math.random() 0.35) { const dx this.player.x - x; const dy this.player.y - y; if (Math.abs(dx) Math.abs(dy)) { moveDir { dx: Math.sign(dx), dy: 0 }; } else { moveDir { dx: 0, dy: Math.sign(dy) }; } } else { moveDir dirs[Math.floor(Math.random() * dirs.length)]; } if (moveDir) { const nx x moveDir.dx; const ny y moveDir.dy; // 敌人不能走进墙、岩石、宝石但可以走进泥土 const target this.grid[ny][nx]; if (target TILE.EMPTY || target TILE.DIRT) { this.grid[ny][nx] TILE.ENEMY; this.grid[y][x] TILE.EMPTY; enemy.x nx; enemy.y ny; } } // 玩家与敌人重叠判定 if (enemy.x this.player.x enemy.y this.player.y) { this.gameOver(caught); } } }这段代码有两点值得说明。第一追踪算法没有用寻路只比较横向和纵向距离哪个大就沿哪边走这是经典解谜游戏里非常标准的「贪心追踪」虽然会被墙挡住但配合随机游走已经能制造足够的心理压力。第二敌人移动在update里按dt累积时间每 250ms 才实际走一步这样敌人速度不会受帧率影响帧率 60 和 144 的电脑上体验一致。4.3 推动机制左右推石头一个方向一个判定Boulder Dash 玩家可以横向推动岩石但不能垂直拉或推头顶的石头。这个规则用代码表达非常简洁但要注意边界情况——石头旁边必须有一个空位能接收它。// 在 movePlayer 里处理 ROCK 时追加这段逻辑 if (target TILE.ROCK dx ! 0) { // 玩家左右推动检查石头另一侧是否为空 const pushX nx dx; const sideTile this.grid[ny][pushX]; if (sideTile TILE.EMPTY) { // 石头被推动一格 this.grid[ny][pushX] TILE.ROCK; this.grid[ny][nx] TILE.PLAYER; this.grid[this.player.y][this.player.x] TILE.EMPTY; this.player.x nx; this.player.y ny; return true; } return false; }注意这里有个隐藏规则只有当玩家从侧面推石头时才允许。dx ! 0保证了上下方向的碰撞不会触发推动逻辑所以玩家不可能把头顶的石头顶上去这也是原版游戏的核心限制之一。另一个细节是推动后立即把玩家放到原来石头的位置而不是先移动玩家再处理石头否则会出现「玩家和石头重叠一帧」的闪烁画面。5. 避坑指南做 JS 游戏最容易翻车的五个细节做这个项目的过程里我踩了不少坑有些坑反复出现这里挑五个最典型的每一条都是「现象 → 原因 → 解决」的完整链路照方抓药基本都能解决。5.1 现象按方向键整个页面跟着滚动游戏画面不动原因方向键是浏览器的默认滚动键。只要页面高度超过视口高度按上下键时浏览器第一优先级是滚动页面keydown事件里如果没拦截游戏根本接收不到输入。解决在keydown监听器里对四个方向键调用e.preventDefault()。注意只拦截方向键不要拦截全部按键——否则F12打开开发者工具、CtrlR刷新页面都会失灵纯属给自己添堵。document.addEventListener(keydown, (e) { if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight].includes(e.key)) { e.preventDefault(); } keys[e.key] true; });5.2 现象快速下落时岩石和宝石闪烁甚至隔着墙「穿」过去原因重力 update 里遍历顺序错误。如果从y 0向下遍历一个石块下落到新位置后同一帧的循环可能再次处理它产生二次下落。当二次下落的目标格里有玩家或边界时碰撞判定被跳过看起来就是穿模。解决从底部向上遍历确保每个石块一帧最多移动一格。同时注意循环的起始行必须是rows - 2因为最后一行是地板不需要检查它下方的格子——直接访问grid[rows][x]是数组越界返回undefined拿undefined跟TILE.EMPTY比较永远为false看起来没报错但代码已经是「坏」的状态。5.3 现象按住方向键不松手角色越走越快松开后还多走一步原因keydown事件在按键按住期间会以约 30 次/秒的频率重复触发。直接在事件回调里调movePlayer()就等于每 33ms 移动一次。松开按键时最后一次keydown已经被浏览器排队所以会「多走一步」。解决用按键状态表主循环里每帧统一读取keys状态。如果想让角色移动有「按键延迟」手感可以加一个inputCooldown计时器——例如每 120ms 才允许一次位移这样按住方向键时角色会连续而匀速地移动而不是被系统重复触发频率绑架。let inputCooldown 0; function updateInput(dt) { inputCooldown - dt; const { dx, dy } getDirection(); if ((dx ! 0 || dy ! 0) inputCooldown 0) { game.movePlayer(dx, dy); inputCooldown 120; // 毫秒 } }5.4 现象Retina 屏上游戏画面模糊像素风格变成毛边原因Canvas 的width和height属性是画布像素分辨率而 CSS 尺寸是显示尺寸。高分屏devicePixelRatio 2 或 3上浏览器把 480×480 的画布拉伸到 960×960 甚至 1440×1440 来显示像素被放大填充自然就糊了。解决按devicePixelRatio缩放画布内部分辨率再通过 CSS 强制显示尺寸。社区里管这个叫「高清屏适配」代码很固定const canvas document.getElementById(gameCanvas); const dpr window.devicePixelRatio || 1; canvas.width 480 * dpr; canvas.height 480 * dpr; canvas.style.width 480px; canvas.style.height 480px; ctx.scale(dpr, dpr);5.5 现象用 setInterval 驱动游戏切后台回来后游戏「暴走」原因setInterval(fn, 16.7)并不能保证 60 帧执行。浏览器对后台标签页的setInterval会强制节流到每秒 1 次甚至暂停长时间切后台回来后积压的更新逻辑被集中触发所有物理和 AI 在同一段时间里疯狂追赶角色直接瞬移几格。解决改用requestAnimationFrame驱动并用时间差dt控制所有自增和自减逻辑。rAF本身会在标签页不可见时暂停但切回来时会重新同步时间戳配合「dt 最大 50ms」的钳制不会出现追帧暴走。6. 跑起来之后验证逻辑正确性的三板斧游戏能跑不算完碰撞逻辑改一版、加个新敌人很容易引入隐蔽问题。我一般用三个手段来做验证比肉眼盯着画面高效得多。6.1 用控制台断言固化关键状态把「游戏进行到某一步时某个格子必须是什么状态」写成断言跑一步查一次// 示例验证第 3 行第 5 列在 10 次移动后必须是宝石 console.assert( game.grid[3][5] TILE.DIAMOND, 第 3 行第 5 列状态异常实际值: ${game.grid[3][5]} );断言只在条件不成立时输出错误信息不会打断流程。我会把每关的关键坐标点比如出口位置、某个宝石位置整理成清单每次启动时跑一遍全量断言任何一次碰撞规则的误改都能立刻暴露。6.2 输入回放让 bug 自动复现有些 bug 是特定操作序列触发的手工反复试不一定能复现。我的做法是录制输入序列然后自动重放const replay []; let startTime 0; document.addEventListener(keydown, (e) { replay.push({ key: e.key, time: performance.now() - startTime }); }); function playReplay() { game.reset(); for (const step of replay) { setTimeout(() { // 直接调用内部方法不经过事件系统避免浏览器干扰 game.movePlayerByKey(step.key); }, step.time); } }录制时从游戏启动那一刻开始记时间戳重放时用setTimeout按时间戳依次触发。注意重放时直接调用game.movePlayerByKey()而不是派发真实键盘事件这样可以排除掉系统按键延迟的干扰保证每次重放都是完全一样的输入序列。6.3 帧耗时测量把优化建立在数据上打开 Chrome DevTools 的 Performance 面板点 Record 玩三十秒观察主线程火焰图有没有超过 16ms 的长任务这个操作太繁琐。我偷懒的做法是在代码里直接测量const t0 performance.now(); game.update(dt); game.render(ctx); const frameCost performance.now() - t0; // 持续 30 帧在 16ms 以上说明有性能问题 if (frameCost 16) { console.warn(帧耗时过高: ${frameCost.toFixed(2)}ms); }这个数字比任何玄学判断都靠谱。如果帧耗时持续超过 16ms优先检查updateGravity里的双重循环——常见瓶颈是每帧都map深拷贝整个grid明明只需要在初始化时拷贝一次。我最早做这个项目时把石头下落逻辑直接写进了玩家移动函数里结果石头跟着玩家走硬生生变成了「保镖」。调试了两个小时才意识到碰撞逻辑和物理逻辑必须分开更新。从那以后每次改动碰撞或物理代码我都强制走一遍输入回放和断言检查——先录一段完整操作再改代码再重放一遍对比前后两次的关卡状态。这套流程虽然多花五分钟但省下来的排查时间是以小时计的。希望帮到你。本文还有配套的精品资源点击获取
返回列表