博客地下城彩蛋:基于Canvas碰撞检测的轻量前端实现
2026/9/6 8:06:03 网站建设 项目流程

如果一个博客不只能读文章,还能在页面底部分明藏着一层可以探索的像素地牢,这样的互动彩蛋,最近确实让不少个人站长重新燃起了折腾博客的兴致。标题 “There's a dungeon under this blog” 说的就是这种玩法:博客表面是内容,底下还藏着一层可以走进去的小游戏。访问者从某个入口点进来,在里面移动、找钥匙、开宝箱、走向下一层,整个过程完全不依赖后端,也不改变博客原有的排版。

这篇我来完整拆一遍:从地图数据的组织方式,到 Canvas 渲染和碰撞检测,再到把游戏挂到博客上的几种入口方案,最后是运行报错和兼容性排查。适合用 Hexo、Hugo、VuePress 或者原生 HTML 博客的朋友。如果你只是想给博客加一个让人记住的小彩蛋,又不想把项目做得很重,这个方向值得试一次。

1. 先搞清楚“博客地下城”到底在玩什么

1.1 它是博客彩蛋,不是普通插件

我一开始以为这是某个博客系统自带的功能,搜了一圈才发现,市面上并没有一个统一的“博客地牢”插件。更多时候,它是开发者根据自己的前端技术栈,在博客里单独做出来的一层游戏页面。

核心逻辑很简单:博客首页或文章页提供一个入口,点击后进入一个独立的小页面。这个页面里有迷宫地图、一个可以移动的角色、一些简单的交互规则。游戏运行结束或者按退出键,再回到博客正文。它不依赖博客后台,也不参与文章分类和评论系统。

这种玩法的标志性文案就是 “There's a dungeon under this blog”。它想传达的不是“博客下面真有地牢”,而是用一种幽默的方式告诉访问者:这位站长不只是写文章,还愿意在页面底下藏点有意思的东西。

1.2 适合哪些站点,不适合哪些站点

从实际体验来看,这类地牢更适合以下几类博客:

  • 个人博客、技术博客,尤其是内容偏前端、偏趣味分享的站点。
  • 访问量不大但希望访客记住自己的个人品牌站。
  • 托管在 GitHub Pages、Vercel、Netlify 等平台的静态博客。
  • 想用一个小项目练手的前端开发者。

不适合的场景也要说清楚。

如果博客是产品文档站、技术手册,或者文章本身信息密度很高,读者进来是为了找答案,那就不适合放一个容易分散注意力的游戏。广告位多、首屏加载压力大的内容站也别加。地牢可以做成独立页面,但不要塞进每一个文章页的首屏,否则会明显影响阅读体验和页面性能。

1.3 选型原则:能纯前端解决就不引入框架

我自己的选择是:零依赖原生的 Canvas 2D 加 JSON 地图数据。原因很简单:

  • 博客页面已经要加载一堆脚本,再引游戏引擎会让体积明显增加。
  • 2D 地牢逻辑不复杂,用 Canvas 2D 足够。
  • 地图数据用数组维护,以后想加新楼层、宝箱、门锁都很方便。
  • 不依赖框架,就不会出现版本升级后游戏打不开的问题。

如果你想用 React 或 Vue 来实现,也可以,但我更建议把游戏页面独立出来,和主博客框架隔离。这样即使游戏脚本出问题,也不会影响博客本身的加载和渲染。

2. 准备一个独立的地牢页面和基础文件

2.1 目录和文件怎么放

静态博客通常有专门放静态资源的目录。Hugo 是static,Hexo 是source,VuePress 是public。在这些目录里新建一个dungeon文件夹,里面放游戏页面自己的文件。

目录结构参考:

static/dungeon/ ├── index.html ├── style.css ├── game.js └── map.json

如果你用的是原生 HTML 博客,不打构建工具,直接把dungeon文件夹放在网站根目录下就行。

放在静态目录里有一个好处:构建发布时它不会被模板引擎处理,而是按原样输出到最终站点。这样你能保证访问路径就是/dungeon/,不会因为路由规则改变而 404。

2.2 给游戏一个干净的 HTML 骨架

