[Phaser]手摸手👬带你画UI写交互

6,849 阅读11分钟

什么是Phaser

开局一个canvas, 剩下的全靠自己画

Phaser 是一款快速、免费以及开源 HTML5 游戏框架,它支持 WebGL 和 Canvas 两种渲染模式,可以在任何 Web 浏览器环境下运行,允许使用 JavaScript 和 TypeScript 进行开发.

因为将自己定位为游戏框架,所以Phaser在游戏功能方面显得相当全面,你能想得到的绝大部分功能Phaser已经替你实现了。

Phaser的仓库地址: github.com/photonstorm…

为什么用Phaser

  • 作者Richard Davey从2013年一直频繁持续迭代至今, 最新稳定版本是3.23, 目前有27k+的star, 开发团队对issue的响应很迅速
  • 使用MIT开源协议
  • Phaser从3.0版本采用自己的渲染引擎, 优秀的性能和兼容性
  • 丰富的官方demo与第三方开源插件
  • 支持TypeScript
  • 适合我们的接近dom的业务场景, 比如提供关于层级, 列表的实现
  • 轻量, 快速上手, 适合独立游戏开发和小团队快速开发中小型游戏项目

脚手架及模版项目

见我的文章: [前端工程化]手摸手👬从零打造自己的cli

Phaser中的一些概念

游戏实例game

const game: Phaser.Game = new Phaser.Game(gameConfig)

场景scene

scene可以被认为是一个独立的世界,它拥有自己的相机系统、显示列表、更新步骤、事件处理、物理系统等等

相机camera

相机功能在 Phaser 3 中完全重建了,因为相机功能在 Phaser 2 中的发挥非常有限,例如对相机执行一些 scale 操作就可能引发各种各样的问题,因此在 Phaser 3 中针对这些问题进行了优化,在 Phaser 3 依然保留跟随、移动、摇晃、淡化等相机功能,新增了 3D、缩放、旋转以及多组相机的功能。

原点origin

默认中心为原点即.5, 可设置[0, 1]的中心点

img.setOrign(x, y)
img.orignX = x
img.orignY = y

游戏实例配置

当创建game实例时, 需要传入配置对象, 常用的配置项如下

const gameConfig: Phaser.Types.Core.GameConfig = {
    type: Phaser.WEBGL, // 渲染模式
    width: window.innerWidth, // canvas宽
    height: window.innerHeight, // canvas高
    resolution, // 分辨率, 不要设置太高, 不然会设备发热严重
    autoFocus: true, // 自动聚焦
    transparent: true, // 背景透明
}

插件

插件有官方插件如骨骼动画插件SpinePlugin, 第三方插件如标签文字rextagtext, 请注意插件版本与Phaser版本的匹配

注册插件

import '../plugins/SpinePlugin.min.js'
const gameConfig: Phaser.Types.Core.GameConfig = {
    //...
    plugins: {
      scene: [
        {
          key: 'SpinePlugin',
          plugin: SpinePlugin,
          mapping: 'spine'
        }
      ]
    },
    //...
}

场景

场景的常用生命周期

export default class DemoScene extends Phaser.Scene {

  constructor () {
    super('DemoScene')
  }

  /**
   * @description 初始化, 接收上一个场景传递的数据
   */
  init (data) {

  }

  /**
   * @description 预加载
   */
  preload () {

  }

  /**
   * @description 创建UI对象
   */
  create () {

  }

  /**
   * @description 刷新帧
   */
  update () {

  }

}

场景过渡

this.scene.transition({
    // 下一个场景的key
    target: nextSceneName,
    // 是否在下面移动
    moveBelow: false,
    // 过渡时间
    duration: 550,
    // 传递给下一个场景的数据
    data,
})

过渡事件

场景可以监听入场和出场的过渡事件

this.events.on('transitionstart', (fromScene, duration) => {})
this.events.on('transitionout', (toScene, duration) => {})

过渡动画

可以用camera实现过渡动画, 如渐入渐出, 水平垂直移入移出, 当然, 还可以用shader实现过渡动画

this.cameras.main.x = this.screenWidth * this.resolution
this.tweens.add({
    targets: this.cameras.main,
    x: 0,
    y: 0,
    duration: 500,
    onCompleteScope: this,
    onComplete: () => {
    }
})

也可用container实现过渡动画

const container = this.add.container(0, 0, this.children.getAll())
container.x = window.innerWidth
this.tweens.add({
    targets: container,
    x: 0,
    duration: 500,
    onCompleteScope: this,
    onComplete: () => {
    }
})

资源加载

preload生命周期里加载资源会自动开始加载, 在create生命周期里加载资源需要手动调用

