用 vue3 + phaser 实现经典小游戏:飞机大战

前端Q

共 40273字,需浏览 81分钟

 ·

2024-06-24 09:15

本文字数:7539

预计阅读时间:30分钟

01

前言

说起小游戏,最经典的莫过于飞机大战了,相信很多同学都玩过。今天我们也来试试开发个有趣的小游戏吧!我们将从零开始,看看怎样一步步实现一个H5版的飞机大战!

首先我们定好目标,要做一个怎样的飞机大战,以及去哪整游戏素材?

刚好微信小程序官方提供了一个飞机大战小游戏的模板,打开【微信开发者工具】,选择【新建项目】-【小游戏】,选择飞机大战的模板,创建后就是一个小程序版飞机大战。


运行小程序之后可以看到下面的效果:


从运行效果上看,这个飞机大战已经比较完整,包含了以下内容:

1.地图滚动,播放背景音效;

2.玩家控制飞机移动;

3.飞机持续发射子弹,播放发射音效;

4.随机出现向下移动的敌军;

5.子弹碰撞敌军时,播放爆炸动画和爆炸音效,同时子弹和敌军都销毁,并增加1个得分;

6.飞机碰撞敌军时,游戏结束,弹出结束面板。

接下来我们以这个效果为参考,并拷贝这个项目中的图片和音效素材,从头做一个H5版飞机大战吧!

02

选择游戏框架

你可能会好奇,既然微信小程序官方已经生成好了完整代码,直接参考那套代码不就好吗?

这里就涉及到游戏框架的问题,小程序那套代码是没有使用游戏框架的,所以很多基础的地方都需要自己实现,比如说子弹移动,子弹与敌军碰撞检测等。

我们以碰撞为例,在小程序项目中是这样实现的:

1.先定义好碰撞检测的方法isCollideWith(),通过两个物体的坐标和宽高进行碰撞检测计算
isCollideWith(sp) {
    let spX = sp.x + sp.width / 2;
    let spY = sp.y + sp.height / 2;

    if (!this.visible || !sp.visible) return false;

    return !!(spX >= this.x && spX <= this.x + this.width && spY >= this.y && spY <= this.y + this.height);
},
2.然后在每一帧的回调中,遍历所有子弹和所有敌军,依次调用isCollideWith()进行碰撞检测
update() {
    bullets.forEach((bullet) => {
        for (let i = 0, il = enemys.length; i < il; i++) {
            if (enemys[i].isCollideWith(bullet)) {
                // Do Something
            }
        }
    });
}

3.而通过游戏框架,可能只需要一行代码。我们以Phaser为例

this.physics.add.overlap(bullets, enemys, () => { 
 // Do Something
}, nullthis);

上面代码的含义是:bullets(子弹组)和enemys(敌军组)发生overlap(重叠)则触发回调。

从上面的例子可以看出,选择一个游戏框架来开发游戏,可以大大降低开发难度,减少代码量。

当开发一个专业的游戏时,我们一般会选择专门的游戏引擎,比如CocosEgretLayaBoxUnity等。但是如果只是做一个简单的H5小游戏,嵌入我们的前端项目中,使用Phaser就可以了。

引用Phaser官网上的介绍:

Phaser是一个快速、免费且有趣的开源HTML5游戏框架,可在桌面和移动Web浏览器上提供WebGLCanvas渲染。可以使用第三方工具将游戏编译为iOSAndroid和本机应用程序。您可以使用JavaScriptTypeScript进行开发。】

同时Phaser在社区也非常受欢迎,Github上收获35.5kStarNpm上最近一周下载量19k

因此我们采用Phaser作为游戏框架。接下来,开始正式我们的飞机大战之旅啦!

03

准备工作

3.1 创建项目

项目采用的技术栈是:Phaser + Vue3 + TypeScript + Vite

当然对于这个游戏来说,核心的框架是Phaser,其他都是可选的。只使用Phaser + Html也是可以开发的,只是我们希望采用目前更主流的开发方式。

进行工作目录,直接使用vue手脚架创建名为plane-war的项目。

npm create vue

项目创建完成,安装依赖,检查是否运行正常。

cd plane-war
npm install
npm run dev

接下来再安phaser

npm install phaser

3.2 整理素材

