正在整理这一页的内容…
Project Detail
用 Phaser.js 写坦克大战:从儿时梦想到设计模式实践
这篇笔记记录一下我用 Phaser.js 开发坦克大战游戏的想法和过程。 我想做这个项目,主要有两个原因。 第一个原因比较实际: 以前看设计模式时,经常会看到这些名字: 状态模式。 策略模式。 工厂模式。 观察者模式。 对象池模式。 单例模式。 每个概念单独看都能理解,示例代码也不难。 但回到实际项目里,我还是会有疑问

这篇笔记记录一下我用 Phaser.js 开发坦克大战游戏的想法和过程。
我想做这个项目,主要有两个原因。
第一个原因比较实际:
我想从一个真正能玩的项目里学习设计模式。以前看设计模式时,经常会看到这些名字:
- 状态模式。
- 策略模式。
- 工厂模式。
- 观察者模式。
- 对象池模式。
- 单例模式。
每个概念单独看都能理解,示例代码也不难。
但回到实际项目里,我还是会有疑问:
这个地方真的需要设计模式吗?
用了以后解决了什么问题?
如果不用,代码最后会变成什么样?第二个原因就比较简单了。
小时候玩坦克大战时,我也想过:
如果以后会写程序了,我能不能自己做一个?现在已经会写前端,也接触过游戏框架,终于可以把这个小时候的想法做出来。
所以这个项目对我来说,不只是练习代码。
它也是在圆一个很小的儿时梦想:
亲手写一个坦克大战,然后真的打开它玩。为什么选择坦克大战
坦克大战看起来不算复杂。
玩家控制坦克移动和开炮,敌方坦克不断出现,地图上有墙、钢板、草地、河流和基地。
但真正开始拆需求以后,会发现它很适合练习游戏开发和代码设计。
它至少包含:
- 玩家输入。
- 角色移动。
- 碰撞检测。
- 子弹发射。
- 敌人 AI。
- 地图加载。
- 不同地形。
- 生命值和无敌状态。
- 关卡切换。
- 音效和动画。
- 游戏暂停、胜利和失败。
这些功能彼此有关,但又不能全部写在一个文件里。
如果只是让画面先动起来,代码可能很快就能写出来。
但随着敌人种类、道具和关卡越来越多,真正困难的地方会变成:
怎么让代码还能继续改?这正好给了设计模式一个真实的使用场景。
为什么选择 Phaser.js
Phaser.js 是一个适合浏览器 2D 游戏开发的 JavaScript 游戏框架。
它已经提供了很多基础能力:
- 场景管理。
- 图片和音频资源加载。
- 精灵与动画。
- 键盘和触摸输入。
- 物理碰撞。
- 摄像机。
- 时间事件。
- 粒子效果。
- Tilemap 地图。
如果完全从 Canvas 开始写,我需要先自己处理渲染循环、资源管理、碰撞和输入。
这些底层内容当然值得学习,但这次项目的重点是:
做出一个完整游戏,并在实际业务对象中练习设计模式。所以我更愿意把基础渲染交给 Phaser,把精力放在游戏规则和代码组织上。
先把游戏拆成几个场景
Phaser 里一个很重要的概念是 Scene。
坦克大战可以先拆成这些场景:
BootScene
-> 加载最基础的启动资源
PreloadScene
-> 加载地图、坦克、子弹、音效等资源
MenuScene
-> 开始游戏、选择关卡
GameScene
-> 运行真正的战斗逻辑
ResultScene
-> 显示胜利、失败和得分如果把所有逻辑都放进 GameScene,一开始可能比较省事。
但后面会很快变成:
加载资源也在这里
菜单也在这里
玩家逻辑也在这里
敌人生成也在这里
结束画面也在这里拆分 Scene 以后,每个场景只关心当前阶段。
这本身也像一种状态管理:
菜单状态
-> 游戏状态
-> 结算状态游戏主循环是怎么工作的
Phaser 场景里经常会看到几个生命周期方法:
preload()
create()
update()可以粗略理解成:
preload
-> 加载资源
create
-> 创建地图、坦克、碰撞关系和输入监听
update
-> 每一帧更新游戏状态例如:
export class GameScene extends Phaser.Scene {
preload() {
this.load.image("player-tank", "assets/player-tank.png");
}
create() {
this.player = this.physics.add.sprite(
200,
500,
"player-tank",
);
}
update() {
this.playerController.update();
}
}update() 会频繁执行。
所以不能把所有业务都没有边界地塞进去。
更合适的做法是让场景负责组织对象,再把具体行为交给对应模块。
坦克应该是一个游戏对象
玩家坦克和敌方坦克有很多共同能力:
- 位置。
- 方向。
- 移动速度。
- 子弹速度。
- 发射间隔。
- 生命值。
- 是否存活。
- 移动。
- 开火。
- 受到攻击。
- 销毁。
可以先抽象一个基础坦克:
type TankOptions = {
speed: number;
bulletSpeed: number;
fireCooldown: number;
health: number;
};
class Tank {
protected direction: Direction = "up";
protected canFire = true;
constructor(
protected readonly scene: Phaser.Scene,
protected readonly sprite: Phaser.Physics.Arcade.Sprite,
protected readonly options: TankOptions,
) {}
move(direction: Direction) {
this.direction = direction;
// 根据方向设置速度和贴图
}
fire() {
// 创建子弹
}
takeDamage(value: number) {
// 扣除生命值并判断是否销毁
}
}玩家和敌人都可以复用这些基础能力。
区别主要在于:
谁决定它往哪里走,什么时候开火。这就可以继续引出策略模式。
用策略模式区分玩家控制和敌人 AI
玩家坦克由键盘控制。
敌方坦克由 AI 控制。
如果直接把两种逻辑都写进 Tank:
if (this.isPlayer) {
// 读取键盘
} else {
// 随机移动
}以后增加更多敌人类型,就会继续出现:
if (this.enemyType === "normal") {
// ...
} else if (this.enemyType === "fast") {
// ...
} else if (this.enemyType === "smart") {
// ...
}坦克类会越来越了解所有控制细节。
更适合的方式是把“怎么控制坦克”抽成策略:
interface TankController {
update(tank: Tank, delta: number): void;
}玩家控制器:
class PlayerController implements TankController {
update(tank: Tank) {
// 根据键盘输入控制移动和开火
}
}普通敌人控制器:
class RandomEnemyController implements TankController {
update(tank: Tank, delta: number) {
// 随机改变方向,随机开火
}
}更聪明的敌人:
class ChaseBaseController implements TankController {
update(tank: Tank, delta: number) {
// 尝试向基地或玩家方向移动
}
}Tank 不需要知道控制者是谁。
它只负责执行:
移动
转向
开火
受伤控制策略负责决定:
什么时候执行这些动作。这时我才真正理解策略模式的价值。
它不是为了多写一个接口,而是为了让行为可以替换。
用状态模式管理坦克状态
坦克并不总是处于普通状态。
玩家出生时可能有短暂无敌。
吃到道具后可能进入强化状态。
被击毁后会进入爆炸和等待重生状态。
可以先列出:
Spawning
Normal
Invincible
Destroyed
Respawning如果全部用布尔值表示:
isInvincible
isDestroyed
isRespawning
canMove
canFire很容易出现互相矛盾的组合:
已经 destroyed,但 canMove 还是 true。
正在 respawning,但又能发射子弹。所以可以把坦克当前状态收拢成一个明确值:
type TankState =
| "spawning"
| "normal"
| "invincible"
| "destroyed"
| "respawning";简单项目可以先用枚举和条件判断。
状态行为变复杂以后,再拆成真正的状态对象:
interface TankState {
enter(tank: Tank): void;
update(tank: Tank, delta: number): void;
takeDamage(tank: Tank, damage: number): void;
exit(tank: Tank): void;
}例如无敌状态收到攻击时,不扣生命值:
class InvincibleState implements TankState {
takeDamage() {
// 无敌状态忽略伤害
}
}普通状态则正常处理:
class NormalState implements TankState {
takeDamage(tank: Tank, damage: number) {
tank.reduceHealth(damage);
}
}状态模式适合解决的是:
同一个对象在不同状态下,对同一事件有不同反应。坦克正好就是这样的对象。
用工厂模式创建不同坦克
游戏里会有多种坦克:
- 普通敌人。
- 快速敌人。
- 重甲敌人。
- 火力更强的敌人。
- 玩家一级、二级、三级坦克。
如果在场景里到处手动创建:
const sprite = this.physics.add.sprite(x, y, "enemy-fast");
const tank = new Tank(this, sprite, {
speed: 140,
bulletSpeed: 260,
fireCooldown: 900,
health: 1,
});每个调用方都要知道贴图、速度、生命值和控制器。
这些配置容易散落。
可以用工厂统一创建:
class TankFactory {
constructor(private readonly scene: Phaser.Scene) {}
createEnemy(type: EnemyType, x: number, y: number) {
const config = enemyConfigs[type];
const sprite = this.scene.physics.add.sprite(
x,
y,
config.texture,
);
return new Tank(
this.scene,
sprite,
config.options,
config.createController(),
);
}
}调用方只需要表达:
tankFactory.createEnemy("fast", x, y);工厂隐藏了创建细节。
以后修改快速坦克的速度,或者给它换一个 AI 策略,只需要改统一配置。
用对象池复用子弹
坦克大战里最频繁创建和销毁的对象之一就是子弹。
如果每次开火都:
创建新子弹
-> 飞行
-> 碰撞
-> 销毁对象长时间运行会产生很多短命对象。
浏览器需要不断分配和回收内存,可能带来卡顿。
对象池的思路是:
子弹失效后不要真正删除,
先放回池里,下次开火再复用。Phaser 的 Group 本身就适合管理这类对象:
const bullets = this.physics.add.group({
classType: Bullet,
maxSize: 100,
runChildUpdate: true,
});开火时从池里取一个:
const bullet = bullets.get(
tank.x,
tank.y,
) as Bullet | null;
bullet?.fire(direction, speed);子弹碰墙或飞出地图后,不一定销毁实例,而是设为不活动:
bullet.disableBody(true, true);下次再通过 get() 复用。
这让我理解到对象池适合的场景:
对象会被频繁创建和回收,
而且结构基本相同。子弹、爆炸动画和粒子都很适合。
用观察者思想降低模块之间的耦合
一辆敌方坦克被击毁以后,可能会发生很多事情:
- 增加得分。
- 播放爆炸音效。
- 更新剩余敌人数。
- 判断关卡是否结束。
- 有概率掉落道具。
如果 Tank 在销毁时直接调用所有模块:
scoreManager.addScore();
audioManager.playExplosion();
levelManager.enemyDestroyed();
itemManager.tryDropItem();坦克就会依赖很多系统。
更合适的方式是发出一个事件:
events.emit("tank-destroyed", {
tank,
source,
});其他系统各自监听:
events.on("tank-destroyed", updateScore);
events.on("tank-destroyed", checkLevelProgress);
events.on("tank-destroyed", playExplosionSound);这样 Tank 只负责告诉外界:
我被击毁了。至于外界要做什么,由各个系统自己决定。
这就是观察者模式或事件驱动思想在游戏里的实际用途。
地图元素不能只看成图片
坦克大战的地图里有很多不同地形:
| 地形 | 行为 |
|---|---|
| 砖墙 | 坦克不能通过,可以被子弹破坏 |
| 钢墙 | 坦克不能通过,普通子弹无法破坏 |
| 草地 | 坦克可以通过,草会显示在坦克上层 |
| 河流 | 坦克不能通过,子弹可以飞过 |
| 冰面 | 坦克移动时可能继续滑行 |
| 基地 | 被击中后游戏失败 |
如果所有地图块只是一张图片,碰撞和规则会很难管理。
更合理的方式是让地图数据表达类型:
0 = 空地
1 = 砖墙
2 = 钢墙
3 = 草地
4 = 河流
5 = 基地加载地图时,再根据类型创建对应对象。
不同地形可以实现统一接口:
interface MapTileBehavior {
blocksTank: boolean;
blocksBullet: boolean;
takeBulletHit?(bullet: Bullet): void;
}这样碰撞系统不需要到处判断具体贴图名称。
它只关心:
这个地图块会不会挡住坦克?
会不会挡住子弹?
被击中以后要做什么?敌人 AI 不需要一开始就很聪明
我一开始容易把“敌人 AI”想得很复杂。
好像一定要有寻路算法,才能算 AI。
但经典坦克大战里的敌人行为其实可以从简单规则开始:
保持当前方向移动
-> 碰到障碍后换方向
-> 每隔一段时间随机转向
-> 满足冷却时随机开火一个简单控制器就能让敌人动起来:
class RandomEnemyController implements TankController {
private directionTimer = 0;
update(tank: Tank, delta: number) {
this.directionTimer -= delta;
if (this.directionTimer <= 0 || tank.isBlocked()) {
tank.move(randomDirection());
this.directionTimer = randomBetween(500, 1800);
}
if (tank.canFire() && Math.random() < 0.01) {
tank.fire();
}
}
}等基础玩法稳定以后,再增加:
- 优先朝玩家方向移动。
- 靠近基地时提高攻击意愿。
- 根据路口选择方向。
- 使用网格寻路。
- 不同敌人使用不同策略。
游戏开发很适合渐进增强。
先让它好玩,再让它聪明。
关卡管理应该独立出来
一个关卡不只是地图。
它还包括:
- 敌人总数。
- 敌人类型和比例。
- 同屏最多敌人数。
- 出生间隔。
- 地图数据。
- 玩家初始生命。
- 胜利条件。
可以把关卡配置写成数据:
const level1 = {
map: "level-1",
maxEnemiesOnScreen: 4,
enemies: [
{ type: "normal", count: 12 },
{ type: "fast", count: 6 },
{ type: "heavy", count: 2 },
],
};LevelManager 负责:
读取关卡配置
-> 按节奏生成敌人
-> 统计剩余敌人
-> 判断胜利或失败GameScene 不需要自己维护所有关卡细节。
以后新增关卡时,尽量只增加地图和配置,而不是复制一整份场景代码。
这也是我想通过这个项目练习的能力:
让变化更多发生在数据中,而不是条件判断中。游戏全局状态要不要做成单例
游戏中会有一些跨场景数据:
- 当前分数。
- 玩家剩余生命。
- 当前关卡。
- 最高分。
- 音量设置。
很容易想到写一个全局单例:
GameManager.getInstance()单例确实能让任何地方都方便访问。
但如果所有模块都依赖它,也会变成一个什么都管的全局对象。
Phaser 本身提供了 Registry 等共享数据能力,也可以在切换 Scene 时显式传递数据。
所以我现在不会为了练习模式而强行写单例。
我会先问:
这份数据是否真的全局唯一?
哪些对象需要读取它?
能不能通过构造参数或场景数据传递?设计模式不是收集得越多越好。
只有解决了真实问题,它才有意义。
先做一个最小可玩版本
这个项目如果一开始就把所有经典功能都列进去,很容易做不完。
我会先完成一个最小可玩版本:
- 加载一张固定地图。
- 玩家坦克可以移动。
- 玩家可以发射子弹。
- 子弹能击毁砖墙和敌人。
- 敌人能随机移动和开火。
- 基地被击中后失败。
- 敌人全部消灭后胜利。
- 可以重新开始游戏。
这一版只要能玩,就已经建立了完整闭环:
开始
-> 战斗
-> 胜利或失败
-> 重新开始然后再逐步增加:
- 多种敌人。
- 坦克升级。
- 道具。
- 多关卡。
- 双人模式。
- 手柄或移动端控制。
- 音效和爆炸效果。
- 地图编辑器。
先完成,再丰富。
设计模式不是项目目标
虽然我想通过这个项目学习设计模式,但设计模式本身不应该成为游戏的主角。
如果一个简单功能只需要几行代码,就没有必要为了“使用模式”拆出很多类。
例如只有一种坦克时,工厂可能没有价值。
只有一个普通状态时,完整状态模式也可能太重。
更自然的学习过程应该是:
先写出功能
-> 发现条件越来越多
-> 看出变化的方向
-> 再选择合适的模式整理这样才能真正理解模式解决了什么。
否则很容易变成:
我学过一个模式,
所以我要在项目里找个地方塞进去。我最后想通的地方
我想做坦克大战,一半是为了学习,一半是因为喜欢。
学习这部分包括:
Phaser 的 Scene 和游戏循环
精灵、动画和物理碰撞
地图和关卡数据
玩家输入和敌人 AI
设计模式在真实项目中的使用儿时梦想这部分就更简单:
小时候玩别人做的坦克大战,
现在想亲手做一个属于自己的版本。这个项目也让我觉得,学习设计模式不一定非要从很抽象的业务系统开始。
游戏里的对象和变化非常直观:
不同 AI -> 策略模式
不同坦克状态 -> 状态模式
创建不同敌人 -> 工厂模式
复用大量子弹 -> 对象池模式
坦克销毁通知多个系统 -> 观察者模式当这些问题真的出现在代码里时,设计模式就不再只是书上的类图。
它会变成一个很具体的答案:
原来这里这样拆,后面增加新玩法会轻松很多。最后如果这个游戏真的能做出来,能移动、能开炮、能守住基地,也能让我自己玩上几局,那这个项目就已经很有意义了。
它既是一份设计模式练习,也是我补给小时候的一份小礼物。