Canvas 2D实战:TopDown Shooter俯视角射击游戏核心机制解析

发布时间:2026/10/11 14:30:33
Canvas 2D实战:TopDown Shooter俯视角射击游戏核心机制解析
简介这是一份基于 C 与 SFML、Box2D 实现的自上而下 2D 射击游戏项目带有视野与光照机制适合学习 2D 游戏架构和物理碰撞的开发者。压缩包共 76 个文件、约 140KB包含 14 个 cpp 与 15 个 h 源码21 个 png 及 svg/xcf 图像素材8 个 pgm 与 4 个 ppm 地图数据以及 4 个 frag 着色器和 Makefile 构建文件目录按源码、地图、精灵图、着色器分模块整理方便快速定位。目前已有 368 人学习下载。项目提供 Linux 源码构建流程与 Windows 二进制版本可对照编译环境内部实现的视野阴影、光照贴图、法线贴图、地图生成器、子弹管理和敌人追踪能直观认识 2D 渲染管线与 Box2D 物理世界如何配合。参考这些代码可直接运行俯视角射击原型并在此基础上扩展武器、关卡或 UI 逻辑。1. TopDownShooter 是什么以及它适合谁来练手开发者第一次接触 TopDownShooter 这个项目多半是在刚学完 JavaScript 基础、想脱离教程做点能玩的东西的阶段。俯视角 2D 射击游戏在“简单”和“硬核”之间刚好踩中平衡点没有重力、没有复杂物理但游戏循环、坐标映射、碰撞检测、对象生命周期这些基本功一个都躲不掉。比起常见的 beat em up 清版过关 2D 模板俯视角射击的反馈更直接几分钟就能看到一颗子弹从枪口飞出去命中敌人。本文就用 Canvas 2D 把这个最小闭环拆开讲清楚适合想用第一行代码验证“我到底能不能写完一个完整小游戏”的开发者。2. 俯视角射击的地基先选游戏循环再谈坐标映射写 TopDownShooter 之前先把循环和坐标这两件最容易返工的事情定下来。2.1 游戏循环requestAnimationFrame 与固定时间步长常见做法是在 requestAnimationFrame 里直接执行updaterender简单直观。但如果你希望游戏在 144Hz 和 60Hz 屏幕上的表现一致就必须引入时间步长。否则角色移动速度会随屏幕刷新率变化高刷屏上快一倍低刷屏上像生了锈。固定时间步长是最稳的方案需要先确定一步的逻辑耗时。下面这段是我常用的骨架// 固定时间步长循环每帧逻辑耗时固定为 16.67ms const STEP 1000 / 60; // 1/60 秒 let lastTime performance.now(); let accumulator 0; function frame(time) { const delta time - lastTime; lastTime time; accumulator delta; // 防止浏览器标签页切换后台切回来后把几秒的帧都补算一遍 if (accumulator 250) accumulator 250; while (accumulator STEP) { update(STEP / 1000); // 传入秒方便速度计算 accumulator - STEP; } render(); requestAnimationFrame(frame); } requestAnimationFrame(frame);update里所有“每帧执行”的速度、冷却、碰撞判定都集中在一个地方逻辑上只有一个入口调试时不用到处找“哪里没乘时间”。参数STEP / 1000传入的是秒所以角色速度定义为“每秒像素数”比“每帧像素数”直观。门槛是首帧performance.now()要把lastTime初始化好否则第一次delta是零第一帧逻辑会被跳过。2.2 世界坐标、屏幕坐标和行走向量俯视角 2D 本质上就是一张平面图。角色在世界空间里有一个浮点坐标(x, y)渲染到屏幕时再取整。千万不要在游戏逻辑里用Math.round保存坐标否则角色会表现出“像素抖动”。移动的核心是向量。键盘按下 W 代表dy -1S 代表dy 1A/D 分别控制左右。斜向移动时必须做归一化不然角色斜着走的时候速度会变成正常的 1.414 倍。// 读取按键累加移动方向 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; // 归一化先算长度再除长度让斜向速度不超过直线 const len Math.hypot(dx, dy); if (len 0) { dx / len; dy / len; } player.x dx * player.speed * dt; player.y dy * player.speed * dt; // 边界限制把角色限制在画布内而不是让它消失在地图外 player.x Math.max(player.radius, Math.min(canvas.width - player.radius, player.x)); player.y Math.max(player.radius, Math.min(canvas.height - player.radius, player.y)); }Math.hypot(dx, dy)就是求sqrt(dx*dx dy*dy)。这里用边界限制clamp而不是“出界就报错”是为了让初学者不被坐标系吓住但也意味着地图没有滚动需求时这个写法最简单直接。后面如果做大地图再把这部分改成“相机跟随”。2.3 为什么要用向量而不是角度很多教程里喜欢存一个angle变量代表朝向然后Math.cos(angle)算方向。这在计算两个物体之间的距离和碰撞时特别别扭。我的习惯是代码里同时存在两种表示移动和碰撞用向量(vx, vy)渲染时用Math.atan2得到角度后旋转画布。function updateEnemy(enemy, player, dt) { const dx player.x - enemy.x; const dy player.y - enemy.y; const dist Math.hypot(dx, dy); // 防止除零两个物体重叠时保持原位 if (dist 0.001) { enemy.x (dx / dist) * enemy.speed * dt; enemy.y (dy / dist) * enemy.speed * dt; } // 转向角度用于渲染画布旋转时需要的是弧度 enemy.angle Math.atan2(dy, dx); }判断“谁在谁前面”用点积判断“有没有碰到”用距离。这套东西写熟以后无论是做弹幕、做塔防还是做俯视角生存游戏都能直接复用。不要信那些“角度才是王道”的说法向量才是你每天都在用的东西。3. 用 Canvas 写出一个最小可玩的 TopDownShooter有了循环和坐标系就可以开始堆实际功能了。这里的目标不是做一个完整游戏而是先让“角色能走、子弹能飞、敌人会追、打中会死”这个最小闭环跑起来。3.1 初始化画布和玩家对象先把画布尺寸固定好。这里用一个 800×600 的画布玩家放在画布中央。鼠标坐标用来决定枪口朝向我需要监听mousemove并把偏移量存下来。const canvas document.getElementById(game); const ctx canvas.getContext(2d); canvas.width 800; canvas.height 600; const player { x: 400, y: 300, radius: 16, speed: 200, // 每秒移动像素 hp: 100, fireCooldown: 0, fireRate: 0.15, // 两次开火间隔秒 }; const bullets []; const enemies []; let mouse { x: 400, y: 300 }; canvas.addEventListener(mousemove, e { mouse.x e.offsetX; mouse.y e.offsetY; });这里把fireCooldown放在玩家对象里而不是全局变量是为了以后再建一个“按住鼠标连续开火”的枪械时能复用同一套逻辑。fireRate是两次开火的间隔时间单位秒。如果将来做成枪械升级直接改这个值就行。3.2 玩家移动与边界限制玩家移动代码在上面已经给过这里补一个实际会踩到的细节鼠标监听只对mousemove生效如果你的页面里还有其他元素比如一个独立的“开始按钮”点击按钮时会触发 canvas 的mouseup事件从而误判成“点击开火”。所以通常只在鼠标按下时记录一个状态。let mouseDown false; canvas.addEventListener(mousedown, e { mouseDown true; mouse.x e.offsetX; mouse.y e.offsetY; }); canvas.addEventListener(mouseup, () mouseDown false);移动逻辑第一步是“读取按键”第二步是“归一化方向”第三步乘以坐标。这个顺序不能乱。很多新手把dxdy直接当速度用结果斜向移动快一截就是这个原因。3.3 发射子弹从枪口位置开始开火不只是enemies.push一颗子弹子弹的出生位置要从“枪口”出来而不是角色中心。否则子弹会穿过角色胸部看起来像从肚脐眼发射的。function tryShoot() { if (mouseDown player.fireCooldown 0) { // 枪口方向从玩家中心朝鼠标位置 const angle Math.atan2(mouse.y - player.y, mouse.x - player.x); const muzzleX player.x Math.cos(angle) * (player.radius 6); const muzzleY player.y Math.sin(angle) * (player.radius 6); bullets.push({ x: muzzleX, y: muzzleY, vx: Math.cos(angle) * 400, // 每秒 400 像素 vy: Math.sin(angle) * 400, life: 1.5, // 1.5 秒后自动消失 }); player.fireCooldown player.fireRate; } } 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) bullets.splice(i, 1); } }life是子弹存活时间而不是飞行距离这样在屏幕上移动速度不同的子弹也能统一管理。子弹更新时一定要从数组尾部往前遍历因为splice会改变数组索引。如果你从前往后删跳过元素是常有的事。3.4 敌人追踪与碰撞命中敌人 AI 就是“向玩家方向每帧移动一点”。上面已经给了追踪逻辑这里补碰撞判定。圆形碰撞判定在 Canvas 2D 里是最省事的两圆心的距离小于两个半径之和就算命中。function circleHit(a, b) { const dist Math.hypot(a.x - b.x, a.y - b.y); return dist a.radius b.radius; } // 每帧检测子弹命中敌人后移除子弹敌人扣血 function checkHits() { for (let i bullets.length - 1; i 0; i--) { for (let j enemies.length - 1; j 0; j--) { if (circleHit(bullets[i], enemies[j])) { bullets.splice(i, 1); enemies[j].hp - 1; if (enemies[j].hp 0) enemies.splice(j, 1); break; // 子弹只命中第一个敌人不穿透 } } } }碰撞检测不是“物理精确”而是“游戏够用”。俯视角射击里一颗子弹穿两个敌人还是只打一个取决于手感设计。我这个版本的枪械是“单发不穿透”所以子弹命中后就break。如果你做穿透型武器就把break去掉同时给子弹加一个hitSet记录已命中敌人防止同一颗子弹反复打到同一个人身上。4. 把 TopDownShooter 变成“2d俯视角射击生存游戏”波次、计分与状态管理一个只有移动和开火的 Demo 称不上“生存游戏”。生存游戏的核心是“越来越难的敌人 玩家成长 死亡重开”。这一章把波次、计分和游戏状态串起来。4.1 波次生成敌人从周围涌出波次不能全屏随机刷否则玩家背后刷怪会毫无体验。常见方案是“绕玩家一圈从屏幕外侧生成”。敌人往玩家方向追自然就进入画面。function spawnWave(wave) { // 每波的敌人数量第 1 波 3 个之后每波 2 const count 3 wave * 2; for (let i 0; i count; i) { // 在玩家周围 200-400 像素的圆周上随机位置生成 const angle (Math.PI * 2 / count) * i Math.random() * 0.5; const dist 200 Math.random() * 200; enemies.push({ x: player.x Math.cos(angle) * dist, y: player.y Math.sin(angle) * dist, speed: 60 wave * 5, // 速度逐波提升 hp: 1 Math.floor(wave / 3), // 每 3 波敌人变厚一点 radius: 14, }); } }count、speed、hp三个参数决定了难度曲线。count线性增长会让后期刷屏所以 HP 是分段增长的。这里生成角度用了(Math.PI * 2 / count) * i random让敌人均匀分布在圆周而不是全挤在一个方向玩家第一波不至于被围死。4.2 计分、生命值和游戏状态生存游戏要有一个明确的状态机。最少要有playing和gameover两个状态。UI 可以用 Canvas 直接画不用 DOM 元素因为 DOM 会拖慢帧率。const state { status: playing, // playing | gameover score: 0, wave: 1, time: 0, }; function update(dt) { if (state.status gameover) return; state.time dt; updatePlayer(player, dt); updateBullets(dt); updateEnemies(dt); // 每秒结束判断一次敌人是否清零 if (enemies.length 0) { state.wave 1; spawnWave(state.wave); } // 玩家 HP 归零就结束 if (player.hp 0) { state.status gameover; } }记分可以直接用击杀数也可以用时长的整数。我一般把score和time分开击杀数影响排行榜存活时长影响难度曲线。spawnWave在敌人全清后立刻触发如果你希望波次之间有喘息时间就加一个waveDelay状态等 3 秒再刷。4.3 用简单的 UI 反馈让手感“成立”很多 Demo 失败在“击中没反馈”。不想写复杂动画的话至少做三件事记录分数、受伤闪红、死亡淡出。function render() { ctx.fillStyle #1a1a2e; ctx.fillRect(0, 0, canvas.width, canvas.height); // 绘制敌人 for (const e of enemies) { ctx.fillStyle #e94560; ctx.beginPath(); ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2); ctx.fill(); } // 绘制玩家 ctx.fillStyle #0f3460; ctx.beginPath(); ctx.arc(player.x, player.y, player.radius, 0, Math.PI * 2); ctx.fill(); // 绘制子弹 for (const b of bullets) { ctx.fillStyle #f5e9e2; ctx.fillRect(b.x - 2, b.y - 2, 4, 4); } // 顶部 UI分数和波次 ctx.fillStyle #fff; ctx.font 16px monospace; ctx.fillText(SCORE ${state.score} WAVE ${state.wave}, 12, 24); }受伤闪红可以用一个全局变量hitFlash检测到circleHit时置 1每帧减dt渲染时用透明度的红色遮罩覆盖全屏。这个遮罩不需要自定义混合模式ctx.globalAlpha hitFlash * 0.3就能看到淡出效果。5. TopDownShooter 高频翻车点五个碰撞与移动的排查实录这里整理的是我自己教学和调试时反复遇到的五个坑每一条都是“现象 → 原因 → 解决”的顺序可逐一对照。5.1 斜向移动变快现象按住 WD 时角色明显比只按 W 或 D 移动得快。原因dx和dy同时为 1向量的模长变成了sqrt(2)约 1.414 倍。直接把dxdy当作速度用就会得到这个结果。解决把方向向量归一化。代码很简单const len Math.hypot(dx, dy); dx / len; dy / len;在len 0的前提下执行。5.2 高刷新率下换机器后速度不一致现象在 144Hz 显示器上开发时速度正常到 60Hz 笔记本上打开觉得角色“拖泥带水”或者反过来。原因循环里把位移直接player.x dx * speed没有乘dt速度成了“每帧像素”和刷新率挂钩。解决按第 2 章的固定时间步长改造所有位移、冷却、生命值减少都必须乘以dt。判断一个游戏是不是“帧率无关”就把它放到 30Hz 和 144Hz 两个环境里跑速度曲线不变才算合格。5.3 子弹“穿过”敌人却没命中现象明明朝着敌人开枪偶尔看到子弹从敌人身上穿过没触发任何碰撞。原因子弹速度太快一帧内位移超过敌人直径比如子弹每秒 400 像素60 FPS 下一帧移动 6.7 像素而敌人只有 14 像素半径基本没问题但如果子弹升到每秒 1000 像素一帧移动 16 像素就可能直接从敌人身体里跨过去。解决给子弹加上“最小步长检测”或者改用“线段与圆相交”的判断。我习惯用t 0.1这样的小步长把子弹位移拆成 2-3 段检测。function updateBullets(dt) { for (let i bullets.length - 1; i 0; i--) { const b bullets[i]; const subSteps 3; // 每帧拆成 3 步降低穿透概率 const step 1 / subSteps; for (let s 0; s subSteps; s) { b.x b.vx * dt * step; b.y b.vy * dt * step; // 每步子步都执行碰撞检测 if (hitEnemy(b)) break; } } }拆成 3 步是廉价版解决方案。如果你做弹幕游戏子弹速度极高适合用正式的点到线段距离公式。5.4 鼠标点击时UI 按钮抢走了开火事件现象画布中央放了一个“开始游戏”按钮点击按钮后角色朝鼠标方向开了一枪很突兀。原因按钮的mousedown事件会冒泡到 canvas 上或者反过来你在mousedown里就调用tryShoot没区分点击的是 UI 还是画布。解决用e.target canvas判断事件目标或者在 UI 元素上调用e.stopPropagation()。如果你是纯 Canvas UI要在点击判定时先检查“当前是否有按钮在这个坐标”再决定是否触发开火。canvas.addEventListener(mousedown, e { if (e.target ! canvas) return; // 防止按钮上的点击被误判 mouseDown true; mouse.x e.offsetX; mouse.y e.offsetY; });5.5 敌人挤成一团像一坨移动的果冻现象随着波次增加敌人之间互相重叠视觉上分不清谁是谁子弹也会一次性打中多个敌人。原因敌人没有互相碰撞或分离逻辑AI 都朝玩家位置移动路线重叠时自然会合并。解决在更新敌人后加一个简单的“邻避分离”循环或者在宽松的场景下接受重叠。如果要做分离最简单的是用弹簧力即当两个敌人距离小于radiusA radiusB时把彼此沿相反方向推开。function separateEnemies(enemies) { for (let i 0; i enemies.length; i) { for (let j i 1; j enemies.length; j) { const a enemies[i], b enemies[j]; const dist Math.hypot(a.x - b.x, a.y - b.y); const minDist a.radius b.radius; if (dist 0 dist minDist) { const push (minDist - dist) / 2; const nx (b.x - a.x) / dist; const ny (b.y - a.y) / dist; a.x - nx * push; a.y - ny * push; b.x nx * push; b.y ny * push; } } } }这个循环是 O(n²)敌人少于 100 个时没有问题超过 200 个就会开始掉帧。到时候用网格哈希来优化才值得否则拆开的收益不大。6. 进阶优化小地图导航、对象池与屏幕震动前面五章做完你已经有一个完整可玩的 TopDownShooter 了。这一章加三个真正影响游戏质感的优化不追求大而全但要能看出效果。6.1 小地图导航2d 导航图制作俯视角生存游戏要有点走位意识小地图是刚需。实现方式是再开一个小的 Canvas或者直接在主画布右上角用ctx.save()和ctx.scale()绘制一个缩放后的世界视图。function renderMinimap() { const scale 0.2; // 小地图缩放比例 const width canvas.width * scale; const height canvas.height * scale; const ox canvas.width - width - 10; const oy 10; ctx.save(); ctx.fillStyle rgba(0, 0, 0, 0.6); ctx.fillRect(ox, oy, width, height); // 敌人位置 for (const e of enemies) { ctx.fillStyle #e94560; ctx.fillRect(ox e.x * scale - 2, oy e.y * scale - 2, 4, 4); } // 玩家位置 ctx.fillStyle #0f3460; ctx.fillRect(ox player.x * scale - 3, oy player.y * scale - 3, 6, 6); ctx.restore(); }如果游戏世界比画布大这里的scale改成“世界范围 / 小地图范围”才准确。雷达上玩家的朝向也可以用一根短线表示背景里加一个rotate就能让地图旋转不过初期不需要。6.2 子弹对象池每帧push再splice会触发垃圾回收和数组塌陷。敌人多、子弹密集时卡顿就来了。对象池是控制子弹数量的常用方案。const bulletPool []; const MAX_BULLETS 300; function getBullet(x, y, vx, vy) { // 从池里取一颗可复用的子弹没有就新建 if (bulletPool.length 0) { const b bulletPool.pop(); b.x x; b.y y; b.vx vx; b.vy vy; b.life 1.5; return b; } return { x, y, vx, vy, life: 1.5 }; } function recycleBullet(b) { if (bulletPool.length MAX_BULLETS) bulletPool.push(b); }在updateBullets里子弹生命结束后调用recycleBullet(b)而不是splice。只有数组里的子弹从bullets中移除。对象池的优势在于子弹对象不会被反复创建和销毁帧率曲线会更稳定。做这个优化前先看帧率有没有问题别为了优化而优化。6.3 屏幕震动的廉价实现屏幕震动可以让射击手感上一个档次。代价几乎为零只需在渲染时对ctx做一个随机平移。let shakeTime 0; function addShake(amount) { shakeTime Math.min(shakeTime amount, 0.3); } function render() { ctx.save(); if (shakeTime 0) { const intensity shakeTime * 8; ctx.translate( (Math.random() - 0.5) * intensity, (Math.random() - 0.5) * intensity ); shakeTime - 1 / 60; } // ... 原有绘制代码 ctx.restore(); }在checkHits里命中敌人时调用addShake(0.1)玩家受伤时调用addShake(0.2)震动衰减在帧循环里做。验证方式很简单命中时肉眼可见画面抖了一下但抖动不能超过 0.3 秒否则玩家会头晕。最后分享一个自己的习惯游戏手感这种东西别光靠“感觉”给update加个count计数器把命中率、每秒帧率打印在画布角落再调数值。永远不要在不看数据的情况下调随机参数。我把这个坑踩过无数次一次调了fireRate连开两枪没反应就是因为忘了把玩家对象里的fireCooldown初始化成 0后来每次改参数都顺手在控制台打一行player.fireCooldown。这个工程最后的成就感其实不来自“做了一个游戏”而是来自“我能把循环、坐标、碰撞这些东西完整地串起来”希望帮到你。本文还有配套的精品资源点击获取