接下来我们重新整理下项目,清除不需要的文件,并把游戏素材拷贝到assets目录,最终目录结构如下:

plane-war
├── src
│   ├── assets
│   │   ├── audio
│   │   │   ├── bgm.mp3
│   │   │   ├── boom.mp3
│   │   │   └── bullet.mp3
│   │   ├── images
│   │   │   ├── background.jpg
│   │   │   ├── boom.png
│   │   │   ├── bullet.png
│   │   │   ├── enemy.png
│   │   │   ├── player.png
│   │   │   └── sprites.png
│   │   └── json
│   │       └── sprites.json
│   ├── App.vue
│   └── main.ts

素材处理1

原本游戏素材中,爆炸动画是由19张独立图片组成,在Phaser中需要合成一张雪碧图,可以通过雪碧图合成工具合成,命名为boom.png,效果如下


素材处理2

原本游戏素材中,结束面板的图片来源一张叫Common.png的雪碧图,我们重命名为sprites.png。并且我们还需要为这个雪碧图制作一份说明,起名为sprites.json。通过它来指定我们需要用到目标图片及其在雪碧图中的位置。

这里我们指定2个目标图片,result是结束面板,button是按钮。

{
    "textures": [
        {
            "image""sprites.png",
            "size": {
                "w"512,
                "h"512
            },
            "frames": [
                {
                    "filename""result",
                    "frame": { "x"0"y"0"w"119"h"108 }
                },
                {
                    "filename""button",
                    "frame": { "x"120"y"6"w"39"h"24 }
                }
            ]
        }
    ]
}

3.3 初步运行

我们重构App.vue,创建了一个游戏对象game,指定父容器#container,创建成功后则会在父容器中生成一个canvas 元素,游戏的所有内容都过这个canvas进行呈现和交互。

<template>
<div id="container"></div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { Game, AUTO, Scale } from "phaser";

let game: Game;
onMounted(() => {
game = new Game({
parent: "container",
type: AUTO,
width: 375,
// 高度依据屏幕宽高比计算
height: (window.innerHeight / window.innerWidth) * 375,
scale: {
// 自动缩放至宽或高与父容器一致,类似css中的contain
// 由于宽高比与屏幕宽高比一致,最终就是刚好全屏效果
mode: Scale.FIT,
},
physics: {
default: "arcade",
arcade: {
debug: false,
},
},
});
});

onUnmounted(() => {
game.destroy(true);
});
</script>
<style>
body {
margin: 0;
}
#app {
height: 100%;
}
</style>

通过npm run dev再次运行项目,我们把浏览器展示区切换:为移动设备展示,此时可以看到canvas,并且其宽高应该正好全屏。

3.4 场景设计

可以看到现在画布还是全黑的,这是因创建game对象时还没有接入任何场景。在Phaser中,一个游戏可以包含多个场景,而具体的游戏画面和交互都是在各个场景中实现的。

接下来我们设计3个场景:

  • 预载场景 :加载整个游戏资源,创建动画,展示等待开始画面。

  • 主场景:游戏的主要画面和交互。

  • 结束场景:展示游戏结束画面。


在项目中我们新增3个自定义场景类:

plane-war
├── src
│   ├── game
│   │   ├── Preloader.ts
│   │   ├── Main.ts
│   │   └── End.ts

自定义场景类继承Scene类,包含了以下基本结构:

import { Scene } from "phaser";

export class Preloader extends Scene {
    constructor() {
        // 场景命名,这个命名在后面场景切换使用
        super("Preloader");
    }
    // 加载游戏资源
    preload() {}
    // preload中的资源全部加载完成后执行
    create() {}
    // 每一帧的回调
    update() {}
}

按上面的基本结构分别实现好3个场景类,并导入到game对象的创建中:

import { onMounted, onUnmounted } from "vue";
import { Game, AUTO, Scale } from "phaser";
import { Preloader } from "./game/Preloader";
import { Main } from "./game/Main";
import { End } from "./game/End";

let game: Game;
onMounted(() => {
    game = new Game({
        // 其他参数省略...
        // 定义场景,默认初始化数组中首个场景,即 Preloader
        scene: [Preloader, Main, End],
    });
});

04

预载场景

