Pixi.js 游戏开发日志01 传奇角色素材的处理

介绍

Pixi.js是一个2d渲染引擎,基于WebGL(以前版本支持CanvasRenderingContext2D,现在也是支持,但是需要引用专门的文件pixi-legacy.js),Canvas和WebGL可能在网上一些教程中涉及了一部分,但很朦胧,我也不会专门说这些底层问题(我也没研究明白),毕竟引用Pixi.js的目的就是减少自己对底层的调用。
image.png
GitHub - pixijs/pixijs: The HTML5 Creation Engine: Create beautiful digital content with the fastest, most flexible 2D WebGL renderer.

目的

我也是没有很多的经验,只是写下来自己针对问题的一些思路,一起讨论,我学习编程的目的就是做个游戏而已。
不知道其他人怎么想的,但是我十多岁的时候就想做游戏了,做游戏对于我的理解就是一个世界的造物主,但是到现在我还只是个普通人,而不是什么技术大佬,我只是要做个游戏,但我只会很基础的编程,而且还不会画画,也不会音乐,怎么才能做出来?
网上看的什么飞机喷射子弹,像素鸟之类的教程什么的demo简直是无感,那种游戏并不是我想象的游戏,最起码有个世界的概念,角色数据,地图数据,UI数据,网络联机,我该做什么样的游戏,最少也得个《传奇》吧。

开始

通过游戏素材网站下载到了传奇的角色素材并且进行了一些基本的处理 image.png
但是我发现了一些问题,这些素材并不是我能直接使用的 image.png 就拿可以待机的动作看出来一个动作有64张图片,每个方向占用8张图片,但是8张图片中待机的动作只有4张能用的,所以64张图片,我需要的仅仅只有32张。
另外并不只是有图片的问题,而且还有每张图片的XY轴偏移坐标,才能形成一个流畅的序列帧动画,否则会导致这个序列帧动画产生抖动。
预先善其事必先利其器,我们首先得开发出来一个处理角色素材的工具,把这些图片处理我们需要的。最后的成品就是一个动作的配置和一张包含八个方向的图片
image.png 为什么刚才那张图片是一大堆碎图,而我的成品是一张图片,Pixi.js里面有个纹理集的概念,可以将很多碎图拼成一张图片,然后进行内部的裁剪后使用。
有了配置和图片,我就可以根据算法来等分这张图片的素材,经过一系列操作便可以,实现角色的动画显示。 为什么不直接加载那么多碎图的原因,主要是网页的图片并发加载一大堆图片引发的性能问题,这个我也不怎么精通,我也不细说了。
为了更少的图片占用还可以使用Texture Packer之类的工具,甚至八个方向的素材,考虑左右对称的原因,直接可以删除三个方向,然后左右中心轴水平翻转,不过我的要求也不高,所以先就和着写个工具吧。

处理角色素材

首先就是新建个文件,然后……等我百度翻译一下 image.png
一个名为Legend.ts的文件创建完成,同时我也开始讲解Pixi.js的一些使用技巧吧

/**
 * 引用pixi.js
 */
import { Application, Container } from "pixi.js";

/**
 * 使用pixi.js第一步,创建一个PIXI.Application
 */
const app: Application = new Application({
    width: innerWidth,
    height: innerHeight,
    backgroundColor: 0x1f1f1f,
});

/**
 * pixi渲染器绑定的canvas画布标签
 */
const canvas: HTMLCanvasElement = app.view;

/**
 * pixi渲染舞台容器
 */
const stage: Container = app.stage;

/**
 * 然后将画布放到页面中
 */
document.body.appendChild(canvas);
canvas.style.position = "absolute";
canvas.style.left = "0px";
canvas.style.top = "0px";
canvas.style.width = "100%"
canvas.style.height = "100%";

这里就是将Pixi.js的画布放到页面中,只得到黑茫茫的一片,这样就算是使用了pixi.js
image.png


