
1. 项目概述与整体设计思路1.1 为什么用纯 HTML 做单文件游戏先说说这东西解决什么问题。很多想做小游戏的朋友一上来就上框架、上打包工具最后发出去还得配服务器、讲依赖、处理跨域折腾半天。我这个坦克大战从立项起就定了死规矩一个 HTML 文件搞定全部不引外部库、不需要构建、不需要联网双击就是玩。文件丢给你你从收到文件到听见炮声不超过五秒钟。单文件的核心价值在于交付。HTML 文件本质上是纯文本既是结构又是逻辑又是样式canvas 画布把渲染目标也统一掉了。整个游戏压缩下来也就几十 KB微信、U 盘、邮件附件都能塞甚至能转成 base64 塞进浏览器地址栏直接运行。我试过把这游戏塞到 data URL 里分享给同事他以为我在开玩笑结果真的点开就玩了二十分钟。技术选型上也省心。CSS 布局只负责外围的操作提示和性能浮层最核心的游戏画面全部交给 canvas 2D 绘制。这样省掉了一堆 DOM 操作的性能开销还避免了你用 div 摆坦克、子弹用 setInterval 挪位置那种又卡又难维护的做法。JavaScript 也不用任何新特性ES6 就够保证了老浏览器双击也能跑。这里有个关键取舍我不是不能用现成的游戏引擎而是这个小场景里引入引擎的代价远大于收益。一个 canvas、一个 requestAnimationFrame 循环、一个碰撞检测库函数加起来不到两百行何必背一个几百 KB 的引擎1.2 从经典 Battle City 到自己的坦克大战这个项目的玩法骨架取自红白机上那款经典的坦克游戏但我没有照搬。我保留了最核心的三个要素玩家坦克守着老家基地、敌人从顶部刷新并往下攻、地图里的砖墙和钢墙构成掩体。在此基础上我给敌人 AI 做了明显升级这也是标题里敢写敌人会包抄的底气。原版老游戏的 AI 说实话比较呆敌人只会在朝下的方向和随机转向之间摇摆。我这次给敌人挂了状态机每个敌人会有巡逻-追踪-包抄-撤退几种状态并且会用方向偏好和协同标记来实现多角度围攻。实际玩起来你会明显感觉到敌人不再是排队上来送人头而是会绕到你的侧翼打你、会停下来堵你的弹道、会留一部分兵力佯攻基地吸引你回头。游戏的地图也做了模块化设计。我用一个二维数组来定义地图数组里的每个数值对应一种地形0 是空地、1 是砖墙、2 是钢墙、3 是基地、9 是出生点。这种做法的好处是换图只改数组不改逻辑后续想做关卡编辑器也比较顺。游戏总共有三张内置地图第一张是教学图敌人数量少、路线开阔第二张开始出现横向掩体和双通道敌人 AI 的包抄威胁就开始显现了第三张地图我取消了左侧围墙彻底开放战线属于给老玩家准备的地狱模式。一句话概括设计和思路单文件是约束约束逼着我把代码写干净AI 是亮点亮点背后藏着一套不算复杂但很有效的状态机逻辑。这两件事我会在下文展开。2. 核心细节解析与实操要点2.1 Canvas 渲染与游戏循环整个游戏的运行心脏是requestAnimationFrame这个浏览器原生 API。它比setInterval强在两点一是它会自动匹配显示器刷新率二是在标签页切到后台时会自动暂停不会白白耗电。我不设固定 FPS每一帧都做一次完整的输入-更新-绘制流程用performance.now()计算时间差来控制移动速度保证在不同刷新率的屏幕上手感一致。绘制阶段我用了分层渲染思路。ctx.clearRect清掉上一帧之后按顺序画地图层砖墙、钢墙、基地、道具层暂无但流了接口、坦克层、子弹层、特效层爆炸动画。先画谁后画谁不是小事坦克叠到砖墙上面还是砖墙叠到坦克上面视觉观感完全不同。地图永远是底板坦克和子弹是活动层爆炸特效应绘制在最上面这样被击毁的坦克消失时能露出底下的残骸墙真实感会强很多。坦克的绘制我没有使用图片资源全部用 canvas 路径命令画。每帧根据坦克的方向状态上、下、左、右重新绘制一个带炮管的梯形车体履带部分用两条虚线矩形模拟。这是纯代码绘图的一个够用的取舍画出来是像素风配上黑色背景反而有一种早期街机的质感。你如果想要复杂美术把图像替换成drawImage也不困难但对我来说路径手绘少了两份图片文件的体积和维护成本这是单文件项目必须精打细算的地方。游戏循环里还维护一个全局的状态标志const gameState { running: false, over: false, paused: false, score: 0, lives: 3, wave: 1 };页面上有暂停按钮、重新开始按钮和计分板这些 DOM 元素和 canvas 是通过几个事件监听器串起来的。键盘事件里我保留了方向键和 WASD 两套键位空格射击。2.2 地图数据与碰撞体系地图的数据结构就是一个普通二维数组我一般管它叫 map grid。每个格子逻辑上对应 40x40 像素这在代码里是一个常量TILE_SIZE 40。整个逻辑游戏区域是 13 列 x 13 行最下面一行留作基地和玩家出生区域。地图数据大概是这样的形态节选自第二关const MAP [ [2,0,0,0,0,0,2,0,0,0,0,0,2], [2,0,0,1,0,0,0,0,0,1,0,0,2], [0,0,0,1,0,2,1,2,0,1,0,0,0], // ... ];这套数据看着枯燥但它撑起了所有碰撞逻辑。坦克的移动不是像素级自由移动而是先把目标坐标换算成格子坐标然后检查目标位置是否和墙体重叠。我用了一个canMove(tank, dx, dy)函数它接受试图移动的坦克实体 横向增量 纵向增量返回布尔值。碰撞检测本身用的是 AABB轴对齐包围盒方式。每个坦克的表面被简化为一个矩形子弹也是矩形两个矩形相交了就判定命中。游戏地图格子我用了rectHitTile辅助函数它遍历坦克四周可能接触到的 2x2 格子区间避免了全地图遍历。这里有个很实用的优化不用每个移动帧都遍历 169 个格子只检测当前坦克覆盖区的四邻格就够了性能差距在小地图上看不出来但你会在看帧率面板时发现明显差距。子弹的碰撞要特别说一个点子弹速度很快一帧能跑十几像素40 像素宽的砖墙可能一帧就直接穿透过去了。这个 bug 在游戏开发里叫隧道效应。很多人第一次做都会遇到然后开始怀疑人生。我的解决方案是给子弹的碰撞检测做一个分段步进逻辑一帧的实际移动距离被拆成多次小步进每次只移动 4 像素就做一次碰撞判定这样即使子弹速度再快也不会穿墙。2.3 坦克实体与子弹系统坦克在这个游戏里统一用一个实体对象表示玩家和敌人共用一套构造函数只有控制器的实现不同这个设计极大减少了重复代码。坦克的参数包括坐标、宽高、速度、当前方向、炮弹冷却时间、生命值和control对象。control 对象里暴露了getInput(state)方法玩家坦克返回键盘按键的状态敌人坦克返回 AI 决策的状态。框架层你不需要区分谁是玩家谁是 AI因为它们就只是一群坦克。坦克类型有三种数值我调了很久坦克类型移动速度开火间隔(ms)生命值行为特征玩家坦克1.8px/帧2603手动控制有初始保护罩普通敌人1.0px/帧9001随机转向追踪趋向低快速敌人1.8px/帧7001追踪高包抄欲望强重甲敌人0.7px/帧12002只会正面推进血量高子弹系统是一个独立的池子池子里最多同时存在 20 发子弹。对你没看错就是用一个固定数组循环利用而不是无限创建新对象。这种做法的好处是避免了频繁的垃圾回收造成掉帧特别是红白机那种一屏幕子弹满天飞的时候数组循环复用的性能优势很明显。子弹有owner属性指向发射它的坦克owner是玩家时碰到敌人会伤害敌人owner是敌人时碰到玩家或基地会伤害目标。这里还得加一个细节基地只给了四条命被敌人的子弹碰一下直接游戏结束被自己误伤也一样结束。这个设定会让玩家的操作紧张感直线上升我从很多朋友玩的时候能看出来越到最后手越抖。这就是塔防/守家玩法的核心情绪——压力。3. 敌人的包抄 AI 是怎么实现的3.1 从随机游走到追踪趋向如果只是随机游走敌人会不断转向永远走不到玩家身边玩家只需要守在家里等敌人送上门游戏就失去了战略博弈的乐趣。我在设计 AI 时定了三条原则敌人必须大致向玩家或基地移动、敌人之间不能完全独立行动、敌人需要根据地形绕过障碍而不是傻乎乎顶墙。我把每个敌人设计成一个轻量状态机状态分别是PATROL、CHASE、FLANK和STUCK。PATROL 状态下敌人沿当前方向缓慢移动随机小概率转向CHASE 状态一旦看到玩家或基地就切换为持续追踪FLANK 是包抄的关键状态下文单独讲STUCK 是检测到一定时间内坐标没变时快速恢复正常路径的一个兜底状态。这里的核心不是寻路而是趋向。我刹车比较深因为这个小游戏的地图规模根本不需要 A* 算法来做全局寻路13x13 的格子用 A* 完全是杀鸡用牛刀。可如果你只做目标在哪个方向就往哪边拐的简单趋向敌人走几步就会撞墙然后在墙前面原地抖。我的实际做法是敌人的方向选择基于曼哈顿距离启发式——它每次决策时会枚举四个候选方向把每一个方向的下一步位置和当前目标玩家或基地的曼哈顿距离算出来再配合一点随机扰动选择距离最小且不撞墙的方向。简化后的伪代码function decideDirection(enemy, target) { const dirs [UP, DOWN, LEFT, RIGHT]; const isValid d canMove(enemy, d.dx, d.dy); const dist d Math.abs(enemy.x - target.x) Math.abs(enemy.y - target.y); let best null; let bestDist Infinity; for (const d of dirs) { if (!isValid(d)) continue; const nd dist(getNextPosition(enemy, d)); if (nd bestDist || Math.random() 0.15) { best d; bestDist nd; } } return best || DOWN; }看到那个Math.random() 0.15了吗这是探索率它保证敌人即使非常接近全局最优方向也有一定概率走偏。这个随机性很重要它让敌人的行动看起来是有自己想法的而不再是算出来的。一点噪音就是自然感。3.2 包抄行为的路径决策包抄是标题的卖点它本质上不是一种寻路算法而是一种行为协同模式。核心思路是我始终记录场上存活的敌人总数和敌人坦克之间的相对位置关系每波敌人刷新时我不会让它们全部堆在一个出生点而是分成三路、两路或按波次配置生成。刷新时会给每个敌人打一个阵型编号标签编号为 0 的敌人走中路编号为 1 的敌人走上路编号为 2 的敌人走下路。但分路走还不叫包抄。真正的包抄发生在游戏进行中段当场上剩余敌人数小于等于 3 时我触发一个包围态。此时所有存活敌人切换到 FLANK 状态它们的寻路目标不再是玩家坦克而是玩家的预测位置。这个预测位置怎么算我把玩家的当前移动方向向量叠加到玩家坐标上得到以当前速度继续移动 n 帧后的位置然后让敌人向那个位置移动。通俗说就是敌人会瞄准你 0.5 秒后会站的地方而不是现在站的地方。如果玩家一直朝左移动敌人会显得预判你的走位从右侧绕过来截击。配合这个预测位置我还会给每个敌人分配一个进攻扇区。举个实际例子假设玩家在场景中央偏右场上剩下三个敌人我会让一个敌人保持中路压制一个敌人切到玩家右侧扇区一个敌人绕到玩家左侧后方。每个扇区其实是一个镜像矩阵根据玩家坐标动态更新。function flankAssignment(enemyIdx, player) { const sectors [ { dx: -1, dy: 0, priority: 1 }, { dx: 1, dy: 0, priority: 2 }, { dx: 0, dy: 1, priority: 3 } ]; const s sectors[enemyIdx % sectors.length]; return { x: player.x s.dx * 120, y: player.y s.dy * 120 }; }这段代码在游戏里是封装成getFlankTarget的目标点每 30 帧重新计算一次。因为不是每帧重新算敌人不会因为目标抖动而在原地跳来跳去整个动作就变得连贯、有目的性。还有一个细节很重要包抄状态的触发不是全局统一开关而是每个敌人独立判断的。我允许不同敌人以略有差异的时机进入 FLANK 状态制造出有些敌人激进、有些敌人保守的错觉。游戏心理学的角度上这就够了。3.3 难度曲线与行为权重调整AI 参数如果不做动态调整容易造成前期太难、后期太简单的失衡体验。我在玩家击杀敌人时累积一个aggression值它会逐渐提高 AI 决策时追踪趋向和包抄趋向的权重。前期 aggression 低敌人多走 PATROL像是在巡逻随着推进aggression 升高敌人开始频繁切 CHASE 和 FLANK压迫感会明显增强。具体数值是这么调的每通过一波aggression 0.12击杀一个敌人也加0.02。当 aggression 超过 0.6 时敌人 AI 的决策参与度从 30% 提升到 55%这个阶段玩家能明显感到敌人的协同性上来了包抄的频次变高经常出现一个敌人正面勾引、一个敌人侧翼偷袭的情况。等 aggression 接近 1.2 时我会额外给快速敌人缩一档开火间隔它们会变成疯狗模式近战缠斗能力非常强。这里给后来者一个调试心得AI 手感好不好不要凭空盯着代码想要开一个带可视化参数的调试面板。我在开发时加了一个 JSON 面板实时显示每个敌人的当前状态、目标坐标、决策权重等。调到后期直接把这些信息画在 canvas 的上方每种敌人的状态用不同颜色的小字标注一眼就能看出 AI 到底在干嘛是卡住了还是正常绕路。这个调试方法救了我很多回。4. 实操过程与关键代码走读4.1 构建单文件 HTML 骨架整个游戏从一个标准的 HTML5 文档起步。DOCTYPE 必须写对这对现代浏览器进入标准模式至关重要。langzh-cn声明也顺手加上影响某些浏览器的默认字体渲染和后期的无障碍体验。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title坦克大战 · 单文件版/title /head body canvas idgameCanvas width520 height520 tabindex0/canvas canvas idfxCanvas width520 height520/canvas script // ... 所有游戏代码 /script /body /html我刻意把样式内联在style标签里JavaScript 全部放在/body前的一个script里。视觉层有两个 canvas一个是主画布一个是特效层。为什么要两个画布因为爆炸动画、弹道残影这类特效如果每帧都清掉重画整个主画布会和坦克移动互相干扰。拆两层的代价很低——多一次 canvas 的透明叠加——但视觉质量提升明显主画布保持稳定特效层只做闪烁和消失。viewport的widthdevice-width是为了手机访问时画布不被缩放得过于离谱虽然这游戏主要面向桌面浏览器的键盘操作但同事用手机浏览器打开时至少画面是完整的。单文件强制要求把所有内容挤在一个文件里这一点有人会觉得乱但我的代码组织顺序是固定的常量定义 → 全局状态 → 工具函数 → 实体构造函数 → AI 逻辑 → 游戏主循环 → 事件绑定 → 初始化。这样从上往下读就是一条清晰的数据流。4.2 敌人感知与决策代码示例下面这段是敌人 AI 的核心决策函数我对关键行加了注释这是整个项目里最值得反复看的部分function enemyThink(enemy, player, base, enemies) { // 周围 200px 内有玩家切追踪 const distToPlayer manhattan(enemy, player); if (distToPlayer 200) { enemy.mode CHASE; } // 进入包围态场上敌人数量 3 且距离玩家较远时 const aliveCount enemies.filter(e e.alive).length; const baseDist manhattan(enemy, base); if (aliveCount 3 baseDist 160 distToPlayer 300) { enemy.mode FLANK; } // 超过 3 秒没有移动强制回归巡逻 if (enemy.stuckFrames 90) { enemy.mode PATROL; enemy.stuckFrames 0; } let target; if (enemy.mode CHASE) { target { x: player.x, y: player.y }; } else if (enemy.mode FLANK) { const flankT getFlankTarget(enemy, player); target flankT; } else { target null; // PATROL 模式不追踪走内置路线 } if (target) { enemy.nextDirection decideDirection(enemy, target); } // 开火时机 if (enemy.canFire) { let shouldFire false; if (enemy.mode CHASE || enemy.mode FLANK) { // 目标在自己炮口方向的扇形区域内时开火 shouldFire isInFireCone(enemy, player, Math.PI / 4); } else { // 巡逻时 10% 概率开火制造压制火力 shouldFire Math.random() 0.1; } if (shouldFire) fireBullet(enemy); } }这块代码看着简短但它是整个 AI 系统昼夜打磨的结果。最初版本没有 STUCK 兜底敌人顶到墙角后会原地重复尝试转左、探测失败、转右、探测失败的循环视觉上就是抽风。加了stuckFrames计数器后AI 会有意识地放弃当前方向选择另一条路径整体流畅度提升了一个档次。isInFireCone是另一个值得展开的细节我算玩家位置相对于坦克炮口的方位角如果这个角落在当前朝向角左右 45 度以内才允许开火。这样敌人不会背身开火显得弱智也不会 360 度无死角的奇幻射击。开火还是一个概率不是必然这给玩家留了走位闪避的空间游戏才不会变成弹幕地狱。4.3 本地验证与跨平台测试开发完后的验证环节我通常按四个场景逐一过桌面 Chrome、桌面 Firefox、手机 Chrome 横屏、以及禁用浏览器自动播放政策的特殊情况这游戏不涉及音频所以基本没问题。在桌面端我重点关注键盘事件有没有被浏览器默认行为干扰——比如空格键会滚动页面方向键会移动焦点这些都通过e.preventDefault()处理掉了。有一个很隐蔽的坑跟双击即玩直接相关如果你直接用本地文件协议file://打开 HTML有些浏览器出于安全策略会限制某些 API比如游戏里可能用到的localStorage存档。我最初把最高分存在 localStorage 里本地双击打开时有概率报安全问题导致整个脚本中断。排查后我的解决方案是包一个 try/catchlocalStorage 用不了就降级为内存变量游戏主体照常运行。这是一个典型的进度条不挡主流程的取舍。跨平台测试还有一个点手机端的触控支持。我给页面额外加了一个简易的屏幕摇杆控件只适配触屏事件touchstart/touchmove/touchend让手机也能玩半成品版本。虽然体验比不上键鼠但至少能跑朋友圈传阅的时候覆盖面就大了一截。这个触摸摇杆是我在项目后期加进去的成本不高但确实让单文件游戏的传播性上了一个台阶。5. 常见问题与排查技巧实录5.1 子弹穿墙、帧率与卡弹问题子弹穿墙是因为之前说的隧道效应这部分我在第 2 章提过解决方案是把一次位移拆成多次小步进。这里补充几个实操排查时的信号如果你发现子弹偶尔消失在墙里面不出来或者某发子弹越过一整面砖墙击中了墙后的坦克那八成是单帧位移大于墙体厚度了。建议把步进上限设到坦克碰撞盒的最短边长的一半我这边是 4px 步进稳得很。卡弹问题则相反表现是子弹发射后卡在原地图格子里不动过一会儿又自己消失。这通常是因为碰撞检测里对出生即碰到墙的情况没有做兜底。坦克的炮口如果紧贴着一块砖墙子弹生成时已经处于碰撞状态按正常逻辑就会判定销毁。我的处理方式是给子弹生成加一个 80ms 的无敌出生帧这帧内只检测 2 格外的墙体不检测自身占位格炮口堵墙也能射出去。别小看这个细节射击手感顺不顺很大程度取决于出生帧怎么处理。帧率不足的情况我见过两次一次是密集弹幕加大量爆炸特效叠加canvas 每帧重绘的开销上去了另一次是后台标签页长时间挂着之后再切回来出现明显的几十帧卡顿。前者我把子弹池上限从 30 调到 20爆炸粒子的最大存活数限制在 60流畅度立刻恢复后者纯粹是浏览器定时器的节流机制搞的鬼在页面重新获得焦点时强制做一次全量重绘初始化就可以了。5.2 敌人卡死与原地打转的修复敌人卡墙是开发 AI 时出现频率最高的问题原因基本都是决策方向时只检查当前帧能不能走没有检查连续两帧能不能走。当一个敌人紧贴墙角时它当前帧的两个可选方向都合法但其中一个方向会在下一帧撞墙AI 选了它然后被堵住、STUCK 兜底触发、换方向、再被另一面墙堵住陷入循环。我的实际修复方式是三层配合。第一层决策函数里对目标方向的两步预测做检测预判下一帧和再下一帧的位置都能移动才候选第二层加 STUCK 兜底坐标连续 90 帧无变化就强制转向并给一个短暂时长的穿墙免碰撞帧帮助它脱离死角第三层地图设计上避免出现 1 格宽的逼仄通道所有通道宽度至少 2 格。三层下来敌人几乎不会再卡住了即便卡住也能在几秒内恢复。还有一个更隐蔽的 AI 问题敌人互相碰撞导致堵路。多个敌人挤在同一个窄通道里就会互相顶着动弹不得看起来就像集体死机。这问题我折腾了很久最后方案是给敌人群加一个简单的排队序位机制——在通道入口检测到前方有敌人的话排在后面的敌人会自动减速并尝试换路也就是雷同于汽车跟车时候的保持车距逻辑。虽然不是多聪明的协作寻路但已经足够避开了。5.3 页面刷新丢进度、存档与加载优化单文件游戏最容易被问的一件事是我打到第三关不小心按了 F5进度全没了。这个问题的标准解法是 localStorage但早期因为本地文件协议的限制我不太想用。我的方案是优先记localStorage失败则降级到location.hash作存档。用 hash 的话你甚至可以把一个带存档的完整网址发给别人对方打开直接接上你的进度这个玩法我觉得挺妙的。具体实现每次过关时把{score, lives, wave}序列化成 JSON 编码进 hash打开页面时检测location.hash有值就尝试解析。基本代码只有几行function save() { const data { s: score, l: lives, w: wave }; try { localStorage.setItem(tank99, JSON.stringify(data)); } catch (e) { location.hash tank btoa(JSON.stringify(data)); } }加载优化的部分主要砍了两块第一块是地图数据。我本来打算用 JSON 直接塞在大数组里后来发现用手写的紧凑字符串格式再解析更省体积。比如#..#..##这种逐行字符串一个字符对应一个格子解析成数组只有二十行代码但源文件体积减少 40%。第二块是初始加载时不要每帧都去绘制全部地图墙体而是在init阶段把地图预渲染到一张离屏 canvas 上之后每帧只需要drawImage把这整块画布贴上去省掉每帧逐格绘制的开销。这两处优化加在一起整个文件从 86KB 降到了 61KB首屏渲染时间在低端手机上大约缩短了一半。这种事听起来很琐碎但单文件作品的体验天花板就在这些细节里。6. 一点体会与可扩展的方向做完这个项目我个人最大的体会是约束反而成全了设计。单文件这个限制逼着我砍掉了所有华而不实的功能留下来的每一个特性都有它存在的必要。敌人会包抄不是因为我实现了一个多么高级的算法而是因为我在一个朴素的状态机里加入了目标预测、扇区分配和随机扰动这三样东西。给后来者一个建议先做一版只有随机移动的坦克大战你会觉得无聊再加一味趋向算法你会觉得对味了再加一分随机性你才感觉到活起来到底是怎样的体验。这个递进过程本身就是游戏 AI 最有趣的学习曲线。后续想扩展的方向我已经想好了几个。一个是给地图编辑器做可视化界面摆墙、调敌人、即时刷新一个是把存档接到 IndexedDB 做多槽位这样不止能存进度还能存回放录像。如果你有兴趣自己改我这个单文件的代码结构其实挺好动刀的AI 决策函数就处在一个很集中的位置想要敌人更激进还是更保守改几行权重就行。也欢迎你把它拉下来玩两把体验一把被三辆敌坦从三个方向同时逼近的压迫感。毕竟做了个游戏最开心的事情永远是看到别人玩得手心冒汗。