尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

纯HTML实现单文件坦克大战:从零手写Canvas游戏与包抄AI

纯HTML实现单文件坦克大战:从零手写Canvas游戏与包抄AI 前阵子翻硬盘翻出一个叫tank.html的旧文件双击打开坦克大战的像素画面直接铺满窗口。这个文件写于两年前既没有联网也没有依赖任何框架或者素材就是纯 HTML 加内联的 CSS 和 JavaScript一个文件塞进了完整的单文件坦克大战。朋友后来问我你是怎么把地图、AI、音效全塞进一个 HTML 里的还带“敌人会包抄”这种聪明行为正好借这篇把实现思路完整拆一遍。本文适合两类人看一类是想用 Canvas 做个小游戏、但对单文件约束下的取舍没概念的初学者另一类是已经能写点小游戏但想知道“包抄 AI”这种看起来聪明的敌人到底怎么设计的人。不聊架构黑话就聊实际能跑的代码和我在本地双击调试时踩过的坑。1. 单文件不是偷懒它其实是一堆约束的产物很多人听到“单文件”第一反应是图省事。我最初的想法也很简单做个小游戏发给朋友总不能让人家先装 Node、再跑构建命令、再开个本地服务器。我想要的效果是——把文件往微信里一拖对方存到桌面双击就响。这个需求本身就带了三个硬约束不联网、没素材、没构建。不联网意味着我不能引 CDN不能用外部的字体、图片、音频文件连 Google Fonts 这种看起来无害的资源都得放弃。没素材意味着所有画面都得用代码现场画小到一颗子弹大到整个战场不能依赖 png / jpg / mp3。没构建则意味着我不能指望打包工具帮我把多文件合并所有 HTML、CSS、JS 从一开始就得装在同一个.html里。这三个约束看着像自缚手脚实际反而帮我砍掉了很多选择成本。比如音效这块既然不能放 mp3我就直接用 Web Audio API 合成声音坦克和砖墙既然不能用图片就用 Canvas 的fillRect和arc画。少了几百个备选项写代码的速度反而更快。多说一句标题里的“纯 HTML”。严格来讲游戏要跑起来肯定还是靠 JavaScript我把 CSS 和内联脚本全塞进 HTML 标签里所以标题说的“纯 HTML”指的是“没有伴随的任何外部文件”属于圈内默认的说法。实际工程里你完全可以把它理解成“一个零外部依赖的.html文件”。我自己总结了一下单文件游戏的约束清单和应对方式大概是这样的约束表面后果我的应对不联网不能引框架、字体、素材Canvas 2D 画一切画面Web Audio 合成全部音效双击即玩在 file:// 协议下运行不使用 ES Module不使用 fetch 读本地文件单文件内联所有代码挤在一个script里用 IIFE 包裹逻辑只向外暴露一个调试对象无构建压缩代码体积全裸奔手写一个 20 行的压缩脚本去注释去换行这些约束同时决定了核心技术选型游戏循环用requestAnimationFrame画面绘制用 Canvas 2D音频用.oscillator合成地图用字符串数组描述。后面几章就围绕这些选型展开说清楚每一步为什么这么做。2. 游戏循环、画布与键盘先把坦克开起来一个小游戏能不能让人觉得“顺滑”三分之一看渲染三分之一看循环三分之一看输入。这章先把骨架搭起来让坦克能在屏幕上动。2.1 帧循环为什么必须管住时间游戏循环最朴素的方式是setInterval(update, 16)听起来是每秒 60 帧实际一跑就出事。浏览器标签页切到后台时会自动降低定时器频率等切回来的时候定时器可能攒了一大堆回调坦克要么瞬移要么卡顿。更稳的姿势是用requestAnimationFrame它跟着显示器刷新率走浏览器会自然调度。我写的循环长这样const canvas document.getElementById(game); const ctx canvas.getContext(2d); canvas.width canvas.height 15 * 32; let last performance.now(); function loop(now) { const frame Math.min(now - last, 50) / 16.667; last now; update(frame); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);核心是frame这个值。(now - last) / 16.667把真实时间换算成“以 60 帧为基准的帧数”这样在 120Hz 高刷屏上跑坦克速度不会变成两倍。Math.min(..., 50)是限幅就算用户切后台 10 秒再切回来这一帧最多按 50ms 算避免坦克直接穿墙穿到地图外面。我早期没写这个限幅结果切后台回来后主角被卡在墙里子弹满屏乱飞就是帧间隔爆炸导致的。2.2 输入处理按住移动而不是一下一下走坦克移动不能直接监听keydown事件。按住方向键时系统会先触发一次 keydown然后延迟几百毫秒再连续触发这样坦克走起来一顿一顿。正确做法是维护一个按键集合在游戏循环里查集合状态const keys new Set(); window.addEventListener(keydown, (e) { keys.add(e.code); if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space].includes(e.code)) { e.preventDefault(); } }); window.addEventListener(keyup, (e) keys.delete(e.code));preventDefault()很重要。如果不写方向键会滚动页面玩着玩着浏览器自己滚起来整局节奏全乱。读取方向时用e.code而不是e.key因为code是物理按键位置不受输入法影响玩家用中文输入法时也不会误触。移动逻辑就简单了每帧从集合里取方向function handleInput(tank) { let dx 0, dy 0; if (keys.has(ArrowUp)) dy -1; if (keys.has(ArrowDown)) dy 1; if (keys.has(ArrowLeft)) dx -1; if (keys.has(ArrowRight)) dx 1; if (keys.has(Space)) shoot(tank, tank.dir); moveTank(tank, dx, dy, tank.speed); }注意方向键和空格在同一集合里按下空格射击时坦克不会停下手感更连续。2.3 实体与生命周期把坦克、子弹、爆炸管起来游戏里有玩家、多个敌人、子弹、爆炸特效四类实体。我统一用对象数组管理每帧先update再render。子弹打中目标后不立即从数组里删除而是先加入一个爆炸对象播放 300ms 动画再清掉视觉上更连贯。const enemies []; const bullets []; const explosions []; function update(frame) { bullets.forEach(b updateBullet(b, frame)); enemies.forEach(e updateEnemy(e, frame)); updatePlayer(frame); explosions explosions.filter(e e.life 0); } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); drawMap(); drawTank(player, #4a6); enemies.forEach(e drawTank(e, e.color)); bullets.forEach(drawBullet); explosions.forEach(drawExplosion); }这里有个值得注意的教训update和render严格分开不要在渲染函数里改游戏状态。我最初图省事在drawTank里顺带更新坐标结果调试时候序一团糟同一个坦克有时画在移动前有时画在移动后。拆开后问题立刻消失也方便以后做“暂停游戏”这类功能。3. 砖墙不是画出来的地图数据结构与碰撞检测坦克大战的核心玩法就是“打砖、绕墙、找路”所以地图和碰撞是底层基础。这章不讲什么高级物理引擎就用数组和 AABB 碰撞检测够用且好调试。3.1 用字符串数组装下整个战场地图我用 15x15 的格子每格 32 像素。每个格子的值用一个字符表示0空地1砖墙2钢墙3水面4树丛5基地。选择字符串数组而不是二维数字数组是因为写地图的时候能直接“看到”形状改起来也很直观const MAP_ROWS [ 000000000000000, 011111101111110, 012222101222210, 011111101111110, 000000000000000, 000311111130000, 000311111130000, 000000005000000, 000011111100000, 010011111100100, 000011111100000, 000000000000000, 000000000000000, ]; function parseMap() { const grid []; for (let r 0; r MAP_ROWS.length; r) { grid[r] []; for (let c 0; c MAP_ROWS[r].length; c) { grid[r][c] Number(MAP_ROWS[r][c]); } } return grid; }基地用5标在地图中间靠下的位置四周用砖墙和钢墙围起来。解析完之后游戏逻辑里只认数字不认字符渲染时再按数字映射到颜色和图案。3.2 AABB 碰撞与“先 X 后 Y”的走位技巧坦克移动时做碰撞检测最直接的办法是检测“坦克矩形”和“格子矩形”是否重叠。坦克大小正好是 32x32跟格子尺寸一致所以判断逻辑很简单把坦克要到达的坐标换算成格子行列范围查这些格子是不是阻挡格。function isBlocked(col, row) { const g grid[row][col]; return g 1 || g 2 || g 3; // 砖、钢、水都挡住坦克 } function tryMove(tank, dx, dy) { const nx tank.x dx; const ny tank.y dy; if (nx 0 || ny 0 || nx tank.size canvas.width || ny tank.size canvas.height) { return false; } const c0 Math.floor(nx / TILE); const c1 Math.floor((nx tank.size - 1) / TILE); const r0 Math.floor(ny / TILE); const r1 Math.floor((ny tank.size - 1) / TILE); for (let r r0; r r1; r) { for (let c c0; c c1; c) { if (isBlocked(c, r)) return false; } } tank.x nx; tank.y ny; return true; }这里有个经验移动的时候拆成“先试 X 轴再试 Y 轴”而不是直接尝试斜向移动。因为如果直接斜着撞墙两边都走不动坦克会被卡死在墙角。拆成两个单轴移动后撞到竖向墙体时会自动沿横向滑动手感好很多function moveTank(tank, dx, dy, speed) { if (dx ! 0) tryMove(tank, dx * speed, 0); if (dy ! 0) tryMove(tank, 0, dy * speed); }tryMove成功与否不看返回值只管走路失败就原地不动。这个写法看着粗糙却让 AI 角色也能复用同一套移动函数后面的包抄 AI 就靠它。3.3 子弹为什么不会穿墙子弹的碰撞逻辑跟坦克不一样。坦克慢子弹快如果子弹每帧移动 8 像素在 32 像素的格子里还算安全如果速度提太高一帧跨过一堵薄墙子弹就会“穿模”。我的方案是把子弹速度限制在每帧不超过半个格子也就是 16 像素保证子弹每次移动都会被至少一个格子捕获到。如果想要更高速的狙击枪子弹就得把子弹移动拆成多个小步逐步检测但坦克大战这种小规模游戏完全没必要。子弹命中砖墙后砖墙要在视觉上被“啃”掉一块。我在格子数据里给砖墙设了生命值hp普通子弹打一下减少 1砖墙初始hp为 4被打 4 次后格子变为空地。这样打砖的表现更细腻也符合原版坦克大战那种“慢慢啃墙”的节奏。4. “会包抄”的敌人从无脑直冲到侧翼夹击标题里最抓人的就是“敌人会包抄”这也是工程量最大的一部分。这章详细说说包抄 AI 是怎么一步步调出来的。我先把最简单、最笨的 AI 写出来再往上叠加聪明行为。4.1 直冲 AI 三行代码玩十分钟就想删游戏第一版敌人 AI 非常朴素计算玩家坐标和敌人坐标的差值朝差值较大的轴移动。加上开火逻辑大概长这样function updateEnemyStraight(e) { const dx player.x - e.x; const dy player.y - e.y; if (Math.abs(dx) Math.abs(dy)) { moveTank(e, Math.sign(dx), 0, e.speed); e.dir { x: Math.sign(dx), y: 0 }; } else { moveTank(e, 0, Math.sign(dy), e.speed); e.dir { x: 0, y: Math.sign(dy) }; } if (Math.hypot(player.x - e.x, player.y - e.y) 200) { shoot(e, e.dir); } }这版 AI 的真实表现是玩家贴着一堵墙走敌人会傻乎乎地朝墙撞永远过不来玩家站在墙后敌人的子弹全打在砖上。玩不到十分钟我就确定了如果发这个版本给朋友等于劝退。问题出在两个地方一是敌人只盯玩家当前坐标没有“预判”和“绕路”的概念二是开火不看炮口方向盲射一通子弹全浪费在墙上。4.2 包抄的核心目标偏移与贴墙绕行要做出“包抄”的效果我给 AI 加了两个能力目标点偏移以及贴墙滑动。目标点偏移的思路是敌人的目标不一定是玩家当前坐标而是玩家坐标旁边偏移一段距离的点。这样敌人会先跑到玩家侧面形成夹击角度。偏移方向可以按敌人类型取顺时针或逆时针两队包抄时自然分路function getFlankTarget(player, enemy) { const dx player.x - enemy.x; const dy player.y - enemy.y; const len Math.hypot(dx, dy) || 1; const offset 64; // 偏移 64 像素大约两个格子 const side enemy.flankDir; // 1 或 -1定义包抄方向 return { x: player.x (-dy / len) * offset * side, y: player.y (dx / len) * offset * side, }; }向量的法线方向(-dy, dx)是垂直于“敌人-玩家连线”的方向乘上side控制向左偏还是向右偏。这样敌人会从玩家侧后方压过来玩家必须调整炮口才能应对。贴墙绕行则依赖上一章的moveTank逻辑。当敌人的目标方向被墙挡住时它不会停在原地发呆而是尝试滑向另一个轴。实际效果就是敌人会贴着墙沿走一段绕过障碍后再回到目标方向function aiSeek(e, tx, ty) { const dx Math.sign(tx - e.x); const dy Math.sign(ty - e.y); // 先尝试主轴副轴兜底实现贴墙绕行 if (Math.abs(tx - e.x) Math.abs(ty - e.y)) { if (!tryMove(e, dx * e.speed, 0)) tryMove(e, 0, dy * e.speed); } else { if (!tryMove(e, 0, dy * e.speed)) tryMove(e, dx * e.speed, 0); } }这套组合下来敌人不再是“无脑撞墙选手”。最直观的对比是原来玩家站在墙后就能苟一辈子现在敌人会从侧面绕过来玩家必须移动。4.3 开火时机别让子弹全打在墙上包抄做得再好子弹乱飞还是像傻子。我给每辆坦克加了一个简单判断开火前检查炮口朝向和“坦克到玩家方向的夹角”只有夹角小于一定角度时才射击。实现方式是用向量点积function canShootAt(e, target) { const dx target.x - e.x; const dy target.y - e.y; const len Math.hypot(dx, dy) || 1; // 归一化目标方向 const tx dx / len; const ty dy / len; // 炮口方向 const gx e.dir.x; const gy e.dir.y; const dot gx * tx gy * ty; return dot 0.7; // 约等于夹角小于 45 度 }夹角判断让 AI 贴墙时不再乱开火而是先移动到能瞄准的位置再射击。配合包抄目标偏移AI 会先绕到玩家侧面然后掉转炮口射击体验一下子就“聪明”了。4.4 敌人个性差异化包抄也要有层次全图所有敌人用同一套 AI 会很单调。我给每辆敌人坦克加了一个aiType字段根据类型决定移动和射击的参数AI 类型移动策略开火距离特点莽撞型直冲玩家当前位置近150px 内冲得猛但容易被遛墙包抄型目标点偏移 贴墙绕行中250px 内标题里说的“会包抄”的主力腹黑型先远离玩家再绕后中行动诡异经常绕背精英型偏移 绕行 预判玩家速度远350px 内会朝玩家移动方向前方射击“腹黑型”是我后加的给它设定是先往远离玩家的方向走再通过贴墙逻辑绕到侧面。实际游戏里这几种类型混合刷新玩家能明显感觉到敌人的作战风格不一样有的冲锋、有的绕侧、有的远距离放冷枪。为了让包抄不变成“五辆坦克排成一条直线”每辆包抄型坦克的flankDir要么是 1 要么是 -1从不同方向绕。敌人之间我还会做一个简单的间距检测两辆坦克靠太近时会往相反方向各自滑开避免叠成一坨。5. 无素材单文件靠什么撑起画面和声音既然不联网、没有素材画质和音效就只能靠代码生成。这章聊聊我用 Canvas 画坦克、用 Web Audio 合成音效的具体做法以及我在本地双击时踩到的一个音频坑。5.1 Canvas 手绘坦克从矩形到有辨识度的像素块坦克看着像“像素风”其实我没用逐像素绘制就是用几个矩形拼出来的。每辆坦克 32x32分三部分履带、车体、炮管。履带画在左右两侧车体画在中间炮管朝四个方向伸出去。颜色不同区分敌我function drawTank(t, fill) { const s t.size; ctx.fillStyle fill; // 左右履带 ctx.fillRect(t.x, t.y, 8, s); ctx.fillRect(t.x s - 8, t.y, 8, s); // 车体 ctx.fillRect(t.x 5, t.y 5, s - 10, s - 10); // 炮管按方向绘制 ctx.fillStyle #ddd; if (t.dir.x 1) ctx.fillRect(t.x s, t.y s / 2 - 2, 8, 4); if (t.dir.x -1) ctx.fillRect(t.x - 8, t.y s / 2 - 2, 8, 4); if (t.dir.y 1) ctx.fillRect(t.x s / 2 - 2, t.y s, 4, 8); if (t.dir.y -1) ctx.fillRect(t.x s / 2 - 2, t.y - 8, 4, 8); }履带颜色用深色车体用亮色炮管统一浅灰辨识度很高。砖墙和钢墙也是类似思路砖墙画成两个并列的红褐色小矩形钢墙画成带高光的灰色圆角矩形。爆炸效果则用了“多帧扩散圆”爆炸对象有个life字段每帧衰减半径随衰减增大透明度随衰减降低看起来是“炸开-消散”的过程。这些绘制逻辑单看都很笨拙但放在一起运行画面就能达到“能玩的程度”。单文件游戏不需要追求美术水平辨识度够、反应清晰就成功了一大半。5.2 程序化音效 Web Audio 直接合成没有 mp3 文件我用 Web Audio API 合成音效。射击声本质是一段高频短爆音爆炸声是低频率噪声衰减。我封装了一个通用函数用几个参数控制波形、频率、时长和音量let audioCtx null; function initAudio() { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } function beep(freq, duration, type square, volume 0.15) { if (!audioCtx) return; const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type type; osc.frequency.setValueAtTime(freq, audioCtx.currentTime); gain.gain.setValueAtTime(volume, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime duration); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime duration); }射击用 880Hz 的方波时长 0.08 秒爆炸用 100Hz 的锯齿波时长 0.3 秒音量更大一些移动音我干脆没做因为频繁触发会变成噪音。实际用下来这两种音效已经能营造出基本街机感。5.3 浏览器限制本地文件下的音频与自动播放策略写音效时我踩过一个坑。明明代码写得没问题双击打开 HTML点击按钮却听不到声音。原因很典型浏览器自动播放策略要求AudioContext必须由用户手势触发启动也就是说页面上必须有一个点击事件让audioCtx.resume()。解决方式也很简单游戏启动时先展示一个“点击开始”的遮罩层玩家点击后同时触发音频初始化、隐藏遮罩、启动游戏循环startBtn.addEventListener(click, () { initAudio(); audioCtx.resume(); startBtn.style.display none; requestAnimationFrame(loop); });这个遮罩层不是摆设它同时解决了另一个问题浏览器在file://协议下对无用户交互的脚本限制很多加一次点击后续绝大多数本地文件限制都绕开了。6. 双击即玩的代价我在本地测试时踩的坑单文件游戏最大的优势是打开门槛低但“双击即玩”四个字背后也有一些隐藏的坑。这章把我在本地环境里踩过的坑集中列出来给想自己做单文件项目的人当参考。6.1 在 file:// 下容易踩的坑第一个坑是 ES Module 用不了。如果你写成script typemodule里面再用import双击打开时会直接报跨域错误因为file://协议下模块加载被浏览器拦截。解决办法就是别用模块把所有代码写在一个普通script标签里。第二个坑是fetch本地方案不可用。我之前试图用fetch(./map.json)加载地图双击打开直接失败。这也是浏览器的本地文件安全策略。最后我把地图改成了内联字符串数组反而更简单还省了一次网络请求。第三个坑是localStorage。在file://协议下 localStrage 能用但不同浏览器的隔离策略不一致。存档功能我实现了但每次都包在try/catch里万一浏览器限制就静默失败不影响游戏正常运行。第四个坑是键盘事件。在file://打开的页面里方向键滚动页面这个问题依然存在必须preventDefault()这个前面已经提过再多说一次因为它真的很影响体验。6.2 性能全量重绘与对象池单文件 Canvas 游戏最常见的性能瓶颈是每帧绘制大量图形。坦克大战的画面元素其实不多坦克最多同时存在 6 辆子弹 10 颗左右爆炸特效 3-5 个砖墙几百个格子。这一规模下每帧全量重绘完全没压力。真正需要注意的是不要每帧创建新对象。比如开火时子弹对象不要new一个扔进数组就完事长期玩会有大量对象产生触发 GC 卡顿。我做了个简单的子弹对象池预先生成几十个子弹对象开火时从池子里拿一个命中时回收。代码不复杂但游戏跑久之后帧率稳定很多。渲染侧还有个优化细节砖墙的格子图案不需要每帧重新算。我预先把整个地图绘制到一张离屏 Canvas 上只在砖墙被子弹打掉时局部重绘每帧drawImage把整张离屏画布贴到主画布上。这个离屏 Canvas 的做法让每帧渲染成本几乎恒定。6.3 把单文件再压小一点压缩与混淆开发时的代码我写得很可读但发给朋友时总想让它小一点。我写了个不到 20 行的压缩脚本把所有//注释去掉把多余换行合并把.html文件重新输出成一行。这一步能把文件从 40KB 压到 20KB 左右。还试过一次手工把变量名从enemyTank改成e、bullet改成b压缩率能到 30KB 以内。因为游戏规模小也不涉及跨文件引用手写压缩就够用了。如果以后项目变大可以引入 Terser 这类压缩器但要注意压缩更名后调试困难调试阶段别开混淆。最后说点心理层面的体会。做这个单文件坦克大战的过程中我最满足的时刻不是代码写完而是第一版直冲 AI 跑起来时虽然笨到令人无语但看着坦克在屏幕上动起来真的很有成就感。后来把所有包抄逻辑调通、又花了一晚上做音效每次迭代都像给一个旧玩具加新零件。如果你也想动手写一个我的建议是从最基础的循环和tryMove开始先把坦克开起来、朝目标走两步再考虑聪明 AI。别一上来就计划做一套完整又复杂的游戏我很确定一晚上能跑起来的“笨坦克”带来的快乐绝不比三个月后雕琢出来的完整作品少。
返回列表