ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Canvas 2D 手搓搜打撤游戏:从零实现地图、AI与存档

Canvas 2D 手搓搜打撤游戏:从零实现地图、AI与存档 1. 为什么我选择用 Canvas 2D 手搓一个搜打撤游戏《逃离鸭科夫》这类搜打撤玩法的核心循环其实非常清晰进入地图、搜刮物资、与AI或其他玩家交火、在限定时间内抵达撤离点、把物资带回仓库。这个循环用游戏引擎做当然快但如果你只是想验证玩法、做个原型或者单纯想搞清楚这类游戏的底层逻辑用 Canvas 2D 从零实现一遍反而是最高效的路径。我这次的项目目标很明确不引入任何游戏引擎纯用 HTML CSS JavaScript渲染层用 Canvas 2D数据持久化用 localStorage做一个可以在浏览器里直接跑起来的搜打撤小游戏。整个项目没有构建工具没有 npm 依赖双击 HTML 文件就能玩。听起来简陋但实际做下来你会发现需要解决的问题一点都不少地图怎么组织、视野怎么裁剪、碰撞怎么检测、AI 怎么寻路、物资怎么随机生成、存档怎么设计、性能怎么优化。这篇文章我会把整个开发过程拆开讲包括我踩过的坑和最后采用的方案。适合有一定 JavaScript 基础、想了解游戏开发底层逻辑、或者想用最轻量的方式做原型的开发者。如果你完全没写过 Canvas也不用担心我会把关键概念用生活化的方式解释清楚。先说结论整个项目核心代码控制在 1500 行左右分成了地图生成、渲染、输入、AI、战斗、背包、存档七个模块。下面逐个拆解。2. 整体架构设计与技术选型思路2.1 为什么不用游戏引擎很多人第一反应是做游戏为什么不用 Phaser 或者 PixiJS。我的考虑有三点。第一搜打撤游戏的核心复杂度在逻辑层而不是渲染层引擎帮你解决的精灵动画、物理系统、场景管理在这个项目里用得并不多。第二引入引擎意味着引入一套新的API和概念调试的时候你需要在引擎的抽象层和浏览器原生层之间来回切换反而增加心智负担。第三也是最重要的一点用原生 Canvas 做一遍你会对游戏循环这件事有非常具体的理解这个理解迁移到任何引擎都是通用的。当然这不是说引擎不好。如果你要做的是横版动作游戏或者需要复杂物理效果的项目引擎能省掉大量时间。但对于搜打撤这种以格子地图、视野、状态管理为主的玩法原生 Canvas 完全够用。2.2 技术栈的边界与分工整个项目的技术分工非常清晰HTML只负责提供一个 canvas 元素和几个 UI 容器结构极简。CSS负责 HUD、背包面板、撤离倒计时这些覆盖在 canvas 之上的界面样式。JavaScript全部游戏逻辑包括地图数据、实体状态、碰撞、AI、战斗结算。Canvas 2D只做一件事把当前帧的游戏世界画出来。localStorage存档包括仓库物资、角色属性、已解锁地图。这里有个关键的设计决策游戏状态和渲染完全分离。游戏世界里有一个world对象里面存着地图、玩家、AI、物资、子弹等所有数据。渲染函数只读取world不修改它。这样做的直接好处是你可以在控制台里直接打印world看当前状态调试效率极高。我见过很多新手把状态写在渲染循环里改一个数值要满屏找非常痛苦。2.3 游戏循环的设计游戏循环用的是最经典的requestAnimationFrame加固定时间步长。为什么不用可变步长因为搜打撤游戏里 AI 移动、子弹飞行、碰撞检测都依赖稳定的时间间隔可变步长会导致不同帧率下行为不一致比如 144Hz 屏幕上 AI 跑得比 60Hz 快。我的做法是累积时间每满 16.67ms约60帧执行一次逻辑更新渲染则每帧都执行。这样逻辑稳定渲染流畅。核心代码大概是这样let lastTime 0; let accumulator 0; const STEP 1000 / 60; function loop(timestamp) { const delta timestamp - lastTime; lastTime timestamp; accumulator delta; while (accumulator STEP) { updateWorld(STEP); accumulator - STEP; } renderWorld(); requestAnimationFrame(loop); }这个模式我用了很多年稳定可靠。注意accumulator要设一个上限防止切标签页回来后一次性执行几百次更新导致卡死。3. 地图生成与视野系统的核心实现3.1 用二维数组还是对象存地图地图我用的是二维数组每个格子存一个数字0 表示空地1 表示墙2 表示物资点3 表示撤离点。为什么不用对象数组因为二维数组的索引访问是 O(1)而且内存占用小。一张 60x60 的地图用二维数组就是 3600 个数字用对象数组可能要几十万个属性差距明显。地图生成我用的是房间加走廊的算法。先随机撒若干个不重叠的矩形房间然后用 L 型走廊把相邻房间连起来。这个算法简单生成的地图有房间有通道适合搜打撤的玩法节奏。关键代码如下function generateMap(width, height, roomCount) { const map Array.from({ length: height }, () new Array(width).fill(1)); const rooms []; for (let i 0; i roomCount; i) { const w randInt(6, 12); const h randInt(6, 12); const x randInt(1, width - w - 1); const y randInt(1, height - h - 1); const room { x, y, w, h }; if (rooms.some(r overlap(r, room))) continue; rooms.push(room); for (let ry y; ry y h; ry) for (let rx x; rx x w; rx) map[ry][rx] 0; } // 连接房间 for (let i 1; i rooms.length; i) { const a center(rooms[i - 1]); const b center(rooms[i]); carveCorridor(map, a, b); } return { map, rooms }; }overlap判断两个矩形是否相交时记得留 1 格间距否则房间会贴在一起视觉上分不清。3.2 视野裁剪只画看得见的部分搜打撤游戏有个特点玩家视野有限地图大部分区域是黑的。这个效果实现起来有两种思路。一种是画完整个地图再用黑色遮罩盖住另一种是只画视野内的格子。我选的是后者因为性能更好尤其是大地图。具体做法是根据玩家位置和视野半径计算出需要绘制的格子范围只遍历这个范围。视野形状我用的是圆形判断格子中心到玩家的距离是否小于视野半径。这里有个细节如果严格按圆形裁剪边缘会有锯齿感我的处理是对边缘格子做透明度渐变距离越远越暗视觉上更自然。function renderMap(ctx, world, camera) { const { map } world; const r world.player.viewRadius; const startX Math.max(0, Math.floor(camera.x / TILE) - 1); const endX Math.min(map[0].length, Math.ceil((camera.x canvas.width) / TILE) 1); const startY Math.max(0, Math.floor(camera.y / TILE) - 1); const endY Math.min(map.length, Math.ceil((camera.y canvas.height) / TILE) 1); for (let y startY; y endY; y) { for (let x startX; x endX; x) { const dist Math.hypot(x - world.player.x, y - world.player.y); if (dist r) continue; const alpha Math.min(1, (r - dist) / 3); ctx.globalAlpha alpha; drawTile(ctx, map[y][x], x, y); } } ctx.globalAlpha 1; }注意globalAlpha用完一定要重置为 1否则后续绘制全部变透明这个坑我踩过不止一次。3.3 相机跟随与边界处理相机跟随玩家但要做边界限制不能让相机超出地图范围露出黑边。相机的目标位置是玩家位置减去画布一半然后 clamp 到[0, mapWidth - canvasWidth]。如果地图比画布小就居中显示。这个逻辑写起来简单但边界情况要测全尤其是地图尺寸和画布尺寸接近的时候。4. 玩家控制、碰撞与战斗系统4.1 输入处理键盘状态表输入我用的是一个键盘状态对象keydown 时置 truekeyup 时置 false。为什么不直接在事件里移动玩家因为事件触发频率和游戏循环不同步会导致移动速度不稳定。用状态表在 update 里根据状态计算移动速度就稳定了。const keys {}; window.addEventListener(keydown, e keys[e.code] true); window.addEventListener(keyup, e keys[e.code] false); function updatePlayer(player, dt) { let dx 0, dy 0; if (keys[KeyW]) dy - 1; if (keys[KeyS]) dy 1; if (keys[KeyA]) dx - 1; if (keys[KeyD]) dx 1; if (dx || dy) { const len Math.hypot(dx, dy); dx / len; dy / len; const speed player.speed * dt / 1000; moveWithCollision(player, dx * speed, dy * speed); } }注意斜向移动要归一化否则斜着走会比直着走快 1.414 倍这是新手常犯的错误。4.2 碰撞检测分轴移动碰撞我用的是分轴移动法。先尝试移动 X 轴如果撞墙就回退 X再尝试移动 Y 轴撞墙就回退 Y。这样处理的好处是玩家贴着墙走的时候可以顺滑滑动不会卡住。如果用整体移动再判断贴墙时会完全停住手感很差。function moveWithCollision(entity, dx, dy) { const nx entity.x dx; if (!isWall(nx, entity.y)) entity.x nx; const ny entity.y dy; if (!isWall(entity.x, ny)) entity.y ny; }isWall检查目标格子是否是墙。这里实体用的是浮点坐标格子用的是整数索引转换时用Math.floor。4.3 射击与子弹系统射击的核心是子弹对象有位置、速度、伤害、归属者。子弹每帧按速度移动检测是否命中实体或墙。命中墙就销毁命中实体就结算伤害。子弹速度要足够快否则会出现穿模就是子弹一帧跨过了敌人。我的处理是子弹每帧做多次小步移动每次移动后检测碰撞这叫连续碰撞检测的简化版。function updateBullets(world, dt) { for (let i world.bullets.length - 1; i 0; i--) { const b world.bullets[i]; const steps Math.ceil(b.speed * dt / 1000 / 4); let hit false; for (let s 0; s steps; s) { b.x b.vx * dt / 1000 / steps; b.y b.vy * dt / 1000 / steps; if (isWall(b.x, b.y)) { hit true; break; } const target findHitEntity(world, b); if (target) { applyDamage(target, b.damage); hit true; break; } } if (hit) world.bullets.splice(i, 1); } }提示子弹的owner字段很重要用来区分是玩家打的还是 AI 打的避免自己打自己。4.4 伤害结算与死亡处理伤害结算要考虑护甲。我的设计是护甲有耐久先扣护甲再扣血。护甲耐久归零后不再提供减伤。这个机制让玩家有动力去搜刮更好的护甲增加了搜打撤的物资价值感。死亡处理分两种玩家死亡直接结束本局AI 死亡掉落物资。5. AI 行为设计与寻路实现5.1 AI 状态机巡逻、警觉、追击、射击AI 我用的是有限状态机四个状态巡逻、警觉、追击、射击。巡逻时沿预设路径点移动发现玩家后进入警觉短暂延迟后转入追击追击到一定距离内转入射击边打边调整位置失去目标一段时间后回到巡逻。状态切换的条件要设计得合理。比如警觉到追击的延迟我设的是 300ms模拟 AI 的反应时间让玩家有机会先手。追击到射击的距离阈值是 200 像素太远打不准太近容易被玩家反杀。function updateAI(ai, world, dt) { const dist Math.hypot(ai.x - world.player.x, ai.y - world.player.y); const canSee dist ai.viewRange hasLineOfSight(ai, world.player, world.map); switch (ai.state) { case patrol: if (canSee) { ai.state alert; ai.alertTimer 300; } else followPath(ai, dt); break; case alert: ai.alertTimer - dt; if (ai.alertTimer 0) ai.state canSee ? chase : patrol; break; case chase: if (!canSee) { ai.lostTimer dt; if (ai.lostTimer 3000) ai.state patrol; } else if (dist 200) ai.state shoot; else moveToward(ai, world.player, dt); break; case shoot: if (dist 250) ai.state chase; else { strafe(ai, dt); shootAt(ai, world.player, world); } break; } }5.2 寻路A* 算法的简化实现AI 追击玩家需要寻路否则会撞墙。我用的是 A* 算法但做了简化只在追击状态下重新计算路径而且路径缓存 500ms避免每帧都算。A* 的启发函数用曼哈顿距离因为地图是四方向移动。A* 的性能瓶颈在开放列表的排序。我用的是简单的数组加每次查找最小值地图不大的时候够用。如果地图超过 100x100建议换成二叉堆。实测 60x60 地图单次寻路在 1ms 以内完全不影响帧率。5.3 视线检测Bresenham 直线算法hasLineOfSight用的是 Bresenham 直线算法从 AI 到玩家画一条线逐格检查是否有墙。这个算法效率高而且实现简单。注意要检查起点和终点之间的格子不包括起点本身。function hasLineOfSight(a, b, map) { let x0 Math.floor(a.x), y0 Math.floor(a.y); const x1 Math.floor(b.x), y1 Math.floor(b.y); const dx Math.abs(x1 - x0), dy Math.abs(y1 - y0); const sx x0 x1 ? 1 : -1, sy y0 y1 ? 1 : -1; let err dx - dy; while (true) { if (x0 x1 y0 y1) return true; if (map[y0][x0] 1) return false; const e2 2 * err; if (e2 -dy) { err - dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }6. 物资系统、背包与 localStorage 存档6.1 物资随机生成与稀有度物资点在地图生成时随机撒在房间里每个物资点有一个稀有度权重。稀有度分普通、稀有、史诗三档权重分别是 70%、25%、5%。搜刮时按权重随机掉落物品。物品有名称、价值、占格数三个属性。占格数是为了背包容量限制让玩家做取舍。const LOOT_TABLE [ { name: 绷带, value: 50, size: 1, weight: 40 }, { name: 弹药, value: 80, size: 1, weight: 30 }, { name: 医疗包, value: 200, size: 2, weight: 15 }, { name: 护甲板, value: 350, size: 2, weight: 10 }, { name: 情报文件, value: 800, size: 1, weight: 5 }, ];搜刮交互是靠近物资点按 E 键弹出拾取列表玩家选择拾取哪些。这里有个体验细节拾取后物资点要标记为已搜刮避免重复拾取。6.2 背包容量与重量系统背包用格子制比如 6x4 共 24 格。每个物品占 1 到 2 格。玩家拾取时检查剩余格子是否够。这个设计比单纯的数量限制更有策略性因为大件物品占格多玩家要权衡带什么。重量系统我简化掉了因为格子制已经足够。如果你要做更硬核的版本可以加重量影响移动速度。6.3 localStorage 存档设计存档用 localStoragekey 是escape_duckov_savevalue 是 JSON 字符串。存的内容包括仓库物品列表、玩家等级、已解锁地图、累计撤离次数。为什么不存整个 world因为 world 里有 AI、子弹这些临时状态存了没意义还会让存档体积膨胀。function saveGame(state) { const data { stash: state.stash, level: state.level, unlockedMaps: state.unlockedMaps, extractCount: state.extractCount, version: 1, }; localStorage.setItem(escape_duckov_save, JSON.stringify(data)); } function loadGame() { const raw localStorage.getItem(escape_duckov_save); if (!raw) return defaultState(); try { const data JSON.parse(raw); if (data.version ! 1) return defaultState(); return data; } catch (e) { return defaultState(); } }注意JSON.parse一定要包 try-catch因为 localStorage 里的数据可能被用户手动改坏或者旧版本格式不兼容。我加了个version字段方便以后做存档迁移。6.4 撤离点与结算逻辑撤离点是地图上固定的几个位置玩家站上去持续 5 秒就撤离成功。撤离时把背包里的物品转移到仓库然后保存存档。如果玩家在撤离过程中移动或者被打断计时重置。这个设计增加了撤离时的紧张感玩家要确保周围安全。结算界面显示本局带出的物资总价值、击杀数、存活时间。这些数据从 world 里读取展示完清空 world回到主菜单。7. 常见问题与排查技巧实录7.1 画面闪烁与撕裂早期版本我遇到画面闪烁排查后发现是渲染时先清空画布再绘制但清空和绘制之间有一帧的空档。解决方法是把整个地图先画到一个离屏 canvas再一次性贴到主 canvas。或者更简单用ctx.clearRect后立即绘制不要有异步操作。7.2 AI 卡墙与抖动AI 卡墙通常是因为寻路路径的最后一个格子是墙或者移动时没有做碰撞检测。我的处理是寻路时目标格子必须是可通行的移动时用和玩家一样的moveWithCollision。抖动则是因为 AI 在追击和射击状态之间频繁切换加一个状态切换的冷却时间就能解决。7.3 localStorage 写入失败localStorage 有 5MB 左右的容量限制如果存档数据太大写入会抛异常。我的处理是存档前先JSON.stringify检查长度超过 4MB 就提示用户清理。另外隐私模式下 localStorage 可能不可用要做降级处理用内存变量代替。7.4 帧率下降与性能优化帧率下降的常见原因有三个一是每帧都创建新对象导致 GC 频繁二是渲染范围太大画了看不见的东西三是 AI 寻路太频繁。我的优化是对象池复用子弹和粒子渲染只画视野内寻路加缓存。优化后 60x60 地图加 20 个 AI帧率稳定在 60。问题现象可能原因排查方法解决方案画面闪烁清空与绘制不同步看是否用了异步绘制同步绘制或离屏 canvasAI 卡墙寻路目标不可达打印路径终点校验目标格子可通行帧率骤降对象频繁创建看 GC 日志对象池复用存档丢失localStorage 满检查写入返回值压缩数据或降级子弹穿模单帧位移过大打印子弹坐标分步移动检测7.5 独家避坑技巧第一个技巧用performance.now()而不是Date.now()做计时前者精度更高后者在有些系统上只有毫秒级精度会导致计时不准。第二个技巧Canvas 的imageSmoothingEnabled设为 false如果你用的是像素风格这样放大后不会模糊。我一开始没设画面糊得像马赛克。第三个技巧键盘事件用e.code而不是e.key因为e.key受输入法和大小写影响e.code是物理按键更稳定。第四个技巧调试时在 canvas 上画碰撞盒和路径用一个debug开关控制。这个习惯帮我省了大量时间肉眼看不到的问题画出来一目了然。8. 后续可以扩展的方向这个项目做完后我又陆续加了几个东西。一个是小地图在右上角画一个缩略图显示玩家位置和已探索区域。另一个是音效用 Web Audio API 生成简单的枪声和脚步声不需要音频文件。还有一个是天气系统下雨天视野变小增加变数。如果你想把联机做进去那就需要引入 WebSocket服务端用 Node.js 做权威服务器客户端只做输入和渲染。这个复杂度会上升一个量级但核心的搜打撤循环不变只是把 AI 换成真人。我个人在实际操作中的体会是用 Canvas 2D 做原型最大的价值不是省了多少时间而是让你对游戏运行的每一帧都心里有数。你知道每个像素是怎么画出来的每个数值是怎么算出来的这种掌控感是引擎给不了的。等你把原型跑通再决定要不要上引擎那时候你的判断会准确得多。
返回列表