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

news2024/11/17 9:15:33

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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

MyBatis从入门到“入土“

&#x1f495;喜欢的朋友可以关注一下&#xff0c;下次更新不迷路&#xff01;&#x1f495;(●◡●) 目录 一、Mybatis为何物&#xff1f;&#x1f44c; 二、快速入门&#x1f923; 1、新建项目&#x1f60a; 2、数据库建表&#x1f60a; 3、导入依赖的jar包&#x1f60a;…

【高级数据结构】B树

B树 一、概念性问题1、前置知识&#xff1a;常见搜索结构2、常规使用数据结构缺陷问题3、B树概念4、存放数量分析 二、代码实现逻辑1、结点定义和基本框架2、Find查找函数&#xff08;1&#xff09;思想&#xff08;2&#xff09;代码实现 3、InsertKey插入关键字函数--InsertK…

采用伪代码及C代码演示如何解决脱机最小值问题

采用伪代码及C代码演示如何解决脱机最小值问题 问题背景算法设计伪代码实现C代码实现证明数组正确性使用不相交集合数据结构最坏情况运行时间的紧确界 问题背景 脱机最小值问题涉及到一个动态集合 &#xff08; T &#xff09; &#xff08;T&#xff09; &#xff08;T&…

国内大模型价格战全面爆发:新旧势力逐鹿江湖【附主流模型价格对比】

近年来&#xff0c;随着人工智能技术的不断发展&#xff0c;大模型逐渐成为行业的焦点。然而&#xff0c;伴随而来的却是一场价格战。DeepSeek率先推出超低价服务&#xff0c;随后字节跳动、阿里巴巴、百度、科大讯飞、腾讯等巨头纷纷跟进&#xff0c;使得这一领域的竞争愈演愈…

echarts-树图、关系图、桑基图、日历图

树图 树图主要用来表达关系结构。 树图的端点也收symbol的调节 树图的特有属性&#xff1a; 树图的方向&#xff1a; layout、orient子节点收起展开&#xff1a;initialTreeDepth、expandAndCollapse叶子节点设置&#xff1a; leaves操作设置&#xff1a;roam线条&#xff1a…

Mysql触发器优化大数据表

背景 数据库的订单数量过多&#xff0c;需要分出热表用于快速查询&#xff0c;热表仅保存10天的订单数据。 解决思路 每次数据库订单表触发增删改时&#xff0c;同步操作到trigger_order_mul_info表&#xff0c;然后trigger_order_mul_info会定期删除超过10天的数据。 增删…

【编译原理复习笔记】正则表达式与自动机

正则表达式 正则表达式是一种用来描述正则语言的更紧凑的表达方法 e.g. r a ( a ∣ b ) ∗ ( ϵ ∣ ( . ∣ ) ( a ∣ b ) ) ra(a|b)^*(\epsilon|(.|\\_ )(a|b)) ra(a∣b)∗(ϵ∣(.∣)​(a∣b)) 正则表达式可以由较小的正则表达式按照特定的规则递归地构建。每个正则表达式定义…

【笔记】软件架构师要点记录(1)

【笔记】软件架构师要点记录 20240517 20240517 连续性&#xff1a;恢复能力&#xff1b;可用性&#xff1a;保持稳定态的时长 增量开发模式&#xff1a;在增量开发中&#xff0c;每个增量都有明确的范围和功能&#xff0c;并按照特定的功能顺序完成。增量之间的范围划分在开发…

防火墙技术基础篇:基于IP地址的转发策略

防火墙技术基础篇&#xff1a;基于IP地址的转发策略的应用场景及实现 什么是基于IP地址的转发策略&#xff1f; 基于IP地址的转发策略是一种网络管理方法&#xff0c;它允许根据目标IP地址来选择数据包的转发路径。这种策略比传统的基于目的地地址的路由更灵活&#xff0c;因…

图片转excel技术在医疗领域的应用探讨

在医疗行业中&#xff0c;图片转Excel技术的应用已经逐渐普及&#xff0c;为医护人员提供了极大的便利。这种技术利用OCR&#xff08;光学字符识别&#xff09;和机器学习的先进算法&#xff0c;将图片中的信息自动转化为Excel表格&#xff0c;大大提高了数据处理和分析的效率。…

