简介:这是一份基于 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 里直接执行update+render,简单直观。但如果你希望游戏在 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 = -1,S 代表dy = 1,A/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 斜向移动变快
现象:按住 W+D 时角色明显比只按 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。这个工程最后的成就感,其实不来自“做了一个游戏”,而是来自“我能把循环、坐标、碰撞这些东西完整地串起来”,希望帮到你。
本文还有配套的精品资源,点击获取