PixiJS游戏开发应用实践(二)

1,164 阅读4分钟

前言

且说上回,PixiJS游戏开发应用实践(一)分享了一些关PixiJs的基础知识,封装了一个简单的资源预加载器,今天把后续的一些功能API分享出来。

帧动画播放

帧动画是一种常用的动画实现形式,通过将动画的动作对象分解成多张连续的关键帧,然后连续播放从而实现动画。

AnimatedSprite对象

作为一个优秀的渲染框架,pixi当然也包含支持帧动画播放的API-AnimatedSprite对象。它的官方类型说明文档如下:

export declare class AnimatedSprite extends Sprite {
    animationSpeed: number; // 帧动画播放速度
    loop: boolean; // 是否循环播放
    updateAnchor: boolean; // 是否更新动画对应纹理的资源
    onComplete?: () => void; // 播放结束回调(loop = false执行)
    onFrameChange?: (currentFrame: number) => void; // 动画精灵纹理重新渲染时的回调
    onLoop?: () => void; // loop = true时,循环播放时触发的回调
    
    play(): void; // 播放方法
}

帧动画设计图的布局

帧动画,每一帧对应的宽高尺寸是完全一致的,帧数多的情况下可能会多行分布,下方就是一张2行*12列的烟花帧动画资源图,且第二行只有两帧,播放完这帧之后如果再播放就会出现空白动画,因此这一帧是它的最后播放帧。

image.png

API封装

首先我们先设计一下这个帧动画播放类需要传入的参数,列举如下:

interface animateParams {
  /** 帧动画资源的纹理名称 */
  name: string
  /** 行数 */
  rows: number
  /** 列数 */
  columns: number
  /** 最后一行空白的帧数 */
  cutnum?: number
  /** 动画播放速度 */
  speed?: number
  /** 动画是否循环播放 */
  loop?: boolean
  /** 动画播放结束回调(loop为false才会触发) */
  onComplete?: () => void
}

其中的name就是我们上一回讲到的通过预加载器Preloader,预加载的纹理对象名称,大概的思路就是读取其中的每一帧,创建一个单帧尺寸大小的矩形区域,然后用纹理Texture对象加载。

export default class extends AnimatedSprite {
  constructor(option: animateParams) {
    const { name, columns, rows, cutnum = 0, speed = 1, loop = true, onComplete } = option
    const texture = TextureCache[name]
    // 单帧的宽高尺寸大小
    const width = Math.floor(texture.width / columns)
    const height = Math.floor(texture.height / rows)
    // 帧动画播放资源组
    const framesList = []
    // 遍历帧动画图片源创建一个动画纹理数组
    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < columns; j++) {
        // 空白区域不绘制
        if (cutnum && i === rows - 1 && j > cutnum) {
          break
        }
        // 创建一帧的矩形区域
        const recttangle = new Rectangle(j * width, i * height, width, height)
        const frame = new Texture(texture.baseTexture, recttangle)
        framesList.push(frame)
      }
    }
    // 执行AnimatedSprite父对象的播放初始化
    super(framesList)
    this.animationSpeed = speed
    this.loop = loop
    this.onComplete = onComplete
  }
}

const animateSprite = new CreateMovieClip({
  name: 'fire',
  rows: 2,
  columns: 12,
  cutnum: 10,
  speed: 0.5,
  loop: true,
  onComplete: () => {
    console.log('动画播放结束')
  }
})
animateSprite.play()

调用方式也比较简单,创建一个播放实例对象,然后调用play方法即可。

碰撞检测

碰撞检测也是游戏开发中常用的一种功能api。在2D环境下,常见的碰撞类型有以下几种:

  1. 轴对称包围盒(Axis-Aligned Bounding Box),即无旋转矩形
  2. 圆形碰撞

轴对称包围盒(双矩形)

  • 概念:判断任意两个(无旋转)矩形的任意一边是否无间距,来判断是否碰撞 核心算法:
Math.abs(vx) < combinedHalfWidths && Math.abs(vy) < combinedHalfHeights
// vx, vy两个矩形中心点横、纵坐标的距离大小
// combinedHalfWidths、combinedHalfHeights为宽高一半之和的距离

