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

cb8a11e8bd3bbef243d0797ea0bbb72d.jpeg

a658413058f122948eb0676cdd217980.gif

本文字数:7539

预计阅读时间:30分钟

01

前言

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

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

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

1704ad87855556aa4882da88d03193a5.png

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

115a965de65c1fe3611d37ef8d57dca3.gif

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

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
}, null, this);

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

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

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

引用Phaser官网上的介绍:

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

同时Phaser在社区也非常受欢迎,Github上收获35.5k的Star,Npm上最近一周下载量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,效果如下:

196d9eb820160c3513808605a3afae52.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,并且其宽高应该正好全屏。

cb48c9979495fbddb6af06ad3224bcff.png

3.4 场景设计

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

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

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

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

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

2ac74f5fad2664e4c2f773412f80d92d.jpeg

在项目中我们新增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(0, 0, width, height, "background").setOrigin(0, 0);
// 背景音乐
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(3, 2)
    .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,
});

运行效果如下:

733389cc10aae37b13d15e0ad5663449.png

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

上面的代码是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(0, 0, width, height, "background").setOrigin(0, 0);
        // 玩家
        this.physics.add.sprite(100, 600, "player").setScale(0.5);
        // 子弹
        this.physics.add.sprite(100, 500, "bullet").setScale(0.25).setVelocityY(-100);
        // 敌军
        this.physics.add.sprite(100, 100, "enemy").setScale(0.5).setVelocityY(100);
        // 爆炸
        this.add.sprite(200, 100, "boom").play("boom");
    }
    update() {
        // 设置背景瓦片不断移动
        background.tilePositionY -= 1;
    }
}

效果如下:

20fa8241e27a5e4d2c86c6581761d12d.gif

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

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

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

6aff7204438698398b77449617cd0c71.jpeg

5.2 玩家类

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

this.physics.add.sprite(100, 600, "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, true, true);
        this.setVelocityY(-300);
        this.scene.sound.play("bullet");
    }
    // 每一帧更新回调
    preUpdate(time: number, delta: number) {
        super.preUpdate(time, delta);
        // 子弹出界事件(子弹走到顶部超出屏幕)
        if (this.y <= -14) {
            this.disableBody(true, true);
        }
    }
}

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(30, 345);
        let y = Math.Between(-20, -40);
        this.enableBody(true, x, y, true, true);
        this.setVelocityY(Math.Between(150, 300));
    }
    // 每一帧更新回调
    preUpdate(time: number, delta: number) {
        super.preUpdate(time, delta);
        let { height } = this.scene.cameras.main;
        // 敌军出界事件(敌军走到底部超出屏幕)
        if (this.y >= height + 20) {
            this.disableBody(true, true)
        }
    }
}

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(0, 0, width, height, "background").setOrigin(0, 0);
        // 创建玩家
        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(10, 10, "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, null, this);
    // 玩家和敌军碰撞
    this.physics.add.overlap(player, enemys, this.gameOver, null, this);
}
// 子弹击中敌军
hit(bullet, enemy) {
    // 子弹和敌军隐藏
    enemy.disableBody(true, true);
    bullet.disableBody(true, true);
    // 显示爆炸
    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(3, 2)
            .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,
        },
    },
    // ...
});

测试一下碰撞:

659a49a34156e429b8f85b929e561111.png

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

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

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

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

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

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

bd0449c9774f541645e1c55f0881fae9.png

08

结语

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

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

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

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

66091e0ad6ef2fe1bd9084fe31ea805f.png

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

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

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

2e26c0512a6576830f535367bf07f682.jpeg

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/645401.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

vue3 依赖-组件tablepage-vue3版本1.1.2~1.1.5更新内容

github求⭐ 可通过github 地址和npm 地址查看全部内容 vue3 依赖-组件tablepage-vue3说明文档&#xff0c;列表页快速开发&#xff0c;使用思路及范例-汇总 vue3 依赖-组件tablepage-vue3说明文档&#xff0c;列表页快速开发&#xff0c;使用思路及范例&#xff08;Ⅰ&#…

