尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Canvas在博客底部实现一个地下城小游戏

用Canvas在博客底部实现一个地下城小游戏 如果你是一个经常逛独立博客的人大概率见过这样的彩蛋某个不起眼的链接背后藏着一整个可以玩的小游戏。“There‘s a dungeon under this blog” 说的就是这么一件事——在博客的页面底下做一个只有你真正往下翻才能发现的地下城。很多人觉得这是炫技或者是一种极客式的幽默。但从技术角度看它其实是一个很值得动手练一遍的前端综合小项目你需要处理 Canvas 渲染、网格地图、碰撞检测、游戏状态管理还要考虑怎么把它优雅地嵌进一个本来就以内容为主的页面里。工程量不算大但每个环节都能踩到真实的坑。这篇文章不是让你去复刻某个具体博客的地下城而是带你从零实现一个可以挂在任意博客底部的地下城小游戏。读完你会得到一份完整的可运行代码一套博客嵌入方案以及几个在生产环境里很容易被忽略的细节。无论你是在用 Hexo、VuePress、WordPress还是干脆就在 CSDN 的自定义 HTML 区里操作都能找到对应的接入方式。1. 为什么要在博客底下藏一个地下城先聊一个更基础的问题个人博客的访客互动为什么难做普通博客的信息架构是一维的。访客从首页进入一篇文章滚动阅读读到末尾结束。整个过程都在一个线性的文本流里除了评论区和点赞按钮几乎没有给访客留下“可以摸一摸”的东西。你花大力气写了技术干货访客看完了关掉标签页下次再来的动力完全取决于内容更新频率。地下城这样的彩蛋改变的是这个体验链路。它在页面底部制造了一个“第二层空间”访客主动往下滚动时会发现入口提示进入之后可以在里面移动、探索、收集宝藏。这个过程会带来两个非常实际的好处延长单次访问的停留时间。一局简单的迷宫探索够让访客在页面上多待一两分钟。制造记忆点。访客不一定记得你文章里某个表格但会记得“我在那个博客的地下城里找到了一把钥匙”。从开发者视角看这个项目的判断也很明确它的难点不在“做一个游戏”而在“把游戏融进一个已经存在的页面”。你需要控制脚本的侵入性确保不影响文章阅读性能还要兼容不同屏幕尺寸。这些恰恰是很多前端教程里不太会讲的工程细节。所以这篇文章的真正主线不是“怎么用 Canvas 画一个迷宫”而是“如何用最小成本为一个内容型网站附加一个可玩的功能模块”。它适合以下读者维护个人博客或公司技术专栏想提升互动率的前端开发者。想找一个短小完整、能拆开练手的 Canvas 小游戏的初学者。需要做内部网站趣味功能的同学需要一个可控、可扩展的玩法外壳。2. 地下城的技术本质网格地图、碰撞检测与游戏循环在动手写代码之前先把地下城拆开看。它看起来是一个小游戏但实际上只用了三类非常基础的前端技术二维网格数据、Canvas 渲染、以及游戏循环。2.1 网格地图是游戏的“数据层”把迷宫抽象成二维数组是这类小项目最简单也最稳定的方案。每一格用一个数字表示含义0表示可通行的地面1表示墙壁2表示宝箱3表示出口这样做的好处非常明显地图不再是一张图片而是一份可编程的数据。你可以随时修改数组重新布局迷宫也可以在运行时动态更新某个格子的值比如玩家拾取宝箱后把2改成0。一个 15 列 × 13 行的地图用数组表示大概长这样const MAP [ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], [1,0,0,0,1,0,0,0,0,0,1,0,0,0,1], [1,0,1,0,1,0,1,1,1,0,1,0,1,0,1], [1,0,1,0,0,0,0,0,1,0,0,0,1,0,1], [1,0,1,1,1,0,1,0,1,1,1,1,1,0,1], [1,0,0,0,0,0,1,0,0,0,0,0,0,0,1], [1,1,1,0,1,1,1,1,1,0,1,1,1,0,1], [1,0,0,0,0,0,0,0,1,0,0,0,1,0,1], [1,0,1,1,1,0,1,0,1,1,1,0,0,0,1], [1,0,1,0,0,0,1,0,0,0,0,0,1,0,1], [1,1,1,0,1,0,1,1,1,0,1,0,1,0,1], [1,0,0,0,1,0,0,0,0,0,1,0,0,0,1], [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], ];这张数组就是整个地下城的世界模型。玩家的位置只是两个坐标值移动逻辑则建立在这份数组之上。2.2 碰撞检测先算目标格子再做决定碰撞检测是这类游戏最核心的逻辑。玩家按下方向键时你不要直接修改玩家坐标而是先计算“目标位置”然后看目标位置对应的地图格是否可通行。这个决策顺序很重要。如果先移动再判断玩家就会卡在墙壁里如果先判断再移动代码会非常直观function tryMove(player, targetRow, targetCol) { const cell MAP[targetRow][targetCol]; if (cell 1) { // 墙壁不允许移动 return false; } player.row targetRow; player.col targetCol; if (cell 2) { // 拾取宝箱 player.treasures; MAP[targetRow][targetCol] 0; } if (cell 3) { // 到达出口 player.reachedExit true; } return true; }这段代码的逻辑在几乎任何 2D 网格游戏里都能复用。理解了这一小段后面处理怪物、传送门、钥匙门都只是在这同一个模式上加分支。2.3 游戏循环让画面动起来Canvas 是“非持久化”的绘图模式。你每画一帧上一帧的画面就消失了所以必须用循环不断重绘。标准做法是使用requestAnimationFramelet lastTime 0; function gameLoop(timestamp) { const deltaTime timestamp - lastTime; lastTime timestamp; update(deltaTime); render(); requestAnimationFrame(gameLoop); }update负责更新游戏状态render负责把状态画到 Canvas 上。对于这个地下城项目来说画面是网格地图状态变化不频繁不需要做复杂的帧率插值所以这个简单的循环结构已经足够。2.4 为何不直接用游戏引擎你可能会问为什么不直接用 Phaser、Cocos 或 Unity原因在于“嵌入博客”这个场景。博客页面需要的是一段轻量、可控、不会拖慢首屏的脚本而引入一个完整游戏引擎等于把整套运行时都搬进来收益很小代价很大。更重要的是地下城玩法本身足够简单用原生 Canvas 反而能让你清楚地理解每一帧发生了什么。这个理解比学会某个引擎的 API 更值钱。3. 搭建环境一个 HTML 文件就够了这个项目对环境的要求低得惊人。不需要 Node.js不需要构建工具甚至不需要安装任何依赖。你只需要一个现代浏览器以及一个能在本地打开 HTML 文件的文本编辑器。如果你想在本地跑一个更“工程化”的开发环境也可以准备一个轻量静态服务器# 如果你已经安装了 Python 3 cd blog-dungeon python -m http.server 8080# 如果你更习惯 Node.js cd blog-dungeon npx serve .但我得说一句这不是必须的。你只需要把下面的 HTML 代码保存成index.html双击用浏览器打开就能立刻体验游戏。之所以把环境门槛压到这么低是因为它最终要嵌入的博客平台各不相同。你无法保证自己的服务器上能跑 Node 服务也无法保证博客平台允许你上传任意脚本文件。所以最稳妥的交付物就是一个自包含的 HTML 片段CSS 内联、JavaScript 内联、没有外部依赖。只要浏览器能打开就能跑。4. 设计地图与游戏数据模型在写代码之前先把数据模型设计好。一个清晰的数据结构可以让后面的渲染和交互逻辑都变得简单。4.1 玩家状态玩家需要记录三类信息当前位置、收集进度、游戏状态。const player { row: 1, col: 1, treasures: 0, reachedExit: false, };游戏是否结束不需要单独用一个布尔值判断。只要reachedExit为true就说明玩家已经站到了出口格子上。4.2 常量定义把格子类型、格子大小、画布尺寸定义成常量方便调整const TILE_SIZE 32; const COLS 15; const ROWS 13; const canvasWidth COLS * TILE_SIZE; const canvasHeight ROWS * TILE_SIZE;这样设计后如果你想调整迷宫大小只需要改COLS和ROWS同时保证MAP数组的维度与它们一致。格子变大变小也只需要改TILE_SIZE。4.3 配色方案为了让地下城在博客里不那么突兀建议使用低饱和度的暗色系。下面这套配色在深色和浅色主题的博客上都能看清楚const COLORS { wall: #2d3142, floor: #f5f0e6, treasure: #f2c14e, exit: #70a288, player: #4f6df5, text: #2d3142, };4.4 计算总宝藏数宝箱总数可以从MAP里统计出来用于在界面上显示进度function countTreasures(map) { let count 0; for (const row of map) { for (const cell of row) { if (cell 2) count; } } return count; }5. 完整的游戏实现代码下面这份代码是一个自包含的index.html。你可以直接复制保存在浏览器里打开体验。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title博客地下城/title style #dungeon-card { max-width: 560px; margin: 40px auto; padding: 16px; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08); font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; } #dungeon-card h3 { margin: 0 0 8px; font-size: 18px; color: #1f2937; } #dungeon-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 14px; color: #6b7280; } #dungeon-toolbar button { padding: 6px 14px; background: #4f6df5; color: #ffffff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; } #dungeon-toolbar button:hover { background: #3b5bdb; } #dungeonCanvas { width: 100%; height: auto; border-radius: 8px; background: #2d3142; } #dungeon-hint { margin-top: 10px; font-size: 13px; color: #9ca3af; text-align: center; } /style /head body div iddungeon-card h3地下城探险/h3 div iddungeon-toolbar span iddungeon-status步数0 宝藏0/3/span button iddungeon-restart重新开始/button /div canvas iddungeonCanvas width480 height416/canvas div iddungeon-hint使用方向键或 WASD 移动收集所有宝箱后走向出口/div /div script (function () { const TILE_SIZE 32; const COLS 15; const ROWS 13; const MAP [ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], [1,0,0,0,1,0,0,0,0,0,1,0,0,0,1], [1,0,1,0,1,0,1,1,1,0,1,0,1,0,1], [1,0,1,0,0,0,0,0,1,0,0,0,1,0,1], [1,0,1,1,1,0,1,0,1,1,1,1,1,0,1], [1,0,0,0,0,0,1,0,0,0,0,0,0,0,1], [1,1,1,0,1,1,1,1,1,0,1,1,1,0,1], [1,0,0,0,0,0,0,0,1,0,0,0,1,0,1], [1,0,1,1,1,0,1,0,1,1,1,0,0,0,1], [1,0,1,0,0,0,1,0,0,0,0,0,1,0,1], [1,1,1,0,1,0,1,1,1,0,1,0,1,0,1], [1,0,0,0,1,0,0,0,0,0,1,0,0,3,1], [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], ]; const COLORS { wall: #2d3142, floor: #f5f0e6, treasure: #f2c14e, exit: #70a288, player: #4f6df5, }; const canvas document.getElementById(dungeonCanvas); const ctx canvas.getContext(2d); const statusEl document.getElementById(dungeon-status); const restartBtn document.getElementById(dungeon-restart); let currentMap []; let player { row: 1, col: 1, treasures: 0, steps: 0, reachedExit: false }; let totalTreasures 0; function resetGame() { currentMap MAP.map((row) [...row]); player { row: 1, col: 1, treasures: 0, steps: 0, reachedExit: false }; totalTreasures countTreasures(currentMap); render(); updateStatus(); } function countTreasures(map) { let count 0; for (const row of map) { for (const cell of row) { if (cell 2) count; } } return count; } function drawCell(row, col) { const x col * TILE_SIZE; const y row * TILE_SIZE; const cell currentMap[row][col]; ctx.fillStyle cell 1 ? COLORS.wall : COLORS.floor; ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE); if (cell 2) { ctx.fillStyle COLORS.treasure; ctx.fillRect(x 8, y 8, TILE_SIZE - 16, TILE_SIZE - 16); ctx.strokeStyle #b98800; ctx.lineWidth 2; ctx.strokeRect(x 8, y 8, TILE_SIZE - 16, TILE_SIZE - 16); } if (cell 3) { ctx.fillStyle COLORS.exit; ctx.beginPath(); ctx.arc(x TILE_SIZE / 2, y TILE_SIZE / 2, 8, 0, Math.PI * 2); ctx.fill(); } } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let row 0; row ROWS; row) { for (let col 0; col COLS; col) { drawCell(row, col); } } const px player.col * TILE_SIZE TILE_SIZE / 2; const py player.row * TILE_SIZE TILE_SIZE / 2; ctx.fillStyle COLORS.player; ctx.beginPath(); ctx.roundRect(px - 10, py - 10, 20, 20, 6); ctx.fill(); } function updateStatus() { statusEl.textContent 步数${player.steps} 宝藏${player.treasures}/${totalTreasures}; } function tryMove(dRow, dCol) { if (player.reachedExit) return; const targetRow player.row dRow; const targetCol player.col dCol; if (targetRow 0 || targetRow ROWS || targetCol 0 || targetCol COLS) { return; } const cell currentMap[targetRow][targetCol]; if (cell 1) return; player.row targetRow; player.col targetCol; player.steps; if (cell 2) { player.treasures; currentMap[targetRow][targetCol] 0; } if (cell 3) { player.reachedExit true; render(); updateStatus(); setTimeout(function () { alert(恭喜你走出了地下城。); }, 80); return; } render(); updateStatus(); } function handleKeydown(event) { const key event.key; if (key ArrowUp || key w || key W) tryMove(-1, 0); if (key ArrowDown || key s || key S) tryMove(1, 0); if (key ArrowLeft || key a || key A) tryMove(0, -1); if (key ArrowRight || key d || key D) tryMove(0, 1); if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight].includes(key)) { event.preventDefault(); } } function init() { resetGame(); document.addEventListener(keydown, handleKeydown); restartBtn.addEventListener(click, resetGame); } init(); })(); /script /body /html代码里有两个细节值得注意。第一个是ctx.roundRect。这个方法在现代浏览器里已经得到广泛支持如果你需要在更老的浏览器上运行可以改用ctx.rect或者手工绘制圆角路径。这里真正容易踩坑的地方是你的浏览器可能支持 Canvas但不支持roundRect一旦调用就会直接抛错导致整个游戏无法初始化。第二个细节是键盘事件处理。我在handleKeydown里对方向键调用了event.preventDefault()目的是防止页面跟随方向键滚动。如果少了这一行玩家在玩地下城时页面可能会上下跳动体验会非常差。6. 运行与验证效果打开上面的index.html你会看到一个 480 × 416 的画布里面是暗色迷宫。玩家是一个蓝色圆角方块位置在左上角的第一个通道格。宝藏是金色小方块出口是绿色圆点。你可以用以下步骤验证游戏逻辑是否正常按方向键或WASD控制角色移动。走到金色方块上上方状态栏里的宝藏数量会加一。走进绿色圆点页面会弹窗提示“恭喜你走出了地下城。”点击“重新开始”地图恢复初始状态步数归零。如果页面没有正常显示先按顺序检查三处打开浏览器开发者工具F12查看 Console 面板是否有报错。确认dungeonCanvas的宽高是否被正确设置。确认当前浏览器支持ctx.roundRect。如果不支持把渲染玩家那一段改成普通的ctx.fillRect。判断游戏是否真正的“可以嵌入博客”还有一个额外标准把一个完整的 iframe 页面加载到博客底部后滚动文章时不应该出现明显的性能卡顿。如果你发现游戏区域滚动掉帧优先检查是否在同一页面重复初始化了多个 Canvas而不是质疑游戏本身。7. 把地下城嵌入博客页面的三种方式游戏本身做出来只是第一步。既然目标是“Theres a dungeon under this blog”那它必须真的出现在博客里。这里给出三种不同平台下的接入方式。7.1 方案一iframe 嵌入最稳妥的方式是把上面的 HTML 保存成独立文件传到你的静态资源目录然后用 iframe 引入。它的好处是脚本和样式完全隔离不会污染博客主体页面。iframe srchttps://yourblog.com/dungeon/index.html stylewidth:100%; max-width:560px; height:540px; border:0; border-radius:12px; margin:32px auto; display:block; loadinglazy title博客地下城 /iframe这里有两个细节loadinglazy可以让 iframe 在滚到可视区域时才加载避免影响首屏性能。给 iframe 设置max-width和height可以保证在手机端不会溢出页面。对于 Hexo、VuePress、Hugo 这类静态站这是最推荐的方式。VuePress 里可以直接放在自定义组件或 Markdown 的 HTML 区域。7.2 方案二直接注入脚本和样式如果你用的是支持自定义代码的平台比如 WordPress 的“自定义 HTML”区块或 CSDN 博客的“自定义 HTML/CSS/JS”区域可以把 CSS 放在style标签里JavaScript 放在script标签里整体粘贴进去。这种方式的好处是不需要单独上传文件。但风险在于平台的编辑器可能会对script做过滤。如果发现粘贴后游戏没有出现第一件事不是检查代码而是确认平台是否允许脚本标签。7.3 方案三封装成 Web Component如果想把这段游戏抽成一个可复用的dungeon-game组件推荐使用 Web Component 封装。这样在多个页面使用时只需要一行标签dungeon-game/dungeon-game封装的核心是把游戏初始化放在自定义元素的connectedCallback里同时注意在元素卸载时移除事件监听避免内存泄漏。不过对于轻量项目来说iframe 方案已经足够Web Component 属于锦上添花。8. 常见问题与排查方法底下这张表整理了这个项目最常见的问题。如果你在接入博客时遇到了异常可以按表排查。现象可能原因排查方式解决方案页面能打开但 Canvas 是空白JavaScript 报错游戏初始化失败或 Canvas 宽高为 0F12 打开控制台查看报错信息检查ctx.roundRect是否受支持改为fillRect确认canvas.width正确按下方向键页面跟着滚动键盘事件没有调用preventDefault在handleKeydown里添加方向键拦截对方向键调用event.preventDefault()iframe 在手机端超出屏幕iframe 没有设置max-width用浏览器开发者工具切换移动模式观察给 iframe 增加width:100%; max-width:560px游戏区域影响文章加载速度没有使用懒加载或脚本放在文章首屏查看 Network 面板的加载时机给 iframe 加loadinglazy或把脚本放到页面底部博客平台不渲染script平台对自定义脚本做了安全过滤查看平台文档或换用其他区块类型改用 iframe 嵌入或把脚本放到允许执行的环境玩家移动非常卡顿每帧全量重绘时做了额外 DOM 操作检查render函数里是否有 DOM 读写把状态更新和 Canvas 绘制分开DOM 操作尽量少9. 给博客小游戏的最佳实践与扩展方向如果你不只想跑通一个示例而是想真正把它做成博客的一部分下面这些建议会很有用。9.1 让入口自然而不是强行弹出地下城之所以叫“under this blog”核心在于隐藏感。不要一进首页就弹窗提示“这里有游戏”而是把入口做成页面底部一顿小字比如“顺着楼梯往下看有一片地下世界”。访客自己发现入口的快乐比任何引导弹窗都强。9.2 记录进度但要克制你可以用localStorage记录玩家是否完成过地下城这样下回访问时可以显示不同的欢迎文案。但不要把这块功能做得太重博客的主体还是内容游戏是彩蛋不是主角。function saveProgress() { localStorage.setItem(dungeon-completed, true); } function hasCompletedDungeon() { return localStorage.getItem(dungeon-completed) true; }9.3 扩展方向从固定地图到程序化生成固定地图的好处是稳定、好调试。但如果想长期维护可以考虑用“随机迷宫生成算法”替代手写数组。下面是一个思路用递归回溯算法生成迷宫然后在地图中随机放置宝藏和出口。function generateMaze(cols, rows) { const maze Array.from({ length: rows }, () Array(cols).fill(1)); const stack []; const startRow 1; const startCol 1; maze[startRow][startCol] 0; stack.push([startRow, startCol]); const directions [ [0, 2], [2, 0], [0, -2], [-2, 0], ]; while (stack.length) { const [row, col] stack[stack.length - 1]; const candidates directions .map(([dRow, dCol]) [row dRow, col dCol]) .filter(([r, c]) r 0 r rows - 1 c 0 c cols - 1 maze[r][c] 1 ); if (candidates.length 0) { stack.pop(); continue; } const [nextRow, nextCol] candidates[Math.floor(Math.random() * candidates.length)]; maze[(row nextRow) / 2][(col nextCol) / 2] 0; maze[nextRow][nextCol] 0; stack.push([nextRow, nextCol]); } return maze; }这段代码的细节不必完全理解你需要记住的核心是生成迷宫只是把“地图数据”从手写变成算法输出渲染和碰撞逻辑完全不用改动。这就是把地图设计成数据层带来的灵活性。9.4 注重访问性能博客是内容站首屏加载体验优先。游戏资源最好全部控制在几十 KB 以内不要引入图片和音效。当前这份代码没有加载任何外部资源整个实现不到 10KB这对博客来说是非常友好的。9.5 安全与权限如果你把地下城做成一个多人排行榜需要后端存储用户数据那么一定要考虑登录鉴权和数据校验。游戏里的得分请求不能直接信任必须由服务端验证操作合法性。不过这里我们用的是纯前端本地版本不存在这个问题。地下城做好了之后下一个值得思考的方向是既然博客可以藏一个地下城那能不能在文章中藏彩蛋、在评论区里藏道具、在代码块里藏线索这其实是在锻炼一种能力把一个常规页面变成一个能让访客主动探索的空间。这种“互动感”才是这个项目的真正价值。你可以在自己博客的底部留一行小字等着第一个访客发现地下城的入口。
返回列表