this.load.start()

加载图片资源

this.load.image('aoligei', url)

加载音频资源

this.load.audio('aoligei', url)

加载帧动画

this.load.multiatlas('aoligei', jsonUrl)

加载骨骼动画

this.load.spine('aoligei', jsonUrl,  atlasUrl)

加载相关事件

// 加载进度
this.load.on('progress', e => {}}
// 加载完成
this.load.on('complete', handler)
this.load.once('complete', handler)

添加对象

资源加载完成了, 接下来介绍如何呈现这些资源

添加纹理

const img: Phaser.GameObjects.Image = this.add.image(0, 0, 'aoligei')
img.setOrigin(0, 0)
img.setDisplaySize(100, 100)
// 更新纹理
img.setTexture('aoligeiaoligei')

声音

const sound: Phaser.Sound.BaseSound = this.sound.add('aoligei')
sound.play()
sound.once('complete', () => {
  console.warn('播放完毕')
})

文字

const text: Phaser.GameObjects.Text = this.add.text(0, 0, 'aoligei', {
  color: '#030303',
  fontSize: 14,
  fontFamily: 'PingFangSC-Regular,PingFang SC',
})
// 更新样式
text.setStyle({
  color: '0x353535'
})
// 更新文案
text.setText('aoligei!')

tagText

带样式的文字, 可自定义标签和对应的样式

文档: rexrainbow.github.io/phaser3-rex…

demo: codepen.io/rexrainbow/…

const tagText = new rextagtext(
  this,
  0,
  0,
  '<class="highlight">aoligei</class>',
  {
    fontSize: 19,
    align: 'left',
    fontFamily: 'PingFangSC-Regular,PingFang SC',
    color: '#030303',
    wrap: {
      mode: 'word',
      width: 200
    },
    tags: {
      highlight: {
        color: '#ff6666',
      },
    }
  }
)

几何图形

常用的几何图形有 圆型, 矩形, 圆角矩形, 三角形

参考: labs.phaser.io/index.html?…

容器

Phaser.GameObjects.Container容器, 顾名思义, 容纳物体的器皿, 可参考dom里的div, 可批量处理元素的定位与动效

// 场景下添加容器
const container = this.add.container(0, 0)
const img: Phaser.GameObjects.Image = this.add.image(0, 0, 'aoligei')
// 元素命名
img.setName('aoligeiImg')
container.add(img)
// 获取某个命名的子元素
container.getByName('aoligeiImg')

层级

默认层级

根据顺序先后来决定层级关系的,越往后创建的层级越大, 显然不满足我们日常的产品开发需求

设置层级

Phaser里的depth相当于CSS中的z-index, depth值越大, 层级越高

const aoligei: Phaser.GameObjects.Sprite = this.add.sprite(0, 0, 'aoligei')
aoligei.depth = 1
aoligei.setDepth(10)

添加动画

Phaser为我们提供了丰富的api让我们能快速实现炫酷的动画效果

补间动画Tween

// Phaser 3 可配置参数一览
this.tweens.add({
    targets: [sprite1, sprite2, sprite3], // 允许单个或多个游戏对象
    paused: false, // 初始是否为暂停状态
    callbackScope: tween,
    
    onStart: function() { }, // 开始时执行回调
    onStartScope: callbackScope,
    onStartParams: [],
    
    delay: 0, // 第一次播放前的停顿时长
    
    duration: 1000, // 动画总时长
    ease: 'Linear', // 提供了多达 44 种动画速度曲线
    easeParams: null,
    
    onUpdate: function() { }, // 补间更新时执行回调
    onUpdateScope: callbackScope,
    onUpdateParams: [],

    hold: 0, // 反向播放前停顿的时长
    yoyo: false, // 是否反向播放
    flipX: false, // 动画结束后,元素是否 X 轴翻转
    flipY: false, // 动画结束后,元素是否 Y 轴翻转
    onYoyo: function() { }, // 反向播放时执行回调
    onYoyoScope: callbackScope,
    onYoyoParams: [],
    
    repeat: 0, //重复播放次数,-1 : infinity
    onRepeat: function() { } // 重复播放时执行回调
    onRepeatScope: callbackScope,
    onRepeatParams: [],
    repeatDelay: 0, // 重复播放之前停顿的时长

    loop: -1, // 循环次数 -1 : infinity
    onLoop: function() { }, // 循环播放时执行回调
    onLoopScope: callbackScope,
    onLoopParams: [],
    loopDelay: 0, // 停顿多久的时长进入下一次循环
	
    completeDelay: 0, // 动画完成前的停顿时间
    onComplete: function () {}, // 动画结束后执行回调
    onCompleteScope: callbackScope,
    onCompleteParams: [],
    
    // 属性值
    x: '+=600',
    y: 500,
    rotation: ...,
    angle: ...,
    alpha: ...,
    // ...
    
    // 或者
    props: {
        x: { value: '+=600', duration: 3000, ease: 'Power2' }
        y: { value: '500', duration: 1500, ease: 'Bounce.easeOut' }
    },
    
    // 又或者
    props: {
        x: {
            duration: 400,
            yoyo: true,
            repeat: 8,
            ease: 'Sine.easeInOut',
            value: {
                getEnd: function (target, key, value) {
                    destX -= 30
                    return destX
                },
                getStart: function (target, key, value) {
                    return value + 30
                }    
            }
        },
        ....
    },
    offset: null, 
    useFrames: false, // 使用帧或是毫秒
})