智能锁千千万,谁是你的NO.1,亲身实测凯迪仕传奇大师K70旗舰新品

智能锁千千万&#xff0c;谁是你的NO.1。欢迎来到智哪儿评测室&#xff0c;这次我们为大家带来了凯迪仕传奇大师K70系列的一款重磅新品。 在科技的浪潮中&#xff0c;家居安全领域正经历着前所未有的变革。智能锁越来越成为家的安全守护神&#xff0c;以及智能生活的得力助手。…

Monodle centerNet3D 瑞芯微RKNN、地平线Horizon芯片部署、TensorRT部署

一直想做一点3D目标检测&#xff0c;先来一篇单目3D目标检测Monodle&#xff08;基于centernet的&#xff09;&#xff0c;训练代码参考官方【代码】&#xff0c;这里只讲讲如何部署。 模型和完整仿真测试代码&#xff0c;放在github上参考链接【模型和完整代码】。 1 模型训练…

Creating Server TCP listening socket *:6379: listen: Unknown error

错误&#xff1a; 解决方法&#xff1a; 在redis安装路径中打开cmd命令行窗口&#xff0c;输入 E:\Redis-x64-3.2.100>redis-server ./redis.windows.conf结果&#xff1a;

智慧校园学工管理系统的部署

学工体系思政服务该怎么规划建造&#xff1f;思政作为高校育人的中心使命&#xff0c;在做到让学生健康高兴生长的一起&#xff0c;也应满意学生生长成才的各类需求。使用技术为学生供给优质的信息化服务&#xff0c;是其间的有效途径。大数据让个性化教育成为可能&#xff0c;…

Python函数、类和方法

大家好&#xff0c;当涉及到编写可维护、可扩展且易于测试的代码时&#xff0c;Python提供了一些强大的工具和概念&#xff0c;其中包括函数、类和方法。这些是Python编程中的核心要素&#xff0c;可以帮助我们构建高效的测试框架和可靠的测试用例。 本文将探讨Python中的函数、…

Swin Transformer 笔记与理解

目录 解决什么问题基本结构理解 解决什么问题 传统的transformer处理于长序列需要非常大的计算量&#xff0c;而且很慢。且传统的transformer虽然的全局信息的获取上有着很好的效果&#xff0c;但是在局部信息的获取上就没有那么强了。Swim transformer的主要的贡献就是使用分…

LLM 大模型学习必知必会系列(十):基于AgentFabric实现交互式智能体应用,Agent实战

LLM 大模型学习必知必会系列(十)&#xff1a;基于AgentFabric实现交互式智能体应用,Agent实战 0.前言 **Modelscope **是一个交互式智能体应用基于ModelScope-Agent&#xff0c;用于方便地创建针对各种现实应用量身定制智能体&#xff0c;目前已经在生产级别落地。AgentFabri…

Java输入与输出详解

Java输入和输出 前言一、Java打印Hello World二、输出到控制台基本语法代码示例格式化字符串 三、从键盘输入读入一个字符正确写法 使用 Scanner 读取字符串/整数/浮点数使用 Scanner 循环读取 N 个数字 前言 推荐一个网站给想要了解或者学习人工智能知识的读者&#xff0c;这…

嵌入式智能硬件茶杯垫的设计与实现方案

iCupBox简介 这是一款智能杯垫产品,基于GTD时间管理理念设计,目的是提醒人们专心工作和及时喝水休息,提高工作效率。 https://gitee.com/jiangtao008/iCupBox 开原许可协议:MIT 项目分为客户端APP和杯垫固件系统: 客户端APP,使用QML开发,集成GTD时间管理方法,与杯垫固…

QQ技术导航源码附带交易系统

网站功能 QQ登录 友联自助交换 友情链接交易功能 多功能搜索 ico小图标本地化 网站图片本地化 蜘蛛日志 文章评论 网站评论 自助链接匿名提交站点&#xff0c;添加友链访问网站自动审核通过 VIP 会员等级 VIP 付费升级 单个文章或者站点付费快审 多背景图片可自定义背景图片…