从零搭建博客地下城:用Canvas实现可嵌入的迷宫探险游戏
2026/8/29 3:43:12 网站建设 项目流程

写在前面:如果你的博客除了文章列表、标签云、友链之外,还藏着一个可以自由走动、打怪、捡金币的复古地下城,访客会不会觉得很有意思?本文就围绕“There's 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 那套复杂的着色器概念,直接用fillRectarc就能画出迷宫、角色和敌人。对于地图尺寸约 31×21、格子数 651 个的地下城来说,Canvas 的性能完全足够,而且代码逻辑更集中。

2. 环境准备与项目结构

2.1 开发环境

这个项目所用到的技术非常基础:

  • HTML5
  • CSS3
  • 原生 JavaScript(ES6 语法)

不需要安装 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)**算法。它的思路可以这样理解:

  1. 从起点格子开始,把它标记为“路”。
  2. 查看当前格子周围距离为 2 的格子(中间隔一堵墙)。
  3. 如果存在未访问过的候选格子,随机选一个,打通中间的墙,然后走到新格子。
  4. 如果周围没有候选格子,就回退到上一个格子。
  5. 重复这个过程,直到所有格子都被访问过。

用这种算法生成的迷宫具有一个显著特点:任意两个通路之间一定存在一条路径,不会出现死胡同无法到达的情况。对于游戏来说,这一点非常重要,否则玩家可能拿到一个永远走不到出口的地图。

算法实现如下:

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 物品、敌人与出口的分布策略

迷宫生成后,地图里只有墙和路,还需要加入金币、敌人和出口。为了让游戏有合理的难度曲线,物品分布不能太随意,需要遵守两个原则:

  1. 不能放在起点格,否则玩家开局就能踩到金币。
  2. 不能放在出口格,否则出口会被其他元素遮挡。

我先收集所有可通行的路,排除起点和出口,然后从剩余格子中随机挑选金币位置。敌人从金币挑选后剩余的路中随机选取。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 为 5,HP 降为 0 则游戏结束。

这个规则虽然简单,但足以体现“资源管理”的核心玩法:玩家不能盲目乱走,需要规划路线,避免在找到出口前被敌人消耗掉太多 HP。

移动时的碰撞判断非常关键:

if (grid[ny][nx] !== 1) return;

这一行代码保证了玩家不会穿墙。很多初学者在写小游戏时,会先移动角色再检测碰撞,导致角色卡进墙里。正确的做法是先判断目标格子是否可通行,再更新坐标

4. 完整实战代码

4.1 搭建页面结构

先写 HTML 骨架和基础样式。整体采用深色终端风格,和“地下城”主题比较搭配。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Blog 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 id="dungeon-app"> <h1>Blog Dungeon</h1> <canvas id="dungeon-canvas" width="620" height="420"></canvas> <div id="dungeon-status"> <span>HP: <span id="hp">5</span></span> <span>金币: <span id="gold">0</span></span> <span>击杀: <span id="kills">0</span></span> <span>WASD / 方向键移动,R 重新开始</span> </div> <div id="dungeon-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 或自己的服务器,然后在博客文章中插入 iframe:

<iframe src="https://your-blog-dungeon-url.example.com/index.html" width="660" height="500" style="border: none; max-width: 100%;" loading="lazy"> </iframe>

方案二:直接粘贴 HTML 片段。如果博客编辑器支持原生 HTML,可以把<div id="dungeon-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、Ctrl+R 等浏览器快捷键。

5.3 典型问题排查:地图生成后没有出口通路

如果出口坐标设置成了偶数坐标,而迷宫生成算法只打通奇数坐标格子,出口就会镶嵌在墙里,导致永远无法到达。

本文中exit = { x: COLS - 2, y: ROWS - 2 },当COLS = 31ROWS = 21时,出口坐标是(29, 19),均为奇数,因此可以正常连通。如果你修改了地图尺寸,一定要确认出口坐标仍然位于奇数位置。

更稳妥的做法是,在地图生成完成后,显式把出口格子设为通路:

grid[exit.y][exit.x] = 1;

这样即使地图边界调整,也不会因为算法改动而导致出口被墙堵死。

5.4 典型问题排查:嵌入 iframe 后高度被截断

iframe 的height是固定值,如果游戏容器内部有自适应高度,容易出现内容被截断。可以给 iframe 设置足够大的高度,比如500px,同时把游戏容器设置为固定宽度并居中。

如果博客平台对 iframe 有安全限制,可以改用 object 标签或链接跳转方式:

<a href="https://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,批量保存或者离开页面时

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

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

立即咨询