准备工作完成后,接下来我们开始真正开发第一个游戏场景:预载场景,对应Preloader.ts文件。

4.1 加载游戏资源

preload方法中加载整个游戏所需的资源。

import { Scene } from "phaser";
import backgroundImg from "../assets/images/background.jpg";
import enemyImg from "../assets/images/enemy.png";
import playerImg from "../assets/images/player.png";
import bulletImg from "../assets/images/bullet.png";
import boomImg from "../assets/images/boom.png";
import bgmAudio from "../assets/audio/bgm.mp3";
import boomAudio from "../assets/audio/boom.mp3";
import bulletAudio from "../assets/audio/bullet.mp3";

export class Preloader extends Scene {
    constructor() {
        super("Preloader");
    }
    preload() {
        // 加载图片
        this.load.image("background", backgroundImg);
        this.load.image("enemy", enemyImg);
        this.load.image("player", playerImg);
        this.load.image("bullet", bulletImg);
        this.load.spritesheet("boom", boomImg, {
            frameWidth: 64,
            frameHeight: 48,
        });
        // 加载音频
        this.load.audio("bgm", bgmAudio);
        this.load.audio("boom", boomAudio);
        this.load.audio("bullet", bulletAudio);
    }
    create() {}
}

4.2 添加元素

接下来我们在create()方法中去添加背景,背景音乐,标题,开始按钮,后续使用的动画,并且为开始按钮绑定了点击事件。

const { width, height } = this.cameras.main;
// 背景
this.add.tileSprite(00, width, height, "background").setOrigin(00);
// 背景音乐
this.sound.play("bgm");

// 标题
this.add
    .text(width / 2, height / 4"飞机大战", {
        fontFamily: "Arial",
        fontSize: 60,
        color: "#e3f2ed",
        stroke: "#203c5b",
        strokeThickness: 6,
    })
    .setOrigin(0.5);

// 开始按钮
let button = this.add
    .image(width / 2, (height / 4) * 3"sprites""button")
    .setScale(32)
    .setInteractive()
    .on("pointerdown"() => {
        // 点击事件:关闭当前场景,打开Main场景
        this.scene.start("Main");
    });

// 按钮文案
this.add
    .text(button.x, button.y, "开始游戏", {
        fontFamily: "Arial",
        fontSize: 20,
        color: "#e3f2ed",
    })
    .setOrigin(0.5);

// 创建动画,命名为 boom,后面使用
this.anims.create({
    key: "boom",
    frames: this.anims.generateFrameNumbers("boom", { start: 0, end: 18 }),
    repeat: 0,
});

运行效果如下:


有个细节可以留意下,就是这个背景是怎样铺满整个屏幕的?

上面的代码是this.add.tileSprite()创建了一个瓦片精灵,素材中的背景图就像一个一个瓦片一样铺满屏幕,所以就要求素材中的背景图是一张首尾能无缝相连的图片,这样就能无限平铺。主场景中的背景移动也是基于此。

05

主场景

5.1 梳理场景元素

在预载场景中点击“开始游戏”按钮,可以看到画面又变成黑色,此时预载场景被关闭,游戏打开主场景。

在主场景中,涉及到的场景元素一共有:背景、玩家、子弹、敌军、爆炸,我们可以先尝试把它们都渲染出来,并加一些简单的动作,比如移动背景,子弹和敌军添加垂直方向速度,播放爆炸动画等。

import { Scene, GameObjects, type Types } from "phaser";

// 场景元素
let background: GameObjects.TileSprite;
let enemy: Types.Physics.Arcade.SpriteWithDynamicBody;
let player: Types.Physics.Arcade.SpriteWithDynamicBody;
let bullet: Types.Physics.Arcade.SpriteWithDynamicBody;
let boom: GameObjects.Sprite;

export class Main extends Scene {
    constructor() {
        super("Main");
    }
    create() {
        const { width, height } = this.cameras.main;
        // 背景
        background = this.add.tileSprite(00, width, height, "background").setOrigin(00);
        // 玩家
        this.physics.add.sprite(100600"player").setScale(0.5);
        // 子弹
        this.physics.add.sprite(100500"bullet").setScale(0.25).setVelocityY(-100);
        // 敌军
        this.physics.add.sprite(100100"enemy").setScale(0.5).setVelocityY(100);
        // 爆炸
        this.add.sprite(200100"boom").play("boom");
    }
    update() {
        // 设置背景瓦片不断移动
        background.tilePositionY -= 1;
    }
}