【计网】广播域和冲突域

一、相关概念 1.各层次设备 2.冲突域 2.1定义 冲突域通俗来讲就是在同一个网络中&#xff0c;两台设备同时传输的话会产生冲突。位于OSI的第一层&#xff1a;物理层 例如在集线器场景下&#xff0c;集线器属于物理层设备&#xff0c;它不具备交换机的功能&#xff0c;当收到节…

问题解决记录1:nvidia-container-cli: initialization error: load library failed

本地docker运行 $ docker run --rm --gpus all nvidia/cuda:11.8.0-base-ubuntu22.04 nvidia-smi 遇到这种报错 Error response from daemon: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error dur…

8.Redis之hash类型

1.hash类型的基本介绍 哈希表[之前学过的所有数据结构中,最最重要的] 1.日常开发中,出场频率非常高. 2.面试中,非常重要的考点, Redis 自身已经是键值对结构了Redis 自身的键值对就是通过 哈希 的方式来组织的 把 key 这一层组织完成之后, 到了 value 这一层~~ value 的其中…

掌握ASPICE标准:汽车软件测试工程师的专业发展路径

掌握ASPICE标准&#xff1a;汽车软件测试工程师的专业发展路径 文&#xff1a;领测老贺 随着新能源汽车在中国的蓬勃发展&#xff0c;智能驾驶技术的兴起&#xff0c;汽车测试工程师的角色变得愈发关键。这一变革带来了前所未有的挑战和机遇&#xff0c;要求测试工程师不仅要具…

1.int 与 Integer 的简单区别

蓝桥杯刷题从此开始&#xff1a; 第一题就是两个数的和&#xff0c;个人看来主要考察 int与integer 的区别&#xff1b; 这是我提交的答案&#xff0c;竟然会报错&#xff1a; import java.util.*; //输入A、B&#xff0c;输出AB。 class add {public static void main(String …

实验一:通过路由器实现内外网互联

通过路由器实现内外网互联 一、实验拓扑 相关配置详见下图&#xff0c;内网区域为AR2以内设备&#xff0c;外网区域以AR1和PC1代替进行实验测试。 二、实验要求 通过路由器实现内外网互联&#xff1a; 1.各内网PC可自动获取ip地址&#xff1b; 2.各内网PC可ping通外网PC&…

知能行——考研数学利器

知能行使用体验全记录 首先&#xff0c;我先介绍一下自己&#xff0c;我是2018级的&#xff0c;2022年6月毕业&#xff0c;本科沈阳工业大学&#xff08;双非&#xff09;&#xff0c;今年二战&#xff0c;专业课自动控制原理&#xff0c;数二英二&#xff0c;目标是江南大学控…

Sentinel Dashboard 规则联动持久化方案

一、Sentinel Dashboard 规则联动持久化方案 Sentinel 是阿里开源的一个流量控制组件&#xff0c;它提供了一种流量控制、熔断降级、系统负载保护等功能的解决方案。并且我们通过 Sentinel Dashboard 可以非常便捷的添加或修改规则策略&#xff0c;但是如果细心的小伙伴应该可…

redis6.2.7安装

1、下载上传到服务器 从官下载redis&#xff0c;地址 https://redis.io/download/#redis-downloads 然后上传到服务器目录 app/apps目录下 2、安装gcc编译器 使用gcc --version命令测试是否已经安装了gcc编译环境&#xff0c;如果没有安装执行以下命令安装 yum install -y …

定积分求解过程是否变限问题 以及当换元时注意事项

目录 定积分求解过程是否变限问题 文字理解&#xff1a; 实例理解&#xff1a; 易错点和易混点&#xff1a; 1&#xff1a;定积分中的换元指什么&#xff1f; 2&#xff1a; 不定积分中第一类换元法和第二类换元法的本质和区别 3&#xff1a; df(x) ----> df(x)这…