时间轴动画

按照时间轴执行多个补间动画

两种常用的创建时间轴动画的方法

const timeline: Phaser.Tweens.Timeline = this.tweens.createTimeline()
timeline.add({
    targets: gameObject,
    x: 400,               // '+=100'
    ease: 'Linear',       // 'Cubic', 'Elastic', 'Bounce', 'Back'
    duration: 1000,
    repeat: 0,            // -1: infinity
    yoyo: false,
    // offset: '-=500',   // starts 500ms before previous tween ends
})
timeline.play()

如下创建的时间轴动画会立即播放

const timeline: Phaser.Tweens.Timeline = this.tweens.timeline({
  targets: gameObject,
  ease: 'Linear',       // 'Cubic', 'Elastic', 'Bounce', 'Back'
  duration: 1000,
  loop: 0,
  tweens: [
    {
        targets: gameObject,
        x: 400,               // '+=100'
        // ease: 'Linear',       // 'Cubic', 'Elastic', 'Bounce', 'Back'
        // duration: 1000,
        // repeat: 0,            // -1: infinity
        // yoyo: false,
        // offset: '-=500',   // starts 500ms before previous tween ends
    },
    // ...
  ]
})

骨骼动画Spine

骨骼动画相较帧动画的优势

  • 更小的文件体积:因为传统逐帧动画需要提供每一帧图片,Spine 需要保存动画数据以及骨骼所需的图片。
  • 美术需求:Spine 动画需要的美术资源更少。
  • 流畅性:Spine 动画使用差值算法计算中间帧,动画总能保持流畅效果。
  • 换肤:图片绑定在骨骼上,能轻松实现换肤效果。
  • 混合动画:允许多个动画进行混合,比如一个游戏人物一边开枪,同时跑动、跳跃、旋转。
  • 程序控制:可以通过代码来控制骨骼动画的状态。

如下第一张图的骨骼动画, 可实现后面5种帧动画的效果

动画缓动贝塞尔曲线

常用缓动函数 参考网站: easings.net/

Power0 : Linear
Power1 : Quadratic.Out
Power2 : Cubic.Out
Power3 : Quartic.Out
Power4 : Quintic.Out
Linear
Quad : Quadratic.Out
Cubic : Cubic.Out
Quart : Quartic.Out
Quint : Quintic.Out
Sine : Sine.Out
Expo : Expo.Out
Circ : Circular.Out
Elastic : Elastic.Out
Back : Back.Out
Bounce : Bounce.Out
Stepped
Quad.easeIn
Cubic.easeIn
Quart.easeIn
Quint.easeIn
Sine.easeIn
Expo.easeIn
Circ.easeIn
Back.easeIn
Bounce.easeIn
Quad.easeOut
Cubic.easeOut
Quart.easeOut
Quint.easeOut
Sine.easeOut
Expo.easeOut
Circ.easeOut
Back.easeOut
Bounce.easeOut
Quad.easeInOut
Cubic.easeInOut
Quart.easeInOut
Quint.easeInOut
Sine.easeInOut
Expo.easeInOut
Circ.easeInOut
Back.easeInOut
Bounce.easeInOut

自定义缓动参数easeParams

timeline.add({
  targets: gameObject,
  scale: 1,
  alpha: 1,
  duration: 1500,
  ease: 'Elastic',
  easeParams: [1, 0.5]
})

添加交互

静态视图构建完毕, 接下来添加交互

container添加交互, 常用有两种方式

const container = this.add.container(0, 0)

// 设置尺寸后添加交互
container.setSize(90, 90)
container.setInteractive()

// 设置几何交互区域
container.setInteractive(new Rectangle(0, 0, 100, 100), Phaser.Geom.Rectangle.Contains)

贴图设置交互

