用Pixi.js做了一版Canvas的捕鱼游戏Demo

开头就上源码

🤔️🤔️ 这几天闲着没事突发奇想搞点2D图形玩玩,前段时间时间接触了pixi.js就用它来试试水。

决定用捕鱼达人的这种模式来试试水。🔥🔥

🥱 🥱 肝了了好几天把基本的鱼游泳动画,随机游泳、炮台、射击撒网基本的逻辑写了

上代码 ⬇️ ⬇️ ⬇️ ⬇️

PIXI 实例入口

pixi很简单由pixi生成了canvas dom(app.view)你它塞到的点HTML Dom里面就行了

ini 复制代码
// <div class="pixijs" ref="pixiDom"></div>
import { shallowRef, onMounted, onUnmounted } from 'vue';import * as PIXI from 'pixi.js';
const pixiDom = shallowRef<HTMLDivElement>();
const screen = {
    width: 1024,
    height: 580,
};
let app: PIXI.Application;
onMounted(async () => {
    app = new PIXI.Application({
        width: screen.width,
        height: screen.height,
    });
    addSceneBackground();
    app.stage.eventMode = 'dynamic';
    pixiDom.value?.appendChild(app.view as any);
    // addFishSprite();
    // addCannon();
});
onUnmounted(() => {
    app?.destroy();
});

添加场景背景

ini 复制代码
const background = PIXI.Sprite.from('xxx/xxx.png');
background.width = screen.width;
background.height = screen.height;
app.stage.addChild(background);

然后写一个载入动画精灵的公共方法

pixi官方的案例载入动画精灵的方法是加载一张图片描述文件进行动画合成

我本地的动画精灵格式是一张图片一帧,在pixi的操作是加载完多个图片在合成一个动画,和官方的示例会有的不太一样,所以我们

ini 复制代码
/**
 * 加载本地动画精灵图集
 * @param localPath 本地路径 import.meta.env.BASE_URL + `${localPath}${name}.png
 * @param fileName 文件名称
 * @param num 图集数量
 * @param add 是否添加到场景
 * @param mcswspj 命名是否是0001格式,默认true, 否则是
 */
const loadAnimatedSprite = async (
    localPath: string,
    fileName: string,
    num: number,
    add = false,
    mcswspj = true
): Promise<PIXI.AnimatedSprite> => {
    const textures: PIXI.Texture[] = [];
    for (let i = 0; i < num; i++) {
        let name = '';
        if (mcswspj) {
            name = i < 9 ? `${fileName}0${i + 1}` : `${fileName}${i + 1}`;
        } else {
            name = `${fileName}${i + 1}`;
        }
        const res = await PIXI.Texture.from(import.meta.env.BASE_URL + `${localPath}${name}.png`);
        textures.push(res);
    }
    const sprite = new PIXI.AnimatedSprite(textures);
    sprite.anchor.set(0.5);
    sprite.zIndex = 1;
    if (add) {
        app.stage.addChild(sprite);
    }
    return Promise.resolve(sprite);
};

往场景添加炮台和子弹发射

先搞定炮台的方向怎么才能再点击场景的时候朝向鼠标或者跟随鼠标的方向移动呢?

这个方法我是百度出来的我也还没琢磨透原理

就是用Math.atan2方法获取两个点的夹角的弧度 radian 然后用****Math.PI / 2减去这个 radian 就得到了炮台的旋转角度

const radian = Math.atan2(event.globalY - cannon.y, event.globalX - cannon.x);

cannon.rotation = Math.PI / 2 - Math.abs(radian);

这样就把炮弹的旋转角度也一起搞定了,接下来要要研究炮弹更新速度

炮弹的路径更新肯定是从炮台的坐标开始更新的

往左右上下直走就是X或者Y轴每一帧更新就加减一个固定的数就行了,就是这样

javascript 复制代码
// delta是每一帧所用的时间
app.ticker.add((delta) => {
    // 当屏幕的帧率是60帧时 相当于60帧走60的距离 如果是120帧率的屏幕则是120帧走了60的距离
   bulletSprite.x += 1 * delta;
   // bulletSprite.y += 1 * delta;
});

那他的运动轨迹就是从炮台的点一直往右边走

⬆️ Y轴

🏃 🏃 🏃 .........🏃 X 轴➡️

那要像上图那样斜着运动到左边或者右边的A点呢?

bulletSprite.x += 1 * delta; // 中的这个1应该是多少合适呢?

按照上面的图,假设炮台发射炮弹直线走到右边C点是4秒,那按勾股定律走到A点就是7秒

炮台到C长度是4, C到A点长度是3 ,斜边等于直角两边相加得出7,总的时间算出来了那速度怎么算呢?

既然炮台走到C点直线距离时间是 1 * 4(秒) * delta(60帧) = 240

那要走到A点的时间应该是 ?***** 7(秒4+3) * delta(60帧) = 240

更直观一点看下图

1 * delta * 60 = 60

(1 + 0.5) * delta * 60 = 60

得出 X轴跟Y轴的每一帧速率

