
写在前面如果你的博客除了文章列表、标签云、友链之外还藏着一个可以自由走动、打怪、捡金币的复古地下城访客会不会觉得很有意思本文就围绕“Theres a dungeon under this blog”这个彩蛋项目带你在纯前端环境里从零实现一个可嵌入博客的迷你迷宫探险游戏。不用后端、不用构建工具打开浏览器就能玩你还能把它挂到自己的博客或主页里。如果你是刚接触 Canvas 和游戏循环的初学者可以直接照着文章一步步敲如果你已有前端基础可以重点看第 3 节的核心设计拆解和第 6 节的工程建议很多思路同样适用于其他小游戏项目。1. “博客地下城”到底是一个什么项目1.1 藏在网页里的互动彩蛋所谓“博客地下城”并不是字面意义上博客下面真的有一个地下建筑而是一种网页彩蛋玩法在个人博客、开发者主页或项目文档里隐藏一个可交互的小游戏模块。访客点击某个入口就可以在浏览器里进入一个随机生成的迷宫完成拾取金币、击败敌人、找到出口的探索流程。这种玩法在独立开发者圈子里并不少见。很多个人主页会在页脚放一个像素小游戏或者在 404 页面里藏一个迷宫。它不会干扰主站内容又能让访客在阅读技术文章之余获得一点娱乐感顺便也能展示作者的前端能力。从这个角度看它更像是一个“个人品牌装饰物”而不是一个大型游戏产品。本文要实现的版本是一个复古复古风格的迷宫探险小游戏包含以下核心玩法随机生成的连通地下城地图。玩家可使用键盘方向键或 WASD 移动。地图中随机分布金币、普通敌人和 Boss。与敌人碰撞会损失 HP击败敌人可以获得金币。抵达出口石碑则通关成功。页面刷新后地图重新生成每次体验都不同。1.2 为什么选 Canvas 而不是 DOM 或终端实现迷宫渲染有几种常见方式各有适用场景。方案优点缺点适用场景DOM 网格调试方便CSS 可控性强格子多时性能下降代码量大小地图、教学演示Canvas 2D渲染性能好适合像素风格需要手动处理坐标和重绘本文选择终端模拟极客风格视觉独特键盘交互复杂浏览器兼容性一般复古 BBS 风格页面Canvas 2D 是比较折中的选择。它不像 DOM 一样需要维护几百个节点也不需要学习 WebGL 那套复杂的着色器概念直接用fillRect、arc就能画出迷宫、角色和敌人。对于地图尺寸约 31×21、格子数 651 个的地下城来说Canvas 的性能完全足够而且代码逻辑更集中。2. 环境准备与项目结构2.1 开发环境这个项目所用到的技术非常基础HTML5CSS3原生 JavaScriptES6 语法不需要安装 Node.js不需要下载任何框架也不需要配置构建工具。你只需要一个现代浏览器和任意文本编辑器即可。建议使用 Chrome、Edge 或 Firefox 的最新版本因为示例代码用到了Array.from、箭头函数、模板字符串等 ES6 特性现代浏览器都原生支持。版本方面不需要纠结根据你的实际情况调整即可。如果你的浏览器版本较老可以把箭头函数改成普通函数风险很小。2.2 项目文件结构为了方便教学我把所有代码放在一个 HTML 文件里。这样做的好处是复制后直接双击就能运行省去了本地服务器的配置。blog-dungeon/ └── index.html如果你后续想拆分成工程化项目推荐这样组织blog-dungeon/ ├── index.html ├── css/ │ └── style.css └── js/ ├── maze.js ├── renderer.js ├── game.js └── main.js单文件版本适合快速验证和嵌入博客拆分版本适合长期维护。本文先以单文件为主在第 6 节会补充模块化思路。2.3 核心模块划分虽然最终是一个单文件但代码内部仍按职责划分成几个逻辑模块地图生成模块负责生成二维数组迷宫。物品模块负责放置金币、敌人和 Boss。渲染模块负责把地图、角色、物品绘制到 Canvas。控制模块负责监听键盘事件并更新玩家位置。状态模块负责维护 HP、金币、击杀数、是否通关等游戏状态。这样的划分能让代码更清晰也方便后续扩展新功能比如多层地下城、道具系统、音效等。3. 核心设计拆解3.1 地图数据结构迷宫本质是一张二维表格因此最合适的数据结构是二维数组。grid[y][x] 0 // 墙 grid[y][x] 1 // 可通行的路这里用y作为第一维x作为第二维和 Canvas 的逐行绘制方式对应。遍历时先循环每一行再循环每一列绘制时也按这个顺序。为了配合迷宫生成算法地图尺寸取奇数比较合适。本文设定COLS 31ROWS 21每个格子大小为TILE 20像素所以 Canvas 的实际尺寸是 620×420。奇数尺寸可以保证迷宫边界是墙内部通路有明确的扩展空间。地图坐标和像素坐标需要转换const pixelX gridX * TILE; const pixelY gridY * TILE;这个公式在渲染模块中会频繁使用。3.2 迷宫生成递归回溯算法迷宫的生成算法有很多这里采用最经典的**递归回溯Recursive Backtracking**算法。它的思路可以这样理解从起点格子开始把它标记为“路”。查看当前格子周围距离为 2 的格子中间隔一堵墙。如果存在未访问过的候选格子随机选一个打通中间的墙然后走到新格子。如果周围没有候选格子就回退到上一个格子。重复这个过程直到所有格子都被访问过。用这种算法生成的迷宫具有一个显著特点任意两个通路之间一定存在一条路径不会出现死胡同无法到达的情况。对于游戏来说这一点非常重要否则玩家可能拿到一个永远走不到出口的地图。算法实现如下function generateMaze(cols, rows) { const grid Array.from({ length: rows }, () Array(cols).fill(0)); const stack [{ x: 1, y: 1 }]; grid[1][1] 1; const dirs [ { dx: 0, dy: -2 }, { dx: 0, dy: 2 }, { dx: -2, dy: 0 }, { dx: 2, dy: 0 } ]; while (stack.length 0) { const current stack[stack.length - 1]; const candidates []; for (const d of dirs) { const nx current.x d.dx; const ny current.y d.dy; if (nx 0 nx cols - 1 ny 0 ny rows - 1 grid[ny][nx] 0) { candidates.push({ x: nx, y: ny, dx: d.dx, dy: d.dy }); } } if (candidates.length 0) { const chosen candidates[Math.floor(Math.random() * candidates.length)]; grid[current.y chosen.dy / 2][current.x chosen.dx / 2] 1; grid[chosen.y][chosen.x] 1; stack.push(chosen); } else { stack.pop(); } } return grid; }这段代码中有几个细节需要说明dirs中的位移都是 2是因为我们只隔一个格子挖路保证墙和路交错。current.y chosen.dy / 2表示当前格与目标格之间的那堵墙的位置。每次生成的迷宫都不同因为候选格子采用Math.random()随机挑选。stack本身模拟了递归回溯避免函数递归过深。3.3 物品、敌人与出口的分布策略迷宫生成后地图里只有墙和路还需要加入金币、敌人和出口。为了让游戏有合理的难度曲线物品分布不能太随意需要遵守两个原则不能放在起点格否则玩家开局就能踩到金币。不能放在出口格否则出口会被其他元素遮挡。我先收集所有可通行的路排除起点和出口然后从剩余格子中随机挑选金币位置。敌人从金币挑选后剩余的路中随机选取。Boss 则放在距离出口比较近的位置形成“Boss 守门”的体验。function getRoadTiles(exceptSet) { const list []; for (let y 0; y ROWS; y) { for (let x 0; x COLS; x) { if (grid[y][x] 1 !exceptSet.has(x , y)) { list.push({ x, y }); } } } return list; }exceptSet使用字符串作为唯一标识格式是x,y。这是一种很常见的二维坐标去重写法比嵌套数组判断更直观。金币我设置为 12 枚普通敌人 4 个Boss 1 个。实际开发中你可以把数量提取成配置项甚至做成难度分级。3.4 玩家移动与战斗规则这一版游戏没有设计复杂的回合制战斗而是采用“碰撞即战斗”的简化模型玩家移动到一个有敌人的格子玩家和敌人各损失 1 点 HP。普通敌人只有 1 点 HP因此碰撞一次就能击败它。Boss 有 3 点 HP需要玩家来回碰撞 3 次才能击败。玩家初始 HP 为 5HP 降为 0 则游戏结束。这个规则虽然简单但足以体现“资源管理”的核心玩法玩家不能盲目乱走需要规划路线避免在找到出口前被敌人消耗掉太多 HP。移动时的碰撞判断非常关键if (grid[ny][nx] ! 1) return;这一行代码保证了玩家不会穿墙。很多初学者在写小游戏时会先移动角色再检测碰撞导致角色卡进墙里。正确的做法是先判断目标格子是否可通行再更新坐标。4. 完整实战代码4.1 搭建页面结构先写 HTML 骨架和基础样式。整体采用深色终端风格和“地下城”主题比较搭配。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleBlog Dungeon/title style body { margin: 0; background: #1a1a2e; font-family: Courier New, Courier, monospace; display: flex; justify-content: center; align-items: center; min-height: 100vh; } #dungeon-app { background: #0f0f1a; border: 4px solid #4ecca3; padding: 16px; border-radius: 8px; box-shadow: 0 0 24px rgba(78, 204, 163, 0.3); max-width: 660px; width: 100%; } #dungeon-app h1 { color: #4ecca3; text-align: center; margin: 0 0 8px; font-size: 20px; letter-spacing: 2px; } #dungeon-canvas { display: block; margin: 0 auto; image-rendering: pixelated; background: #0f0f1a; border: 2px solid #2d2d44; } #dungeon-status { color: #e0e0e0; margin-top: 10px; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; font-size: 14px; line-height: 1.6; } #dungeon-message { color: #ffd369; margin-top: 6px; min-height: 20px; font-size: 14px; text-align: center; } /style /head body div iddungeon-app h1Blog Dungeon/h1 canvas iddungeon-canvas width620 height420/canvas div iddungeon-status spanHP: span idhp5/span/span span金币: span idgold0/span/span span击杀: span idkills0/span/span spanWASD / 方向键移动R 重新开始/span /div div iddungeon-message向下走寻找出口石碑/div /div /body /html样式上需要注意的是image-rendering: pixelated它能保证画布在缩放时保持像素风格避免模糊。flex-wrap: wrap让状态栏在窄屏上自动换行增强移动端兼容性。4.2 实现地图生成与游戏逻辑接下来把 JavaScript 核心逻辑放到script标签中。这一部分包含地图生成、物品放置、渲染、移动控制和游戏状态管理。(function () { use strict; // 配置 const COLS 31; const ROWS 21; const TILE 20; const PLAYER_HP 5; // 状态 let grid []; let player { x: 1, y: 1, hp: PLAYER_HP, gold: 0 }; let golds []; let enemies []; let boss null; let kills 0; let gameOver false; let win false; const exit { x: COLS - 2, y: ROWS - 2 }; // DOM 元素 const canvas document.getElementById(dungeon-canvas); const ctx canvas.getContext(2d); const hpEl document.getElementById(hp); const goldEl document.getElementById(gold); const killsEl document.getElementById(kills); const messageEl document.getElementById(dungeon-message); // 迷宫生成 function generateMaze(cols, rows) { const g Array.from({ length: rows }, () Array(cols).fill(0)); const stack [{ x: 1, y: 1 }]; g[1][1] 1; const dirs [ { dx: 0, dy: -2 }, { dx: 0, dy: 2 }, { dx: -2, dy: 0 }, { dx: 2, dy: 0 } ]; while (stack.length 0) { const current stack[stack.length - 1]; const candidates []; for (const d of dirs) { const nx current.x d.dx; const ny current.y d.dy; if (nx 0 nx cols - 1 ny 0 ny rows - 1 g[ny][nx] 0) { candidates.push({ x: nx, y: ny, dx: d.dx, dy: d.dy }); } } if (candidates.length 0) { const chosen candidates[Math.floor(Math.random() * candidates.length)]; g[current.y chosen.dy / 2][current.x chosen.dx / 2] 1; g[chosen.y][chosen.x] 1; stack.push(chosen); } else { stack.pop(); } } return g; } // 获取可通行格子 function getRoadTiles(exceptSet) { const list []; for (let y 0; y ROWS; y) { for (let x 0; x COLS; x) { if (grid[y][x] 1 !exceptSet.has(x , y)) { list.push({ x, y }); } } } return list; } // 随机布置物品 function randomItems() { const except new Set([1,1, exit.x , exit.y]); const road getRoadTiles(except); const shuffledRoad road.sort(() Math.random() - 0.5); // 金币 12 枚 golds shuffledRoad.slice(0, 12).map((p) ({ x: p.x, y: p.y })); // 普通敌人 4 个 const remainingRoad shuffledRoad.slice(12); const enemyPositions remainingRoad.sort(() Math.random() - 0.5).slice(0, 4); enemies enemyPositions.map((p) ({ x: p.x, y: p.y, hp: 1 })); // Boss 放在距离出口较近的位置 const bossCandidates remainingRoad .filter((p) Math.abs(p.x - exit.x) Math.abs(p.y - exit.y) 3) .sort(() Math.random() - 0.5); if (bossCandidates.length 0) { const bp bossCandidates[0]; boss { x: bp.x, y: bp.y, hp: 3 }; enemies enemies.filter((e) !(e.x bp.x e.y bp.y)); } } // 状态更新 function updateStatus() { hpEl.textContent player.hp; goldEl.textContent player.gold; killsEl.textContent kills; } function showMessage(msg) { messageEl.textContent msg; } function checkGameOver() { if (player.hp 0) { gameOver true; showMessage(你倒在了地牢里…… 按 R 重新开始。); } } // 移动逻辑 function moveTo(nx, ny) { if (gameOver || win) return; if (grid[ny][nx] ! 1) return; player.x nx; player.y ny; // 拾取金币 const goldIndex golds.findIndex((g) g.x nx g.y ny); if (goldIndex ! -1) { golds.splice(goldIndex, 1); player.gold 1; showMessage(获得 1 枚金币); } // 普通敌人 const enemyIndex enemies.findIndex((e) e.x nx e.y ny); if (enemyIndex ! -1) { player.hp - 1; enemies[enemyIndex].hp - 1; if (enemies[enemyIndex].hp 0) { enemies.splice(enemyIndex, 1); kills 1; player.gold 2; showMessage(击败一个骷髅获得 2 枚金币。); } else { showMessage(被骷髅攻击了损失 1 点 HP。); } checkGameOver(); } // Boss if (boss boss.x nx boss.y ny) { player.hp - 1; boss.hp - 1; if (boss.hp 0) { boss null; kills 1; player.gold 5; showMessage(你击败了地下城守卫获得 5 枚金币。); } else { showMessage(你撞上了地下城守卫还需要攻击它。); } checkGameOver(); } // 抵达出口 if (nx exit.x ny exit.y) { win true; showMessage(恭喜你找到了出口石碑通关了); } updateStatus(); draw(); } // 渲染 function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let y 0; y ROWS; y) { for (let x 0; x COLS; x) { const px x * TILE; const py y * TILE; if (grid[y][x] 0) { ctx.fillStyle #1b1b2f; ctx.fillRect(px, py, TILE, TILE); ctx.fillStyle #24243e; ctx.fillRect(px 1, py 1, TILE - 2, TILE - 2); } else { ctx.fillStyle #23253a; ctx.fillRect(px, py, TILE, TILE); } } } // 出口 ctx.fillStyle #4ecca3; ctx.fillRect(exit.x * TILE 6, exit.y * TILE 6, TILE - 12, TILE - 12); // 金币 ctx.fillStyle #ffd700; for (const g of golds) { ctx.beginPath(); ctx.arc(g.x * TILE TILE / 2, g.y * TILE TILE / 2, 5, 0, Math.PI * 2); ctx.fill(); } // 普通敌人 ctx.fillStyle #ff6b6b; for (const e of enemies) { ctx.beginPath(); ctx.arc(e.x * TILE TILE / 2, e.y * TILE TILE / 2, 6, 0, Math.PI * 2); ctx.fill(); } // Boss if (boss) { ctx.fillStyle #c56cf0; ctx.fillRect(boss.x * TILE 3, boss.y * TILE 3, TILE - 6, TILE - 6); } // 玩家 ctx.fillStyle #feca57; ctx.beginPath(); ctx.arc(player.x * TILE TILE / 2, player.y * TILE TILE / 2, 7, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle #1a1a2e; ctx.fillRect(player.x * TILE 6, player.y * TILE 6, 3, 3); } // 重置游戏 function resetGame() { grid generateMaze(COLS, ROWS); player { x: 1, y: 1, hp: PLAYER_HP, gold: 0 }; kills 0; gameOver false; win false; randomItems(); updateStatus(); showMessage(向下走寻找出口石碑); draw(); } // 键盘监听 document.addEventListener(keydown, (e) { const key e.key; if (key r || key R) { resetGame(); return; } let dx 0; let dy 0; if (key ArrowUp || key w || key W) { dy -1; } else if (key ArrowDown || key s || key S) { dy 1; } else if (key ArrowLeft || key a || key A) { dx -1; } else if (key ArrowRight || key d || key D) { dx 1; } else { return; } e.preventDefault(); moveTo(player.x dx, player.y dy); }); // 初始化 resetGame(); })();把这段代码放到 HTML 中/body结束标签之前即可运行。如果按 4.1 和 4.2 两段代码拼接就是一个完整的index.html。4.3 运行效果说明用浏览器打开index.html你会看到左上角是玩家用黄色圆形表示。地图中的黄色小圆点是金币。红色圆点是普通敌人紫色方块是 Boss。右下角的青色方块是出口石碑。页面下方的状态栏实时显示 HP、金币和击杀数。按方向键或 WASD 移动按 R 可以重新生成地图并开始新一局。玩家碰到敌人会扣血击败敌人会获得金币到达出口石碑后页面会显示通关提示。4.4 如何把地下城嵌入博客完成游戏后嵌入博客是最后一步。不同博客平台支持的自定义程度不同这里提供三种方案。方案一iframe 嵌入最通用。先把游戏页面部署到任意可访问的静态地址比如 GitHub Pages、Gitee Pages 或自己的服务器然后在博客文章中插入 iframeiframe srchttps://your-blog-dungeon-url.example.com/index.html width660 height500 styleborder: none; max-width: 100%; loadinglazy /iframe方案二直接粘贴 HTML 片段。如果博客编辑器支持原生 HTML可以把div iddungeon-app以及对应的style和script直接粘贴到文章中。需要注意很多平台的编辑器会过滤script此时方案一会更可靠。方案三做成博客侧边栏小组件。把游戏缩小到 300px 宽度移除标题和状态栏只保留 Canvas然后挂到页脚或侧边栏。由于 Canvas 的交互依赖键盘侧边栏版本最好同时提供触摸方向按钮否则移动端访客无法操作。无论使用哪种方案都建议在嵌入时明确告诉访客“这是一个彩蛋小游戏”否则用户可能误以为页面出现了故障。5. 常见问题与排查思路5.1 常见问题一览问题现象常见原因解决思路页面打开后地图全是墙迷宫生成时起始点坐标错误或generateMaze未执行检查resetGame()是否被调用检查stack初始坐标是否在边界内按方向键页面跟着滚动方向键默认行为未被阻止在keydown监听中调用e.preventDefault()玩家无法移动目标格子为墙或gameOver为 true检查移动坐标计算检查游戏状态是否意外结束移动端无法操作没有触摸按钮键盘事件在手机端不可用增加虚拟方向按钮监听touchstart/click事件嵌入博客后样式错乱博客全局 CSS 影响了局部样式给容器添加独立类名使用all: initial或 iframe 隔离敌人打不死碰撞逻辑中敌人 HP 未更新或数组未删除检查enemies.splice是否执行检查findIndex是否匹配成功5.2 典型问题排查方向键导致页面滚动这是小游戏开发中很容易踩的坑。方向键和空格键在浏览器中是默认滚动快捷键所以直接在keydown里处理方向键时页面会跟着滚动。解决办法是在监听了方向键的分支中手动阻止默认行为document.addEventListener(keydown, (e) { if (e.key.startsWith(Arrow)) { e.preventDefault(); } });注意preventDefault()要放在所有条件判断之外确保只有方向键被阻止而不影响 F12、CtrlR 等浏览器快捷键。5.3 典型问题排查地图生成后没有出口通路如果出口坐标设置成了偶数坐标而迷宫生成算法只打通奇数坐标格子出口就会镶嵌在墙里导致永远无法到达。本文中exit { x: COLS - 2, y: ROWS - 2 }当COLS 31、ROWS 21时出口坐标是(29, 19)均为奇数因此可以正常连通。如果你修改了地图尺寸一定要确认出口坐标仍然位于奇数位置。更稳妥的做法是在地图生成完成后显式把出口格子设为通路grid[exit.y][exit.x] 1;这样即使地图边界调整也不会因为算法改动而导致出口被墙堵死。5.4 典型问题排查嵌入 iframe 后高度被截断iframe 的height是固定值如果游戏容器内部有自适应高度容易出现内容被截断。可以给 iframe 设置足够大的高度比如500px同时把游戏容器设置为固定宽度并居中。如果博客平台对 iframe 有安全限制可以改用 object 标签或链接跳转方式a hrefhttps://your-blog-dungeon-url.example.com/index.html target_blank 进入地下城探险 /a这个方案最简单也最容易通过平台审核因为不涉及任何脚本注入。6. 最佳实践与工程建议6.1 代码组织从单文件到模块化单文件在演示阶段很方便但它把布局、样式、逻辑混合在一起一旦项目规模扩大修改起来会非常痛苦。建议在进入长期维护阶段后把代码拆成多个文件maze.js只负责地图生成。renderer.js只负责绘制。game.js只负责游戏状态和玩家移动。main.js负责初始化。模块化带来的最大好处是职责清晰。比如你想把迷宫算法换成“房间走廊”模式只需要修改maze.js不需要动渲染代码。6.2 事件监听与内存管理document.addEventListener(keydown, handler)会长期占用监听器。在单页不销毁的场景下问题不大但如果你把游戏做成一个弹窗组件在关闭弹窗时一定要移除监听器否则会重复触发。推荐写法function onKeydown(e) { // 处理逻辑 } document.addEventListener(keydown, onKeydown); // 销毁时 document.removeEventListener(keydown, onKeydown);另外在移动端增加触摸按钮时也要注意防止按钮点击后的背景高亮和事件冒泡可以在按钮上添加e.preventDefault()。6.3 渲染优化避免不必要的重绘当前版本的draw()函数每次移动都会完整重绘整个画布。对于 31×21 的地图来说绘制 651 个格子完全不构成性能压力所以没必要过度优化。但如果地图扩大到 100×100或者你计划增加大量动态粒子效果就可以考虑“脏矩形”策略只重绘发生变化的那一小块区域而不是整个画布。做法是记录玩家的旧坐标和新坐标把两个格子范围内的区域设为“脏”每次只对脏区域调用clearRect和重绘。此外Canvas 在高分屏上会显得模糊可以通过 devicePixelRatio 适配const dpr window.devicePixelRatio || 1; canvas.width COLS * TILE * dpr; canvas.height ROWS * TILE * dpr; canvas.style.width COLS * TILE px; canvas.style.height ROWS * TILE px; ctx.scale(dpr, dpr);6.4 存档与持久化为了让玩家关闭页面后还能继续游戏可以用localStorage保存游戏状态。保存内容不需要太复杂至少包括玩家位置。玩家 HP、金币、击杀数。当前地图的二维数组。剩余金币、敌人和 Boss 的状态。加载时用JSON.parse恢复保存时用JSON.stringify写入。这里需要注意不要每次移动都写一次localStorage批量保存或者离开页面时