ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用JavaScript+Canvas复刻坦克大战:从地图建模到AI与碰撞检测

用JavaScript+Canvas复刻坦克大战:从地图建模到AI与碰撞检测 简介用 JavaScript 与 HTML 实现的坦克大战游戏面向前端开发学习者和游戏编程入门者。相比网上常见版本该实现加入了选关与跳关机制并设置每十关一个 Boss 关难度分为 A级、B级、S级三档玩家默认有5条命可按下空格键补血敌人数量随关卡逐级增加从5个起步每升一关增加一个。坦克速度、子弹射速、玩家颜色、背景音乐均提供多档选项右侧信息栏会同步显示得分、关卡、命数与敌人数每消灭一辆坦克得100分。所有配置项集中在 gameSet.js 中便于读者修改参数调整难度也能帮助理解游戏循环、碰撞检测、Canvas 绘图与模块划分。压缩包共19个文件主要为7个 JavaScript 脚本、3个 HTML 页面、2个 CSS 样式搭配 mp3、wav 音效和图片素材整体约56.41MB已有981人学习下载。1. 用 JavaScript HTML Canvas 复刻坦克大战先定边界再动手坦克大战Battle City的复刻 demo 网上不少但多数只做到「能显示画面」一加敌方 AI、碰撞回退、子弹归属这些需求代码就散架。用 jshtml 实现坦克大战难点不在画图而在三件事用一套数据描述地图和实体、在离散格子和连续像素之间做碰撞判断、用可靠的帧循环驱动 AI 与输入。下面这套实现只用原生 JS 加一个 canvas 标签不引任何库按地图、坦克、子弹、AI、存档五块逐段落地每段都给最小可运行代码和参数抄完打开浏览器就能打一局。适合刚做完 JS 基础教程想找项目练手的人也适合想给团队做游戏逻辑入门培训的工程师——碰撞检测和状态枚举正是日常业务里常写但没写透的部分。运行环境按现代 Chrome / Edge / Firefox 兼容移动端触摸不在本次范围。2. 地图建模与碰撞检测二维数组在坦克大战里的用法和 3 个易错参数2.1 用二维数组定义地图0、1、2、3 的约定先写进常量坦克大战的地图本质是一张 13×13 的格子表每个格子只有少数几种状态。常见做法是用二维数组存关卡外层是行、内层是列值用数字表示类型。先把类型定为常量再写关卡数据后面所有判断都引用常量而不是裸数字否则调两个小时的 bug最后发现是一处把 1 和 2 写反了。const COLS 13, ROWS 13, TILE 40; // 40px 一格画布 520×520 const EMPTY 0, BRICK 1, STEEL 2, BASE 3; const LEVEL_1 [ [0,0,0,0,0,0,0,0,0,0,0,0,0], [0,1,0,1,0,1,0,1,0,1,0,1,0], [0,1,0,1,0,1,0,1,0,1,0,1,0], [0,1,0,1,0,0,0,0,0,1,0,1,0], [0,0,0,1,0,2,2,2,0,1,0,0,0], [0,2,0,0,0,1,1,1,0,0,0,2,0], [0,2,0,1,0,1,0,1,0,1,0,2,0], [0,0,0,1,0,1,1,1,0,1,0,0,0], [0,1,0,0,0,0,0,0,0,0,0,1,0], [0,1,0,2,0,1,1,1,0,2,0,1,0], [0,0,0,0,0,1,3,1,0,0,0,0,0], [0,2,1,1,1,1,3,1,1,1,1,2,0], [0,0,0,0,0,0,0,0,0,0,0,0,0], ]; let map LEVEL_1.map(row [...row]); // 运行时拷贝别直接改关卡原数据代码逻辑说明前三行是全局参数COLS 和 ROWS 决定遍历次数TILE 同时服务绘制与碰撞两套逻辑最后一行用map做深层拷贝是为了让砖墙能被打掉而不污染关卡原始数据重开时重新拷一份即可。值 3 是基地占第 10、11 行的第 6 列两个格子玩家生成在第 12 行第 6 列。这里有一个新手常踩的坑地图是 13×13 的离散格子但坦克移动是连续像素所以「格子索引」和「像素坐标」是两套坐标系。索引坐标用来读写数组像素坐标用来渲染和定位实体二者只在判断某个像素点落在哪个格子时见一次面换算统一用col Math.floor(x / TILE)、row Math.floor(y / TILE)。这个换算要封装成函数别在移动、射击、绘制里各抄一遍后面调碰撞时你就知道少写一次 Math.floor 会出现什么后果。2.2 渲染地图砖墙、钢墙、基地的绘制分层拿到二维数组之后第一次渲染可以先不做任何优化直接每帧遍历 169 个格子画矩形。等后面加上坦克和子弹再考虑静态图层缓存这个阶段追求正确性比追求性能重要。function drawMap(ctx) { for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { const x c * TILE, y r * TILE; const v map[r][c]; if (v BRICK) { ctx.fillStyle #c84c30; ctx.fillRect(x, y, TILE, TILE); // 砖缝横竖两条中线形成四块砖的视觉 ctx.strokeStyle #5c2a1a; ctx.strokeRect(x 1, y 1, TILE - 2, TILE - 2); ctx.beginPath(); ctx.moveTo(x, y TILE / 2); ctx.lineTo(x TILE, y TILE / 2); ctx.moveTo(x TILE / 2, y); ctx.lineTo(x TILE / 2, y TILE); ctx.stroke(); } else if (v STEEL) { ctx.fillStyle #b8c0cc; ctx.fillRect(x 2, y 2, TILE - 4, TILE - 4); } else if (v BASE) { ctx.fillStyle #f4d03f; ctx.beginPath(); ctx.moveTo(x 4, y 32); ctx.lineTo(x 20, y 6); ctx.lineTo(x 36, y 32); ctx.closePath(); ctx.fill(); } } } }参数说明TILE 是 40所以第 3 行算出的 x、y 是每个格子左上角的像素坐标。砖墙用底色加砖缝两层绘制视觉复杂度不影响碰撞结果数据上这一格仍然是 1。钢墙四边各留 2 像素是为了让坦克靠近时「感觉」到钢墙有厚度纯视觉习惯。BASE 的三角形只是占位画法后续换精灵图时只改这一段。一个容易忽略的问题map 数组的值在游戏过程中会被子弹改成 EMPTY所以 drawMap 必须在每次 render 里重新读 map不能用「首次绘制的静态图」一直显示。等帧率真的不够了再考虑把不变的钢墙和空地合到离屏 canvas 里砖墙区域单独做脏矩形更新。2.3 碰撞检测格子重叠遍历与 3 个易错参数坦克与地图的碰撞不建议用「四角采样」判断。四角只检查包围盒的 4 个角点落在什么格子坦克贴墙滑动时会出现半格悬空、被墙「吸住」的鬼畜现象。正确做法是求出坦克矩形覆盖了哪些格子遍历所有重叠格任何一个不可通过就阻止移动。先用表格把通过性定死值含义坦克通过子弹效果备注0空地可无默认值1砖墙不可击中后该格变 0可破坏2钢墙不可子弹消失钢墙不变不可破坏3基地不可子弹命中则游戏结束敌方子弹同样判定function isSolid(r, c) { const v map[r][c]; return v BRICK || v STEEL || v BASE; } function collidesMap(x, y, w, h) { const c0 Math.floor(x / TILE); const c1 Math.floor((x w - 1) / TILE); // 减 1 是关键 const r0 Math.floor(y / TILE); const r1 Math.floor((y h - 1) / TILE); if (r0 0 || c0 0 || r1 ROWS || c1 COLS) return true; // 出界按碰撞处理 for (let r r0; r r1; r) { for (let c c0; c c1; c) { if (isSolid(r, c)) return true; } } return false; }三个容易出问题的参数第一是结尾的- 1取结束格时用x w - 1而不是x w当坦克右边恰好压在格子边界上时x w会算到下一格产生「明明对齐却判定碰撞」的抖动第二是出界原版坦克大战四周没有物理墙是靠出界判定挡住坦克的这里把出界也当成碰撞坦克就永远停在画布内第三是坦克尺寸建议设TANK_SIZE 36比 TILE 小 4 像素留出缝隙让转向更顺滑如果设成 40两辆坦克擦身而过时几乎必然卡死。坦克对坦克的碰撞是另一个函数AABB 相交的判断四个比较式必须成对出现function collidesTank(tank, others) { return others.some(t { if (t tank || !t.alive) return false; return tank.x t.x TANK_SIZE tank.x TANK_SIZE t.x tank.y t.y TANK_SIZE tank.y TANK_SIZE t.y; }); }说明移动逻辑采用「先试探、后提交」。按方向算出新坐标nx, ny同时通过地图碰撞和坦克碰撞才真正赋值否则原地不动。这个写法是整个移动系统不出 bug 的关键比「先移动、发现撞了再退回去」少很多边界问题。3. 坦克对象与子弹命中移动、转向、开火在 JS 里的写法3.1 坦克类方向映射表与像素/秒的速度单位坦克对象不需要复杂继承一个类加一个方向映射表就够了。方向是四选一用字符串枚举比数字可读性好调试时打印player.dir直接能看懂。速度字段强烈建议存「像素/秒」而不是「像素/帧」这样后面接入 deltaTime 时逻辑一致换 144Hz 显示器游戏也不会变快。const DIRS { up: { dx: 0, dy: -1 }, down: { dx: 0, dy: 1 }, left: { dx: -1, dy: 0 }, right: { dx: 1, dy: 0 } }; class Tank { constructor(x, y, dir, speed, isPlayer) { this.x x; this.y y; this.dir dir; this.speed speed; // 像素/秒 this.isPlayer isPlayer; this.alive true; this.fireCd 0; // 开火冷却单位秒 } tryMove(dt) { const d DIRS[this.dir]; const nextX this.x d.dx * this.speed * dt; const nextY this.y d.dy * this.speed * dt; if (!collidesMap(nextX, nextY, TANK_SIZE, TANK_SIZE) !collidesTank(this, [...enemies, player])) { this.x nextX; this.y nextY; } } }参数说明dx、dy是单位方向增量乘上speed * dt才是实际位移dt 单位是秒。speed 为 120 时每秒移动 120 像素跨过 3 个格子需要 1 秒。fireCd是冷却计时器每帧减 dt减到 0 才允许开火这是防止按住空格变成机关枪的标准做法。注意tryMove里的碰撞对象列表要把玩家自己也排除掉否则和自己比永远返回 true。绘制坦克时方向决定炮管朝向用 canvas 的旋转接口比手动画四个方向的贴图省事得多function drawTank(ctx, t) { ctx.save(); ctx.translate(t.x TANK_SIZE / 2, t.y TANK_SIZE / 2); ctx.rotate(DIRS[t.dir].angle || 0); // 上0其他方向加 PI/2 的倍数 ctx.fillStyle t.isPlayer ? #2e7d32 : #b26a00; ctx.fillRect(-TANK_SIZE / 2, -TANK_SIZE / 2, TANK_SIZE, TANK_SIZE); ctx.fillRect(-3, -TANK_SIZE / 2 - 4, 6, 8); // 炮管 ctx.restore(); }这段的旋转角度建议在 DIRS 里直接存好up 是 0、left 是 -PI/2、down 是 PI、right 是 PI/2绘制时只查表不写一堆 if-else。3.2 键盘输入keydown/keyup 维护按键表拦截默认行为键盘输入最忌讳用 keypress 事件它对功能键支持差还会受输入法影响。常见做法是维护一个按键状态表keydown 置 true、keyup 置 false主循环每帧去查表。用e.code而不是e.keye.key在全角输入法下可能返回中文e.code始终是物理按键名。const keys {}; window.addEventListener(keydown, e { keys[e.code] true; if ([ArrowUp,ArrowDown,ArrowLeft,ArrowRight,Space].includes(e.code)) { e.preventDefault(); // 阻止方向键滚动页面 } }); window.addEventListener(keyup, e { keys[e.code] false; }); function handlePlayerInput(dt) { if (keys[ArrowUp]) player.dir up; else if (keys[ArrowDown]) player.dir down; else if (keys[ArrowLeft]) player.dir left; else if (keys[ArrowRight]) player.dir right; if (keys[Space] player.fireCd 0) { spawnBullet(player); player.fireCd 0.25; } }按键映射表如下后续做自定义按键时只需要改这张表功能e.code说明向上ArrowUp转向并移动向下ArrowDown转向并移动向左ArrowLeft转向并移动向右ArrowRight转向并移动开火Space受 fireCd 节流控制说明preventDefault只拦方向键和空格避免用户按方向键时页面上下滚动这是用 jshtml 写键盘游戏最容易忽略的一步。内嵌在博客或管理系统里的游戏页忘记这一步会导致按一下方向键页面就跳一下。3.3 子弹系统炮口出膛与中心点命中子弹比坦克简单但两个细节决定手感出膛位置必须从炮口算命中判定用子弹中心点对格子取整。子弹速度通常是坦克的 34 倍所以它不做碰撞回退直接改坐标并检测命中一帧穿过多个格子也没关系。const BULLET_SIZE 8; const MUZZLE { up: [TANK_SIZE / 2 - BULLET_SIZE / 2, -BULLET_SIZE], down: [TANK_SIZE / 2 - BULLET_SIZE / 2, TANK_SIZE], left: [-BULLET_SIZE, TANK_SIZE / 2 - BULLET_SIZE / 2], right: [TANK_SIZE, TANK_SIZE / 2 - BULLET_SIZE / 2] }; function spawnBullet(tank) { const [ox, oy] MUZZLE[tank.dir]; bullets.push(new Bullet(tank.x ox, tank.y oy, tank.dir, 240, tank.isPlayer)); } class Bullet { constructor(x, y, dir, speed, fromPlayer) { this.x x; this.y y; this.dir dir; this.speed speed; this.fromPlayer fromPlayer; this.alive true; } update(dt) { const d DIRS[this.dir]; this.x d.dx * this.speed * dt; this.y d.dy * this.speed * dt; this.hitTest(); } hitTest() { const r Math.floor(this.y / TILE); const c Math.floor(this.x / TILE); if (r 0 || c 0 || r ROWS || c COLS) { this.alive false; return; } const v map[r][c]; if (v BRICK) { map[r][c] EMPTY; this.alive false; } else if (v STEEL) { this.alive false; } else if (v BASE) { this.alive false; triggerGameOver(); } } }参数说明fromPlayer字段只用于区分敌我归属不能用坦克的isPlayer代替否则敌方子弹会打到自己人。子弹速度 240 像素/秒是坦克 120 的两倍原版里高级子弹更快调这个数就能做道具效果。子弹与坦克的碰撞放在主循环里统一处理遍历 bullets对每颗子弹查所有活着的坦克AABB 判断后按归属决定打死谁。提示清理已死亡子弹和敌机时用bullets bullets.filter(b b.alive)在 update 末尾整体替换不要在遍历中 splice否则数组索引错位会导致漏判。4. 敌方 AI 与游戏主循环requestAnimationFrame 驱动的帧更新与生成逻辑4.1 主循环update 与 render 分离dt 必须截断游戏循环是整块实现的地基。setInterval 定时不准切后台会积压回调requestAnimationFrame 由浏览器在渲染前统一调度是 canvas 游戏的事实标准。但 rAF 的入参是时间戳必须用两帧差值算出 dt否则同一个移动逻辑在不同刷新率下速度完全不一样。let lastTime performance.now(); function loop(now) { const dt Math.min((now - lastTime) / 1000, 0.05); // 上限 50ms lastTime now; handlePlayerInput(dt); updateEnemies(dt); updateBullets(dt); spawnController(dt); render(ctx); requestAnimationFrame(loop); } requestAnimationFrame(loop);参数说明dt 的单位是秒所有位移和冷却都乘 dt游戏速度就与屏幕刷新率无关。Math.min(dt, 0.05)是必须的一刀用户把标签页切走 30 秒再切回来rAF 会给你一个 30 秒的 dt不截断的话坦克瞬移、子弹穿墙、碰撞检测全部失效。0.05 对应的最大单帧位移是 120 × 0.05 6 像素视觉上可接受。update 和 render 分离还有个附加好处渲染阶段只读状态、不写状态。所有实体的清理统一在 update 末尾用 filter 做绘制阶段永远遍历一个结构稳定的数组。4.2 敌方 AI 三招直线行军、随机转向、朝玩家修正敌方坦克不需要复杂的寻路算法原版里它们的行为就是「随机为主、趋势为辅」。直线行军保证它们会推进随机转向防止所有敌人排成一列走同一边朝玩家修正则维持压迫感。关键是所有概率都要换算成「概率/秒」乘上 dt 后与帧率解耦。function updateEnemies(dt) { for (const e of enemies) { if (!e.alive) continue; // 每秒约 60% 概率尝试随机转向 if (Math.random() 0.6 * dt) { const dirs [up, down, left, right]; e.dir dirs[(Math.random() * 4) | 0]; } // 朝玩家修正横向距离大就优先横移约 30% 概率每秒 const dx player.x - e.x, dy player.y - e.y; if (Math.abs(dx) Math.abs(dy) Math.random() 0.3 * dt) { e.dir dx 0 ? right : left; } const oldX e.x, oldY e.y; e.tryMove(dt); if (e.x oldX e.y oldY) { // 撞墙或撞车换一个非当前方向的方向 const dirs [up, down, left, right].filter(d d ! e.dir); e.dir dirs[(Math.random() * 3) | 0]; } if (e.fireCd 0 Math.random() 0.9 * dt) { spawnBullet(e); e.fireCd 0.8; } } }AI 调参表如下改系数比改逻辑好用得多参数作用调大后的效果0.6每秒随机转向概率敌人更飘忽难以预判0.3每秒朝玩家修正概率敌人更聪明追击意图明显0.9每秒开火概率弹幕更密玩家走位压力大0.8敌方开火冷却秒数调小变成连发难度陡增一个常见误用是直接把固定概率写进每帧Math.random() 0.01在 60Hz 下每 1.6 秒触发一次换到 144Hz 就变成每 0.7 秒一次同一份代码在不同屏幕上难度完全不同。所有概率一律写成概率值 × dt的形式。4.3 生成点、同屏上限与波次控制const SPAWNS [ [0, 0], [6, 0], [12, 0] // 列、行即顶部三个格子 ]; let spawnTimer 0; let enemiesLeft 18; // 本关总敌人数 const MAX_ALIVE 4; function spawnController(dt) { spawnTimer - dt; if (spawnTimer 0 || enemiesLeft 0) return; const aliveCount enemies.filter(e e.alive).length; if (aliveCount MAX_ALIVE) return; const idx (Math.random() * SPAWNS.length) | 0; const [col, row] SPAWNS[idx]; const sx col * TILE, sy row * TILE; if (!collidesMap(sx, sy, TANK_SIZE, TANK_SIZE)) { enemies.push(new Tank(sx, sy, down, 80, false)); enemiesLeft--; spawnTimer 2; // 每 2 秒最多生成一辆 } }参数说明MAX_ALIVE4 对应原版「同屏最多 4 辆敌坦」spawnTimer2 控制生成间隔两个参数共同决定难度曲线。生成前要检查该格子是否空闲——三个出生点相邻偶尔会出现两辆坦克挤同一个点的情况不检查就会出现坦克叠在一起的渲染错误。enemiesLeft 的减 1 逻辑必须放在生成处很多人误把它写在击杀处结果击杀一次减两遍关卡提前结束。分数和剩余敌人的处理建议放在击杀判定处玩家子弹命中敌方坦克时敌方alive置 falsescore 100enemiesLeft--。注意这里不要再碰 spawnTimer生成节奏完全交给 spawnController 自我管理。5. 一局能玩的坦克大战分数存档、FPS 验证与廉价的 WebAudio 音效5.1 用 localStorage 保存最高分单机小游戏的存档诉求就一个刷新页面后最高分还在。localStorage 的读写是同步的量级只有几十字节不需要封装复杂存储层直接在加分函数里做比较写入即可。const BEST_KEY tank_best; function addScore(n) { score n; const best parseInt(localStorage.getItem(BEST_KEY) || 0, 10); if (score best) { localStorage.setItem(BEST_KEY, String(score)); } }注意parseInt第二个参数 10 必须写否则08这类带前导零的字符串会被当成八进制解析。游戏结束界面读取localStorage.getItem(BEST_KEY)显示即可不需要额外的同步机制。如果以后要存关卡进度把对象序列化成 JSON 再存但记得在读取时用 try-catch 包裹用户清缓存或隐私模式都可能让 getItem 拿到空值。5.2 FPS 计数器验证主循环是否被打满判断一个 canvas 游戏是否流畅别靠肉眼挂一个 FPS 计数器最直接。实现上统计 0.5 秒内的帧数和耗时算出平均值再取整let fps 0, frameCount 0, fpsTime 0; function updateFps(dt) { frameCount; fpsTime dt; if (fpsTime 0.5) { fps Math.round(frameCount / fpsTime); frameCount 0; fpsTime 0; } } // render 里ctx.fillText(FPS: ${fps}, 8, 20);如果帧率长期低于 55优先查两处一是fillStyle或strokeStyle是否在循环体里被重复赋值——颜色字符串解析是有成本的应尽量提出循环二是是否每帧都调用了createLinearGradient之类的昂贵 API。这版实现里地图只有 169 个格子单帧绘制开销远达不到瓶颈真正卡顿大多来自浏览器插件或页面里其他定时器抢占主线程。调试碰撞时再按 D 键切换一个开关把坦克的包围盒画出来let debug false; window.addEventListener(keydown, e { if (e.code KeyD) debug !debug; }); // render 里 if (debug) { ctx.strokeStyle rgba(0,255,0,0.6); [...enemies, player].forEach(t { ctx.strokeRect(t.x, t.y, TANK_SIZE, TANK_SIZE); }); }这个调试视图能直接看出坦克贴墙时包围盒与格子的重叠情况配合 collidesMap 的返回值排查「哪一格挡了路」比加 console.log 高效得多。5.3 音效三个 WebAudio 节点替代音频文件加载 mp3 文件做音效在这个项目里不划算还要处理文件路径和预加载。用 WebAudio 的振荡器临时合成射击声和爆炸声代码量少且没有资源依赖。注意 AudioContext 必须在用户点击后创建否则浏览器会拦截let audioCtx null; function initAudio() { // 挂在页面第一次 click 事件上 if (!audioCtx) { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } } function playShoot() { if (!audioCtx) return; const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type square; osc.frequency.setValueAtTime(320, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime 0.12); gain.gain.setValueAtTime(0.06, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime 0.12); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime 0.12); }频率从 320Hz 指数下降到 80Hz模拟出炮弹出膛后远去的听感。爆炸声同理只是把起始频率降到 150Hz、时长拉到 0.25 秒。把这些合成函数统一放在一个sfx.js里后面想换真实音效时也不用动游戏逻辑只替换函数内部实现。把initAudio挂到第一次 click 事件里同时把 gain 的值控制得小一些这是成本最低又最接近原版听感的一套方案。本文还有配套的精品资源点击获取
返回列表