/**
 * Graphics是一个绘图的基本方法
 * 我现在要做的就是绘制出来一个正方形用作点击的按钮
 */
// 创建一个Graphics对象
const button: Graphics = new Graphics();
// 背景填充的颜色设置一个白色
button.beginFill(0xffffff);
// 线条样式的颜色和粗度的一个设置
button.lineStyle({ width: 1, color: 0xdcdfe6, });
// 绘制一个长方形的图形 切记一定要设置前面两个样式 否则会绘制出来不同样式的 顺序很重要
button.drawRect(0, 0, 90, 35);
// button在Canvas的位置
button.position.set(10, 10);
// button的点击触摸交互的打开,打开才能进行触摸和点击的事件监听
button.interactive = true;
// button的鼠标样式变成点击的样子
button.buttonMode = true;
// button触摸点击松开的事件监听
button.on("pointerup", function (): void { });
// 最后最重要的一步就是将这个图形放到canvas画布里面
stage.addChild(button);

/**
 * Text是Pixi.js内部的一个文字显示
 * 它的作用可以用作角色或者NPC头上的名称,地图的名称,聊天框的聊天消息,装备名称和介绍还有属性的显示
 * 不过这个buttonText在这里的作用就是用来充当按钮显示的文本
 */
const buttonText: Text = new Text("选择文件", {
    // 字体样式
    fontFamily: "Helvetica Neue,Helvetica,PingFang SC,Hiragino Sans GB,Microsoft YaHei,SimSun,sans-serif",
    // 字体大小
    fontSize: 20,
    // 字体颜色
    fill: 0x606266
});
// 将这个buttonText添加到button里面
button.addChild(buttonText);
// buttonText在button里的位置
buttonText.position.set(5, 5);

image.png
茫茫的一片黑中,多出来一个按钮和一个字体,就完成了我们的基本显示
然后就是选择本地图片和偏移值数据文件的功能,这里我们就用不到了Pixi.js了毕竟只是个2d渲染器,也没有选择文件这种功能,还是得需要input标签进行原生操作

/**
 * 一个用作获取本地文件上传的input标签
 * 并且进行一个基本的文件操作
 */
const input: HTMLInputElement = document.createElement("input");
// 设置表单控件为file,用来选择文件
input.type = "file";
// 限制选择文件的格式
input.accept = "image/png,text/plain";
// 打开文件多选
input.multiple = true;
// 文件选择改变的一个监听
input.addEventListener("change", function (): void {

});

于此同时将刚才的按钮监听事件添加一个事件,并且将定义input这些代码放到button上面,以保证执行优先级

// button触摸点击松开的事件监听
button.on("pointerup", function (): void {
    // input标签点击事件,出现选择文件的窗口
    input.click();
});

于是我们便得到了它 image.png 下一步要做的就是将这些文件进行一个处理,将只有1x1的文件去除,并且将其对应的无用的偏移值文件去除

/**
 * 将文件对象转换成Image
 * 目的是为了放到Pixi.js使用
 */
async function getImage(file: File): Promise<HTMLImageElement> {
    // 返回一个Promise<HTMLImageElement>
    return new Promise<HTMLImageElement>(async function (resolve) {
        // 创建一个Image对象
        const image: HTMLImageElement = new Image();
        // File继承于Blob,所以可以使用URL.createObjectURL转换成一个blob://地址
        image.src = URL.createObjectURL(file);
        // 加载完成的事件监听
        image.onload = function () {
            // 加载完成的一个跳出,目的是为了返回的Image对象
            resolve(image);
        }
    });
}

/**
 * 将文件的内容转换成X轴和Y轴的值
 * 目的是为了拼接图片时候的计算使用
 */