游戏的入口页面不需要继承博客的主题样式,因为游戏一旦进入,应该是一个独立空间,而不是博客页面里挤出来的一个小模块。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>博客地下城</title> <link rel="stylesheet" href="style.css" /> </head> <body> <div id="app"> <h1>博客地下城</h1> <canvas id="dungeonCanvas"></canvas> <p class="hint">按方向键或 WASD 移动,找到出口进入下一层。</p> </div> <script src="game.js"></script> </body> </html>

viewport 这个 meta 标签不能省略。它决定了移动端访问时页面宽度是否正确。没有它,手机上的 Canvas 会变得很小或者出现横向滚动。

2.3 基础样式注意两个点

样式文件不复杂,但有两个细节我比较在意。

body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #111; color: #eee; font-family: sans-serif; } canvas { border: 2px solid #333; max-width: 100%; height: auto; background: #e6d9c5; }

第一,min-height: 100vh加偏左偏上居中,可以让游戏画布在各种屏幕上基本居中。第二,canvasmax-width: 100%height: auto是为了防止小屏幕下 Canvas 超出可视范围。

但要注意:CSS 缩放的只是显示尺寸,不是 Canvas 内部像素坐标。如果你的地图逻辑分辨率是 400x300,手机显示时宽度会缩小,游戏内部逻辑不会变。新手最容易在这里产生困惑,以为 Canvas 被“拉伸模糊”是因为代码画错了,其实只是没有设置合适的适配策略。

3. 从地图数据写起:渲染、移动、碰撞和交互

3.1 地图用二维数组表示

地牢地图最直观的存储方式就是二维数组。数组的每一项对应一个格子,数字代表格子类型。

{ "tileSize": 40, "layers": [ [ [1,1,1,1,1,1,1], [1,0,0,0,1,0,1], [1,0,1,0,0,0,1], [1,0,1,1,1,0,1], [1,0,0,0,0,0,1], [1,1,1,1,1,1,1] ] ], "playerStart": { "row": 1, "col": 1 } }

在这个例子里:

  • 0表示可通行的地面。
  • 1表示墙,角色不能穿过去。
  • layers数组可以后续扩展,放第二层、第三层地图。
  • playerStart是玩家出生位置。

为什么用数字而不是字符?因为数字解析简单、体积小,而且很容易扩展。比如把2定义为门,3定义为钥匙,4定义为宝箱,5定义为通往下一层的楼梯。地图数据本身不需要被阅读,数字约定完全够用。

3.2 加载地图:内嵌比 fetch 更省事

一个常见做法是通过fetch('map.json')请求外部文件。

let mapData fetch('map.json') .then(r => r.json()) .then(data => { mapData = data initGame(data) })

这个写法没问题,但容易踩一个坑:如果你的博客部署在子目录,比如https://example.com/blog/,那么相对路径map.json的解析结果可能不是你预期的那个目录,导致请求 404。

对于这个小项目,我更建议直接在地图文件里内嵌 JSON 数据,或者把地图常量写进game.js。地图数据总共也就几十行,内嵌之后反而少了一次网络请求,也避免路径问题。

const MAP_DATA = { tileSize: 40, layers: [ ... ], playerStart: { row: 1, col: 1 } }

游戏逻辑第一次跑通之后,再决定要不要把地图抽成单独文件。先求稳,再优化。

3.3 Canvas 绘制:先画地图,再画角色

游戏初始化时,要读取当前楼层的地图,逐个格子绘制。

const canvas = document.getElementById('dungeonCanvas') const ctx = canvas.getContext('2d') const TILE = MAP_DATA.tileSize let currentLayer = 0 let map = MAP_DATA.layers[currentLayer] function drawMap() { const rows = map.length const cols = map[0].length canvas.width = cols * TILE canvas.height = rows * TILE for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const value = map[row][col] ctx.fillStyle = value === 1 ? '#3b3b3b' : '#e6d9c5' ctx.fillRect(col * TILE, row * TILE, TILE, TILE) } } }

这里有一个顺序问题:要先设置canvas.widthcanvas.height,再进行绘制。因为一旦修改 Canvas 的宽高,画布内容会被清空,先画再改宽高就会出现白屏。

