如果一个博客不只能读文章,还能在页面底部分明藏着一层可以探索的像素地牢,这样的互动彩蛋,最近确实让不少个人站长重新燃起了折腾博客的兴致。标题 “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加偏左偏上居中,可以让游戏画布在各种屏幕上基本居中。第二,canvas的max-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.width和canvas.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上,并且只在游戏页面激活时启用。通过focus和blur事件控制一个状态变量,避免游戏不在前台时还在监听方向键。
还有一个细节:方向键在浏览器里默认会触发页面滚动。在地牢页面里,一定要在keydown事件里调用e.preventDefault(),否则按方向键时,页面会跟着上下滚动,体验非常差。
6. 常见问题排查清单:从空白画布到穿墙
6.1 打开页面只有空白画布
先别急着改代码,按顺序排查:
- 打开浏览器控制台,看有没有 JavaScript 报错。
- 在 Network 面板里确认
game.js、map.json是否正确加载,有没有 404。 - 查看 Canvas 是否被 CSS 设置成了宽高为 0。
- 检查浏览器是否支持你使用的语法,比如可选链、箭头函数、
async/await。 - 如果是修改后没有生效,先强制刷新清缓存。
很多所谓“白屏”,其实是脚本加载失败或者 Canvas 宽度被设置成了 0,而不是绘图逻辑有问题。
6.2 键盘按下没反应
键盘无反应时,常见原因不是代码错了,而是焦点不在页面上。
排查顺序:
- 进入地牢页面后,先用鼠标点击一下画布,再按方向键。
- 确认
keydown监听是绑在window上,而不是某个局部元素上。 - 检查是不是博客全局脚本把
keydown事件拦截了。 - 在事件处理函数第一行打印
console.log(e.key),确认到底有没有触发监听。 - 确认没有把
preventDefault()放在所有按键上,导致事件处理异常中断。
6.3 角色穿墙或者卡在地图外
这个问题大多数情况下是坐标行列顺序弄反了。
地图二维数组的结构是map[row][col],也就是说第一层下标是行,第二层下标是列。如果你在渲染或移动时写成了map[col][row],就会出现横向和纵向对调,看起来像穿了墙。
还有两种情况。
一种是移动判定逻辑没有用目标格子:玩家先移动,再判断是否撞墙,结果已经进入墙体。另一种是地图数组本身有越界风险:数组的最后一行或最后一列没有做边界判断。
排查时,先在控制台打印玩家移动后的player对象,再对照地图数组看位置是否合理。如果玩家坐标出现在墙壁数值为1的格子里,就说明碰撞检测逻辑写反了。
6.4 iframe 打不开或者脚本不执行
如果只在 iframe 模式下打不开,而单独访问/dungeon/正常,问题基本出在服务器响应头。
常见的限制是X-Frame-Options: DENY或frame-ancestors限制。有一些静态托管服务为了安全会默认加上这样的头,导致 iframe 无法被嵌入。
解决办法有三条:
- 换一个托管服务或关闭该限制,但很多时候服务商不允许修改。
- 放弃 iframe,改用全屏遮罩模式。
- 使用同源路径并在服务端允许嵌入,前提是你对自己的服务有控制权。
如果单独访问也不正常,就回到前面的排查顺序,先解决脚本加载和路径问题。
6.5 页面发布后入口链接 404
入口链接写的是/dungeon/,但发布后打不开,最常见的原因就是子目录部署。
先直接访问https://你的域名/dungeon/,确认是否能打开。如果打不开,再访问https://你的域名/博客所在子目录/dungeon/。如果后者能打开,就说明是路径问题,按照前面说的方案改用相对路径或拼接博客 base 路径。
另外还要注意:如果博客用了前端路由,比如 VuePress 的动态路由约定,那么/dungeon/可能被路由接管。解决办法是把地牢目录放到public静态目录下,不给前端路由参与的机会。
收尾:先做一张图,稳住,再考虑机关
这个玩法真正的难点,不是做不出复杂的机关,而是让访客在第一次进入时获得清晰的反馈:看得见地图、走得动角色、找得到出口。地图数据再花哨,如果键盘没反应、角色穿墙、手机点不动,体验就全毁了。
我个人的建议是第一天只做一件事:用一张很小的地图,实现地面、墙壁、玩家移动、出口四件套。能跑通之后,优化移动端按钮;再之后,加入钥匙、宝箱、楼梯和新的楼层。等真正跑过一轮,你会发现很多报错不是代码本身的问题,而是路径、焦点、资源加载这些容易被忽略的细节。
博客地下城做好后,也可以把它当成持续更新的彩蛋。每写一篇新文章,就在地牢里加一个新房间、一个新道具,甚至把文章标题改编成墙上文字。这样博客就不再只是单向输出内容,而变成了一个可以反复探索的小空间。