async function getPoint(file: File): Promise<{ x: number, y: number }> {
    // 返回一个Promise<{ x: number, y: number }>
    return new Promise<{ x: number, y: number }>(function (resolve) {
        // 创建一个文件读取
        const reader: FileReader = new FileReader();
        // 读取文件并将其内容转换成字符串
        reader.readAsText(file);
        // 读取完成的事件监听
        reader.onload = function () {
            // 字符串转换成包含一个XY轴的数组
            const result: string[] = (<string>reader.result).split("\r\n");
            // 返回XY轴的坐标
            resolve({ x: ~~result[0], y: ~~result[1] });
        }
    });
}

/**
 * 读取input.files里面的文件并全部转换成图片
 */
async function getImageList(files: FileList): Promise<HTMLImageElement[]> {
    // 创建一个空对象,并存储读取图片
    const imageList: HTMLImageElement[] = [];
    // 遍历整个files
    for (let index = 0; index < files.length; index++) {
        // 获取当前的文件对象
        const file = files[index];
        // 判断文件的类型是否为png文件不是则跳过
        if (file.type != "image/png") continue;
        // 获取图片对象
        const image: HTMLImageElement = await getImage(file);
        // 如果是1x1的图片则跳过
        if (image.width * image.height == 1) continue;
        // 将条件通过的图片对象添加到数组
        imageList.push(image);
    }
    // 返回图片对象的数组
    return imageList;
}

/**
 * 读取input.files里面的文件并全部转换成偏移值坐标
 */
async function getPointList(files: FileList): Promise<{ x: number, y: number }[]> {
    // 创建一个空的数组,为了存储读取的偏移值坐标
    const pointList: { x: number, y: number }[] = [];
    // 循环遍历整个files
    for (let index = 0; index < files.length; index++) {
        // 获取当前的文件对象
        const file = files[index];
        // 判断文件的类型是否是文本文件 不是的话则跳过
        if (file.type != "text/plain") continue;
        // 读取文件的偏移值
        const point: { x: number, y: number } = await getPoint(file);
        // 由于传奇角色素材1x1图片对应的偏移值XY轴相加都是-576所以不用额外的判断,直接跳过
        if (point.x + point.y == -576) continue;
        // 将合格的资源放进数组里
        pointList.push(point);
    }
    // 返回包含偏移值坐标的数组
    return pointList;
}

最后有了图片对象和偏移值的数据,就可以进行利用拼接了
image.png
由于写教程真的太累了,所以我直接就把拼接的代码和动画显示的代码和最后生成文件的代码都放出来了

/**
 * 引用pixi.js
 */
import { Application, Container, Extract, Graphics, Sprite, Text, Texture } from "pixi.js";

/**
 * 使用pixi.js第一步,创建一个PIXI.Application
 */
const app: Application = new Application({
    width: innerWidth,
    height: innerHeight,
    backgroundColor: 0x1f1f1f,
});

/**
 * pixi渲染器绑定的canvas画布标签
 */
const canvas: HTMLCanvasElement = app.view;

/**
 * pixi渲染舞台容器
 */
const stage: Container = app.stage;

/**
 * 然后将画布放到页面中
 */
document.body.appendChild(canvas);
canvas.style.position = "absolute";
canvas.style.left = "0px";
canvas.style.top = "0px";
canvas.style.width = "100%"
canvas.style.height = "100%";

/**
 * 一个用作获取本地文件上传的input标签
 * 并且进行一个基本的文件操作
 */
const input: HTMLInputElement = document.createElement("input");
// 设置表单控件为file,用来选择文件
input.type = "file";
// 限制选择文件的格式
input.accept = "image/png,text/plain";
// 打开文件多选
input.multiple = true;
// 文件选择改变的一个监听
input.addEventListener("change", function (): void {
    packer();
});

/**
 * Pixi.js内部截图API接口
 * 可以将显示对象转换成img标签 canvas标签 base64数据 RGBA像素数组
 */
const extract: Extract = app.renderer.plugins.extract;

/**
 * Graphics是一个绘图的基本方法
 * 我现在要做的就是绘制出来一个正方形用作点击的按钮
 */