效果如下:


看起来似乎已经有了雏形,但是这里还需要优化一下代码设计。我们不希望场景中的所有元素创建,交互都糅合Main.ts这个文件中,这样就显得有点臃肿,不好维护。

我们再设计出:玩家类、子弹类、敌军类、炸弹类,让每个元素它们自身的事件和行为都各自去实现,而主场景只负责创建它们,并且处理它们之间的交互事件,不需要去关心它们内部的实现。

虽然这个游戏的整体代码也不多,但是通过这个设计思想,可以让我们的代码设计更加合理,当以后开发其他更复杂的小游戏时也可以套用这种模式。


5.2 玩家类

回顾上面的创建玩家的代码:

this.physics.add.sprite(100600"player").setScale(0.5);

原本的代码是直接创建了一个“物理精灵对象“,我们现在改成新建一个Player类,这个类继承Physics.Arcade.Sprite,然后在主场景中通过new Player()也同样生成"物理精灵对象"。相当于Player类拓展了原本Physics.Arcade.Sprite,增加了对自身的一些事件处理和行为封装。后续的子弹类,敌军类等也是同样的方式。

Player主要拓展了"长按移动事件",具体实现如下:

import { Physics, Scene } from "phaser";

export class Player extends Physics.Arcade.Sprite {
    isDown: boolean = false;
    downX: number;
    downY: number;

    constructor(scene: Scene) {
        // 创建对象
        let { width, height } = scene.cameras.main;
        super(scene, width / 2, height - 80"player");
        scene.add.existing(this);
        scene.physics.add.existing(this);
        // 设置属性
        this.setInteractive();
        this.setScale(0.5);
        this.setCollideWorldBounds(true);
        // 注册事件
        this.addEvent();
    }
    addEvent() {
        // 手指按下我方飞机
        this.on("pointerdown"() => {
            this.isDown = true;
            // 记录按下时的飞机坐标
            this.downX = this.x;
            this.downY = this.y;
        });
        // 手指抬起
        this.scene.input.on("pointerup"() => {
            this.isDown = false;
        });
        // 手指移动
        this.scene.input.on("pointermove"(pointer) => {
            if (this.isDown) {
                this.x = this.downX + pointer.x - pointer.downX;
                this.y = this.downY + pointer.y - pointer.downY;
            }
        });
    }
}

5.3 子弹类

Bullet类主要拓展了"发射子弹"和"子弹出界事件",具体实现如下:

import { Physics, Scene } from "phaser";

export class Bullet extends Physics.Arcade.Sprite {
    constructor(scene: Scene, x: number, y: number, texture: string) {
        // 创建对象
        super(scene, x, y, texture);
        scene.add.existing(this);
        scene.physics.add.existing(this);
        // 设置属性
        this.setScale(0.25);
    }
    // 发射子弹
    fire(x: number, y: number) {
        this.enableBody(true, x, y, truetrue);
        this.setVelocityY(-300);
        this.scene.sound.play("bullet");
    }
    // 每一帧更新回调
    preUpdate(time: number, delta: number) {
        super.preUpdate(time, delta);
        // 子弹出界事件(子弹走到顶部超出屏幕)
        if (this.y <= -14) {
            this.disableBody(truetrue);
        }
    }
}

5.4 敌军类

Enemy类主要拓展了"生成敌军"和"敌军出界事件",具体实现如下:

import { Physics, Math, Scene } from "phaser";

export class Enemy extends Physics.Arcade.Sprite {
    constructor(scene: Scene, x: number, y: number, texture: string) {
        // 创建对象
        super(scene, x, y, texture);
        scene.add.existing(this);
        scene.physics.add.existing(this);
        // 设置属性
        this.setScale(0.5);
    }
    // 生成敌军
    born() {
        let x = Math.Between(30345);
        let y = Math.Between(-20-40);
        this.enableBody(true, x, y, truetrue);
        this.setVelocityY(Math.Between(150300));
    }
    // 每一帧更新回调
    preUpdate(time: number, delta: number) {
        super.preUpdate(time, delta);
        let { height } = this.scene.cameras.main;
        // 敌军出界事件(敌军走到底部超出屏幕)
        if (this.y >= height + 20) {
            this.disableBody(truetrue)
        }
    }
}