角色可以用一个简单的圆点表示,不需要图片资源。

const player = { row: MAP_DATA.playerStart.row, col: MAP_DATA.playerStart.col } function drawPlayer() { const x = player.col * TILE + TILE / 2 const y = player.row * TILE + TILE / 2 ctx.fillStyle = '#e74c3c' ctx.beginPath() ctx.arc(x, y, TILE * 0.32, 0, Math.PI * 2) ctx.fill() }

用圆点当角色,样式上确实朴素,但好处是零资源、零加载时间。等后面想换成像素小人,再引入精灵图即可。

3.4 键盘移动和碰撞检测,顺序很关键

移动逻辑是整个游戏最容易出错的部分。我建议把移动和碰撞放在同一个函数里处理,不要先改坐标再判断。

const keyDirections = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], w: [-1, 0], s: [1, 0], a: [0, -1], d: [0, 1] } function tryMove(dRow, dCol) { const newRow = player.row + dRow const newCol = player.col + dCol if (!map[newRow]) return if (map[newRow][newCol] === 1) return player.row = newRow player.col = newCol drawMap() drawPlayer() } window.addEventListener('keydown', (e) => { const direction = keyDirections[e.key] if (!direction) return e.preventDefault() tryMove(direction[0], direction[1]) })

碰撞检测的要点是:按下方向键后,先计算目标格子坐标,检查目标格子是否合法,再决定是否更新玩家位置。千万不要先让玩家走到新位置,再回头把玩家坐标重置,这样在快速连按时会出现角色穿墙或者卡在墙里的问题。

map[newRow]的判断也很重要,它防止玩家在数组最后一行时继续向下移动导致数组越界。

3.5 门、钥匙、宝箱和下层入口的扩展思路

如果只有地面和墙壁,地下城很快就失去了探索感。可以按数字约定扩展地图对象。

  • 0地面
  • 1墙壁
  • 2
  • 3钥匙
  • 4宝箱
  • 5出口

tryMove中增加对应判断:

  • 遇到3:钥匙数量加 1,当前格子变成0
  • 遇到2:如果钥匙数量大于 0,钥匙减 1,当前格子变成0;否则提示“需要钥匙”。
  • 遇到4:记录已收集宝箱,当前格子变成0
  • 遇到5:进入下一层,重置玩家位置到下一层playerStart

第一次实现时没有必要把所有规则都写完。先做地面、墙壁、玩家移动、出口四件事,能跑通后再加钥匙和门。规则越多,排查问题越困难。

4. 把地牢挂到博客:三种入口和路径问题

游戏页面写好后,下一步就是把它挂到博客里。入口方式不同,体验差别很大。

4.1 页脚链接:最稳定,也最不被注意

最简单的入口是在博客页脚加一个普通链接:

<a href="/dungeon/">进入博客地下城</a>

这个方案不依赖 JavaScript,不产生额外的 CSS 干扰,对搜索引擎也很友好。缺点是存在感低,访客大概率不会注意到。

如果你接受“只给真正感兴趣的读者发现”的设定,页脚链接足够。很多站点就是靠这种低调入口形成一种探索感。

4.2 右下角悬浮球:更容易被发现

如果想让入口更显眼,可以用一个固定在右下角的悬浮按钮。

