游戏0到1 - tap bird - 基于pixijs

836 阅读7分钟

codesandbox.io/s/tap-bird-… 访问页面查看效果

image.png

对每个步骤进行了单独的编码,可以对照这代码进行阅读,更易于理解

初识别PIXI

网上文章比较多,这里就不展开了,如果你对游戏引擎还不了解,欢迎阅读下面的内容

H5场景小动画实现之PixiJs实战 - 知乎 (zhihu.com)

api手册 PixiJS API Documentation

开始

素材准备

我们的图片素材放在了 public/assets 里面,编译后相对程序文件的读取位置是 ./assets/bird.png 这样的路径

image.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里面内置了大量的类定义,彼此之间有比较强的继承关系,比如 ContainerSprite 都是继承于 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);

材质的生成,除了可以来自与图片,还可以

  1. 从已经存在的材质里面拉取,相当于可以对现有材质进行剪切
  2. 可以直接用绘图工具类绘制简单的图形等内容,比如一个圆,一个三角形
  3. 也可以从显示对象上面直接扣取下来,比如我们可以把整个游戏界面的图扣下来,生成新的材质,然后存储为图片,做成屏幕截图

这部分代码,可以查看 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 优化代码结构与懒加载

至此,我们发现了一些问题

  1. 资源加载延迟的问题可能会导致界面显示不正常
  2. 在未来我们还需要增加很多类的创建和逻辑代码,全部写在一个流程里面不容易维护

针对上面的问题,我们做如下的调整

自定义 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;
  }
}

然后我们在 Applicationtick 调用它进行刷新

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 添加障碍物

一个障碍物,具有上下两个管子

image.png

而我们的素材文件是

image.png

还记得前面讲的材质的来源吗,我们可以将一个图片进行分割,生成左右两个材质。结合前面 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 绘图层级

image.png

现在你会发现障碍物把地板挡住了,这是因为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 加入小鸟

  1. 使用 AnimatedSprite 类进行小鸟的创建
  2. 任意时刻小鸟有一个速度 vy, 初始值是0,每次更新位置时应该是 this.y += vy
  3. 小鸟的速度不是一成不变的,他会受到重力影响, 因此每次刷新位置之前我们还要更新速度 this.vy += a,a 为常量,根据需要调整
  4. 再点击屏幕的时候,小鸟应该获得一个向上的速度,this.y = -8(上为负数),由于还是有加速度的的存在,这个速度没一会儿他就会变成向下了,因此小鸟最终还是会向下运动

step 9 碰撞检测

  1. 使用getBounds 获取元素的x, y, width, height,两个元素之间如何进行交叉判断,即判断A矩形区域的四个点在不在B矩形区内
  2. 我们会发现小鸟的视觉范围包含了一些透明的部分,直接用这个矩形区判断会有那么点不准确,我们可以重写 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 计分板

image.png

计分板使用 Container 进行包裹,将分数转成字符串,然后遍历使用精灵进行元素创建

写在最后

如果你有需要可以私信我,欢迎提出指教,感谢阅读