我是那种看见“游戏引擎”四个字就手痒但偏偏又不想被引擎绑架的前端开发。前阵子刷到各种“搜打撤”玩法的视频脑子里一直有个念头如果不用任何游戏引擎纯靠 Canvas 2D 和浏览器能不能把一个类似《逃离鸭科夫》风格的“搜打撤”游戏原型肝出来为了验证这个想法我真的动手写了一个可玩的版本。这篇文章就是完整的记录包括核心思路、架构拆解、实现细节、踩坑记录和最终效果给同样想在 Canvas 2D 上做游戏的朋友一个参考。如果你是个前端开发者或者对浏览器游戏感兴趣又不想一上来就背起 Unity、Godot 这种大引擎那我们这个方向是同一路的。用纯 Canvas 2D 做“搜打撤”这件事听起来像自讨苦吃实际做下来其实是把渲染、碰撞、AI 和状态管理全部嚼碎咽进去的过程。适合谁看适合想从零理解游戏循环的人、想练 Canvas 2D 底层能力的人以及想快速验证玩法、不想被引擎编辑器拖住的人。1. 先聊清楚为什么非要用 Canvas 2D 硬刚“搜打撤”1.1 “搜打撤”玩法循环到底在玩什么“搜打撤”这个词核心就三个字搜、打、撤。搜是搜索物资打是战斗对抗撤是从地图上安全撤离。这三个动作放在一起形成一个玩家熟悉后就会上瘾的循环带着低成本进场通过搜索和战斗积累物资然后承担风险向撤离点移动成功撤离后结算物资再带着更好的装备进入下一局。这种玩法最大的特点就是风险与收益并存。你在搜刮一栋房子的时候听到脚步声由远及近心跳就开始加速是继续舔包还是立刻撤退这种“每时每刻都要做决策”的压力恰恰是搜打撤游戏能吸引人的核心。用 Canvas 2D 做这种玩法其实比对游戏引擎门槛低得多。因为是 2D 俯视角物资摆放、雷达显示、敌人巡逻路线、撤离点判定都可以用比较简单的坐标计算搞定。3D 游戏里需要处理的物理引擎、光影系统、射线跟踪在这里统统不需要你要面对的核心就是坐标、数组、碰撞和状态。1.2 不用游戏引擎的三个理由有人会问明明 Unity 或 Godot 里搭个俯视角游戏也就几小时的事何必自己拿 Canvas 2D 硬写我个人有三点真实感受。第一同场景下的心智负担更小。我给一个简单的浏览器游戏套上 Unity 编辑器光是场景、预制体、组件系统就要折腾好一阵子。而 Canvas 2D 只需要一个 HTML 文件加一个 JS 文件就能跑不用安装软件、不用配置环境、不用处理平台导出问题。浏览器本身就是跨平台容器你写完了扔到服务器上手机和电脑都能直接玩。第二能直接看到所有底层细节。用引擎就像开自动挡体验很好但发动机怎么工作的你不知道。用 Canvas 2D 是自己造一辆手动挡车主循环怎么转、坐标怎么算、碰撞怎么判每一步都在你掌控中。这种“看到全貌”的感觉对于理解游戏开发核心概念非常有帮助。第三原型验证速度快得惊人。我不需要等引擎加载几秒的大工程保存代码刷新浏览器就到位。对于玩法测试来说这个迭代速度太关键了。想调整包满后撤离的速度改个变量就完事。想给敌人加一个警戒状态加个条件判断就行。1.3 这个项目到底要做什么不做什么明确了方向以后我得先给项目划边界。做一个“搜打撤”风格的完整商业游戏那是不可能的事但做一个验证“搜打撤”循环的核心原型完全可行。我会实现的内容包括一张可探索的 2D 地图、玩家角色和相机跟随、可互动的物资点、搜索进度机制、背包容量、简单 AI 敌人、射击和命中判定、敌人掉落物品、撤离点区域、撤离成功结算。其中 AI 敌人不需要多聪明能巡逻、能发现玩家、能追一段距离并且开枪就行。地图也不用做太大几百乘几百的像素空间放十几个物资点加六七个敌人建筑就足够。不做什么也很重要不做存档系统、不做多人在线、不做复杂 AI、不做移动端适配、不做音效音乐。把这些排除掉以后项目范围非常清晰开发才可能推进得下去。2. 整体设计从上到下先把架构搭明白2.1 游戏对象模型设计动手写代码之前我花了不少时间先把对象模型想清楚。所有游戏实体都是一个个对象这些对象需要有统一的接口方便被主循环调度。我设计了五类核心对象玩家、敌人、物资、子弹、地图。玩家和敌人都属于“可移动单位”他们有共同属性位置坐标 x/y、移动速度、生命值、朝向角度、当前状态。所以我先定义了一个基础的角色对象模型包含 id、x、y、hp、speed、state 这几个字段。玩家额外拥有背包和装备信息敌人额外拥有警戒范围和攻击间隔。物资点是一个独立对象它有坐标、类型、剩余搜索时间、是否已过期等属性。子弹对象则简单得多只有坐标、方向、速度、伤害、归属方。地图在我这里不是对象而是一个数据结构。我用一个二维数组来表示障碍物1 代表不可行走0 代表可通行。这个数组同时用于碰撞检测和 AI 寻路是整个项目的地基。2.2 主循环与状态机游戏要跑起来必须有一个不停执行的主循环。我这边的做法是标准套路用 requestAnimationFrame 作为主循环驱动每一帧做两件事先更新逻辑再绘制画面。更新逻辑包括玩家移动、AI 行为、子弹飞行、搜索进度、碰撞检测绘制则把所有可见的物体画到 Canvas 上。为了不让游戏流程乱掉我加了一个简单的状态机。游戏一共有四个状态大厅、战斗、结算、死亡。大厅状态处理角色选择和开始按钮战斗状态是核心循环所在结算状态展示搜刮到的物资和撤离判定死亡状态则弹出敌人击杀界面并清空本次背包。这样做状态机的好处是每个状态的逻辑彼此隔离。我不需要在战斗逻辑里频繁判断“现在是不是结算”只需要在主循环开头检查当前状态然后只调用当前状态对应的更新函数。代码结构清晰得多了。2.3 地图数据结构与视野裁剪地图数据结构我会稍微展开说。我选用的方案是网格碰撞用一个二维数组定义整张地图每个格子是 0 或者 1。这个方案比多边形碰撞简单太多——判断一个点能不能走只要算一下这个点所在的格子是不是 1 就行。但网格碰撞有一个绕不开的问题地图大了以后绘制开销很大。假设地图是 100x100 个格子每个格子渲染成一个矩形那一帧就是一万个矩形绘制。这样帧率必崩。所以必须做视野裁剪只绘制相机可视范围内的部分。视野裁剪的思路是根据相机当前位置和 Canvas 尺寸反推出哪些格子会出现在屏幕上然后只遍历这些格子。假设屏幕是 800x600每个格子是 40 像素那么屏幕上只有 20x15 个格子需要绘制其他的都不用管。这个优化一上性能瞬间提升好几个量级。3. 核心系统实现细节抄作业级别3.1 玩家控制与相机跟随玩家控制的实现思路很直接。键盘 WASD 控制移动鼠标位置控制瞄准方向。每一帧检查四个方向键是否按下然后合成一个移动向量再根据速度乘以时间差算出位移。这里有一个关键细节如果没有做归一化斜向移动会快一截。比如同时按 W 和 D移动向量的长度就是根号2等于走对角线会比直线快 1.4 倍很明显的速度作弊。解决办法是计算出移动向量以后做 normalize再乘速度。我实测过这个 Bug确实容易犯。相机跟随的做法是每一帧把相机坐标向玩家坐标插值移动。用一个简单的线性插值。这样既能保证玩家基本在屏幕中央又不会因为人物快速转向导致画面跳来跳去。碰撞检测的代码思路是先计算玩家下一步的新坐标然后分别检查 x 轴和 y 轴有没有撞到障碍物如果某个轴撞到了就把这个轴的移动量清零。先用 x 轴更新再判定再用 y 轴更新再判定这样玩家贴着墙走的时候不会卡住。我在地图初始化阶段把障碍物坐标单独存了一份数组里面存的是所有不可行走格子的 x、y、宽、高。这样做成矩形列表以后碰撞检测就不需要去查二维数组了直接遍历这个数组做 AABB轴对齐包围盒测试就行效率很高。如果地图有 200 个障碍物格子一帧遍历 200 个矩形做四个判断性能完全不叫事。3.2 物资搜刮与背包系统物资系统是“搜”这个动作的灵魂。玩家在地图上靠近一个物资点以后按住交互键就开始搜索。搜索需要一个进度条进度条从 0 涨到 100% 的过程中玩家不能远离物资点否则搜索会被重置。为了让搜索有真实感我设定不同的物资点是不同的搜索时间。普通箱子需要 3 秒保险柜需要 8 秒。这个变量直接存在物资点对象里初始化的时候随机分配。进度条拿什么来表示我直接在玩家头顶画一个当前搜索进度核心是让玩家能实时看到反馈。这里的反馈逻辑很简单每一帧如果玩家靠近且按下交互键progress 就加 deltaTime否则 progress 就回退到 0。当 progress 大于等于 maxTime 时触发掉落生成。背包系统的核心是容量约束。每个玩家背包有一个容量上限例如 20 格每件物品占不同的格数。玩家搜到物资后要先判断背包剩余空间是否足够够才能拾取不够就提示背包已满。这个“格子约束”正是搜打撤里搜索收益和行动空间矛盾感的来源。更细一点的实现物资点可搜出不同品质的战利品品质越高掉落概率越低。这段我用随机数表实现初始化物资点时给它分配一个品质搜完之后按品质从掉落表里随机选物品。背包最终只做成了数组存储为了直观我把背包面板画在屏幕右侧用来展示物品图标和占用格数。这样一个简单的背包系统就完整了不需要搞嵌套格子那种复杂的拖拽逻辑。3.3 战斗系统射击、命中与 AI射击系统的核心是子弹对象。玩家点击鼠标左键时从玩家位置沿当前瞄准方向发射一颗子弹子弹有固定速度和伤害。每一帧更新子弹的位置然后检查子弹是否碰到障碍物或者敌人。我没有做玩家向鼠标方向的射线检测而是真的让子弹飞出去。这样做的好处是战斗反馈很真实你能看到子弹飞行轨迹能预判提前量。考虑到 2D 游戏性能压力不大子弹列表即便同时存在几颗遍历检测的开销也完全可以忽略。命中检测的做法对每个敌人做 AABB 矩形判定检查子弹坐标是否落在敌人的包围盒内。如果命中扣除敌人 hp子弹消失。如果子弹碰到障碍物也消失。敌人在我这里的实现分为三种巡逻状态、警戒状态、追击状态。巡逻状态的敌人沿着预设路径点走走到终点就掉头。警戒状态是玩家进入敌人视野半径但没被发现的具体位置敌人会停下来面朝警戒方向持续几秒后回到巡逻。追击状态是敌人发现玩家后的行为会直接冲向玩家并有概率开枪。最简单的状态切换逻辑就是读取视野半径和距离。每一帧计算玩家到敌人的距离如果距离小到视野半径内且中间没有障碍物遮挡就切换成追击。视野半径我给了几个档位普通敌人是 120 像素精英敌人是 250 像素。这样玩家可以用偷背身的方式靠近普通敌人。AI 普及版的代码思路是追击状态下敌人每一步沿着玩家当前位置方向移动如果路上撞到障碍物就尝试往某个方向偏移绕开。绕开逻辑非常简单但有效当碰撞检测到障碍物后先尝试水平方向移动再尝试垂直方向移动找到能走的方向就走。这个方法做不到最优路径但能应付大多数简单地形。击杀敌人后敌人身上会掉落随机物品。掉落物也是一个物资对象玩家走过去靠近即可拾取。这样“打”和“搜”两个环节就串起来了。3.4 撤离点与游戏结算撤离点在地图上设置一个或者几个固定位置用显眼的绿色圆圈标识。玩家进入撤离区域并停留 10 秒就能触发撤离成功逻辑。停留的 10 秒是个很微妙的平衡点。太短没有紧张感太长玩家会无聊试了几轮下来 10 秒的效果最好。撤离成功的判定逻辑计算玩家是否在撤离点圆内如果在则撤离进度累计 deltaTime同时在屏幕上显示撤离进度条。一旦进度条满切换到结算状态结算界面统计玩家本局搜到的所有物资、击杀数和存活时间。如果玩家在战斗中死亡则判定为失败本局所有背包里的物资清零。这个设计是该“确认每个决策的成本”的关键——如果你搜了一堆好东西但贪心不走被打死就全没了。战斗过程中的状态显示是左下角实时显示剩余生命值和当前背包容量右上角显示撤离点方位提示。这些信息让玩家能快速做出战术决策是很基础的 HUD 设计但非常关键。4. 实操过程从零到可玩版本的真实开发记录4.1 循序渐进的开发顺序我做这个项目大概花了五天时间每天的节奏是晚上下班后写两三个小时。开发顺序非常关键我强烈推荐按照“地图→玩家→相机→碰撞→搜索→战斗→结算”的顺序来每一步都是下一步的必要前提。第一天搭地图和玩家。先用数组定义地图用双重 for 循环画格子把障碍物可视化。然后做 WASD 移动把玩家画成一个带朝向的矩形或者圆形。这一步做完你已经能在浏览器里“走”起来了体验感和成就感直接拉满。第二天做相机跟随和碰撞检测。相机跟随的核心代码就十行但加完之后整个游戏视角从“固定大局”变成了“跟随角色”体验立刻不一样。碰撞检测要同时处理移动和绘制两个层面移动时防止玩家进墙绘制时大地图的障碍物不会出画。第三天做物资搜刮和背包。这一步相对简单主要是把交互键按下的检测、进度条渲染和背包 UI 画好。进度条绘制过程里我踩了一个时间单位的坑后面详细说。第四天做战斗系统和 AI。这是最大的一块。先做子弹系统再做敌人巡逻 AI然后做玩家与敌人的互相伤害。如果子弹有飞行速度记得把子弹坐标循环更新和碰撞检测放在同一个循环里做。第五天做撤离点、结算界面和流程串联。把大厅→战斗→结算的流程理顺调整数值平衡最终生成一个可以完整跑通循环的版本。4.2 我踩过的几个大坑第一个坑是时间单位混乱。我在做进度条的时候一开始用帧数累加来做计数progress。但是 requestAnimationFrame 在 60Hz 的屏幕上每帧约 16.6 毫秒在 144Hz 的屏幕上只有约 6.9 毫秒。当我换到高刷屏上测试时游戏里所有计时都跑得飞快搜索三秒钟的箱子一秒就搜完了。解决办法是统一使用 deltaTime也就是上一帧到当前帧的真实时间差。所有和秒相关的数值都乘以 deltaTime。这个坑新手如果不在两个刷新率不同的设备上测试很难发现。第二个坑是 Canvas 高 DPI 模糊问题。在普通笔记本上看着没问题但拿到 2K 屏上画面糊得没法看。原因是 CSS 像素和物理像素不一样。解决方案是检测 window.devicePixelRatio然后把 canvas 的宽高乘以这个值再用 scale 调整坐标系。代码很简单但很容易被忽略。第三个坑是子弹穿过薄墙。我在做子弹碰撞时一开始只检测了子弹运动终点所在的格子如果子弹速度过快一帧就跨过一个格子就会直接穿墙。解决思路是做步进检测子弹这一帧的位移超过格子边长时把移动拆分成多步来检测。我把子弹最大运动距离限定在一个格子边长以内达不到就直接分段移动检测。这个方案虽然粗暴但非常管用。第四个坑是 AI 卡墙。敌人追击玩家时一头扎到墙角就一直原地抽搐。因为我只是在追击时检测到障碍物没有设计绕障逻辑。后来我给敌人加了一个“切换寻路模式”的机制连续检测到障碍物超过一定次数后让敌人随机选择与障碍物法线垂直的一个方向移动直到不再撞墙再恢复原来的追击方向。这个简单策略能把绝大多数卡墙问题解决掉。4.3 性能优化三板斧这块必须单独讲因为 Canvas 2D 写游戏最容易碰到的就是性能瓶颈。我的优化手段总结起来就是三板斧。第一板斧是离屏 Canvas 缓存静态场景。地图上一大半的障碍物和背景装饰是不变的我把这些静态内容先画到一个单独的 canvas 上之后每一帧只需要通过 drawImage 把缓存画布贴到主画布上再画动态物体即可。这样原来每一帧要循环上千次画格子变成了只画一张图片开销瞬间降低了两个数量级。第二板斧是视野裁剪。不仅仅是障碍物所有游戏对象包括物资、敌人、子弹也要做边界判断不在相机的可视矩形范围内就不绘制、不更新。这个逻辑放在每个对象 update 函数的开头用当前相机位置和画布尺寸做判断。剪掉那些看不见的对象后游戏在后期子弹很多、敌人很多的情况下都保持 60 帧无压力。第三板斧是减少 fillStyle 切换。Canvas 的 fillStyle 切换是带成本的频繁切换颜色会导致绘制性能变差。我尽量把所有同颜色的对象排在一起绘制比如先画所有墙体统一设置颜色再画所有箱子再画敌人。虽然代码结构看起来没那么内聚但性能提升非常明显。5. 常见问题与排查技巧实录5.1 问题速查表我在实际开发过程中攒了一堆问题下面这些是最容易遇到的整理成一个速查表方便大家直接对照排查。问题现象可能原因解决方案角色移动时一卡一卡时间单位用了帧数改用 deltaTime 计算位移画面在高分辨率屏幕上模糊未处理 devicePixelRatio将 canvas 物理像素乘以设备像素比子弹穿过墙体子弹速度过快一帧跨过多个格子拆分步进检测或限制单帧最大移动距离敌人卡在墙角抖动追击方向被障碍物挡住无绕行逻辑增加随机偏转方向策略背包物品拾取不了背包格数计算错误先减去掉落物占用格数再判断是否为正撤离进度一直在涨但永远不触发成功进度条用整数累加但 deltaTime 是小数检查是否越过了 maxTime 边界使用 判断敌人明明距离很远却能看到玩家视野半径数值设置过大调小视野半径或者增加障碍物遮挡检测5.2 适合新手的调试小技巧有一个调试技巧我特别推荐把开发模式的调试开关常驻在界面上。在 Canvas 游戏里加一个 debug 开关按 F 键开启或关闭开启时把相机范围、敌人视野半径、碰撞盒、撤离点范围全部用半透明色块画出来。这样做的好处是任何逻辑看起来不对的时候你能立刻看到运行中的真实数值。比如敌人视野半径如果不把圆圈画出来你很难直观理解为什么敌人能隔着墙那么远发现你。画上之后你就会发现其实不是敌人视野太远而是墙体遮挡检测没有生效。这类问题如果不做可视化排查起来真的会把头发挠秃。另一个技巧是做一个慢动作调试模式把时间缩放系数调成 0.2也就是游戏世界以五分之一的速度运行。这样子弹飞行轨迹、AI 的移动路径都能看得清清楚楚对理解逻辑非常有帮助。这个功能实现起来很简单在更新逻辑里把 deltaTime 乘以一个系数就行。5.3 个人体会与后续扩展方向最后分享一些我自己做完这个项目的真实感受。用 Canvas 2D 写一个搜打撤风格的玩法原型绝不是一个“降级替代”方案它反而逼你在做每一块系统时都清楚自己在干什么。没有引擎替你隐藏复杂度你必须亲手解决渲染、碰撞、AI、状态管理等一系列问题。这些能力一旦沉淀下来以后用任何引擎做游戏你都会觉得如虎添翼。后续如果要扩展我会优先加三样东西本地存储的持久化背包数据敌人掉落物列表的丰富度以及更多样化的地图类型。还有一个有意思的方向是尝试把这个项目移植到手机上触屏操作版最大的难点是虚拟摇杆的手感但核心逻辑完全不用改。这个本体拿着就已经能完整地跑通搜打撤循环了再往下就是内容量的活而不是技术深度的活了。