ini 复制代码
const speed_x = (1/1.5) * delte;
const speed_y = (0.5/1.5) * delte;

炮台和发射子弹源码

ini 复制代码
const addCannon = async () => {
    const cannon = await loadAnimatedSprite('/fishcatcher/cannon/cannon6/', 'cannon600', 7, true);
    // 设置原点在底部中间
    cannon.anchor.set(0.5, 1);
    cannon.scale.set(0.5);
    cannon.animationSpeed = 0.8;
    // 设置炮台在场景设置原点在底部中间
    cannon.x = screen.width / 2;
    cannon.y = screen.height;
    cannon.onLoop = () => {
        cannon.stop();
    };
    app.stage.on('pointermove', (event) => {
        const radian = Math.atan2(event.globalY - cannon.y, event.globalX - cannon.x);
        cannon.rotation = Math.PI / 2 - Math.abs(radian);
    });
    // 子弹精灵
    const bullet = await loadAnimatedSprite('/fishcatcher/bullet/bullet6/', 'bullet600', 9, true);
    let run = false;
    let speed_x = 0;
    let speed_y = 0;

    app.stage.on('pointerdown', async (event) => {
        cannon.play();
        bullet.position = cannon.position;
        bullet.rotation = cannon.rotation + 0;
        // 用点击的坐标和炮台的坐标获取直角的位置
        const c_x = event.globalX;
        const c_y = cannon.y;
        // 点击的点到直角点的距离
        const b2c_l = Math.abs(c_y - event.globalY);
        // 炮台的坐标到直角的坐标的距离
        let a2c_l = 0;
        if (event.globalX > bullet.x) {
            a2c_l = c_x - cannon.x;
        } else {
            a2c_l = cannon.x - c_x;
        }
        // 直角三角形斜边长
        const a2b_l = Math.abs(b2c_l + a2c_l);
        // 速度比例 = 直角边的轴 / 斜边长 * 增加多少速度
        const _speed_x = (a2c_l / a2b_l) * 30;
        const _speed_y = (b2c_l / a2b_l) * 30;
        speed_y = -_speed_y;
        // 判断为右上
        if (event.globalX > bullet.x && event.globalY < bullet.y) {
            speed_x = _speed_x;
        }
        // 判断为左上
        if (event.globalX < bullet.x && event.globalY < bullet.y) {
            speed_x = -_speed_x;
        }
        run = true;
    });

    app.ticker.add((delta) => {
        if (run) {
            bullet.x += speed_x * delta;
            bullet.y += speed_y * delta;
        }
        if (bullet.y < 0) {
            run = false;
        }
    });
};

添加随机游动的鱼

鱼的随机游泳思路跟上面的算法逻辑是一样,获取一个随机的坐标坐标,根据炮台位置坐标和随机的坐标位置得出直角三角形的直角坐标算出 speed_x speed_y的****速率

ini 复制代码
const addFishSprite = async (key = '5', num = 30) => {
    const fish = await loadAnimatedSprite(
        `/fishcatcher/fishimg/fish${key}/live/`,
        `fish${key}_live00`,
        num,
        true
    );
    fish.animationSpeed = 0.1;
    fish.play();
    fish.x = 0;
    fish.y = 0;
    // 假如随机的点位是 x: 800, y: 400;
    const endPoint = { x: 800, y: 400 };
    const radian = Math.atan2(endPoint.y - fish.y, endPoint.x - fish.x);
    fish.rotation = radian;
    app.ticker.add((delta) => {
        fish.x += (800 / 1200) * delta;
        fish.y += (400 / 1200) * delta;
    });
};

效果图

本文源码