<a class="dungeon-float" href="/dungeon/">地牢</a>
.dungeon-float { position: fixed; right: 16px; bottom: 16px; width: 48px; height: 48px; border-radius: 24px; background: #1f1f1f; color: #fff; display: flex; align-items: center; justify-content: center; z-index: 9999; cursor: pointer; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); text-decoration: none; font-size: 14px; }

悬浮球的优势是固定可见、不随内容滚动。但它也有缺点:在移动端可能遮挡正文内容,需要设置较高的z-index才能压在博客自身元素上面。

如果你担心影响阅读,可以考虑只在首页或“关于”页显示悬浮球,而不是全站显示。

4.3 iframe 嵌入文章页:适合做玩法介绍

另一种方式是把地牢嵌进文章页,通常用于专门介绍这个玩法的博客文章里。

<iframe src="/dungeon/" width="100%" height="360" loading="lazy" style="border:0"></iframe>

iframe 的好处是样式隔离,游戏页面的 CSS 不会干扰博客主题。缺点也很明显:

  • 游戏内部焦点和博客页面滚动容易互相冲突。
  • 部分静态托管服务会在响应头里加X-Frame-Options或 CSP,导致 iframe 打不开。
  • 如果每个文章页都嵌入一个游戏,会显著增加页面加载量。

所以 iframe 更适合放在单独一篇文章或一个固定页面里,不建议全站通用。

4.4 全屏遮罩:进入感最强

我自己比较倾向的方案是:用 JS 动态创建一个全屏遮罩层,游戏页面以 iframe 形式放在遮罩里,按 Escape 退出。

function openDungeon() { const overlay = document.createElement('div') overlay.className = 'dungeon-overlay' overlay.innerHTML = '<iframe src="/dungeon/" width="100%" height="100%"></iframe>' document.body.appendChild(overlay) } document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const overlay = document.querySelector('.dungeon-overlay') if (overlay) overlay.remove() } })

这种模式的体验更接近“掉进一个地下空间”。点击入口后,整页被游戏占据,退出时又回到博客。缺点是需要处理遮罩层的样式和滚动锁定,代码量比页脚链接多不少。

4.5 静态博客的路径问题,这里要单独说

我把路径问题单独拿出来,是因为很多地牢页面做完了,链接却打不开。

如果博客部署在根域名,比如https://example.com/,那么/dungeon/没有问题。但如果博客部署在子目录,比如https://example.com/blog/,那么绝对路径/dungeon/会指向https://example.com/dungeon/,而不是https://example.com/blog/dungeon/,结果就是 404。

解决办法有两个:

  • 使用相对路径./dungeon/,让它基于当前页面路径解析。
  • 在生成或模板阶段,根据博客的 base 路径拼接完整路径。

使用绝对路径在本地预览时通常没问题,但发布到子目录后就会出现。我习惯在博客配置里定义一个变量,比如siteBase,入口链接写成{{ siteBase }}/dungeon/,这样构建时能自动适配。

5. 性能、触控与页面兼容性怎么处理

5.1 不需要每帧重绘地图

很多同学写 Canvas,第一反应就是requestAnimationFrame循环。但地牢游戏不是动作游戏,角色只在按键时移动一次。在这种情况下,每帧重绘是浪费。

更合理的做法是:只在玩家移动、地图状态变化、楼层切换时重新绘制。移动前调用一次drawMap,再调用drawPlayer。这样地图很小的时候,CPU 占用几乎可以忽略。

如果以后加入火焰动画、漂浮效果、粒子特效,再考虑引入局部更新或资源回收机制。当前阶段,能少画一帧就少画一帧。

5.2 移动端触摸控制的两种思路

PC 上方向键和 WASD 足够,但移动端必须处理触摸。

第一种思路是做四个方向按钮。

<div id="dpad"> <button>let startX = 0 let startY = 0 canvas.addEventListener('touchstart', (e) => { startX = e.touches[0].clientX startY = e.touches[0].clientY }) canvas.addEventListener('touchend', (e) => { const dx = e.changedTouches[0].clientX - startX const dy = e.changedTouches[0].clientY - startY if (Math.abs(dx) > Math.abs(dy)) { tryMove(0, dx > 0 ? 1 : -1) } else if (dy !== 0) { tryMove(dy > 0 ? 1 : -1, 0) } })

滑动控制的优点是不占用页面空间,缺点是手指短距离滑动时容易误判。建议先实现方向按钮,再考虑滑动。两者也可以同时支持。

5.3 键盘冲突和焦点问题

博客页面很可能有全局键盘事件,比如按/打开搜索框,按Escape关闭弹窗。这些事件和地牢游戏的按键监听同时存在时,容易互相冲突。

最稳妥的办法是:游戏监听挂到window上,并且只在游戏页面激活时启用。通过focusblur事件控制一个状态变量,避免游戏不在前台时还在监听方向键。

还有一个细节:方向键在浏览器里默认会触发页面滚动。在地牢页面里,一定要在keydown事件里调用e.preventDefault(),否则按方向键时,页面会跟着上下滚动,体验非常差。