// 创建一个Graphics对象
const button: Graphics = new Graphics();
// 背景填充的颜色设置一个白色
button.beginFill(0xffffff);
// 线条样式的颜色和粗度的一个设置
button.lineStyle({ width: 1, color: 0xdcdfe6, });
// 绘制一个长方形的图形 切记一定要设置前面两个样式 否则会绘制出来不同样式的 顺序很重要
button.drawRect(0, 0, 90, 35);
// button在Canvas的位置
button.position.set(10, 10);
// button的点击触摸交互的打开,打开才能进行触摸和点击的事件监听
button.interactive = true;
// button的鼠标样式变成点击的样子
button.buttonMode = true;
// button触摸点击松开的事件监听
button.on("pointerup", function (): void {
    // input标签点击事件,出现选择文件的窗口
    input.click();
});
// 最后最重要的一步就是将这个图形放到canvas画布里面
stage.addChild(button);

/**
 * Text是Pixi.js内部的一个文字显示
 * 它的作用可以用作角色或者NPC头上的名称,地图的名称,聊天框的聊天消息,装备名称和介绍还有属性的显示
 * 不过这个buttonText在这里的作用就是用来充当按钮显示的文本
 */
const buttonText: Text = new Text("选择文件", {
    // 字体样式
    fontFamily: "Helvetica Neue,Helvetica,PingFang SC,Hiragino Sans GB,Microsoft YaHei,SimSun,sans-serif",
    // 字体大小
    fontSize: 20,
    // 字体颜色
    fill: 0x606266
});
// 将这个buttonText添加到button里面
button.addChild(buttonText);
// buttonText在button里的位置
buttonText.position.set(5, 5);

/**
 * 将文件对象转换成Image
 * 目的是为了放到Pixi.js使用
 */
async function getImage(file: File): Promise<HTMLImageElement> {
    // 返回一个Promise<HTMLImageElement>
    return new Promise<HTMLImageElement>(async function (resolve) {
        // 创建一个Image对象
        const image: HTMLImageElement = new Image();
        // File继承于Blob,所以可以使用URL.createObjectURL转换成一个blob://地址
        image.src = URL.createObjectURL(file);
        // 加载完成的事件监听
        image.onload = function () {
            // 加载完成的一个跳出,目的是为了返回的Image对象
            resolve(image);
        }
    });
}

/**
 * 将文件的内容转换成X轴和Y轴的值
 * 目的是为了拼接图片时候的计算使用
 */
async function getPoint(file: File): Promise<{ x: number, y: number }> {
    // 返回一个Promise<{ x: number, y: number }>
    return new Promise<{ x: number, y: number }>(function (resolve) {
        // 创建一个文件读取
        const reader: FileReader = new FileReader();
        // 读取文件并将其内容转换成字符串
        reader.readAsText(file);
        // 读取完成的事件监听
        reader.onload = function () {
            // 字符串转换成包含一个XY轴的数组
            const result: string[] = (<string>reader.result).split("\r\n");
            // 返回XY轴的坐标
            resolve({ x: ~~result[0], y: ~~result[1] });
        }
    });
}

/**
 * 读取input.files里面的文件并全部转换成图片
 */
async function getImageList(files: FileList): Promise<HTMLImageElement[]> {
    // 创建一个空对象,并存储读取图片
    const imageList: HTMLImageElement[] = [];
    // 遍历整个files
    for (let index = 0; index < files.length; index++) {
        // 获取当前的文件对象
        const file = files[index];
        // 判断文件的类型是否为png文件不是则跳过
        if (file.type != "image/png") continue;
        // 获取图片对象
        const image: HTMLImageElement = await getImage(file);
        // 如果是1x1的图片则跳过
        if (image.width * image.height == 1) continue;
        // 将条件通过的图片对象添加到数组
        imageList.push(image);
    }
    // 返回图片对象的数组
    return imageList;
}

/**
 * 读取input.files里面的文件并全部转换成偏移值坐标
 */