const img: Phaser.GameObjects.Image = this.add.image(0, 0, 'aoligei')
img.setOrigin(0, 0)
img.setDisplaySize(100, 100)
img.setInteractive()

指针事件

pointerover移入 pointerout移出 pointerdown按下 pointerup松开

img.on('pointerdown', () => {
    img.once('pointerup', e => {
        if (Math.abs(e.downX - e.upX) < 5 && Math.abs(e.downY - e.upY) < 5) {
            console.warn('按下又松开而且按下的点和松开的点很接近')
        }
        
    })
})

滚轮事件

Phaser暂时不支持滚轮事件, 可hack实现

// 注册鼠标滚轮监听
registerWheelListener () {
    var prefix = '',
      _addEventListener,
      onwheel,
      support
    // detect event model
    if (window.addEventListener) {
      _addEventListener = 'addEventListener'
    } else {
      _addEventListener = 'attachEvent'
      prefix = 'on'
    }
    // detect available wheel event
    support =
      'onwheel' in document.createElement('div')
        ? 'wheel' // 各个厂商的高版本浏览器都支持"wheel"
        : (document as any).onmousewheel !== undefined
        ? 'mousewheel' // Webkit 和 IE一定支持"mousewheel"
        : 'DOMMouseScroll' // 低版本firefox
    ;(window as any).addWheelListener = function(elem, callback, useCapture) {
      _addWheelListener(elem, support, callback, useCapture)
    
      // handle MozMousePixelScroll in older Firefox
      if (support == 'DOMMouseScroll') {
        _addWheelListener(elem, 'MozMousePixelScroll', callback, useCapture)
      }
    }
    function _addWheelListener(elem, eventName, callback, useCapture) {
      elem[_addEventListener](
        prefix + eventName,
        support == 'wheel'
          ? callback
          : function(originalEvent) {
              !originalEvent && (originalEvent = window.event)
    
              // create a normalized event object
              var event = {
                // keep a ref to the original event object
                originalEvent: originalEvent,
                target: originalEvent.target || originalEvent.srcElement,
                type: 'wheel',
                deltaMode: originalEvent.type == 'MozMousePixelScroll' ? 0 : 1,
                deltaX: 0,
                deltaZ: 0,
                preventDefault: function() {
                  originalEvent.preventDefault
                    ? originalEvent.preventDefault()
                    : (originalEvent.returnValue = false)
                }
              }
              // calculate deltaY (and deltaX) according to the event
              if (support == 'mousewheel') {
                ;(event as any).deltaY = (-1 / 40) * originalEvent.wheelDelta
                // Webkit also support wheelDeltaX
                originalEvent.wheelDeltaX && (event.deltaX = (-1 / 40) * originalEvent.wheelDeltaX)
              } else {
                ;(event as any).deltaY = originalEvent.detail
              }
              // it's time to fire the callback
              return callback(event)
            },
        useCapture || false
      )
    }
}

然后监听滚轮事件

window['addWheelListener'](this.game.canvas, e => {
  const handleWheel: Function = e => {
    console.warn('e.deltaY', e.deltaY)
    e.preventDefault()
  }
  let timeId = null
  window.clearTimeout(timeId)
  timeId = window.setTimeout(() => {
    handleWheel(e)
  }, 200)
})

input

可设置只触发上层元素的交互, 防止点击穿透

this.input.topOnly = true

物理引擎

在 Phaser 2 中已集成了三款物理引擎,分别是:Arcade、P2 和 Ninja,在 Phaser 3 中又新增了对 Matter.js 物理引擎的支持,Matter.js 是一款非常优秀的物理引擎,你可以到 Github - Matter.js 查看所有的 Demo 案例。官方为了可以更轻松的管理 Phaser 游戏引擎和 Matter.js 物理引擎的碰撞,在 Phaser 3 上对 Matter.js 的 API 封装推出了 phaser-matter.collision-plugin 插件来降低开发成本。

适配

适配的思路, 可利用相机缩放让内容区域缩放到最合适的大小, 然后利用相机移动让内容区域水平居中或者垂直居中, 这样实现了全局的适配, 开发时按照UI稿的尺寸和坐标添加元素即可, 类似px-to-rempx-to-vw方案, 开发体验很nice

可以参考我的这篇文章: juejin.cn/post/684490…

实践

兼容问题

菊厂手机里绘制带圆角的矩形出现线条 解决办法, 绘制圆角矩形后生成纹理, 再添加贴图

const graphics: Phaser.GameObjects.Graphics = this.add.graphics()
graphics.fillStyle(0xF56327) 
graphics.fillRoundedRect(0, 0, 125, 125, 8)
graphics.generateTexture('roundRectTexture', 125, 125)
graphics.destroy()
const image: Phaser.GameObjects.Image = this.add.image('roundRectTexture')
image.setOrigin(0)

vconsole

移动端开发工具, 可让其隐藏, 在一定时间内连续点击某一区域后展示, 以便查看定位问题

const VConsole = require('vconsole')
const vConsole = new VConsole({
    defaultPlugins: ['system', 'network', 'element', 'storage'], // 可以在此设定要默认加载的面板
    maxLogNumber: 1000
})
const __vconsole = document.getElementById('__vconsole')
__vconsole.style.display = 'none'
const div = window.document.createElement('div')
window.document.body.appendChild(div)
div.setAttribute('class', 'debug')
div.style.opacity = '0'
let clickedCount = 0
let timer
let hasShowed = false
div.addEventListener('click', () => {
if (++clickedCount >= 10 && !hasShowed) {
  hasShowed = true
  if (__vconsole && __vconsole.style) {
    __vconsole.style.display = 'block'
  }
}
if (!timer) {
  timer = setTimeout(() => {
    clickedCount = 0
    window.clearTimeout(timer)
    timer = null
  }, 5000)
}
})

性能监控

可查看帧率, 内存占用情况

function showPerformance () {
  var script = document.createElement('script')
  script.onload = function () {
    var stats = new window['Stats']()
    stats.dom.style.setProperty('position', 'absolute')
    stats.dom.style.setProperty('top', '0')
    stats.dom.style.setProperty('left', 'auto')
    stats.dom.style.setProperty('right', '0')
    stats.dom.style.setProperty('z-index', '999')
    document.body.appendChild(stats.dom)
    requestAnimationFrame(function loop() {
      stats.update()
      requestAnimationFrame(loop)
    })
  }
  script.src = '//mrdoob.github.io/stats.js/build/stats.min.js'
  document.head.appendChild(script)
}

体验优化

  • 防暴力点击
  • 合理的扩展点击区域
  • 控制好动画和音频播放的节奏

性能优化

  • 压缩图片, 合并小碎图
  • 图片声音等资源的双cdn切换
  • 能用Graphics绘制的UI不要用图片
  • 尽量复用对象, 如Image, Text, Graphics, 移出场景不再复用的元素及时销毁, 及时销毁
  • 对于需要反复销毁创建的元素, 可用对象池
  • 对事件监听的及时销毁
  • 利用好场景的update, 不要创建延迟定时器和周期定时器
  // 在基类场景中用`update`实现周期定时器, 并在`dispose`时自动清除定时器, 或用`timeId`手动清除定时器
  /**
   * @description 获取当前时间
   */
  protected now (): number {
    return (performance && performance.now instanceof Function && performance.now()) || new Date().getTime()
  }

  /**
   * @description 设置周期定时
   * @param callback 回调函数
   * @param duration 定时周期
   */
  protected setInterval (callback: Function, duration: number): number {
    const startTime: number = this.now()
    const currentIntervalId: number = this.intervalId
    this.intervalMemory[this.intervalId] = {
      duration,
      startTime,
    }
    const handleUpdate: Function = (): void => {
      const now: number = this.now()
      const delta: number = now - this.intervalMemory[currentIntervalId].startTime
      if (delta >= duration) {
        this.intervalMemory[currentIntervalId].startTime = now
        callback()
      }
    }
    this.intervalMemory[this.intervalId].handleUpdate = handleUpdate
    this.events.on('update', handleUpdate)
    return this.intervalId++
  }

  /**
   * @description 清除周期定时器
   * @param timerId 定时器id
   */
  protected clearInterval (timerId: number): void {
    if (this.intervalMemory[timerId] && this.intervalMemory[timerId].handleUpdate instanceof Function) {
      this.events.off('update', this.intervalMemory[timerId].handleUpdate)
      delete this.intervalMemory[timerId]
    }
  }
  
  /**
   * @description 销毁
   */
  dispose () {
    Object.keys(this.intervalMemory).forEach(intervalId => {
      this.clearInterval(Number(intervalId))
    })
  }

  • gameConfig的分辨率resolution不要太大, 不然会引起设备的发热
  • 请勿反复销毁创建game实例, 会造成内存泄漏memory leak, 见我提给作者的issue: github.com/photonstorm…
  • 代码复用, do not repeat yourself, 用sonar检查
  • 冗余代码的tree shaking
  • 非核心代码的按需动态加载, 可利用dynamic import配合webpackdmagic comment, 提升code coverage, 可利用chrome-dev-tool code coverage查看
  • 避免内存泄漏, 可利用chrome-dev-tool momory snapshoot, 对比内存变化

学习资料