6. 常见问题排查清单:从空白画布到穿墙

6.1 打开页面只有空白画布

先别急着改代码,按顺序排查:

  1. 打开浏览器控制台,看有没有 JavaScript 报错。
  2. 在 Network 面板里确认game.jsmap.json是否正确加载,有没有 404。
  3. 查看 Canvas 是否被 CSS 设置成了宽高为 0。
  4. 检查浏览器是否支持你使用的语法,比如可选链、箭头函数、async/await
  5. 如果是修改后没有生效,先强制刷新清缓存。

很多所谓“白屏”,其实是脚本加载失败或者 Canvas 宽度被设置成了 0,而不是绘图逻辑有问题。

6.2 键盘按下没反应

键盘无反应时,常见原因不是代码错了,而是焦点不在页面上。

排查顺序:

  1. 进入地牢页面后,先用鼠标点击一下画布,再按方向键。
  2. 确认keydown监听是绑在window上,而不是某个局部元素上。
  3. 检查是不是博客全局脚本把keydown事件拦截了。
  4. 在事件处理函数第一行打印console.log(e.key),确认到底有没有触发监听。
  5. 确认没有把preventDefault()放在所有按键上,导致事件处理异常中断。

6.3 角色穿墙或者卡在地图外

这个问题大多数情况下是坐标行列顺序弄反了。

地图二维数组的结构是map[row][col],也就是说第一层下标是行,第二层下标是列。如果你在渲染或移动时写成了map[col][row],就会出现横向和纵向对调,看起来像穿了墙。

还有两种情况。

一种是移动判定逻辑没有用目标格子:玩家先移动,再判断是否撞墙,结果已经进入墙体。另一种是地图数组本身有越界风险:数组的最后一行或最后一列没有做边界判断。

排查时,先在控制台打印玩家移动后的player对象,再对照地图数组看位置是否合理。如果玩家坐标出现在墙壁数值为1的格子里,就说明碰撞检测逻辑写反了。

6.4 iframe 打不开或者脚本不执行

如果只在 iframe 模式下打不开,而单独访问/dungeon/正常,问题基本出在服务器响应头。

常见的限制是X-Frame-Options: DENYframe-ancestors限制。有一些静态托管服务为了安全会默认加上这样的头,导致 iframe 无法被嵌入。

解决办法有三条:

  • 换一个托管服务或关闭该限制,但很多时候服务商不允许修改。
  • 放弃 iframe,改用全屏遮罩模式。
  • 使用同源路径并在服务端允许嵌入,前提是你对自己的服务有控制权。

如果单独访问也不正常,就回到前面的排查顺序,先解决脚本加载和路径问题。

6.5 页面发布后入口链接 404

入口链接写的是/dungeon/,但发布后打不开,最常见的原因就是子目录部署。

先直接访问https://你的域名/dungeon/,确认是否能打开。如果打不开,再访问https://你的域名/博客所在子目录/dungeon/。如果后者能打开,就说明是路径问题,按照前面说的方案改用相对路径或拼接博客 base 路径。

另外还要注意:如果博客用了前端路由,比如 VuePress 的动态路由约定,那么/dungeon/可能被路由接管。解决办法是把地牢目录放到public静态目录下,不给前端路由参与的机会。

收尾:先做一张图,稳住,再考虑机关

这个玩法真正的难点,不是做不出复杂的机关,而是让访客在第一次进入时获得清晰的反馈:看得见地图、走得动角色、找得到出口。地图数据再花哨,如果键盘没反应、角色穿墙、手机点不动,体验就全毁了。

我个人的建议是第一天只做一件事:用一张很小的地图,实现地面、墙壁、玩家移动、出口四件套。能跑通之后,优化移动端按钮;再之后,加入钥匙、宝箱、楼梯和新的楼层。等真正跑过一轮,你会发现很多报错不是代码本身的问题,而是路径、焦点、资源加载这些容易被忽略的细节。

博客地下城做好后,也可以把它当成持续更新的彩蛋。每写一篇新文章,就在地牢里加一个新房间、一个新道具,甚至把文章标题改编成墙上文字。这样博客就不再只是单向输出内容,而变成了一个可以反复探索的小空间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询