phaser3+react做一款类似口红机的小游戏

前言

最近项目开发中需要做一款类似于商场里的口红机游戏,看到GitHub上有推荐phaser作为游戏/动画框架,学习并开发了一款,把学习过程和开发过程中遇到的问题记录一下。

先看一下最终实现效果:

小兔子收萝卜.gif

开始

安装 npm install phaser

import Phaser, { Game } from 'phaser'
import LoadScene from './scenes/LoadSence'

constructor (props) {
    super(props)
    this.state = {
      config: {// game配置
        type: Phaser.AUTO,
        width: 1920,
        height: 1080,
        transparent: true,
        parent: 'canvas',
        scene: [//场景设置
          LoadScene...
        ]
      }
    window.game = new Game(this.state.config)
  }

在Phaser3中用一个对象作为游戏的构造器,这个对象包含了游戏的各种配置属性。

type的属性可以是Phaser.CANVAS、Phaser.WEBGL或Phaser.AUTO,它决定了游戏的渲染模式。一般推荐设置为Phaser.AUTO,渲染器将尝试使用WebGL模式,如果浏览器或设备不支持,它将使用Canvas模式。

width和height是Phaser设置画布的大小。可以在config中指定父容器,canvas的div的ID。

在Phaser3中,我们把scene添加到一个数组中,再把这个数组添加到config对象的scene属性中。默认情况下,游戏将自动从数组的第一个scene启动。

至此,一个游戏实例就可以加载了。

游戏场景

根据游戏的需求,就可以开始定义我们的第一个游戏场景scene了。

import Phaser, {Scene} from 'phaser'
const launch = require('assets/img/needle/launch1.png')
const target = require('assets/img/needle/plate.png')
const sitRadish = require('assets/img/needle/radishActive.png')
const radish = require('assets/img/needle/radish.png')
const radishBg = require('assets/img/needle/rightBg.png')
const pass1 = require('assets/img/needle/pass1.png')
const pass2 = require('assets/img/needle/pass2.png')
const pass2A = require('assets/img/needle/pass2A.png')
const pass3 = require('assets/img/needle/pass3.png')
const pass3A = require('assets/img/needle/pass3A.png')

const json = {
  'knife': launch,
  'target': target,
  'sitRadish': sitRadish,
  'radish': radish,
  'radishBg': radishBg,
  'pass1': pass1,
  'pass2': pass2,
  'pass2A': pass2A,
  'pass3': pass3,
  'pass3A': pass3A
}

class LoadSence extends Scene{
      constructor(){
        super("load")
      }
      preload(){
          this.loadAssets(json)
      }
     loadAssets (json) {
        Object.keys(json).forEach((key) => {
          this.load.image(key, `${json[key]}`)
        })
      }
      create(){
         this.scene.start('passN', {number: 0})
      }
      update(){

      }
    }

在Phaser游戏中,scene创建时会先运行preload函数,用来预加载图片,然后运行create函数,执行初始化代码,最后在每一步中调用update函数更新。

游戏中我定义第一个scene来加载我所需要的图片。在加载图片的过程中遇见了第一个问题。
运行后有一些图片报错无法加载,并且无法加载的图片URL console.log出来是base64格式(Local data URIs are not supported...)这与webpack处理图片资源中url-loader的配置有关系。

 {
  test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
  loader: require.resolve('url-loader'),
  options: {
  limit: 10000,
  name: 'static/media/[name].[hash:8].[ext]'
  }
 }

limit这个参数是针对图片的大小,如果小于设置的size,会自动将图片转成base64格式,导致加载不成功。

第二个场景

根据游戏的需求,这款游戏有三关,所以在游戏开始的时候要确定是哪一关,就有了第二个场景passN。

import { Scene } from 'phaser'

class PassScene extends Scene {
  constructor () {
    super('passN')
  }
  init (data) {
    this.number = data.number
  }
  create () {
  } 
}

这里涉及到场景之间传值、添加sprit图、添加文字和配置tween(补间动画)。

1.场景之间传值
//load场景中调用passN场景
this.scene.start('passN', {number: 0})
//passN场景中
init (data) {
  this.number = data.number
}
2.创建精灵图、添加文字
//css中自定义字体
@font-face {
  font-family: 'Needle'
  src:  url('~assets/needle.ttf')
  font-weight: normal
  font-style: normal
}
//passN中引用字体并配置
create () {
    var passN = ['一', '二', '三']
   //字体在sprite之上
    this.sprite = this.add.sprite(window.game.config.width / 2, 0, 'passPng')
    var config1 = {
      x: window.game.config.width / 2 - 110,
      y: 0,
      text: '第' + passN[this.number] + '关',
      style: {
        fontSize: '76px',
        fontFamily: 'Needle',
        fill: '#f08f3f',
        align: 'center'
      }
    }
    this.text = this.make.text(config1)
  }
3.创建补间动画
gameStart () {
    this.tweens.add({
      targets: [this.sprite, this.text],//tweens动画目标
      ease: 'Bounce',//运动方式
      y: window.game.config.height / 2,// 目标的y坐标,
      duration: 1000,//动画时间
      callbackScope: this,//回调函数的this值
      onComplete: () => {//完成时的回调函数
        setTimeout(() => { this.scene.start('game', {pass: this.number}) }, 500)
      }
    })
  }

Phaser3中的补间动画特别方便,我们只用简单地给一个对象传递了补间动画的配置属性,就可以实现动画效果。
上面的代码就是将文字和sprite图从屏幕上方运动至中间的一个简单的动画效果,在运动结束后就正式进入游戏场景。

动起来的游戏

下面就是主要的游戏场景了,有转动的盘子,从右边发射的胡萝卜,还有创建的萝卜精灵组,每关15s的倒计时。
options为三关游戏的配置。

const options = [
  {// 第一关
    rotationSpeed: 3,//转动速度
    throwSpeed: 150,//目标萝卜飞出的速度
    minAngle: 20,//旋转度的距离
    radishNum: 6//每一关的萝卜数
  }, {// 第二关
    rotationSpeed: 4.5,
    throwSpeed: 150,
    minAngle: 20,
    radishNum: 8
  }, {// 第三关
    rotationSpeed: 4,
    throwSpeed: 150,
    minAngle: 20,
    radishNum: 8
  }
]

然后创建game中的游戏场景。

import Phaser, { Scene } from 'phaser'
import options from '../constants/options'

class GameScene extends Scene {
  constructor () {
    super('game')
  }

  preload () {

  }

  init (data) {
    this.pass = data.pass
    this.translate = true
  }

  create () {
    // radish背景
    this.radishBg = this.add.sprite(window.game.config.width - 241 - 163, -30, 'radishBg').setOrigin(0)

    this.total = 15
    var config1 = {
      x: 1570,
      y: 130,
      text: '15秒',
      style: {
        fontSize: '70px',
        fontFamily: 'Needle',
        fill: '#f08f3f',
        align: 'center'
      }
    }
    this.secondText = this.make.text(config1)
    // 创建定时器
    this.time.addEvent({
      delay: 1000,
      callback: this.updateCounter,
      callbackScope: this,
      loop: true
    })

    this.canThrow = true
    // 创建盘子上knife精灵组
    this.knifeGroup = this.add.group()
    this.knife = this.add.sprite(window.game.config.width / 2 + 400, window.game.config.height / 2, 'knife')

    this.target = this.add.sprite(window.game.config.width / 2 - 250, window.game.config.height / 2, 'target')
    this.target.depth = 1
    // radish组
    this.radishGroup = this.add.group()
    this.creatRadish(options[this.pass].radishNum)
    // 事件
    this.input.on('pointerdown', this.throwKnife, this)
  }
  // 倒计时
  updateCounter () {
    if (this.total > 0) {
      this.total--
      var textNUM = this.total < 10 ? '0' + this.total + '秒' : this.total + '秒'
      this.secondText.setText(textNUM)
    } else {
      //失败事件
    }
  }
 
  // 创建萝卜
  creatRadish (n) {
    let x = 1537 + 50
    let y = 270
    let offsety = n === 6 ? 90 : 70
    for (var i = 0; i < n; i++) {
      y = y + offsety
      this.add.sprite(x, y, 'sitRadish').setOrigin(0)
      this.radishGroup.add(this.add.sprite(x, y, 'radish').setOrigin(0))
    }
  }
  destroySprite () {
    const children = this.radishGroup.getChildren()
    // 销毁精灵
    children[0].destroy()
  }
  update () {
    const children = this.knifeGroup.getChildren()
     // 变速
    if ((this.pass === 2 && this.knifeGroup.children.size > 2 && this.knifeGroup.children.size < 5) || this.pass === 1) {
        this.target.angle -= options[this.pass].rotationSpeed
        this.knifeSpeed(children, 0)
     } else {
        // target转速
        this.target.angle += options[this.pass].rotationSpeed
        this.knifeSpeed(children, 1)
     }
    }
  
  // knife精灵转速精灵转速
  knifeSpeed (children, addOrSub) {
    for (let i = 0; i < children.length; i++) {
      // knife精灵转速
      if (addOrSub) {
        children[i].angle += options[this.pass].rotationSpeed
      } else {
        children[i].angle -= options[this.pass].rotationSpeed
      }
      // knife精灵组的位置
      const radians = Phaser.Math.DegToRad(children[i].angle)
      children[i].x = this.target.x + (this.target.height / 2) * Math.cos(radians)
      children[i].y = this.target.y + (this.target.height / 2) * Math.sin(radians)
    }
  }
  throwKnife () {
    if (!this.canThrow) {
      return
    }
    this.canThrow = false
    // knife扔出动画
    this.tweens.add({
      targets: [this.knife],
      // ease: 'Bounce',
      x: this.target.x + this.target.height / 2,
      duration: options[this.pass].throwSpeed,
      callbackScope: this,
      onComplete: this.onCompleteThrowKnife
    })
  }

  onCompleteThrowKnife () {
    let legalHit = true
    const children = this.knifeGroup.getChildren()
    // 销毁占位萝卜
    this.destroySprite()
    // 用角度判断是否插入成功
    for (let i = 0; i < children.length; i++) {
      const isSameAngle = Math.abs(Phaser.Math.Angle.ShortestBetween(this.target.angle, children[i].impactAngle)) < options[this.pass].minAngle
      if (isSameAngle) {
        legalHit = false
        break
      }
    }

    if (legalHit) {
      this.canThrow = true
      const knife = this.add.sprite(this.knife.y, this.knife.x, 'knife')
      knife.impactAngle = this.target.angle
      this.knifeGroup.add(knife)
      // knife回到原始位置
      this.knife.x = window.game.config.width / 2 + 400
      if (this.knifeGroup.children.size >= options[this.pass].radishNum) {
        this.knife.destroy()
        this.canThrow = false
        if (this.pass < 2) {
          setTimeout(() => {
            this.changePass(this.pass + 1)
            this.scene.start('passN', {number: this.pass})
          }, 500)
        } else {
          //成功事件
        }
      }
    } else {
      // knife碰撞到飞出动画
      this.tweens.add({
        targets: [this.knife],
        x: this.sys.game.config.width + this.knife.height,
        rotation: 5,
        duration: options[this.pass].throwSpeed * 4,
        callbackScope: this,
        onComplete: () => {
          //失败事件
        }
      })
    }
  }
}
1.创建目标盘子、Goup对象集合

将目标盘子转动起来,在update 函数中执行。

this.target.angle += options[this.pass].rotationSpeed

创建发射的萝卜、还有右侧储备的萝卜集合,每次发射之前销毁右边一个萝卜。

2.萝卜发射的逻辑

用一个监听函数来控制萝卜发射。

this.input.on('pointerdown', this.throwKnife, this)

在鼠标按下时检测是否可以发射,如果可以的话就给发射的萝卜添加一个tweens动画,当发射到盘子上以后,要检测是否与其他萝卜有重合。

每次发射的时候将当前转盘的角度保存下来,当下次发射的时候当前盘面旋转度和以往的旋转度距离是否小于最小值,如果小于最小值就执行失败函数,否则就继续。

当扔出萝卜全部结束就继续下一关(执行passN场景)或者挑战成功。

//执行下一关
this.scene.start('passN', {number: this.pass})
//销毁游戏
this.sys.game.destroy(true)
3.update函数中更新位置

游戏设定第一关盘子顺时针转动,第二关逆时针转动,第三关顺逆时针转动。

在update中遍历每一个knifeGroup里面的子萝卜的位置,萝卜移动时计算每一步偏移的角度,从而判断出子萝卜child的x,y位移。

 // knife精灵组的位置
 const radians = Phaser.Math.DegToRad(children[i].angle)
 children[i].x = this.target.x + (this.target.height / 2) * Math.cos(radians)
 children[i].y = this.target.y + (this.target.height / 2) * Math.sin(radians)
4.定时器

每一关设置游戏时间为15s,所以创建定时器,时间结束就是游戏失败。

// 创建定时器
    this.time.addEvent({
      delay: 1000,
      callback: this.updateCounter,
      callbackScope: this,
      loop: true
    })
5.其他

因为在Phaser3中,所有对象的原点默认为它们的中心,可以用setOrigin改变默认原点。例如代码:this.add.sprite(x, y, 'sitRadish').setOrigin(0)可重置原点到图像的左上顶点,这样在绘制过程中会简化一些计算。

刚开始安装的Phaser版本是^3.9,在销毁游戏this.sys.game.destroy(true)时,遇到了报错,后来在github的Issues中找到此问题是一个bug,后更新至最新版本后解决了问题。

结束

至此,这个小游戏的开发就结束了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342