codesandbox.io/s/tap-bird-… 访问页面查看效果
对每个步骤进行了单独的编码,可以对照这代码进行阅读,更易于理解
初识别PIXI
网上文章比较多,这里就不展开了,如果你对游戏引擎还不了解,欢迎阅读下面的内容
H5场景小动画实现之PixiJs实战 - 知乎 (zhihu.com)
api手册 PixiJS API Documentation
开始
素材准备
我们的图片素材放在了 public/assets 里面,编译后相对程序文件的读取位置是 ./assets/bird.png
这样的路径
step 1 建立舞台
游戏的所有视觉动作都在canvas内,为了方便我们做相关的坐标换算等工作,我们假定我们的屏幕宽度是750,高度是1334,这意味着,一张750 x 1334 的图片可以铺满整个游戏。因此我们的 canvas 看起来就会像
<canvas width="750" height="1334" />
然而在实际的设备中,并非所有设备的尺寸都是 750 x 1334,因此我们可能要进行缩放,比如当屏幕宽度是 375 时,则我们缩放尺寸 x0.5。我们利用 style 的宽高属性来进行控制
<canvas width="750" height="1334" style="width: 375px; height: 667px" />
<!-- width, height 表示的是canvas内部的像素尺寸,对应的是内存区中对应的像素 -->
<!-- 而 style 的 width, height 则表示dom的尺寸,是浏览器再将canvs画布的像素
数据进行界面渲染时做的缩放尺寸 -->
结合 pixi
的初始化方法,于是
// this.$refs.canvas 是对应的canvas节点
application = new PIXI.application({
width: 750,
height: 1334,
view: this.$refs.canvas,
backgroundColor: 0x0099ff, // 标记成蓝色,方便观察结果
});
this.$refs.canvas.style.width = this.width + "px";
this.$refs.canvas.style.height = this.height + "px";
step 2 小试牛刀,添加背景
创建完舞台后,我们就可以尝试在这个舞台中添加一些视图元素。PIXI是基于面向对象的方式进行开发的,不同的视觉元素所使用的类不相同,比如
类名 | 描述 |
---|---|
Sprite | 精灵类。常规图片渲染等内容 |
Text | 文本类。主要用来渲染文字的 |
AnimatedSprite | 相比于精灵类,多了动画帧的控制,一般用来做逐帧的动画元素,比如行走的人,飞动的鸟 |
Container | 容器,主要用来做容器包裹 |
PIXI里面内置了大量的类定义,彼此之间有比较强的继承关系,比如 Container
和 Sprite
都是继承于 DisplayObject
,而 AnimatedSprite
继承于 Sprite
,因此很多属性方法以及概念都是相同的
在游戏引擎的世界里面,我们管图片(像素点集合)为材质,在初始化一个精灵类时,我们需要将我们的背景图进行加载,生成一个材质对象,然后用作初始化
// 初始化材质,从静态文件中拉取
const bgTexture = Texture.from("./assets/bg.png");
// 创建精灵对象
const bg = new PIXI.Sprite(bgTexture);
// 配置尺寸,坐标等内容
bg.width = 750;
bg.height = 1334;
bg.x = 0;
bg.y = 0;
// 我们操作的舞台,是在app里面的stage
application.stage.addChild(bg);
材质的生成,除了可以来自与图片,还可以
- 从已经存在的材质里面拉取,相当于可以对现有材质进行剪切
- 可以直接用绘图工具类绘制简单的图形等内容,比如一个圆,一个三角形
- 也可以从显示对象上面直接扣取下来,比如我们可以把整个游戏界面的图扣下来,生成新的材质,然后存储为图片,做成屏幕截图
这部分代码,可以查看 tap-bird - CodeSandbox
step 3 新增地板
接下来我们增加一个地板。我们创建一个容器,并使用两个地板的图片首尾相接,使他成为一个较长的图片,方便后面做动画控制
const ground = new Container();
const texture = Texture.from("./assets/ground.png");
// 添加地板
// 创建两个地板合成一个大底板,用于循环滚动
const ground1 = new Sprite(texture);
ground1.x = 0;
const ground2 = new Sprite(texture);
ground2.x = ground1.width;
ground.height = ground1.height;
ground.width = 2 * ground1.width;
ground.addChild(ground1);
ground.addChild(ground2);
ground.y = 1334 - ground.height;
app.stage.addChild(ground);
注意 可能会出现地板不出现的情况,这是由于资源加载并不是及时的,会导致 width 与 height 访问时存在一些异常,从而导致元素的尺寸和位置存在问题,可以多切换几次刷新效果,我们会在第四步里面解决这些问题
这部分代码,可以查看 tap-bird - CodeSandbox
step 4 优化代码结构与懒加载
至此,我们发现了一些问题
- 资源加载延迟的问题可能会导致界面显示不正常
- 在未来我们还需要增加很多类的创建和逻辑代码,全部写在一个流程里面不容易维护
针对上面的问题,我们做如下的调整
自定义 Application
类代替 PIXI.Application
这部分的工作主要是为了将初始化的逻辑移出入口代码
// vue文件中, 引入自定义的class
import Application from './Application';
// mounted阶段进行调整
{
mounted() {
this.app = new Application({
width: 750, //this.width,
height: 1334, //this.height,
view: this.$refs.canvas,
backgroundColor: 0xffffff,
})
this.$refs.canvas.style.width = this.width + "px";
this.$refs.canvas.style.height = this.height + "px";
},
}
编写 Application
类
import {
Application
} from "pixi.js";
// 继承自 pixi.Application, 未填充初始化逻辑
export default class game extends Application {
constructor(props) {
super(props);
// todo 初始化逻辑
}
}
使用 Loader
进行资源预加载
我们在初始化阶段之前,使用 PIXI 的 Loader
来做资源的预先加载,待加载完成后,在进行元素的创建等工作,因此 Application
类需要改为
import {
Application,
Loader,
Sprite
} from "pixi.js";
// 继承自 pixi.Application, 未填充初始化逻辑
export default class game extends Application {
constructor(props) {
super(props);
// 资源加载,允许跨域,避免图片报错
Loader.shared.add({ url: "./assets/bg.png", crossOrigin: true });
Loader.shared.add({ url: "./assets/ground.png", crossOrigin: true });
Loader.shared.add({ url: "./assets/bird.png", crossOrigin: true });
Loader.shared.add({ url: "./assets/holdback.png", crossOrigin: true });
Loader.shared.add({ url: "./assets/number_2.png", crossOrigin: true });
// 加载完成后触发init函数,使用bind可以避免this指针的改变
Loader.shared.onComplete.once(this.init.bind(this));
// 启动加载
Loader.shared.load();
}
init(){
// 初始化逻辑
// 添加背景
// 材质的引用方式发生了变化
const bg = new Sprite(Loader.shared.resources["./assets/bg.png"].texture);
bg.width = 750;
bg.height = 1334;
bg.zIndex = 0;
this.stage.addChild(bg);
}
}
调整后,材质的引用方式发生了变化
// 原
const bg = new Sprite(Texture.from("./assets/bg.png"));
// 新
const bg = new Sprite(Loader.shared.resources["./assets/bg.png"].texture);
step 5 让地板动起来
按照前面的思想我么,我们将 Ground
也单独封装成一个类出去, 并为它增加一个新的方法, onUpdate
用于更新地板的滚动位置
import { Container, Sprite, Loader } from "pixi.js";
export default class Ground extends Container {
constructor(props) {
super(props);
// 见前面的代码
}
onUpdate() {
// 每次滚动,整个盒子向左(负数)较少10个px, 当超过一屏时,重置为0
this.x = (this.x - 10) % 750;
}
}
然后我们在 Application
用 tick
调用它进行刷新
export default class game extends Application {
init() {
// 新增地板,并做好定位
this.ground = new Ground();
this.ground.y = 1334 - this.ground.height;
this.ground.x = 0;
this.stage.addChild(this.ground);
// tick
this.onUpdate = this.onUpdate.bind(this);
this.ticker.add(this.onUpdate);
}
onUpdate() {
// 刷新地板
this.ground.onUpdate();
}
}
step 6 添加障碍物
一个障碍物,具有上下两个管子
而我们的素材文件是
还记得前面讲的材质的来源吗,我们可以将一个图片进行分割,生成左右两个材质。结合前面 Container
的用法,我们将上下两个管子包装在一个容器
import { Container, Sprite, Texture, Loader } from "pixi.js";
export default class Holdback extends Container {
constructor(offset) {
super();
const HOLDBACK_TEXTURE =
Loader.shared.resources["./assets/holdback.png"].texture;
// 材质剪切生成两个管道对象
this.top = new Sprite(
new Texture(HOLDBACK_TEXTURE, {
x: HOLDBACK_TEXTURE.width / 2,
y: 0,
width: HOLDBACK_TEXTURE.width / 2,
height: HOLDBACK_TEXTURE.height
})
);
this.bottom = new Sprite(
new Texture(HOLDBACK_TEXTURE, {
x: 0,
y: 0,
width: HOLDBACK_TEXTURE.width / 2,
height: HOLDBACK_TEXTURE.height
})
);
// 通过入参对两个管道进行定位处理
// anchor.set 是设置对象的中心点,1表示100%的意思
this.top.anchor.set(0, 1);
this.top.y = offset;
this.bottom.y = this.top.y + 300;
this.addChild(this.top);
this.addChild(this.bottom);
}
}
然后我们在主逻辑代码里面,增加动态创建以及销毁的逻辑
onUpdate(){
// this.holdbacks 是用来保存已经创建的障碍物数组
let temp = [];
for (let i = 0; i < this.holdbacks.length; i++) {
const holdback = this.holdbacks[i];
// 进行位置刷新
holdback.x = holdback.x - 10;
// 判断是否完全已经在屏幕外面
if (holdback.x <= -holdback.width) {
// 从舞台中删除
this.stage.removeChild(holdback);
} else {
// 放入缓存
temp.push(holdback);
}
}
// 未删除的重新存起来,在下个周期继续进行判断
this.holdbacks = temp;
// 生成障碍物体
this.dist -= 10;
if (this.dist % 300 === 0) {
// 生成一个障碍物,this.getNextOffset 会产生一个用于控制缺口位置的数字
const holdback = new Holdback(this.getNextOffset());
holdback.x = 750;
holdback.zIndex = 2;
// 插入到舞台中间
this.stage.addChild(holdback);
this.holdbacks.push(holdback);
this.dist = 0;
}
}
getNextOffset
方法是根据时间使用 sin
生成的
getNextOffset() {
return (
Math.sin((((Date.now() - this.startTime) % 3000) / 3000) * 2 * Math.PI) *
100 +
350
);
}
step 7 绘图层级
现在你会发现障碍物把地板挡住了,这是因为pixi里面,渲染层级和插入的顺序有关系,因此我们需要给他们增加一个渲染层级,并进行排序
我们在元素创建的时候给他指定 zIndex
,然后 update
阶段进行排序
this.ground = new Ground();
this.zIndex = 1;
const holdback = new Holdback(this.getNextOffset());
holdback.zIndex = 0;
// 排序
this.stage.children.sort((a, b) => a.zIndex - b.zIndex);
PIXI 里面本身不支持这个功能, zIndex 属性本身不是做这个用途
step 8 加入小鸟
- 使用
AnimatedSprite
类进行小鸟的创建 - 任意时刻小鸟有一个速度 vy, 初始值是0,每次更新位置时应该是
this.y += vy
- 小鸟的速度不是一成不变的,他会受到重力影响, 因此每次刷新位置之前我们还要更新速度
this.vy += a
,a 为常量,根据需要调整 - 再点击屏幕的时候,小鸟应该获得一个向上的速度,
this.y = -8
(上为负数),由于还是有加速度的的存在,这个速度没一会儿他就会变成向下了,因此小鸟最终还是会向下运动
step 9 碰撞检测
- 使用
getBounds
获取元素的x
,y
,width
,height
,两个元素之间如何进行交叉判断,即判断A矩形区域的四个点在不在B矩形区内 - 我们会发现小鸟的视觉范围包含了一些透明的部分,直接用这个矩形区判断会有那么点不准确,我们可以重写 bird 类的
getBounds
方法(记得先call一下父类的方法)然后将区域调整为 1/2
getBounds(...args) {
const rect = AnimatedSprite.prototype.getBounds.call(this, ...args);
rect.x = rect.x + rect.width / 4;
rect.y = rect.y + rect.height / 4;
rect.width *= 0.5;
rect.height *= 0.5;
return rect;
}
step 10 计分板
计分板使用 Container
进行包裹,将分数转成字符串,然后遍历使用精灵进行元素创建
写在最后
如果你有需要可以私信我,欢迎提出指教,感谢阅读