
1. 为什么我选择用 Canvas 2D 手搓一个搜打撤游戏《逃离鸭科夫》这个游戏最近在圈子里讨论度很高它的核心玩法其实不复杂进入地图、搜刮物资、和敌人或AI交火、找到撤离点、带着战利品跑路。失败就丢掉身上所有东西成功就一夜暴富。这种“高风险高回报”的循环让人上头但它的实现门槛其实没有想象中那么高。很多人一听到“做游戏”第一反应就是Unity、Godot、虚幻引擎觉得没有引擎就做不了游戏。我一开始也这么想直到我花了一个周末用纯Canvas 2D把核心玩法跑通之后才发现对于2D俯视角、没有复杂物理模拟、没有3D模型的搜打撤玩法Canvas 2D完全够用而且开发效率极高。这篇文章面向的是有基础HTML、CSS、JavaScript知识想动手做一个完整小游戏但不想被引擎的复杂概念劝退的开发者。我会从整体架构讲到具体实现包括地图生成、角色移动、碰撞检测、物资搜刮、敌人AI、撤离逻辑这些核心模块。代码可以直接抄思路可以直接用。注意本文假设你已经了解JavaScript基础语法、DOM操作和Canvas基本API。如果这些还不熟建议先补一下再回来不然看代码会比较吃力。2. 整体架构设计与技术选型思路2.1 为什么不用游戏引擎先说清楚一件事我不是反对用引擎。Unity做3D游戏、Godot做像素风RPG这些都是非常合理的选择。但搜打撤这个品类有几个特点让它特别适合用Canvas 2D手搓第一视角固定。俯视角2D意味着不需要处理摄像机旋转、透视投影、光照烘焙这些3D才有的问题。Canvas 2D的坐标系直接就是屏幕坐标系省掉了一层转换。第二物理简单。搜打撤游戏不需要刚体物理、不需要布料模拟、不需要复杂的碰撞响应。角色移动就是坐标加减碰撞检测就是矩形相交判断子弹就是射线检测。这些用几十行代码就能搞定。第三状态管理清晰。一局游戏的状态无非就是地图数据、玩家数据、敌人数据、物资数据、撤离点数据。用JavaScript对象存就行不需要引擎的实体组件系统。第四调试方便。Canvas 2D的渲染结果就是像素你可以在任何浏览器里打开DevTools直接看到每一帧的绘制调用。引擎反而会加一层抽象调试的时候要多绕几个弯。当然Canvas 2D也有它的局限。如果你要做3D搜打撤、要做复杂的动画状态机、要做网络同步那还是老老实实上引擎。但对于一个单机、2D、俯视角的搜打撤原型Canvas 2D是最短路径。2.2 核心模块划分我把整个游戏拆成六个模块每个模块职责单一模块之间通过一个全局的GameState对象通信模块职责关键文件地图系统生成随机地图、管理墙壁和地面map.js渲染系统每帧绘制地图、角色、物资、UIrender.js输入系统监听键盘鼠标、维护输入状态input.js实体系统管理玩家、敌人、子弹、物资entity.js游戏循环固定时间步更新、渲染调度loop.js状态管理存储全局游戏状态、胜负判定state.js这种划分方式的好处是每个模块都可以单独测试。比如地图生成写完之后你可以先不写渲染直接在控制台打印地图数组看对不对。渲染写完之后你可以先不放敌人只画一个方块看能不能动。逐步叠加比一上来就写一个几千行的main.js要可控得多。2.3 游戏循环的设计游戏循环是整个游戏的骨架。我用的是固定时间步长加插值渲染的方案const FIXED_DT 1 / 60; // 固定更新步长60Hz let accumulator 0; let lastTime performance.now(); function gameLoop(currentTime) { const frameTime (currentTime - lastTime) / 1000; lastTime currentTime; accumulator frameTime; // 防止螺旋死亡单帧最多累积0.25秒 if (accumulator 0.25) accumulator 0.25; while (accumulator FIXED_DT) { update(FIXED_DT); accumulator - FIXED_DT; } render(accumulator / FIXED_DT); requestAnimationFrame(gameLoop); }为什么用固定时间步长因为搜打撤游戏里有很多基于时间的逻辑比如子弹飞行、敌人巡逻、物资刷新。如果直接用可变时间步长不同帧率下这些逻辑的行为会不一致。固定步长保证无论你的显示器是60Hz还是144Hz游戏逻辑的更新频率都是60次每秒。插值渲染是为了让画面更流畅。因为更新频率是60Hz但显示器可能是144Hz如果不插值画面会有轻微的抖动。插值就是用上一次和这一次的状态做线性混合让渲染位置更平滑。实操心得如果你觉得插值太麻烦可以先不做直接用可变时间步长。等游戏跑通了再优化。我一开始就是直接update(frameTime)后来发现子弹速度在不同电脑上不一样才改成固定步长的。3. 地图生成与渲染的核心细节3.1 用二维数组表示地图地图我用一个二维数组表示每个格子是一个数字代表不同的地形类型const TILE { FLOOR: 0, // 可通行地面 WALL: 1, // 不可通行墙壁 EXTRACT: 2, // 撤离点 LOOT: 3, // 物资点 SPAWN: 4 // 玩家出生点 }; // 地图尺寸40x30格每格32像素 const MAP_W 40; const MAP_H 30; const TILE_SIZE 32; let map []; function generateMap() { map []; for (let y 0; y MAP_H; y) { const row []; for (let x 0; x MAP_W; x) { // 边界全是墙 if (x 0 || y 0 || x MAP_W - 1 || y MAP_H - 1) { row.push(TILE.WALL); } else { row.push(TILE.FLOOR); } } map.push(row); } // 随机放置一些墙壁形成房间感 placeRandomWalls(15); // 放置撤离点 placeExtractPoints(3); // 放置物资点 placeLootPoints(20); // 放置玩家出生点 placeSpawnPoint(); }用二维数组的好处是查询极快。判断某个坐标能不能走只需要map[gridY][gridX] TILE.FLOOR。渲染的时候也是遍历这个数组遇到墙就画深色方块遇到地面就画浅色方块。3.2 随机墙壁的放置策略随机墙壁不能完全随机放否则地图会变成迷宫玩家根本走不通。我的做法是随机选一个起点然后沿着一个方向延伸一段长度形成一堵墙。重复这个过程15次就能得到有房间感但不至于堵死的地图。function placeRandomWalls(count) { for (let i 0; i count; i) { const startX 2 Math.floor(Math.random() * (MAP_W - 4)); const startY 2 Math.floor(Math.random() * (MAP_H - 4)); const length 3 Math.floor(Math.random() * 8); const horizontal Math.random() 0.5; for (let j 0; j length; j) { const x horizontal ? startX j : startX; const y horizontal ? startY : startY j; if (x 0 x MAP_W - 1 y 0 y MAP_H - 1) { map[y][x] TILE.WALL; } } } }这里有个细节墙壁不能放在边界上因为边界已经是墙了。也不能放在太靠近边界的位置否则会把出生点堵死。所以我限制起点在2到MAP_W-4之间。注意事项随机生成的地图有可能把某个区域完全封死导致玩家走不到撤离点。我的解决方案是在生成完墙壁之后跑一次洪水填充检查从出生点能不能到达所有撤离点。如果不能就重新生成。这个检查只在地图生成时跑一次不影响运行时性能。3.3 渲染优化只画可见区域40x30的地图总共1200个格子。如果每帧都全部画一遍其实也还好Canvas 2D处理1200个矩形填充没什么压力。但如果地图再大一点比如100x100那就需要做视口裁剪了。我的做法是计算当前视口覆盖的格子范围只画这个范围内的格子function renderMap(ctx, camera) { const startX Math.max(0, Math.floor(camera.x / TILE_SIZE)); const startY Math.max(0, Math.floor(camera.y / TILE_SIZE)); const endX Math.min(MAP_W, Math.ceil((camera.x canvas.width) / TILE_SIZE)); const endY Math.min(MAP_H, Math.ceil((camera.y canvas.height) / TILE_SIZE)); for (let y startY; y endY; y) { for (let x startX; x endX; x) { const tile map[y][x]; const screenX x * TILE_SIZE - camera.x; const screenY y * TILE_SIZE - camera.y; switch (tile) { case TILE.WALL: ctx.fillStyle #2a2a2a; break; case TILE.FLOOR: ctx.fillStyle #4a4a4a; break; case TILE.EXTRACT: ctx.fillStyle #2a5a2a; break; case TILE.LOOT: ctx.fillStyle #5a5a2a; break; default: ctx.fillStyle #4a4a4a; } ctx.fillRect(screenX, screenY, TILE_SIZE, TILE_SIZE); } } }相机跟随玩家但要做边界限制不能让相机超出地图范围。这个逻辑很简单camera.x clamp(player.x - canvas.width / 2, 0, MAP_W * TILE_SIZE - canvas.width)。4. 角色移动、碰撞与战斗实现4.1 玩家移动的输入处理输入系统我单独抽了一个模块维护一个keys对象记录每个键当前是否按下const keys {}; window.addEventListener(keydown, (e) { keys[e.code] true; }); window.addEventListener(keyup, (e) { keys[e.code] false; }); function getMoveDirection() { let dx 0, dy 0; if (keys[KeyW] || keys[ArrowUp]) dy - 1; if (keys[KeyS] || keys[ArrowDown]) dy 1; if (keys[KeyA] || keys[ArrowLeft]) dx - 1; if (keys[KeyD] || keys[ArrowRight]) dx 1; // 归一化防止斜向移动更快 if (dx ! 0 dy ! 0) { const len Math.sqrt(dx * dx dy * dy); dx / len; dy / len; } return { dx, dy }; }这里有个新手常踩的坑如果不做归一化斜向移动的速度会是直线移动的1.414倍。因为斜向的时候dx和dy都是1合速度是根号2。归一化之后斜向和直线的速度就一致了。4.2 碰撞检测分轴处理碰撞检测我用的是AABB轴对齐包围盒加分轴处理。先尝试移动X轴如果碰撞就回退X再尝试移动Y轴如果碰撞就回退Y。这样处理的好处是玩家贴着墙走的时候不会卡住。function moveWithCollision(entity, dx, dy) { // 先移动X轴 const newX entity.x dx; if (!collidesWithWall(newX, entity.y, entity.w, entity.h)) { entity.x newX; } // 再移动Y轴 const newY entity.y dy; if (!collidesWithWall(entity.x, newY, entity.w, entity.h)) { entity.y newY; } } function collidesWithWall(x, y, w, h) { const left Math.floor(x / TILE_SIZE); const right Math.floor((x w - 1) / TILE_SIZE); const top Math.floor(y / TILE_SIZE); const bottom Math.floor((y h - 1) / TILE_SIZE); for (let ty top; ty bottom; ty) { for (let tx left; tx right; tx) { if (tx 0 || tx MAP_W || ty 0 || ty MAP_H) return true; if (map[ty][tx] TILE.WALL) return true; } } return false; }分轴处理的关键在于X轴碰撞回退之后Y轴仍然可以移动。这样玩家在贴着墙走的时候虽然X方向被挡住了但Y方向还能继续走手感会顺滑很多。实操心得碰撞检测的精度取决于实体的碰撞盒大小。如果碰撞盒和渲染大小一样玩家会感觉“明明没碰到墙却走不过去”。我的做法是碰撞盒比渲染大小小2到4个像素视觉上看起来更合理。4.3 子弹与射击逻辑射击逻辑很简单鼠标点击时从玩家位置向鼠标方向发射一颗子弹。子弹每帧沿着方向移动碰到墙壁或敌人就消失。function shoot(player, targetX, targetY) { const dx targetX - (player.x player.w / 2); const dy targetY - (player.y player.h / 2); const len Math.sqrt(dx * dx dy * dy); const speed 800; // 像素每秒 bullets.push({ x: player.x player.w / 2, y: player.y player.h / 2, vx: (dx / len) * speed, vy: (dy / len) * speed, life: 1.0, // 存活时间秒 damage: 25 }); } function updateBullets(dt) { for (let i bullets.length - 1; i 0; i--) { const b bullets[i]; b.x b.vx * dt; b.y b.vy * dt; b.life - dt; // 碰到墙壁或超时 if (b.life 0 || collidesWithWall(b.x, b.y, 2, 2)) { bullets.splice(i, 1); continue; } // 碰到敌人 for (const enemy of enemies) { if (rectIntersect(b.x, b.y, 2, 2, enemy.x, enemy.y, enemy.w, enemy.h)) { enemy.hp - b.damage; bullets.splice(i, 1); break; } } } }子弹的碰撞检测用的是点对矩形因为子弹很小用矩形对矩形有点浪费。点对矩形只需要判断点的坐标是否在矩形范围内。5. 敌人AI与搜刮撤离机制5.1 敌人AI的状态机设计敌人AI我用了一个简单的状态机三个状态巡逻、追击、攻击。const AI_STATE { PATROL: patrol, CHASE: chase, ATTACK: attack }; function updateEnemy(enemy, dt) { const distToPlayer distance(enemy, player); switch (enemy.state) { case AI_STATE.PATROL: // 沿着预设路径点巡逻 patrol(enemy, dt); // 如果玩家进入视野范围切换到追击 if (distToPlayer enemy.sightRange hasLineOfSight(enemy, player)) { enemy.state AI_STATE.CHASE; } break; case AI_STATE.CHASE: // 向玩家移动 moveToward(enemy, player, dt); // 如果进入攻击范围切换到攻击 if (distToPlayer enemy.attackRange) { enemy.state AI_STATE.ATTACK; } // 如果玩家跑远了回到巡逻 if (distToPlayer enemy.sightRange * 1.5) { enemy.state AI_STATE.PATROL; } break; case AI_STATE.ATTACK: // 停止移动开火 enemy.fireCooldown - dt; if (enemy.fireCooldown 0) { enemyShoot(enemy, player); enemy.fireCooldown enemy.fireRate; } // 如果玩家离开攻击范围回到追击 if (distToPlayer enemy.attackRange * 1.2) { enemy.state AI_STATE.CHASE; } break; } }视野检测我用的是距离加射线检测。距离判断很快但不够精确因为敌人可能隔着墙看到玩家。射线检测用Bresenham算法从敌人位置向玩家位置画一条线检查这条线是否穿过墙壁。function hasLineOfSight(from, to) { const x0 Math.floor(from.x / TILE_SIZE); const y0 Math.floor(from.y / TILE_SIZE); const x1 Math.floor(to.x / TILE_SIZE); const y1 Math.floor(to.y / TILE_SIZE); let dx Math.abs(x1 - x0); let dy Math.abs(y1 - y0); let sx x0 x1 ? 1 : -1; let sy y0 y1 ? 1 : -1; let err dx - dy; while (true) { if (map[y0] map[y0][x0] TILE.WALL) return false; if (x0 x1 y0 y1) return true; const e2 2 * err; if (e2 -dy) { err - dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }注意事项Bresenham算法在格子地图上做视线检测非常高效但它有一个小问题如果视线刚好穿过两个格子的交界处可能会误判。实际使用中影响不大因为游戏节奏很快玩家不会注意到这种细节。5.2 物资搜刮的实现物资点在地图上用LOOT标记。玩家走到物资点附近按E键搜刮。搜刮需要时间期间玩家不能移动否则中断。let looting null; // { x, y, progress, duration } function tryLoot() { if (looting) return; // 已经在搜刮了 const px Math.floor((player.x player.w / 2) / TILE_SIZE); const py Math.floor((player.y player.h / 2) / TILE_SIZE); // 检查周围3x3范围内有没有物资点 for (let dy -1; dy 1; dy) { for (let dx -1; dx 1; dx) { const tx px dx; const ty py dy; if (map[ty] map[ty][tx] TILE.LOOT) { looting { x: tx, y: ty, progress: 0, duration: 2.0 // 搜刮需要2秒 }; return; } } } } function updateLooting(dt) { if (!looting) return; // 如果玩家移动了中断搜刮 const moveDir getMoveDirection(); if (moveDir.dx ! 0 || moveDir.dy ! 0) { looting null; return; } looting.progress dt; if (looting.progress looting.duration) { // 搜刮完成给玩家随机物资 const loot generateLoot(); player.inventory.push(loot); // 把地图上的物资点标记为已搜刮 map[looting.y][looting.x] TILE.FLOOR; looting null; } }物资的生成我用了一个权重表不同物品有不同的稀有度物品权重价值绷带3050弹药2580医疗包15200护甲10350稀有零件5800权重越高抽到的概率越大。稀有零件虽然概率低但价值高给玩家一种“赌一把”的刺激感。5.3 撤离逻辑与胜负判定撤离点在地图上用EXTRACT标记。玩家走到撤离点范围内持续站3秒就能撤离成功。撤离期间如果移动计时重置。let extracting false; let extractTimer 0; const EXTRACT_DURATION 3.0; function updateExtraction(dt) { const px Math.floor((player.x player.w / 2) / TILE_SIZE); const py Math.floor((player.y player.h / 2) / TILE_SIZE); const onExtract map[py] map[py][px] TILE.EXTRACT; if (onExtract) { const moveDir getMoveDirection(); if (moveDir.dx ! 0 || moveDir.dy ! 0) { extractTimer 0; extracting false; return; } extracting true; extractTimer dt; if (extractTimer EXTRACT_DURATION) { endGame(true); // 撤离成功 } } else { extracting false; extractTimer 0; } }胜负判定很简单玩家血量归零游戏失败丢失所有物资玩家成功撤离游戏胜利物资保留。这个循环就是搜打撤的核心魅力所在。6. 常见问题与排查技巧实录6.1 画面撕裂与帧率不稳最开始我直接用requestAnimationFrame驱动更新和渲染结果发现快速移动的时候画面会有撕裂感。原因是更新和渲染在同一个帧里如果某一帧更新耗时太长渲染就会延迟。解决方案就是前面提到的固定时间步长加插值。把更新和渲染解耦更新固定60Hz渲染跟随显示器刷新率。这样即使某一帧更新慢了渲染也能用上一次的状态插值画面不会卡顿。6.2 碰撞检测的“卡墙”问题玩家贴着墙走的时候有时候会卡在墙角动不了。排查下来发现是碰撞盒太大导致玩家在墙角的时候X轴和Y轴的碰撞检测同时触发两个方向都被回退就卡住了。解决方法有两个一是缩小碰撞盒让碰撞盒比渲染大小小几个像素二是分轴处理的时候如果X轴碰撞了不要直接回退而是尝试把玩家推到墙边。我用的是第一种方案简单有效。6.3 敌人AI的“隔墙打人”问题敌人有时候会隔着墙向玩家射击原因是视野检测只用了距离判断没有做视线检测。加上Bresenham射线检测之后敌人就不会隔墙看到玩家了。但新的问题又来了敌人有时候会卡在墙角因为追击的时候直线移动碰到墙就卡住了。解决方案是给敌人加一个简单的寻路或者让敌人在碰到墙的时候沿着墙滑动。我用的是后者实现简单效果也够用。6.4 性能问题排查表现象可能原因排查方法解决方案帧率低于30每帧绘制对象太多在render里打点计时视口裁剪只画可见区域移动卡顿碰撞检测太频繁在collidesWithWall里打点缩小碰撞盒减少检测次数子弹穿墙子弹速度太快单帧位移超过墙厚打印子弹每帧位置子弹做射线检测而不是点检测内存持续增长子弹或粒子没有回收打印数组长度及时splice或用对象池输入延迟事件监听太多用Performance面板看合并事件用状态对象而不是回调实操心得性能问题不要猜一定要用数据说话。Chrome DevTools的Performance面板可以录制一段游戏过程然后看每一帧的耗时分布。大部分时候瓶颈都在渲染而不是逻辑。6.5 代码组织的一点建议最后说一个工程上的经验不要把所有的代码都塞进一个文件。我一开始就是所有逻辑写在一个main.js里写到800行的时候已经很难维护了。后来拆成map.js、render.js、input.js、entity.js、loop.js、state.js六个文件每个文件不超过300行改起来就舒服多了。模块之间通过全局的GameState对象通信而不是互相import。这样做的好处是避免循环依赖而且调试的时候可以在控制台直接访问GameState看当前的所有状态。// state.js const GameState { map: [], player: null, enemies: [], bullets: [], loot: [], camera: { x: 0, y: 0 }, running: true, result: null // win | lose | null };这个GameState对象就是整个游戏的数据中心。任何模块都可以读写它但每个模块只负责自己那部分数据。比如map.js只写GameState.maprender.js只读GameState里的数据来画图。职责清晰不容易出bug。如果你也想用Canvas 2D做一个搜打撤游戏我的建议是先跑通最小闭环一个玩家、一张地图、一个撤离点。能走能撤就算成功。然后再加敌人、加物资、加战斗。每加一个功能都确保之前的能跑。这样一步步来比一上来就设计一个大而全的架构要靠谱得多。