博客地下城彩蛋:基于Canvas碰撞检测的轻量前端实现 如果一个博客不只能读文章还能在页面底部分明藏着一层可以探索的像素地牢这样的互动彩蛋最近确实让不少个人站长重新燃起了折腾博客的兴致。标题 “Theres a dungeon under this blog” 说的就是这种玩法博客表面是内容底下还藏着一层可以走进去的小游戏。访问者从某个入口点进来在里面移动、找钥匙、开宝箱、走向下一层整个过程完全不依赖后端也不改变博客原有的排版。这篇我来完整拆一遍从地图数据的组织方式到 Canvas 渲染和碰撞检测再到把游戏挂到博客上的几种入口方案最后是运行报错和兼容性排查。适合用 Hexo、Hugo、VuePress 或者原生 HTML 博客的朋友。如果你只是想给博客加一个让人记住的小彩蛋又不想把项目做得很重这个方向值得试一次。1. 先搞清楚“博客地下城”到底在玩什么1.1 它是博客彩蛋不是普通插件我一开始以为这是某个博客系统自带的功能搜了一圈才发现市面上并没有一个统一的“博客地牢”插件。更多时候它是开发者根据自己的前端技术栈在博客里单独做出来的一层游戏页面。核心逻辑很简单博客首页或文章页提供一个入口点击后进入一个独立的小页面。这个页面里有迷宫地图、一个可以移动的角色、一些简单的交互规则。游戏运行结束或者按退出键再回到博客正文。它不依赖博客后台也不参与文章分类和评论系统。这种玩法的标志性文案就是 “Theres 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 是staticHexo 是sourceVuePress 是public。在这些目录里新建一个dungeon文件夹里面放游戏页面自己的文件。目录结构参考static/dungeon/ ├── index.html ├── style.css ├── game.js └── map.json如果你用的是原生 HTML 博客不打构建工具直接把dungeon文件夹放在网站根目录下就行。放在静态目录里有一个好处构建发布时它不会被模板引擎处理而是按原样输出到最终站点。这样你能保证访问路径就是/dungeon/不会因为路由规则改变而 404。2.2 给游戏一个干净的 HTML 骨架游戏的入口页面不需要继承博客的主题样式因为游戏一旦进入应该是一个独立空间而不是博客页面里挤出来的一个小模块。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title博客地下城/title link relstylesheet hrefstyle.css / /head body div idapp h1博客地下城/h1 canvas iddungeonCanvas/canvas p classhint按方向键或 WASD 移动找到出口进入下一层。/p /div script srcgame.js/script /body /htmlviewport 这个 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 classdungeon-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/ width100% height360 loadinglazy styleborder:0/iframeiframe 的好处是样式隔离游戏页面的 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/ width100% height100%/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 iddpad 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静态目录下不给前端路由参与的机会。收尾先做一张图稳住再考虑机关这个玩法真正的难点不是做不出复杂的机关而是让访客在第一次进入时获得清晰的反馈看得见地图、走得动角色、找得到出口。地图数据再花哨如果键盘没反应、角色穿墙、手机点不动体验就全毁了。我个人的建议是第一天只做一件事用一张很小的地图实现地面、墙壁、玩家移动、出口四件套。能跑通之后优化移动端按钮再之后加入钥匙、宝箱、楼梯和新的楼层。等真正跑过一轮你会发现很多报错不是代码本身的问题而是路径、焦点、资源加载这些容易被忽略的细节。博客地下城做好后也可以把它当成持续更新的彩蛋。每写一篇新文章就在地牢里加一个新房间、一个新道具甚至把文章标题改编成墙上文字。这样博客就不再只是单向输出内容而变成了一个可以反复探索的小空间。

相关新闻

最新新闻

STM32CubeMX配ADC:EOC选项为何消失?扫描模式与HAL库的真相

STM32CubeMX配ADC:EOC选项为何消失?扫描模式与HAL库的真相

用STM32CubeMX配ADC,遇到过这么一件怪事:我在Parameter Settings里翻来覆去,就是找不到一个叫“EOC DISABLE”之类的选项。去ST社区和StackOverflow上一搜,问这个问题的人还真不少,有人还专门发了“STM32CubeMX ADC mi…

2026/8/29 19:22:12
STM32调试报错No target found?从硬件连接到芯片锁死的完整排查指南

STM32调试报错No target found?从硬件连接到芯片锁死的完整排查指南

做嵌入式开发的人应该都见过这个红字:Error: No STM32 target found! If your product embeds Debug Authentication, please perform a discovery using Debug Authentication。这个报错在STM32调试里出现频率非常高,烦人程度也排得上号。它可能出现在你…

2026/8/29 19:22:12
STM32G4双Bank Flash实现安全OTA升级与启动原理详解

STM32G4双Bank Flash实现安全OTA升级与启动原理详解

做嵌入式固件开发的朋友,几乎迟早会撞上“现场升级”这道坎。以前我在一些不带双Bank的MCU上做OTA,最提心吊胆的就是升级过程中断电,或者新固件校验通过但跳过去就死机,最后要么返厂要么用烧录器救砖。后来换到STM32G4上做电机控制…

2026/8/29 19:22:12
蓝桥杯单片机决赛实战:环境监测系统设计全解析

蓝桥杯单片机决赛实战:环境监测系统设计全解析

1. 赛题回顾与核心挑战解析 “蓝桥杯”全国软件和信息技术专业人才大赛的单片机设计与开发赛道,一直是电子、自动化、计算机等相关专业学生检验和提升实践能力的试金石。第11届的决赛题目,以其综合性、实战性和对细节的极致要求,给参赛选手留…

2026/8/29 19:22:12
STM32H5 USBx下为HID设备添加OUT端点实现双向通信

STM32H5 USBx下为HID设备添加OUT端点实现双向通信

做HID设备双向通信的时候,我在LAT1658这块STM32H5板卡上踩了一个很典型的坑:USBx中间件默认生成的HID工程,只有一条IN端点,设备能往主机发数据,主机却连一条指令都发不下来。鼠标键盘这类纯上报场景够用,但…

2026/8/29 19:22:12
Claude 挑战黎曼猜想?大模型数学推理能力边界与工程复现

Claude 挑战黎曼猜想?大模型数学推理能力边界与工程复现

先说结论:这次“Claude 挑战黎曼猜想”,并不是真的把 160 年悬案解决了,而是模型在数学问题上的“长链条推理表现”超出了不少人的预期——它输出了大规模、看起来结构完整的推理文本,却在一个关键节点上犯了实质错误。真正让数学…

2026/8/29 19:17:12