async function getPointList(files: FileList): Promise<{ x: number, y: number }[]> {
    // 创建一个空的数组,为了存储读取的偏移值坐标
    const pointList: { x: number, y: number }[] = [];
    // 循环遍历整个files
    for (let index = 0; index < files.length; index++) {
        // 获取当前的文件对象
        const file = files[index];
        // 判断文件的类型是否是文本文件 不是的话则跳过
        if (file.type != "text/plain") continue;
        // 读取文件的偏移值
        const point: { x: number, y: number } = await getPoint(file);
        // 由于传奇角色素材1x1图片对应的偏移值XY轴相加都是-576所以不用额外的判断,直接跳过
        if (point.x + point.y == -576) continue;
        // 将合格的资源放进数组里
        pointList.push(point);
    }
    // 返回包含偏移值坐标的数组
    return pointList;
}

// 拼接图片
async function packer(): Promise<void> {
    // 清除stage里面的显示对象
    stage.removeChildren();
    // 用来盛放Sprite的容器
    const container: Container = new Container();
    // 获取全部图片
    const imageList = await getImageList(input.files);
    // 获取对应的偏移值
    const pointList = await getPointList(input.files);
    // 创建一个存储Sprite对象的数组
    const spriteList: Sprite[] = [];
    // 目的为了一些偏移值的计算
    let top = 0, left = 0, width = 0, height = 0;
    // 将图片全部转换成Sprite对象,目的是为了Pixi.js里面的处理
    for (let index = 0; index < imageList.length; index++) {
        // 图片对象
        const image = imageList[index];
        // 创建一个Sprite对象 Sprite.from(image)是一个方便的API,否则创建一个Sprite对象需要new Sprite(new Texture(new BaseTexture(image)))
        // 创建Sprite需要一个纹理,而创建一个纹理又需要一个基础纹理,纹理是基础纹理的一部分,也就是我讲的纹理集的概念
        // 可以将一张图片切割成无数个一部分,而这些一部分被重复使用
        const sprite = Sprite.from(image);
        // 将Sprite放到数组
        spriteList.push(sprite);
        // 目的是为了偏移值的计算,找出偏移值最大的XY坐标
        top = Math.min(top, pointList[index].y);
        left = Math.min(left, pointList[index].x);
    }
    // 遍历整个Sprite对象数组,并将XY坐标进一步修正
    for (let index = 0; index < spriteList.length; index++) {
        // 获取当前的Sprite对象
        const sprite = spriteList[index];
        // 将Sprite对象的坐标调整到偏移值对应的坐标并统一减去最大偏移值
        sprite.position.set(pointList[index].x - left, pointList[index].y - top);
        // 目的是为了等分的时候,每张图片的宽高一致
        width = Math.max(width, sprite.width + sprite.x);
        height = Math.max(height, sprite.height + sprite.y);
    }
    // 遍历整个Sprite对象数组,并进行拼接
    for (let index = 0; index < spriteList.length; index++) {
        // 获取当前的Sprite对象
        const sprite = spriteList[index],
            // 这个x的意思是这个图片在整张图片中动作的某一帧
            x: number = index % (spriteList.length / 8),
            // 这个y的意思是这张图片在整张图片中的某个方向
            y: number = (index - x) / (spriteList.length / 8);
        // 将Sprite放到容器里
        container.addChild(sprite);
        // 并调整Sprite在图片的位置 由于之前有一些计算我没有用变量存放,直接在这里+=了
        sprite.x += width * x;
        sprite.y += height * y;
    }
    // 将容器添加到Pixi.js的舞台正式开始显示
    stage.addChild(container);
    // 将上面的一些数组进行保存
    const config: { frameNumber: number, direction: number, width: number, height: number } = {
        // 每个动作的帧数,因为是8方向需要除以8
        frameNumber: spriteList.length / 8,
        // 每个动作有几个方向 因为传奇素材都是8方向 我直接固定了
        direction: 8,
        // 等分后的宽度
        width: width,
        // 等分后的高度
        height: height,
    };
    // 将拼接的图片转换成动画显示出来
    createAnimation(container, config);
}

