
从PIXI引擎搭建到完整玩法这里记录了把经典坦克射击游戏搬到HTML5的完整过程。没有华丽的包装只有一步步可执行的思路和能直接用的代码。1. 项目拆解从游戏玩法到技术清单1.1 经典坦克大战的核心玩法元素先抛开代码想想坦克大战这游戏到底在做什么。玩家控制一辆坦克在由砖墙、钢墙、水域、树林组成的地图里移动射击敌人坦克保护地图中央的基地。敌人一波波从固定出生点刷出来打完一波进下一关基地被摧毁或所有坦克耗光则游戏结束。听起来简单但拆成技术问题后你会发现这几乎是2D游戏开发里所有基础系统的迷你集合地图数据表示、渲染分层、玩家输入、移动与碰撞、发射物管理、简单AI、状态控制、UI更新。能把这一套走通后面做任何同类型2D游戏都会顺手很多。所以在动手写代码前我建议先列一张清单把玩法元素映射成技术模块玩法元素技术模块战场地图瓦片地图数据 地图绘制砖墙 / 钢墙 / 水 / 树静态碰撞体 可摧毁逻辑玩家坦克实体类位置、速度、朝向、状态键盘操作输入监听 按键状态管理子弹与命中发射物管理器 碰撞检测敌人刷新与AI计时器 简单决策逻辑基地与胜负血量状态 关卡流程控制计分与生命数UI引用 事件更新这张清单就是整个项目的开发序章。我建议按它从上到下推进每完成一行就算一个里程碑调试起来思路也会非常清晰。1.2 为什么选PIXI而不是原生Canvas也不用重型引擎很多第一次做HTML5游戏的人会纠结直接用Canvas写不行吗Phaser、Cocos不是更省事直接用Canvas写完全行但问题出在资源管理和渲染性能上。坦克大战的贴图虽然不多可一旦子弹、爆炸动画、敌人同时出现原生Canvas如果代码组织得不好很容易陷入“每次都重绘整张画布”的坑。PIXI提供的是场景图结构你可以把背景、坦克、子弹、UI拆成不同容器引擎只更新有变化的部分而且底层默认走WebGL渲染效率比2D Canvas高不少同时也保留了Canvas回退模式——这对老机器和低端手机很重要。Phaser这类完整游戏框架确实强大但学习曲线更陡很多功能对坦克大战这种小体量项目是过载的。PIXI更像一个“渲染引擎”而不是“游戏引擎”它不强行规定你用什么碰撞系统、怎么组织游戏循环只把精灵、容器、动画、交互这些底层能力给你。对想搞懂游戏逻辑本身的人来说PIXI是更好的教学工具你需要自己搭游戏循环自己写碰撞自己管理状态但对PIXI的掌控感会非常强。这个选择适合当前项目也适合这个系列的循序渐进思路。先用PIXI把渲染和结构跑顺后续如果遇到需要场景管理、寻路、复杂动画的项目再上Phaser也不迟。1.3 文件结构与渲染层级的初步设计动手写代码前先想清楚文件结构。我习惯按“入口层 → 场景层 → 实体层 → 工具层”四层切分src/ ├── main.js // 入口初始化PIXI应用加载资源 ├── scenes/ │ └── GameScene.js // 主游戏场景管理整个游戏流程 ├── entities/ │ ├── Tank.js // 坦克基类玩家坦克和敌人坦克共用 │ ├── PlayerTank.js │ ├── EnemyTank.js │ └── Bullet.js ├── map/ │ ├── TileMap.js // 地图数据与静态物体管理 │ └── levels.js // 关卡地图数据 ├── systems/ │ ├── Input.js // 键盘输入管理 │ └── Collision.js // 碰撞工具 └── utils/ └── constants.js // 颜色、尺寸、速度常量渲染层级方面PIXI的容器就是天然的分层工具。我的做法是把场景分为四个容器从底到顶依次是背景容器、障碍物容器、坦克容器、子弹容器UI单独再放一层。这样做的最大好处是层级关系一目了然而且调试时可以单独隐藏某一层观察问题。坦克在移动时只需要在坦克容器内更新坐标子弹层、背景层都不用动减少了不少不必要的重绘操作。2. 环境准备与PIXI引擎接入2.1 初始化项目并安装PIXI我用Vite搭基础工程。为什么选它启动快开发时热更新体验好打包产物干净对小游戏项目来说足够轻量而且对ES模块支持得比老式Webpack配置省心得多。mkdir tank-battle cd tank-battle npm create vitelatest . -- --template vanilla npm install pixi.js装的是PIXI v7版本。注意v7和v8的API有不少差异网上很多教程还在用v6甚至v5的写法版本不同会出现“拿不到某个属性”的情况。如果你跟着改报错先检查package.json里的pixi版本。我的代码示例按v7写v8需要改的地方我会在对应位置单独提示。2.2 创建应用实例与自适应画布PIXI v7里创建一个应用的标准方式是import * as PIXI from pixi.js; const app new PIXI.Application({ width: 832, height: 832, backgroundColor: 0x000000, resolution: window.devicePixelRatio || 1, autoDensity: true, antialias: false, }); document.getElementById(game-container).appendChild(app.view);这里有几个细节值得说明width和height我用832是因为我设计的瓦片地图是26×26每个瓦片格子32像素26×32832正好整除。做像素风游戏尺寸尽量是瓦片尺寸的整数倍这样地图边界、碰撞计算都干净。resolution和autoDensity这两个参数是让画面在高分屏上保持清晰的关键。手机屏幕像素密度比是2甚至更高不设置分辨率就会发虚。像素风游戏建议把antialias设为false否则边缘会被插值模糊失去颗粒感。配合CSS里的image-rendering: pixelated效果更纯粹。自适应方面项目很可能要在不同尺寸的屏幕上玩。我在CSS里用一个简单方案外层容器设max-width,max-height然后给canvas设width:100%; height:100%让canvas在保持比例的前提下缩放。2.3 素材处理自己画还是找贴图坦克大战的素材可以自己用像素绘图也可以用程序临时生成。我第一版为了快速跑通逻辑直接用了PIXI的Graphics来画简单的方块坦克。就是两个矩形拼起来一个车体一个炮管颜色区分玩家和敌人。用程序画图的好处是零素材依赖、加载快、方便改尺寸。但等到要做爆炸动画、多帧履带效果时程序画就会非常吃力这时候最好切换成贴图。如果用贴图建议做“精灵图集”。把多张坦克图、墙体图、爆炸帧拼成一张大图再用工具生成对应的atlas JSON。PIXI加载图集用Assetsv7以上或loaderv6以下v7的写法是PIXI.Assets.add(atlas, assets/tank-atlas.json); const atlas await PIXI.Assets.load(atlas); // atlas.textures 里就是各个帧的纹理对象这里踩过一个大坑如果图集里带Trim信息为了节省空间裁掉透明区域锚点和碰撞框的位置会和你预期的不一样所有坐标都会莫名其妙偏移。后来我果断关掉了Trim宁愿文件大一点也不跟它折腾。3. 地图系统与瓦片碰撞3.1 瓦片地图数据结构怎么定坦克大战的地图适合用二维数组表达。每一个数字代表一种地形我给不同地形分配的编号是0空地1砖墙2钢墙3水4树5基地。关卡数据就是这样的一串数组export const LEVEL_ONE [ [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0], // ... 中间省略 [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], ];地图的边界干脆直接用钢墙兜一圈这样玩家和敌人的移动范围天然被限制住不用单独写边界检测。这个设计让碰撞逻辑少了一大分支很划算。3.2 从地图数据到PIXI显示对象的映射拿到二维数组后映射给PIXI渲染遍历数组每遇到非0值就在对应位置生成一个Sprite或Graphics对象放进障碍物容器。坐标换算公式是const x col * TILE_SIZE; const y row * TILE_SIZE;我在这个环节不把地图数据直接画成一张大图而是一个个独立对象这样后续子弹打掉某块砖时可以精准删除对应的那个精灵而不是重新绘制整张地图。这也是PIXI容器和对象模型带来的便利。砖墙我处理成“单块瓦片即一个碰撞体”。为了让坦克打起来有破碎感可以在瓦片内部再拆成四小份被子弹打中时只销毁命中的小份。这个效果不难实现但会让视觉反馈丰富不少。我后续会更新到这个版本第一版先保持整块砖的逻辑。3.3 特殊地形水、树、冰面的行为差异不同类型的地形不只是贴图不同碰撞规则也不同整理如下砖墙、钢墙硬障碍坦克和子弹都过不去。砖墙可以被子弹摧毁。水子弹直接穿过坦克不能通行。这在经典规则里是铁律我按这个做。树贴图盖在坦克头上但碰撞不拦截坦克可以穿过。实现上是把树层画在坦克层上面且不参与碰撞计算。基地玩家要保护的核心位置被子弹打中游戏直接结束。它本身按砖墙物理来算。这个“视觉层和碰撞层分离”的思路很重要。很多新手把“能看到的东西”和“能撞到的东西”当成一回事结果做树或装饰物时明明应该让玩家穿过却被挡住或者反过来。一个好的做法永远是把碰撞数据独立于显示对象显示层只是拿来给人看的。3.4 基地防御设计与地图刷新基地在地图上位于正中央下方。我用一个标记位isBase来区分它和普通砖墙这样子弹命中时的处理逻辑可以分支打基地转成“游戏结束”流程打砖墙则转成“扣除血量/移除瓦片”。关卡切换时要清空障碍物容器里的所有瓦片精灵重新读取下一关数据并生成。这时候容器的removeChildren()很省事。但不能只清显示对象碰撞数据也要同步重置。我的碰撞数据就挂在每个瓦片精灵的collidable属性上清掉显示对象就等于清掉了碰撞体只要记得把地图数据数组换成新关卡的数据即可。4. 坦克实体与玩家控制4.1 坦克类的基础属性设计玩家和敌人都叫坦克所以我把通用逻辑放在一个Tank基类里它包含的字段有属性用途container显示容器装有车体和炮管row, col当前所在瓦片坐标x, y当前屏幕坐标左上角direction0上1右2下3左speed每帧移动像素数hp血量玩家和敌人坦克初始都是1alive存活状态fireCooldown开火冷却计时lastShotTime上次开火时间direction这个字段很关键因为坦克移动和炮管朝向是绑定的我通过方向值计算出旋转角度再应用到炮管精灵的rotation属性上省得每次单独记录炮管坐标。4.2 键盘输入与八方向移动玩家控制我用了方向键或者WASD两组按键方便不同习惯。输入模块要解决的第一个问题是“按一下键盘却持续移动”——靠keydown/keyup维护一组按下状态而不是在keydown里直接改坐标否则玩家按住按键时会断续移动。const keys {}; window.addEventListener(keydown, (e) { keys[e.code] true; }); window.addEventListener(keyup, (e) { keys[e.code] false; }); function handleMovement(delta) { let dx 0, dy 0; if (keys[ArrowUp] || keys[KeyW]) dy -1; if (keys[ArrowDown] || keys[KeyS]) dy 1; if (keys[ArrowLeft] || keys[KeyA]) dx -1; if (keys[ArrowRight] || keys[KeyD]) dx 1; // 归一化斜向移动速度 if (dx ! 0 dy ! 0) { const len Math.hypot(dx, dy); dx / len; dy / len; } const speed TANK_SPEED * delta; this.x dx * speed; this.y dy * speed; }为什么要归一化如果不归一化斜着走的合成速度是1.414倍玩家会明显感觉“斜着走比直线走快”这对坦克大战这种偏严肃操作的游戏来说很不专业。4.3 移动碰撞检测我是这么处理的坦克的碰撞目标是静态瓦片和动态坦克。这里有两种常见方案一种是先尝试移动然后与所有碰撞体做重叠检测发生重叠就回退另一种是预检测先算目标位置是否与碰撞体重叠不重叠才真正移动。我用的是“移动后修正”的方式因为它对高速移动的对象更稳健不会因为单帧位移过大直接穿墙。具体做法是this.x dx * speed; if (this.collidesWithWall()) { this.x - dx * speed; // 回退X方向 } this.y dy * speed; if (this.collidesWithWall()) { this.y - dy * speed; // 回退Y方向 }这个“分轴回退”的好处是坦克贴着墙横向移动时不会因为竖向探测失败而被卡住。X和Y分开处理等于得到了滑墙的手感对操作流畅度提升非常明显。很多新手把X、Y合在一起回退坦克稍微碰一点墙就完全卡死这其实就是这个细节没处理好。4.4 炮管朝向与开火冷却炮管方向应该和坦克最后移动的方向一致而不是实时跟随移动键。这个逻辑在实战中要稍微处理一下记录上次改变移动方向的时间开火时使用“当前朝向”或“目标朝向”都行但画面表现上炮管要先转向再开火。开火冷却我用简单的Date.now()时间戳判断两次开火之间至少间隔400ms。采用了“按住开火键连发”的模式按住空格键会以冷却时间为间隔持续发射。这是我个人觉得手感最好的方式比“每按一次发一枪”更符合街机习惯。5. 子弹系统与命中反馈5.1 子弹的生成与生命周期管理子弹也是精灵挂在子弹容器下。玩家子弹和敌方子弹外观不同我用颜色区分玩家金色敌人银色。子弹的移动方向和发射时的坦克朝向一致速度是坦克的三倍左右。子弹生命周期的核心问题是“什么时候消失”。我定义了几个条件超出地图边界命中砖墙并摧毁砖块命中钢墙不摧毁但子弹消失命中坦克接触水或树则穿过去因为子弹可能一帧内移动超过一个瓦片速度很快时只检测子弹中心点与瓦片是否重叠是不够的会漏判。我在子弹对象上额外记录上一帧的位置用两点连线与瓦片做相交检测避免“子弹穿墙”的视觉穿帮。5.2 子弹与墙、坦克的命中判定碰撞检测我写了一个统一函数接收两组“矩形数据”x, y, width, height判断是否相交。这是最老套但最可靠的方案。命中瓦片判定时遍历当前地图中所有collidable瓦片找到相交的第一个瓦片就停止。命中坦克同理遍历所有alive坦克玩家子弹跳过玩家自己的坦克敌方子弹跳过射击者自己。关键点是“同一个子弹在同一帧内不要命中多个目标”。我通过碰撞后立即把子弹标记为dead并且在遍历循环里跳过已死子弹来保证。5.3 爆炸动画与命中反馈坦克被摧毁后不能直接destroy()玩家需要看到爆炸反馈。我用一个简单的动画实现const explosion new PIXI.AnimatedSprite(explosionFrames, true); explosion.anchor.set(0.5); explosion.position.set(tank.x TILE_SIZE / 2, tank.y TILE_SIZE / 2); explosion.animationSpeed 0.2; explosion.loop false; explosion.onComplete () explosion.destroy(); app.stage.addChild(explosion); explosion.play();这套思路适用于子弹打中砖墙时的小碎裂动画也适用于坦克被摧毁时的大爆炸。爆炸结束后记得移除精灵否则每次爆炸都会残留一个显示对象时间久了内存和渲染都会扛不住。5.4 屏幕震动低成本但反馈明显的技巧屏幕震动是一个非常划算的反馈手段。实现方式是发生爆炸时把游戏内容容器除UI外的位置短暂偏移几像素然后通过缓动恢复原状。function shakeScreen(intensity 4, duration 150) { const startTime performance.now(); const container gameContainer; const origX container.x, origY container.y; function updateShake() { const elapsed performance.now() - startTime; if (elapsed duration) { container.x origX; container.y origY; return; } const strength intensity * (1 - elapsed / duration); container.x origX (Math.random() - 0.5) * strength * 2; container.y origY (Math.random() - 0.5) * strength * 2; requestAnimationFrame(updateShake); } updateShake(); }注意震动只会影响游戏内容层不能把UI也带着抖否则计分文字会晃得看不清。所以我把UI放在独立的UI容器里不参与震动。6. 敌人AI与关卡判定6.1 敌人基础AI巡逻、追击、随机开火敌人AI不用多先进够用就好。我的策略是三个状态轮着来状态A直线移动。按当前方向前进碰到障碍物或边界就换向。状态B随机转向。每隔一段时间随机挑一个新方向防止敌人永远困在一个角落。状态C射击。当玩家在敌人前方直线视野内且没有墙体阻挡时敌人开火。否则按固定概率随机放枪。这套AI会让敌人看起来“有点聪明但并不狡猾”和原作的观感很接近。update(delta) { // 每隔2~4秒随机改变一次方向 this.aiTimer - delta; if (this.aiTimer 0) { this.changeRandomDirection(); this.aiTimer 2 Math.random() * 2; } // 如果玩家大致在自己的前方直线视野里尝试瞄准并开火 if (this.hasLineOfSightTo(playerTank) Math.random() 0.3 * delta) { this.shoot(); } this.moveForward(delta); }hasLineOfSightTo的实现很简单比较坦克朝向与玩家相对方向如果同轴且在有效距离内再检查中间是否有砖墙或钢墙。不让子弹穿透墙打人这个判断能降低AI的“作弊感”。6.2 敌人刷新机制与波次控制原作中敌人是“活着到上限死了补新”。我做的是更保守的模型同一时间屏幕上最多3个敌人敌人池总数为20个。每死一个过2秒从出生点补充一个直到池子清空。这样做有几个好处战斗节奏比较平稳不会被一窝蜂的敌人压垮性能也更容易控制。出生点有3个每个都是单独的坐标。刷新时优先选择离玩家当前位置较远的出生点避免敌人刚刚出生就踩脸开火这个设计对新手玩家非常友好。6.3 胜负判定与关卡切换胜负判定有两个条件玩家生命数耗尽或者基地血量归零 → 游戏结束所有敌人清空且无剩余待刷新 → 过关我单独维护一个gameState枚举取值是playing,victory,gameover。只有playing状态下更新游戏逻辑避免游戏结束后还继续刷子弹和AI这点很容易被忽略。过关后展示一个短暂的全屏遮罩显示“进入下一关”然后重置坦克位置、子弹、瓦片和敌人池。切换关卡时还要注意清空所有子弹否则上一关残留的子弹会飞到下一关打穿墙。6.4 调试辅助状态可视化和慢速模式敌人AI这类逻辑看不见摸不着调试时我加了一个“调试开关”把每个敌人的状态直接用文字精灵显示在头顶上。按D键可以切换显示T键可以让时间流逝速度变慢方便观察AI决策。这个习惯强烈推荐。游戏逻辑层的问题用眼睛看比用console.log有效率得多。PIXI里给对象加一行文字的代码也就十行但排查问题能省半小时起步。7. 常见问题与踩坑记录7.1 WebGL初始化失败与PIXI回退PIXI优先用WebGL但有些浏览器或旧设备不支持。这时候PIXI会自动尝试回退到Canvas渲染器但前提需要在创建Application时开启forceCanvas参数吗实际上PIXI v7的错误处理方式是如果WebGL创建失败会抛异常。我的做法是捕获异常并再次创建应用let app; try { app new PIXI.Application({ ... }); } catch (e) { app new PIXI.Application({ ..., forceCanvas: true }); }不过在几乎所有的现代浏览器里WebGL 1都是可用的这个兜底更多是保险。7.2 碰撞抖动与分离向量坦克贴墙移动时如果用简单的“整体回退”会发生抖动因为回退量可能不够下一帧又撞上再回退来回反复。解决的思路我在前面提到过——分轴回退但还有一个进阶技巧是计算最小分离向量MTV。简单说就是如果坦克与墙发生重叠找出重叠最小的方向把坦克沿这个方向推出。这个技巧在新手阶段不一定要实现但如果你发现坦克抖动严重优先检查回退逻辑是不是把X、Y绑架在一起了。7.3 键盘粘滞与页面焦点丢失一个特别典型的坑玩家按住方向键的同时切到别的窗口回到游戏后会发现自己没按任何键但坦克还在移动。原因是keyup事件在窗口失焦时丢失了按键状态停留在true。修复方法是监听窗口的blur事件在失焦时清空所有按键状态window.addEventListener(blur, () { Object.keys(keys).forEach(k keys[k] false); });这个坑要是没有处理会让人误以为是移动逻辑写错了排查了半小时最后发现是焦点问题。7.4 贴图模糊与像素风适配如果你用贴图而不是程序绘制很可能会遇到“像素风画面模糊”问题。原因大多是canvas被插值缩放了。解决方案分两步PIXI创建时把resolution设为window.devicePixelRatio || 1同时autoDensity: trueCSS里给canvas加image-rendering: pixelated7.5 音效无法播出的autoplay策略浏览器的自动播放限制会让音效在用户交互前无法播放。你可以在用户第一次点击页面时提前“解锁”音频上下文常见做法是window.addEventListener(pointerdown, () { const ctx new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state suspended) ctx.resume(); // 把ctx存起来供后续播放音效用 });把音效播放的时机都放在用户交互或游戏事件后就不会被浏览器拦截。8. 性能优化与后续扩展方向8.1 渲染性能的直觉优化先看有没有必要优化。坦克大战这种规模如果只是几十个精灵同时存在现代设备完全跑得动高帧率。最容易拖垮性能的不是精灵数量而是“每一帧都重复创建”的临时对象。子弹和爆炸就是重灾区。我第一版的子弹发射就是new PIXI.Sprite再destroy()一口气打几十发后发现明显的掉帧和GC卡顿。后面改成“对象池”方案预先创建一批子弹精灵放在池子里需要发射时从池子取出用完了隐藏而不是销毁。这个优化对长期运行时稳定性的提升非常明显尤其在移动端上。8.2 自适应的进一步思考如果你的目标是手机横屏玩整流不变形、忽略safe area的问题都需要考虑。要做得更好我建议把游戏核心逻辑建立在固定尺寸的逻辑坐标系上然后通过一个缩放比例映射到实际屏幕这样后续适配iPad、折叠屏、异形屏都只需改这一处。8.3 后面还能怎么扩展这个项目做完后我计划从几个方向扩展双人模式第二辆坦克用不同的按键控制UI增加P2生命值。随机地图生成用简单的BSP算法或Perlin噪声生成砖墙分布让每一关都不一样。更细腻的动画履带动画、屋顶被破坏的碎片物理、子弹拖尾光效。这其中的每一个都对应一个新的技术点可以在后续系列文章里专门展开。我最后的几点体会做这个坦克大战Demo最大的收获不是“会了PIXI API”而是把游戏开发里最常用的一套工作流完整跑通了数据驱动地图、实体管理、碰撞检测、状态机切换、调试技巧。这些是底层能力换任何引擎、换任何玩法都通用。如果你正在跟着做我的建议是先别用精美素材用色块和形状把逻辑跑顺确认操作手感和碰撞反馈都没问题之后再替换素材、加音效、做优化。模块顺序从地图开始依次走到坦克、子弹、AI、UI每完成一个模块就玩一下亲手感受变化。这种“能看到自己进步”的节奏比闷头写两小时代码再一次性调试要舒服得多。如果在某个环节卡住了欢迎带着问题来讨论。毕竟游戏开发里最常挂在嘴边的三个字就是——试一下很多问题不亲手试永远不知道坑在哪里。