c++(一)

c&#xff08;一&#xff09; C与C有什么区别命名空间使用 输入输出流引用指针和引用的区别定义拓展 函数重载例子测试函数重载原理 参数默认值什么是参数默认值注意 在c中如何引入c的库动态内存分配new、delete与malloc、free的区别&#xff1f; C与C有什么区别 <1>都是…

【高数】重点内容,公式+推导+例题,大学考试必看

目录 1 隐函数求导1.1 公式1.2 说明1.3 例题 2 无条件极值2.1 运用2.2 求解2.3 例题 3 条件极值3.1 运用3.2 求解3.3 例题 4 二重积分4.1 直角坐标下4.2 极坐标下4.3 例题 5 曲线积分5.1 第一型曲线积分5.2 第二型曲线积分5.3 例题 6 格林公式6.1 公式6.2 说明6.3 例题 &#x…

C++课程设计:学校人员信息管理系统(可视化界面)

目录 学校人员信息管理系统 操作演示 MP4转GIF动图 设计功能要求 评分标准 QT Creator安装和新建项目 QT安装 QT新建项目 管理系统程序设计 mainwindow.h 文件 mainwindow.h 程序释义 mainwindow.cpp 文件 mainwindow.cpp 程序释义 main.h 文件 TXT文件生成 博主…

Java进阶学习笔记12——final、常量

final关键字&#xff1a; final是最终的意思。可以修饰类、方法、变量。 修饰类&#xff1a;该类就被称为最终类&#xff0c;特点是不能被继承了。 修饰方法&#xff1a;该方法是最终方法&#xff0c;特点是不能被重写了。 修饰变量&#xff1a;该变量只能被赋值一次。 有些…

[书生·浦语大模型实战营]——第三节:茴香豆:搭建你的 RAG 智能助理

0.RAG 概述 定义&#xff1a;RAG&#xff08;Retrieval Augmented Generation&#xff09;技术&#xff0c;通过检索与用户输入相关的信息片段&#xff0c;并结合外部知识库来生成更准确、更丰富的回答。解决 LLMs 在处理知识密集型任务时可能遇到的挑战, 如幻觉、知识过时和缺…

栈的实现(C语言)

文章目录 前言1.栈的概念及结构2.栈的实现3.具体操作3.1.初始化栈(StackInit)和销毁栈(StackDestory)3.2.入栈(StackPush)和出栈(StackPop)3.3.获得栈的个数(StackSize)、获得栈顶元素(StackTop)以及判空(StackEmpty) 前言 前段时间我们学习过了链表和顺序表等相关操作&#x…

【全网最全】2024电工杯数学建模A题54页A题保奖成品论文+配套代码

您的点赞收藏是我继续更新的最大动力&#xff01; 一定要点击如下的卡片链接&#xff0c;那是获取资料的入口&#xff01; 【全网最全】2024电工杯数学建模A题成品论文前三题完整解答matlabpy代码等&#xff08;后续会更新成品论文&#xff09;「首先来看看目前已有的资料&am…

《Ai学习笔记》自然语言处理 (Natural Language Processing):机器阅读理解-基础概念解析01

自然语言处理 (Natural Language Processing)&#xff1a; NLP四大基本任务 序列标注&#xff1a; 分词、词性标注 分类任务&#xff1a; 文本分类、情感分析 句子关系&#xff1a;问答系统、对话系统 生成任务&#xff1a;机器翻译、文章摘要 机器阅读理解的定义 Machi…

用LabVIEW进行CAN通信开发流程

本文详细介绍了在LabVIEW中开发CAN&#xff08;Controller Area Network&#xff09;通信的流程&#xff0c;包括硬件配置、软件编程和调试步骤。重点讨论了开发过程中需要注意的问题&#xff0c;如节点配置、数据帧格式和错误处理等&#xff0c;为开发高效可靠的CAN通信应用提…