5.5 爆炸类

Boom 类主要拓展了"显示爆炸"和“隐藏爆炸”,具体实现如下

import { GameObjects, Scene } from "phaser";

export class Boom extends GameObjects.Sprite {
    constructor(scene: Scene, x: number, y: number, texture: string) {
        super(scene, x, y, texture);
        // 爆炸动画播放结束事件
        this.on("animationcomplete-boom"this.hide, this);
    }
    // 显示爆炸
    show(x: number, y: number) {
        this.x = x;
        this.y = y;
        this.setActive(true);
        this.setVisible(true);
        this.play("boom");
        this.scene.sound.play("boom");
    }
    // 隐藏爆炸
    hide() {
        this.setActive(false);
        this.setVisible(false);
    }
}

5.6 重构主场景

上面我们实现了玩家类,子弹类,敌军类,爆炸类,接下来我们在主场景中重新创建这些元素,并加入分数文本元素。

import { Scene, Physics, GameObjects } from "phaser";
import { Player } from "./Player";
import { Bullet } from "./Bullet";
import { Enemy } from "./Enemy";
import { Boom } from "./Boom";

// 场景元素
let background: GameObjects.TileSprite;
let player: Player;
let enemys: Physics.Arcade.Group;
let bullets: Physics.Arcade.Group;
let booms: GameObjects.Group;
let scoreText: GameObjects.Text;

// 场景数据
let score: number;

export class Main extends Scene {
    constructor() {
        super("Main");
    }
    create() {
        let { width, height } = this.cameras.main;
        // 创建背景
        background = this.add.tileSprite(00, width, height, "background").setOrigin(00);
        // 创建玩家
        player = new Player(this);

        // 创建敌军
        enemys = this.physics.add.group({
            frameQuantity: 30,
            key: "enemy",
            enable: false,
            active: false,
            visible: false,
            classType: Enemy,
        });

        // 创建子弹
        bullets = this.physics.add.group({
            frameQuantity: 15,
            key: "bullet",
            enable: false,
            active: false,
            visible: false,
            classType: Bullet,
        });

        // 创建爆炸
        booms = this.add.group({
            frameQuantity: 30,
            key: "boom",
            active: false,
            visible: false,
            classType: Boom,
        });

        // 分数
        score = 0;
        scoreText = this.add.text(1010"0", {
            fontFamily: "Arial",
            fontSize: 20,
        });

        // 注册事件
        this.addEvent();
    },
    update() {
        // 背景移动
        background.tilePositionY -= 1;
    }
}

需要注意的是,这里的子弹,敌军,爆炸都是按组创建的,这样我们可以直接监听子弹组和敌军组的碰撞,而不需要监听每一个子弹和每一个敌军的碰撞。另一方面,创建组时已经把组内的元素全部创建好了,比如创建敌军时指定frameQuantity: 30,表示直接创建30个敌军元素,后续敌军不断出现和销毁其实就是这30个元素在循环使用而已,而并非源源不断地创建新元素,以此减少性能损耗。

最后再把注册事件实现,主场景就全部完成了。

// 注册事件
addEvent() {
    // 定时器
    this.time.addEvent({
        delay: 400,
        callback: () => {
            // 生成2个敌军
            for (let i = 0; i < 2; i++) {
                enemys.getFirstDead()?.born();
            }
            // 发射1颗子弹
            bullets.getFirstDead()?.fire(player.x, player.y - 32);
        },
        callbackScope: this,
        repeat: -1,
    });

    // 子弹和敌军碰撞
    this.physics.add.overlap(bullets, enemys, this.hit, nullthis);
    // 玩家和敌军碰撞
    this.physics.add.overlap(player, enemys, this.gameOver, nullthis);
}
// 子弹击中敌军
hit(bullet, enemy) {
    // 子弹和敌军隐藏
    enemy.disableBody(truetrue);
    bullet.disableBody(truetrue);
    // 显示爆炸
    booms.getFirstDead()?.show(enemy.x, enemy.y);
    // 分数增加
    scoreText.text = String(++score);
}
// 游戏结束
gameOver() {
    // 暂停当前场景,并没有销毁
    this.sys.pause();
    // 保存分数
    this.registry.set("score", score);
    // 打开结束场景
    this.game.scene.start("End");
}

