前言
最近项目开发中需要做一款类似于商场里的口红机游戏,看到GitHub上有推荐phaser作为游戏/动画框架,学习并开发了一款,把学习过程和开发过程中遇到的问题记录一下。
先看一下最终实现效果:
开始
安装 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,后更新至最新版本后解决了问题。
结束
至此,这个小游戏的开发就结束了。