// 创建一个显示动画
function createAnimation(container: Container, config: { frameNumber: number, direction: number, width: number, height: number }): void {
    // 将容器转换成纹理
    const texture: Texture = Texture.from(extract.image(container));
    // 创建一个动画显示对象
    const animation: Sprite = Sprite.from(texture);
    // 将动画对象放到Pixi.js的舞台上
    stage.addChild(animation);
    // 因为有张拼接好的Sprite占地方,所以调整一下动画的x坐标,别盖住了,x坐标正好是那个Sprite的宽度
    animation.x += container.width;
    // 动画的y坐标是那个Sprite的高度的一半
    animation.y += container.height / 2;
    // 将动画的点击交互打开
    animation.interactive = true;
    // 鼠标放到动画对象上会自动变成点击打开
    animation.buttonMode = true;
    // 监听动画触摸点击交互松开的时间
    animation.on("pointerup", function (): void {
        // 将配置转换成Blob对象为变成配置文件做准备
        const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }),
            // a标签模拟下载点击
            a: HTMLAnchorElement = document.createElement("a"),
            // 一个bug的解决方法 生成的图片的宽度和高度可能会比配置文件小点,需要绘制一个不影响观看的小点在需要的配置宽度高度那里
            graphics: Graphics = new Graphics();
        // 将这个小点放到容器里
        container.addChild(graphics);
        // 设置小点的颜色和透明度 为了影响特别小所以为0.001
        graphics.beginFill(0, 0.001);
        // 绘制一个小点在0,0的位置
        graphics.drawCircle(1, 1, 1);
        // 绘制一个小点在配置的宽度高度那里
        graphics.drawCircle(config.width * config.frameNumber - 1, config.height * config.direction - 1, 1);
        // 生成配置文件的名称
        a.download = Date.now() + ".json";
        // 生成配置文件的路径
        a.href = URL.createObjectURL(blob);
        // 模拟点击下载
        a.click();
        // 生成图片文件的名称
        a.download = Date.now() + ".png";
        // 生成图片的路径
        a.href = extract.base64(container);
        // 模拟点击下载
        a.click();
    });
    // 一个实现动画的API
    requestAnimationFrame(loop);
    // 动画时间间隔的判断参数
    let time: number = Date.now(), index = 0;
    // 动画不断执行的方法
    function loop(): void {
        // 需要不断执行
        requestAnimationFrame(loop);
        // 判断时间间隔不到则结束这个方法
        if (Date.now() - time < 200) return;
        // 更新新的时间戳,为了时间判断
        time = Date.now();
        // 下一帧
        index++;
        // 获取当前显示的动画帧
        let x: number = (index % (config.frameNumber * config.direction)) % config.frameNumber,
            // 获取当前显示的方向
            y: number = ((index % (config.frameNumber * config.direction)) - x) / config.frameNumber;
        // 将整张图片分割成确定帧数的核心
        // 确定角色动作的帧数
        texture.frame.x = config.width * x;
        // 确定角色动作的方向
        texture.frame.y = config.height * y;
        // 角色素材的宽度
        texture.frame.width = config.width;
        // 角色素材的高度
        texture.frame.height = config.height;
        // 更新UV坐标进行显示
        texture.updateUvs();
    }
}

最后的效果就是这样
image.gif

最后

实际上这个教程是我遇到的第一个问题,我没有美术,所以我会编程也没法做出来游戏,从游戏资源网站上找到序列帧,我还没有办法使用,还需要转换成我需要的素材
下一个教程就是将角色动作的序列帧打包成zip在前端内解压缩变成需要的图片和配置文件并且在画布上显示出来
image.png 最后说明一点,Pixi.js只是一个渲染器,很多东西需要实现。但是这不就是另一种乐趣吗?