用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物理角色控制器

相关推荐
崔庆才丨静觅13 分钟前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60611 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了1 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅1 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment2 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax