尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Canvas 2D 硬写搜打撤游戏:无引擎实现与性能优化实战

Canvas 2D 硬写搜打撤游戏:无引擎实现与性能优化实战 1. 为什么我放弃了游戏引擎选择 Canvas 2D 硬写1.1 一个“搜打撤”玩法的核心诉求《逃离鸭科夫》这个品类核心乐趣其实就三件事搜刮物资、遭遇战斗、活着撤离。听起来简单但真要把这套循环做出来你会发现它跟传统关卡制游戏完全是两码事。传统游戏是“设计好一条路让玩家走”搜打撤是“给玩家一张图让他自己决定什么时候贪、什么时候跑”。这意味着游戏需要实时处理大量动态状态物资刷新、AI 巡逻路线、玩家背包、撤离点倒计时、伤害判定、视野遮挡……每一项都在持续变化。我一开始也想过用 Unity 或者 Godot毕竟现成的物理引擎、寻路系统、动画状态机摆在那里拖拖拽拽就能跑起来。但实际动手之后发现一个 2D 俯视角的搜打撤游戏真正需要引擎“帮忙”的地方其实没那么多。物理只需要圆和矩形的碰撞检测寻路用简单的网格 A* 就够了动画更是几张序列帧来回切。引擎带来的额外复杂度——场景文件、组件系统、构建流程、包体大小——反而成了负担。更关键的是我想让这个游戏在浏览器里直接打开就能玩不需要下载安装不需要等待加载。Canvas 2D 配合原生 JavaScript一个 HTML 文件加几个 JS 模块就能跑起来部署成本几乎为零。这对于一个想快速验证玩法、随时分享给朋友试玩的项目来说吸引力太大了。1.2 Canvas 2D 到底能不能扛住很多人对 Canvas 2D 的印象还停留在“画个图表”“做个粒子特效”的阶段觉得它性能不行、做不了正经游戏。我实测下来的结论是对于 2D 俯视角、同屏实体数量在 200 以内的游戏Canvas 2D 完全够用前提是你得知道怎么用。Canvas 2D 的绘制调用是即时模式immediate mode每一帧你都要重新描述整个画面。这跟引擎的保留模式retained mode不一样后者会帮你缓存场景图。即时模式的好处是控制力极强你想画什么就画什么没有中间层坏处是如果每帧都无脑重绘所有东西性能会迅速崩掉。我的优化策略很简单只画镜头里能看到的东西。游戏世界可以很大但屏幕就这么大视野外的实体直接跳过。再加上用离屏 Canvas 预渲染静态地形每帧只需要把地形图层拷贝一次然后在上层画动态实体。这套组合拳下来我在一台五年前的笔记本上跑 150 个实体同屏帧率稳定在 60fps。还有一个容易被忽略的点Canvas 的drawImage比逐个像素操作快几个数量级。所有精灵图我都提前打包成一张图集绘制时用drawImage的九参数版本从图集里裁切避免频繁切换图像源。这个技巧在粒子效果多的时候尤其明显。1.3 这个项目适合谁来参考如果你符合下面任意一条这篇内容应该能帮到你想做一个 2D 小游戏但不想被引擎的复杂概念劝退已经会 JavaScript想找个项目把 Canvas API 练熟对搜打撤玩法感兴趣想自己搭个原型验证想法需要在一个受限环境比如只能跑浏览器里快速出可玩版本我不假设你精通游戏开发但默认你至少写过一些 JavaScript知道requestAnimationFrame是干嘛的。如果你连 Canvas 的getContext(2d)都没用过建议先花半小时看看基础教程再回来读这篇体验会顺畅很多。2. 整体架构没有引擎我自己搭一套2.1 游戏循环与状态管理没有引擎帮你管生命周期第一件事就是自己写游戏循环。核心就是一个requestAnimationFrame驱动的 tick 函数每帧做三件事处理输入、更新逻辑、渲染画面。let lastTime 0; function gameLoop(timestamp) { const deltaTime (timestamp - lastTime) / 1000; lastTime timestamp; handleInput(); update(deltaTime); render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这里有个关键细节deltaTime 必须做上限截断。如果玩家切到别的标签页再切回来deltaTime 可能是好几秒直接传给物理更新会导致实体瞬移穿墙。我的做法是Math.min(deltaTime, 0.05)也就是单帧最多按 50ms 计算超出的部分直接丢弃。状态管理我用了一个极简的有限状态机。游戏只有几个大状态LOADING、PLAYING、PAUSED、EXTRACTED、DEAD。每个状态有自己的 enter、update、exit 钩子。比如进入EXTRACTED状态时暂停所有 AI 更新弹出结算面板退出时重置关卡数据。这套东西手写也就一百多行比引入状态机库轻量得多。2.2 实体系统不用 ECS用组合ECS实体-组件-系统架构很流行但对于这个规模的项目来说有点杀鸡用牛刀。我采用的是更朴素的组合模式每个游戏对象是一个普通 JavaScript 对象包含位置、速度、渲染信息、碰撞体等属性行为通过函数注入。function createPlayer(x, y) { return { type: player, x, y, vx: 0, vy: 0, radius: 12, hp: 100, inventory: [], update(dt) { /* 玩家更新逻辑 */ }, render(ctx) { /* 玩家渲染逻辑 */ } }; }所有实体塞进一个entities数组每帧遍历更新和渲染。需要分类处理时用type字段过滤。这种写法在实体数量几百以内完全不会成为瓶颈而且调试起来极其直观——你随时可以在控制台打印某个实体的完整状态。2.3 地图与碰撞网格化处理搜打撤游戏的地图通常有大量墙壁、障碍物、可交互容器。我用二维网格来表示地图每个格子记录地形类型空地、墙壁、草丛、容器。网格的好处是碰撞检测和寻路可以共用同一套数据结构。碰撞检测我用了最直接的圆-矩形相交。玩家和 AI 都是圆形碰撞体墙壁是矩形格子。检测时只需要判断圆心到矩形最近点的距离是否小于半径。这个方法比像素级碰撞快得多而且对于俯视角游戏来说精度完全够用。function circleRectCollide(cx, cy, r, rx, ry, rw, rh) { const nearestX Math.max(rx, Math.min(cx, rx rw)); const nearestY Math.max(ry, Math.min(cy, ry rh)); const dx cx - nearestX; const dy cy - nearestY; return dx * dx dy * dy r * r; }移动时我采用分轴处理先尝试水平移动如果碰撞就回退水平速度再尝试垂直移动碰撞就回退垂直速度。这样玩家贴着墙走的时候会自然滑动不会卡死。这个技巧在几乎所有 2D 游戏里都通用实现简单但效果拔群。2.4 渲染分层地形、实体、UI渲染我分了三层用三个离屏 Canvas 做缓存地形层静态不变只在关卡加载时绘制一次。包含地面纹理、墙壁、装饰物。实体层每帧清空重绘包含玩家、AI、子弹、掉落物。UI 层血条、背包、小地图、提示文字直接画在主 Canvas 上。地形层用离屏 Canvas 的好处是即使地图很大每帧也只需要一次drawImage把可见区域拷贝过来。实体层因为要频繁更新用主 Canvas 直接画反而更快省去了离屏切换的开销。视野遮挡我用了一个简化方案射线检测 扇形视野。玩家和 AI 都有一个朝向角度和视野范围每帧向周围发射若干条射线碰到墙壁就截断形成一个可见多边形。这个多边形用来裁剪实体层的绘制区域。听起来复杂但实际代码也就几十行效果却非常明显——玩家能直观感受到“墙后面看不见”。3. 核心玩法模块的落地细节3.1 搜刮系统容器、背包与重量搜刮是搜打撤的“搜”。地图上散布着各种容器箱子、柜子、尸体。玩家靠近后按交互键弹出搜刮界面。每个容器有一个战利品表按权重随机生成物品。const lootTable [ { item: bandage, weight: 40, min: 1, max: 3 }, { item: ammo_9mm, weight: 30, min: 10, max: 30 }, { item: medkit, weight: 10, min: 1, max: 1 }, { item: keycard, weight: 2, min: 1, max: 1 } ];权重随机不是简单地把权重当概率而是累加权重后取随机数。比如总权重 82随机一个 0 到 82 的数落在哪个区间就出哪个物品。这样调整掉落率只需要改权重值不用保证总和为 100。背包系统我加了重量限制。每个物品有重量玩家有负重上限。超重会减速严重超重会无法奔跑。这个设计逼着玩家做取舍是带满弹药还是留空间给高价值物品撤离前的那几秒背包管理往往比战斗还紧张。注意搜刮界面打开时游戏世界不能完全暂停。我一开始图省事直接暂停了结果玩家在搜刮时被 AI 打死体验极差。后来改成“搜刮时玩家移速降为零但世界继续运转”紧张感立刻就出来了。3.2 战斗系统射击、弹道与伤害战斗是“打”。我实现了两种攻击方式近战和远程。近战就是扇形范围判定远程是射线检测。远程射击的核心是弹道模拟。子弹不是一个瞬间到达的判定而是一个有速度的实体。每帧子弹向前移动检测与墙壁和实体的碰撞。这样做的好处是子弹可以被躲避也可以被墙壁挡住真实感强很多。function updateBullet(bullet, dt) { const steps Math.ceil(bullet.speed * dt / 8); for (let i 0; i steps; i) { bullet.x bullet.vx * dt / steps; bullet.y bullet.vy * dt / steps; if (checkWallCollision(bullet.x, bullet.y)) { bullet.dead true; spawnImpactEffect(bullet.x, bullet.y); return; } // 检测实体碰撞... } }这里有个关键点子弹速度太快会导致穿墙。如果子弹一帧移动 50 像素而墙壁只有 16 像素厚直接检测当前位置就会漏掉。我的解法是把子弹的移动拆成多个子步每步最多移动 8 像素逐步检测。这个“子步进”技巧在快速移动物体上必须用否则各种诡异穿透会让你怀疑人生。伤害计算我用了部位倍率。虽然是个 2D 俯视角游戏但我给每个实体定义了“核心区”和“边缘区”。命中核心区伤害翻倍边缘区伤害减半。实现方式就是判断命中点与实体中心的距离。这个设计让走位和瞄准有了意义而不是无脑对射。3.3 撤离机制倒计时与风险博弈撤离是“撤”。地图上有若干撤离点玩家进入撤离区域后开始倒计时倒计时结束即成功撤离。倒计时期间玩家必须待在区域内离开则重置。这个机制看似简单但它是整个游戏张力最大的来源。我做了几个调整来强化这种张力撤离倒计时全局可见AI 也会被吸引过来撤离点开启有时间窗口不是一直可用携带高价值物品时撤离倒计时更长最后一条是我从实际测试中加的。测试时发现玩家拿到好东西后直接冲撤离点一路无脑跑毫无紧张感。加了“负重影响撤离时间”之后玩家开始权衡要不要扔掉一些东西加快撤离还是冒险带着这个决策点非常有意思。3.4 AI 行为巡逻、追击与搜索AI 是搜打撤游戏里最容易被做砸的部分。太笨了没挑战太聪明了玩家没法玩。我用了分层状态机来组织 AI 行为巡逻层沿预设路径点移动到达后随机等待一段时间警觉层听到声音或看到可疑目标转向调查追击层确认敌人直接追击并射击搜索层丢失目标后在最后已知位置附近搜索状态切换的触发条件包括视觉检测射线 视野角度、听觉检测枪声、脚步声、受击检测被打了当然要还手。function canSee(entity, target) { const dx target.x - entity.x; const dy target.y - entity.y; const dist Math.sqrt(dx * dx dy * dy); if (dist entity.viewDistance) return false; const angle Math.atan2(dy, dx); const angleDiff Math.abs(normalizeAngle(angle - entity.facing)); if (angleDiff entity.viewAngle / 2) return false; return !raycastWall(entity.x, entity.y, target.x, target.y); }实操心得AI 的视野检测不要每帧都做。我一开始每帧对每个 AI 做射线检测10 个 AI 就把帧率拉下来了。后来改成每 3 帧检测一次并且用空间网格先做粗筛只检测距离范围内的目标性能立刻回来了。玩家根本感觉不到区别。4. 性能优化让 Canvas 2D 跑出引擎的感觉4.1 绘制调用的合并与裁剪Canvas 2D 最大的性能陷阱是频繁的样式切换。每次修改fillStyle、strokeStyle、globalAlpha都会触发状态重设。如果每画一个实体就改一次颜色几百个实体下来开销非常可观。我的做法是按材质分组绘制。同一帧内先把所有需要画同一种精灵图的实体收集起来一次性设置好样式然后连续drawImage。这样样式切换从几百次降到几次。// 不好的做法每个实体单独设置 entities.forEach(e { ctx.fillStyle e.color; ctx.fillRect(e.x, e.y, e.w, e.h); }); // 好的做法按颜色分组 const groups groupBy(entities, color); for (const [color, list] of groups) { ctx.fillStyle color; list.forEach(e ctx.fillRect(e.x, e.y, e.w, e.h)); }另一个关键是视野裁剪。每帧计算镜头矩形只绘制与镜头相交的实体。这个判断本身有开销所以我又加了一层空间网格把地图分成若干区块每个区块记录包含的实体。镜头移动时只检查相邻区块进一步减少遍历量。4.2 离屏 Canvas 与图集静态地形用离屏 Canvas 预渲染这个前面提过了。这里补充一个细节离屏 Canvas 不要开太大。浏览器对单个 Canvas 的尺寸有限制而且太大的 Canvas 会占用大量内存。我的做法是把地图分块每块 512x512按需渲染和缓存。图集方面我用了一个简单的打包脚本把所有精灵图拼成一张 2048x2048 的大图同时生成 JSON 描述每个精灵的位置和尺寸。运行时只需要加载一张图绘制时用drawImage的九参数版本裁切。// 从图集绘制精灵 ctx.drawImage( atlas, sprite.sx, sprite.sy, sprite.sw, sprite.sh, entity.x - sprite.ox, entity.y - sprite.oy, sprite.sw, sprite.sh );4.3 对象池与垃圾回收JavaScript 的垃圾回收是性能的隐形杀手。如果每帧都创建新对象子弹、粒子、伤害数字GC 会频繁触发导致帧率波动。我的解法是对象池。所有频繁创建销毁的对象都预先分配好用的时候从池里取用完还回去。const bulletPool { pool: [], get() { return this.pool.pop() || createBullet(); }, release(bullet) { bullet.dead false; this.pool.push(bullet); } };粒子效果尤其要注意。爆炸时可能瞬间产生几十个粒子如果每次都 new 一个对象GC 压力很大。用对象池之后帧率曲线明显平滑了。注意对象池不是万能的。如果对象持有大量内存比如大数组池化反而会导致内存泄漏。只对生命周期短、创建频繁的小对象使用池化。4.4 帧率控制与自适应不是所有设备都能跑 60fps。我的做法是动态调整渲染质量如果检测到帧率持续低于 45fps自动关闭粒子效果、降低视野多边形精度、减少 AI 视野检测频率。这些调整对玩法没有影响但能显著提升流畅度。let frameCount 0; let lastFpsCheck 0; let currentQuality high; function checkPerformance(timestamp) { frameCount; if (timestamp - lastFpsCheck 1000) { const fps frameCount; frameCount 0; lastFpsCheck timestamp; if (fps 45 currentQuality high) { currentQuality medium; disableParticles(); } else if (fps 30 currentQuality medium) { currentQuality low; reduceViewPrecision(); } } }这套自适应机制上线后低端设备的留存率明显提升。玩家不会因为卡顿而直接关掉页面。5. 常见问题与排查实录5.1 画面撕裂与闪烁现象快速移动时画面出现横向撕裂或者实体闪烁。原因Canvas 的绘制不是原子的如果在一帧内分多次绘制而浏览器在中间进行了合成就会看到不完整的画面。解决所有绘制操作必须在同一个requestAnimationFrame回调内完成。不要用setTimeout或setInterval驱动渲染。另外确保canvas的尺寸与 CSS 尺寸匹配避免浏览器缩放导致的模糊和撕裂。// 正确设置 Canvas 尺寸 const dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.scale(dpr, dpr);5.2 碰撞检测漏判现象高速移动的实体穿过墙壁或者两个实体重叠时没有触发碰撞。原因离散碰撞检测只检查当前位置如果一帧内移动距离超过障碍物厚度就会漏判。解决前面提到的子步进是标准解法。另外对于玩家和 AI 的移动我加了最小步长限制如果单帧移动距离超过碰撞体半径就拆成多步移动。function moveWithCollision(entity, dx, dy) { const maxStep entity.radius * 0.8; const dist Math.sqrt(dx * dx dy * dy); const steps Math.ceil(dist / maxStep); for (let i 0; i steps; i) { entity.x dx / steps; entity.y dy / steps; resolveCollision(entity); } }5.3 输入延迟与按键冲突现象玩家感觉操作“粘滞”或者同时按多个键时行为异常。原因输入处理放在了渲染之后或者用了keydown的重复触发。解决输入状态用布尔标记记录在每帧开始时读取而不是在事件回调里直接驱动逻辑。const keys {}; window.addEventListener(keydown, e keys[e.code] true); window.addEventListener(keyup, e keys[e.code] false); function handleInput() { if (keys[KeyW]) player.vy -speed; if (keys[KeyS]) player.vy speed; // ... }另外用e.code而不是e.key避免输入法切换导致按键失效。这个坑我在测试时踩过中文输入法激活时e.key会变成Process导致玩家动不了。5.4 内存泄漏与页面卡死现象玩了几分钟后页面越来越卡最终无响应。原因事件监听器没有移除、定时器没有清理、对象池无限增长。解决每次关卡切换时执行一次清理流程移除所有事件监听、清空对象池、取消未完成的动画帧。我写了一个destroy()函数在离开游戏状态时调用。function destroy() { cancelAnimationFrame(rafId); window.removeEventListener(keydown, onKeyDown); window.removeEventListener(keyup, onKeyUp); bulletPool.pool.length 0; entities.length 0; }实操心得用 Chrome DevTools 的 Memory 面板做堆快照对比能快速定位泄漏点。我在开发过程中发现每次重开关卡都会多出一批 AI 对象最后查到是 AI 的定时器没有清理。养成“谁创建谁清理”的习惯能省掉大量调试时间。5.5 常见问题速查表问题现象可能原因排查方向解决方案画面撕裂绘制跨帧检查 rAF 回调所有绘制在单帧内完成实体穿墙离散碰撞漏判检查移动步长子步进 最小步长限制操作粘滞输入处理时机不对检查事件绑定状态标记 每帧读取帧率骤降GC 频繁触发检查对象创建对象池 减少临时对象内存增长监听器/定时器未清理堆快照对比destroy 流程 弱引用画面模糊Canvas 尺寸不匹配检查 DPR按设备像素比设置尺寸AI 卡墙寻路路径未平滑检查路径点路径平滑 碰撞回退音效延迟音频未预加载检查 Audio 上下文预加载 解码后播放6. 从原型到可玩我的迭代节奏6.1 第一周能跑起来就行第一周我只有一个目标让一个方块在地图上移动碰到墙壁会停下。没有美术没有音效没有 AI。就是验证最核心的移动和碰撞。这个阶段最重要的是快速看到东西动起来给自己正反馈。我用最简单的矩形绘制地图就是随机生成的网格。跑通之后立刻加了第二个方块作为“敌人”让它朝玩家移动。这时候还没有战斗就是单纯的追逐。但已经能感受到一点点紧张感了。6.2 第二周加入搜刮和撤离第二周开始加玩法循环。容器、背包、撤离点这三个东西一加上游戏立刻有了“局”的概念。玩家有了目标搜东西、找撤离点、活着出去。这个阶段我花了大量时间调数值容器刷新率、物品价值、撤离倒计时长度、AI 数量。数值调不好游戏要么太简单要么太挫败。我的方法是自己反复玩记录每次死亡的原因和撤离的成功率然后针对性调整。6.3 第三周打磨手感和反馈第三周全部花在“手感”上。射击的后坐力、命中时的顿帧、拾取物品的音效、撤离成功的结算动画。这些东西不影响玩法逻辑但决定了玩家愿不愿意再开一局。我加了一个简单的屏幕震动效果开枪时镜头轻微抖动被击中时抖动更明显。实现就是渲染时给镜头加一个随机偏移几行代码但打击感提升了一个档次。let shakeAmount 0; function render() { const shakeX (Math.random() - 0.5) * shakeAmount; const shakeY (Math.random() - 0.5) * shakeAmount; ctx.save(); ctx.translate(shakeX, shakeY); // ... 绘制世界 ctx.restore(); shakeAmount * 0.9; // 衰减 }6.4 第四周性能优化和兼容性最后一周专门处理性能和兼容性。在低端安卓机上测试发现帧率只有 20 多。通过前面说的自适应质量、对象池、视野裁剪最终把低端机也拉到了 40fps 以上。兼容性方面主要是 Safari 的一些怪癖。比如 Safari 对requestAnimationFrame的时间戳精度处理不同还有音频上下文需要用户交互后才能启动。这些坑我都记在了代码注释里避免以后忘记。7. 一些让我少走弯路的工具和习惯7.1 调试工具不止 console.logCanvas 游戏调试光靠console.log效率太低。我常用的几个手段可视化碰撞体按 F1 切换显示所有碰撞体轮廓一眼就能看出碰撞检测对不对。实时状态面板在角落画一个半透明面板显示帧率、实体数量、玩家坐标、当前状态。慢动作模式按 F2 把deltaTime乘以 0.2方便观察快速发生的碰撞和伤害判定。这些调试功能在发布版本里用条件编译去掉开发时极其好用。7.2 代码组织模块化但不复杂我没有用打包工具直接用 ES Module 的import/export。浏览器原生支持不需要构建步骤。文件按功能分engine/游戏循环、输入、渲染、碰撞game/玩家、AI、子弹、物品、地图ui/HUD、菜单、结算面板data/物品表、AI 配置、关卡数据每个文件不超过 300 行职责单一。找 bug 的时候能快速定位到具体文件。7.3 版本控制小步提交每完成一个小功能就提交一次commit message 写清楚改了什么。游戏开发经常需要回退——某个改动导致手感变差或者引入了一个难查的 bug。小步提交让回退成本极低。我还会在关键节点打 tag比如“第一个可玩版本”“AI 重做前”“性能优化后”。这样随时可以对比不同版本的表现。7.4 测试习惯自己玩让别人玩自己玩能发现逻辑 bug但发现不了体验问题。我每隔几天就会把链接发给朋友让他们试玩并录屏。看别人玩的时候你会发现很多自己完全没意识到的问题有人不知道按哪个键交互有人找不到撤离点有人被 AI 打死之后不知道发生了什么。这些反馈比任何自动化测试都有价值。游戏最终是给人玩的人的感受才是唯一标准。8. 后续可以继续折腾的方向这个项目目前已经是一个完整可玩的搜打撤原型但还有很多可以深挖的地方。我列几个自己接下来想尝试的方向也给有兴趣的读者一些参考。联机对战用 WebSocket 做帧同步或者状态同步让两个玩家在同一张地图里搜刮和对抗。技术难点在于延迟补偿和作弊防护但玩法上的可能性会成倍增加。地图编辑器做一个可视化的关卡编辑工具让地图设计从手写数组变成拖拽生成。这个工具本身也可以用 Canvas 2D 来做算是“用 Canvas 做 Canvas 工具”。更丰富的 AI 生态现在的 AI 行为还比较单一。可以加入不同阵营的 AI它们之间也会互相攻击或者加入“Boss 级”AI有独特的技能和掉落。移动端适配目前的操作还是键盘鼠标为主。加一套虚拟摇杆和触摸按钮让手机玩家也能玩。Canvas 2D 的触摸事件处理很直接主要是 UI 布局需要重新设计。数据持久化用 IndexedDB 存玩家的仓库、装备、进度做成一个轻量的“局外成长”系统。这样每局撤离带出的物资就有了长期价值玩家粘性会更强。这些方向每一个都可以独立展开但核心思路是一样的先用 Canvas 2D 把玩法跑通再考虑要不要引入更重的技术方案。很多时候最简单的工具反而能让你把注意力集中在真正重要的事情上——游戏好不好玩。
返回列表