算法缺点:

  1. 局限,两个物体必须是矩形,且不能旋转,即水平和垂直方向对称
  2. 对于一些包含图案(未填满整个矩形)的矩形进行碰撞检测,可能存在精度不足的问题

圆形碰撞

  • 比较两个圆形圆心之间的距离和半径之和的大小 核心算法:
Math.sqrt(Math.pow(circleA.x - circleB.x, 2) + Math.pow(circleA.y - circleB.y, 2)) < circleA.radius + circleB.radius

算法缺点:同轴对称包围盒第二条类似。

事件代理

pixi有自己的一套事件处理系统,支持绘制元素上直接监听事件。但是在开发过程中,发现其中的tap事件在跨端上有bug,即绑定在精灵对象上的tap事件,用户在移动端真机上上下滑动也会触发该事件,导致用户体验非常差。因此针对这种情况,要么做用户touch时长判断到底是click还是scroll事件,但是时间并不可控。因此需要对原有的基础事件类型做一层封装,加上一些支持跨端的联合事件类型。

原理

利用发布-订阅者设计模式,实现事件监听、触发事件回调的功能,这其中就要用到eventemitter3这个特别经典的事件发送者库。

export default class EventManager {
    private static event = new EventEmitter() // 定义一个事件监听对象
    // 定义一些pc+mobile联合事件类型
    public static ALL_CLICK: string = 'tap+click'
    public static ALL_START: string = 'touchstart+mousedown'
    public static ALL_MOVE: string = 'touchmove+mousemove'
    
    /**
    * 事件监听-静态方法,不需要实例化调用,直接通过类来调用
    * @param {String} name-事件名称
    * @param {Function} fn-事件触发回调
    * @param {Object} context-上下文对象
    */
    public static on(
     name: string,
     fn: Function,
     context?: any
    ) {
     EventManager.event.on(name, <any>fn, context)
    }
    
    /**
    * 事件触发
    * @param {String} name-事件名称
    */
    public static emit(name: string, ...args: Array<any>) {
     EventManager.event.emit(name, ...args)
    }
}

定义完上面的事件管理器后,我们知道pixi的继承关系:DisplayObject > Container > Sprite,因此我们需要直接代理DisplayObject-显示对象原型上的onof方法,通过重写原型上的方法:

const on = PIXI.Container.prototype.on
const off = PIXI.Container.prototype.off

/**
 * 代理on监听器
 */
function patchedOn<T extends PIXI.DisplayObject>(
  event: string,
  fn: Function
): T {
  // 针对联合事件单独处理
  switch (event) {
    case EventManager.ALL_CLICK:
      // tap+click联合事件
      on.call(this, EventManager.TOUCH_CLICK, fn)
      return on.call(this, EventManager.MOUSE_CLICK, fn)
    case EventManager.ALL_START:
      on.call(this, EventManager.TOUCH_START, fn)
      return on.call(this, EventManager.MOUSE_DOWN, fn)
    case EventManager.ALL_MOVE:
      on.call(this, EventManager.TOUCH_MOVE, fn)
      return on.call(this, EventManager.MOUSE_MOVE, fn)
  }
  return on.apply(this, arguments)
}
// off监听器原理一样
// 重新反向赋值
PIXI.Container.prototype.on = patchedOn
PIXI.Container.prototype.off = patchedOff

使用方式也很简单,需要将下面的代理模块引入,然后直接调用EventManager模块的方法:

// 初始化事件监听器
EventManager.on('circle', (data: unknown) => {
    console.log(data)
})
circle.on(EventManager.ALL_CLICK, (e) => {
    // 触发事件
    EventManager.emit('circle', e.type)
})
// 监听`ALL_CLICK`联合事件就不会出现滑动触发`tap`事件的bug

后记

本次是这个系列的终篇,针对PixiJs做了一个比较深入的应用实践总结,分享给大家,希望有所帮助。后续针对游戏开发这个方向,会继续分享更多实用的内容,大家一起进步,有问题评论区码字,也欢迎指错勘误!

参考

1. “等一下,我碰!”——常见的2D碰撞检测

2. pixijs官方文档