06

结束场景

最后再实现一下结束场景,很简单,主要包含结束面板,得分,重新开始按钮。

import { Scene } from "phaser";

export class End extends Scene {
    constructor() {
        super("End");
    }
    create() {
        let { width, height } = this.cameras.main;
        // 结束面板
        this.add.image(width / 2, height / 2"sprites""result").setScale(2.5);

        // 标题
        this.add
            .text(width / 2, height / 2 - 85"游戏结束", {
                fontFamily: "Arial",
                fontSize: 24,
            })
            .setOrigin(0.5);

        // 当前得分
        let score = this.registry.get("score");
        this.add
            .text(width / 2, height / 2 - 10`当前得分:${score}`, {
                fontFamily: "Arial",
                fontSize: 20,
            })
            .setOrigin(0.5);

        // 重新开始按钮
        let button = this.add
            .image(width / 2, height / 2 + 50"sprites""button")
            .setScale(32)
            .setInteractive()
            .on("pointerdown"() => {
                // 点击事件:关闭当前场景,打开Main场景
                this.scene.start("Main");
            });
        // 按钮文案
        this.add
            .text(button.x, button.y, "重新开始", {
                fontFamily: "Arial",
                fontSize: 20,
            })
            .setOrigin(0.5);
    }
}

07

优化

经过上面的代码,整个游戏已经基本完成。不过在测试的时候,感觉玩家和敌军还存在一定距离就触发了碰撞事件。在创建game时,我们可以打开debug模式,这样就可以看到Phaser为我们提供的一些调试信息

game = new Game({
    physics: {
        default"arcade",
        arcade: {
            debug: true,
        },
    },
    // ...
});

测试一下碰撞:


可以看到两个元素的边框确实发生碰撞了,但是这并不符合我们的要求,我们希望两个飞机看起来是真的挨到一起才触发碰撞事件。所以我们可以再优化一下,飞机本身不变,但是边框缩小。

Player.ts的构造函数中追加如下:

export class Player extends Physics.Arcade.Sprite {
    constructor() {
        // ...
        // 追加下面一行
        this.body.setSize(120120);
    }
}

Enemy.ts的构造函数中追加如下:

export class Enemy extends Physics.Arcade.Sprite {
    constructor() {
        // ...
        // 追加下面一行
        this.body.setSize(10060);
    }
}

最终可以看到边框已经被缩小,效果如下:


08

结语

至此,飞机大战全部开发完成。

回顾一下开发过程,我们先搭建项目,创建游戏对象,接下来又设计了:预载场景、主场景、结束场景,并且为了减少主场景的复杂度,我们以场景元素的维度,将涉及到的场景元素进行封装,形成:玩家类、子弹类、敌军类、爆炸类,让这些场景元素各自实现自身的事件和行为。

Phaser中的场景元素又可以分为普通元素和物理元素,物理元素是来自Physics,其中玩家类,子弹类,敌军类都是物理元素,物理元素具有物理属性,比如重力,速度,加速度,弹性,碰撞等。

在本文代码中涉及到了很多PhaserAPI,介于篇幅没有一一解释,但是很多通过字面意思也可以理解,比如说disableBody表示禁用元素,setVelocityY表示设置轴方向速度。并且我们也可以通过编译器的代码提示功能去了解这些方法的说明和参数含义:


最后,本文的所有代码都已上传gitee,有兴趣的同学可以拉取代码看下。

演示效果:https://yuhuo.online/plane-war/(点击"阅读原文"访问链接)

源码地址:https://gitee.com/yuhuo520/plane-war

往期推荐


来自38岁大厂程序员的忠告!
一种更好的前端组件结构:组件树
面试官:如何防止接口重复请求?我给出了三个方案!

最后


  • 欢迎加我微信,拉你进技术群,长期交流学习...

  • 欢迎关注「前端Q」,认真学前端,做个专业的技术人...

点个在看支持我吧

浏览 39
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报