xml 复制代码
<template>    <div class="pixijs" ref="pixiDom"></div></template><script setup lang="ts">import { shallowRef, onMounted, onUnmounted } from 'vue';import * as PIXI from 'pixi.js';const pixiDom = shallowRef<HTMLDivElement>();const screen = {    width: 1024,    height: 580,};let app: PIXI.Application;onMounted(async () => {    app = new PIXI.Application({        width: screen.width,        height: screen.height,    });    addSceneBackground();    app.stage.eventMode = 'dynamic';    pixiDom.value?.appendChild(app.view as any);    addFishSprite();    addCannon();});onUnmounted(() => {    app?.destroy();});const addSceneBackground = () => {    const background = PIXI.Sprite.from(import.meta.env.BASE_URL + '/fishcatcher/img/BG01.png');    background.width = screen.width;    background.height = screen.height;    app.stage.addChild(background);};const addFishSprite = async (key = '5', num = 30) => {    const fish = await loadAnimatedSprite(        `/fishcatcher/fishimg/fish${key}/live/`,        `fish${key}_live00`,        num,        true    );    fish.animationSpeed = 0.1;    fish.play();    fish.x = 0;    fish.y = 0;    // 假如随机的点位是 x: 800, y: 400;    const endPoint = { x: 800, y: 400 };    const radian = Math.atan2(endPoint.y - fish.y, endPoint.x - fish.x);    fish.rotation = radian;    app.ticker.add((delta) => {        fish.x += (800 / 1200) * delta;        fish.y += (400 / 1200) * delta;    });};const addCannon = async () => {    const cannon = await loadAnimatedSprite('/fishcatcher/cannon/cannon6/', 'cannon600', 7, true);    // 设置原点在底部中间    cannon.anchor.set(0.5, 1);    cannon.scale.set(0.5);    cannon.animationSpeed = 0.8;    // 设置炮台在场景设置原点在底部中间    cannon.x = screen.width / 2;    cannon.y = screen.height;    cannon.onLoop = () => {        cannon.stop();    };    app.stage.on('pointermove', (event) => {        const radian = Math.atan2(event.globalY - cannon.y, event.globalX - cannon.x);        cannon.rotation = Math.PI / 2 - Math.abs(radian);    });    // 子弹精灵    const bullet = await loadAnimatedSprite('/fishcatcher/bullet/bullet6/', 'bullet600', 9, true);    let run = false;    let speed_x = 0;    let speed_y = 0;    app.stage.on('pointerdown', async (event) => {        cannon.play();        bullet.position = cannon.position;        bullet.rotation = cannon.rotation + 0;        // 用点击的坐标和炮台的坐标获取直角的位置        const c_x = event.globalX;        const c_y = cannon.y;        // 点击的点到直角点的距离        const b2c_l = Math.abs(c_y - event.globalY);        // 炮台的坐标到直角的坐标的距离        let a2c_l = 0;        if (event.globalX > bullet.x) {            a2c_l = c_x - cannon.x;        } else {            a2c_l = cannon.x - c_x;        }        // 直角三角形斜边长        const a2b_l = Math.abs(b2c_l + a2c_l);        // 速度比例 = 直角边的轴 / 斜边长 * 增加多少速度        const _speed_x = (a2c_l / a2b_l) * 30;        const _speed_y = (b2c_l / a2b_l) * 30;        speed_y = -_speed_y;        // 判断为右上        if (event.globalX > bullet.x && event.globalY < bullet.y) {            speed_x = _speed_x;        }        // 判断为左上        if (event.globalX < bullet.x && event.globalY < bullet.y) {            speed_x = -_speed_x;        }        run = true;    });    app.ticker.add((delta) => {        if (run) {            bullet.x += speed_x * delta;            bullet.y += speed_y * delta;        }        if (bullet.y < 0) {            run = false;        }    });};/** * 加载本地动画精灵图集 * @param localPath 本地路径 import.meta.env.BASE_URL + `${localPath}${name}.png * @param fileName 文件名称 * @param num 图集数量 * @param add 是否添加到场景 * @param mcswspj 命名是否是0001格式,默认true, 否则是 */const loadAnimatedSprite = async (    localPath: string,    fileName: string,    num: number,    add = false,    mcswspj = true): Promise<PIXI.AnimatedSprite> => {    const textures: PIXI.Texture[] = [];    for (let i = 0; i < num; i++) {        let name = '';        if (mcswspj) {            name = i < 9 ? `${fileName}0${i + 1}` : `${fileName}${i + 1}`;        } else {            name = `${fileName}${i + 1}`;        }        const res = await PIXI.Texture.from(import.meta.env.BASE_URL + `${localPath}${name}.png`);        textures.push(res);    }    const sprite = new PIXI.AnimatedSprite(textures);    sprite.anchor.set(0.5);    sprite.zIndex = 1;    if (add) {        app.stage.addChild(sprite);    }    return Promise.resolve(sprite);};</script><style scoped lang="scss">.pixijs {    position: relative;    min-height: var(--content-height);    display: flex;    align-items: center;    justify-content: center;    overflow: auto;}</style>

在线预览地址 https://chenhuajie.gitee.io/vue-material-admin/#/graphics/pixijs

最后!玩Web3D的你们一定也要看看我的这个 Babylon物理角色控制器

相关推荐
麻花201314 分钟前
WPF学习之路,控件的只读、是否可以、是否可见属性控制
服务器·前端·学习
.54815 分钟前
提取双栏pdf的文字时 输出文件顺序混乱
前端·pdf
jyl_sh23 分钟前
WebKit(适用2024年11月份版本)
前端·浏览器·客户端·webkit
杨荧1 小时前
【JAVA毕业设计】基于Vue和SpringBoot的宠物咖啡馆平台
java·开发语言·jvm·vue.js·spring boot·spring cloud·开源
zhanghaisong_20151 小时前
Caused by: org.attoparser.ParseException:
前端·javascript·html·thymeleaf
Eric_见嘉1 小时前
真的能无限试(白)用(嫖)cursor 吗?
前端·visual studio code
DK七七2 小时前
多端校园圈子论坛小程序,多个学校同时代理,校园小程序分展示后台管理源码
开发语言·前端·微信小程序·小程序·php
老赵的博客2 小时前
QSS 设置bug
前端·bug·音视频
Chikaoya2 小时前
项目中用户数据获取遇到bug
前端·typescript·vue·bug
南城夏季2 小时前
蓝领招聘二期笔记
前端·javascript·笔记