引言
因为最近要举办世界杯的缘故,刚好自己也在学习canvas,一直没做过canvas的游戏与动画,于是决定尝试自己实现一个2d射门的小游戏,不过因为自己UI不是特别好,所以实现了一个《简易版》,以下内容全部为自己独立思考,实现的过程中,受益良多,希望这篇文章对大家有所帮助。
需求分析
整体需求类似于一个点球大战,长按进行蓄力射门,需求拆解如下:
- 包含一个球。
- 包含一个球场,球碰到球场边界则算作出界。
- 包含一个球门,球进入球门则算进球。
- 包含一个门将,门将会自动左右移动,阻止进球。
- 包含一个蓄力条,控制球的力度。
- 包含一个方向条,控制球射出的方向。
- 包含一个展示日志,用以提示用户信息。
功能实现
在我思考的过程中,我决定把各个功能挨个实现,先从一些基础的且耦合低的功能入手,整体功能使用Class去实现。
游戏类Game
这里我们首先需要一个整体的游戏类Game去驱动整个游戏,游戏整体流程都需要在Game中实现,Game首先要负责的就是初始化canvas并设置整体的宽width高height,Game后续的功能我们再依次实现。
class Game {
constructor() {
this.initCanvas()
}
// 初始化canvas
initCanvas() {
// 设置canvas
this.canvas = document.querySelector('#canvas')
// 设置上下文
this.ctx = this.canvas.getContext('2d')
// 设置整体宽高为500 * 500
this.width = 500
this.height = 500
this.canvas.width = this.width
this.canvas.height = this.height
}
}
const game = new Game()
球场类Field
- 球场类
Field主要的责任是负责渲染整个场地,通过Game传递过来的坐标与宽度等信息,Field包含一个render方法,可以设置边框作为球场线并设置场地为白色。 - 之后我们再
Game类中引入Field,设置初始化方法initField用以实例化Filed,并在Game类提供一个render方法,调用球场的实例对象,渲染球场。
// 球场类
class Field {
constructor(ctx, x, y, width, height) {
this.ctx = ctx
this.x = x
this.y = y
this.width = width
this.height = height
}
render() {
this.ctx.save()
this.ctx.fillStyle = '#fff'
this.ctx.strokeRect(0, 0, this.width, this.height)
// 防止与边框重合
this.ctx.fillRect(1, 1, this.width - 2, this.height - 2)
this.ctx.restore()
}
}
// 游戏类
class Game {
constructor() {
// ...
this.initField()
}
//...
// 初始化球场
initField() {
this.field = new Field(this.ctx, 0, 0, this.width, this.height)
}
// 渲染
render() {
this.field.render()
}
}
const game = new Game()
game.render()
球门类Goal
球门类Goal同样与球场类Field类似于,单纯的创建一个球门类实例对象,初始化,传入坐标与大小,然后渲染在画布上。
// 球门类
class Goal {
constructor(ctx, x, y, width, height) {
this.ctx = ctx
this.x = x
this.y = y
this.width = width
this.height = height
}
render() {
const { ctx,x, y, width, height} = this
ctx.save()
ctx.strokeRect(x, y, width, height)
ctx.restore()
}
}
// 游戏类
class Game {
constructor() {
// ...
this.initGoal()
}
// ...
// 初始化球门
initGoal() {
const y = 50
const width = 250
const height = 100
const x = this.width / 2 - width / 2
this.goal = new Goal(this.ctx, x, y, width, height)
}
// 渲染
render() {
// ...
// 渲染球门
this.goal.render()
}
}
日志类Log
日志类Log主要是在列表上显示一些提示用户的文字,并可以根据需要动态设置一些文字,游戏类Game会传入坐标信息用以初始化Log类,Log类对外暴露一个setText方法,供外部调用。
// 日志类
class Log {
constructor(ctx, x, y) {
this.ctx = ctx
this.x = x
this.y = y
this.text = '长按足球开始游戏'
}
render() {
const { ctx, x, y, text } = this
ctx.save()
ctx.font = '30px san-self'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, x, y)
ctx.restore()
}
setText(text) {
this.text = text
}
}
// 游戏类
class Game {
constructor() {
// ...
this.initLog()
}
// ...
// 初始化日志
initLog() {
this.log = new Log(this.ctx, this.width / 2, 25)
}
// 渲染
render() {
// ...
// 渲染日志
this.log.render()
}
}
力度条类Bar
前几个功能都是静态的绘制,接下里的流程会稍显复杂,这里我们需要实现的是一个力度条的功能,我们将功能拆解为一下几个步骤:
- 构造函数中会被传入上下文、位置信息、大小信息用以渲染
- 会拥有一个
force变量表示当前力度,力度值为0~100 - 提供一个
render函数,根据当前位置、大小、力度进行渲染。 - 提供
setForce、getForce、reset函数,用以设置、获取、重置力度。 - 提供一个
update方法,用累加力度,我们这里的逻辑是不断增长到100%的时候,会归0。 - 提供
start方法,开始定时器不断调用update方法,增加力度。 - 提供
stop方法,关闭定时器,每次start前都进行关闭,确保定时器不会累加。
完成后,我们暂时先不将力度条
Bar类,在游戏类Game中实例化。
// 力度条
class Bar {
constructor(ctx, x = 0, y = 0, width = 20, height = 200) {
this.ctx = ctx
this.width = width
this.height = height
this.force = 0
this.x = x
this.y = y
this.intervalNo = null
this.intervalTime = 10
}
// 渲染边框
renderBorder() {
const { width, height, x, y, ctx } = this
ctx.strokeRect(x, y, width, height)
}
// 渲染力度
renderForce() {
const {x, width, ctx} = this
ctx.save()
ctx.fillStyle = 'green'
const y = this.y + (1 - this.force / 100) * this.height
const height = this.force / 100 * this.height
ctx.fillRect(x, y, width, height)
ctx.restore()
}
// 渲染
render() {
this.renderBorder()
this.renderForce()
}
// 增加力度
update() {
if(this.force >= 100) {
this.setForce(0)
}
this.setForce(this.force + 1)
}
// 重置力度
reset() {
this.setForce(0)
}
// 获取力度
getForce() {
return this.force
}
// 设置力度
setForce(force) {
this.force = force
}
// 开启定时器
start() {
this.stop()
this.intervalNo = setInterval(() => {
this.update()
}, this.intervalTime)
}
// 关闭定时器
stop() {
if(this.intervalNo) {
clearInterval(this.intervalNo)
this.intervalNo = null
}
}
}
游戏类Game:添加渲染动画
因为刚刚的力度条类需要开始处理动画了,但canvas处理动画的本质是不断的清空画布,并重新渲染,于是看似无用的球场类Field起了清空画布的作用,当然球场类的作用不仅仅是清空画布,还有判断边界的作用,我们需要为游戏类Game添加一个animate方法,他会不断的调用游戏类的render函数进行清空渲染。这样,我们的其他的类就只要考虑自身的渲染与逻辑,而不用考虑清空自己的逻辑。
这里我们使用
requestAnimateFrame方法,来代替setInterval或者setTimeout,这个方法告诉浏览器,需要在下次重绘前执行传入的函数,通过递归来实现不断执行。通常浏览器的刷新频率为60hz,即我们以每秒fps60的速度刷新动画。
拆解一下几个步骤:
- 为游戏类
Game新建animate方法,不断调用render进行画布渲染。 - 在游戏类
Game中的构造函数中调用animate方法,使其开始不断刷新。 - 将力度条
Bar在游戏类Game中实例化,并将力度条的render函数添加到游戏类的render方法中。
为了方便测试,我们在
Game类的构造函数中调用力度条实例对象的start方法,查看效果,但后续记得删除。
class Game {
constructor() {
// ...
this.initBar()
this.animate()
// fixme: 测试效果
this.bar.start()
}
// ...
// 初始化力度条
initBar() {
const x = 20
const y = 200
const width = 20
const height = 100
const ctx = this.ctx
this.bar = new Bar(ctx, x, y, width, height)
}
// 渲染
render() {
// ...
// 渲染力度条
this.bar.render()
}
animate() {
this.render()
requestAnimationFrame(() => {
this.animate()
})
}
}
点球点类Mark
点球点类Mark主要做一个标记,没有渲染逻辑,只是方便后续其他类的使用
// 点球点
class Mark {
constructor(x, y) {
this.x = x
this.y = y
}
// 获取罚球点坐标
getMarkPosition() {
return {
x: this.x,
y: this.y
}
}
}
// 游戏类
class Game {
constructor() {
// ...
this.initMark()
this.animate()
}
// ...
// 初始化点球点
initMark() {
const x = this.width / 2
const y = this.height - 45
this.mark = new Mark(x, y)
}
}
方向箭头类Arrow
方向箭头类Arrow主要是在射门时控制一个射门的方向,他会自点球点Maker的坐标生成一个箭头,不断从左右摆动,当射门时,生成一个射门的方向,我们将步骤进行拆解:
- 向方向箭头类
Arrow的构造函数中,传入点球点Maker的实例对象,根据该点生成一个一个箭头。 - 设置一个控制箭头显示/隐藏的属性
hidden - 将
canvas的原点translate到点球点的位置,方便后续操作。 - 设置当前角度
angle、最大角度maxAngle、最小角度minAngle、角的加速度speedAngle - 提供
update方法,该方法会将当前角度angle加上加速度speedAngle,当到达最大最小值时speedAngle变为-speedAngle,从而指向反方向。 - 提供
start方法,开始定时器不断调用update方法,改变角度。 - 提供
stop方法,关闭定时器,每次start前都进行关闭,确保定时器不会累加。 - 绘制该箭头,这里可能需要用到三角函数,同时根据当前角度
Angle对整体进行rotate,从而绘制出箭头的角度。 - 提供
getDirection方法获取当前角度,供外部调用。
为了方便测试,我们在
Game类的构造函数中调用箭头实例对象的start方法,查看效果,但后续记得删除。
// 方向箭头类
class Arrow {
constructor(ctx, mark) {
this.ctx = ctx
this.mark = mark
this.hidden = true // 是否隐藏
const { x, y } = this.mark.getMarkPosition()
this.angle = 0 // 初始角度
this.speedAngle = 1 // 角度加速度
this.maxAngle = 60 // 最大角度
this.minAngle = -60 // 最小角度
this.x = x
this.y = y
this.intervalNo = null
this.intervalTime = 10
}
// 渲染
render() {
// 若已经被隐藏,则不进行渲染
if(this.hidden) {
return
}
const ctx = this.ctx
ctx.save()
ctx.beginPath()
ctx.translate(this.x, this.y)
// 弧度 = 角度 * π / 180
const radian = this.angle * Math.PI / 180
ctx.rotate(radian)
const x = 0
const y = 0
ctx.moveTo(x, y)
// 绘制一条之间
const pointA = {x, y: y - 100}
// 绘制左右两条斜线,角度分别为30度与-30度
const pointB = this.getPointPosition(-30, pointA.x, pointA.y, 20)
const pointC = this.getPointPosition(30, pointA.x, pointA.y, 20)
ctx.lineTo(pointA.x, pointA.y)
ctx.lineTo(pointB.x, pointB.y)
ctx.moveTo(pointA.x, pointA.y)
ctx.lineTo(pointC.x, pointC.y)
ctx.stroke()
ctx.restore()
}
// 获取方向
getDirection() {
return this.angle
}
// 重置
reset() {
this.angle = 0
this.hidden = true
}
// 更新
update() {
// 当指向最小值或最大值时,箭头开始指向另一边
if(this.angle >= this.maxAngle || this.angle <= this.minAngle) {
this.speedAngle = -this.speedAngle
}
this.angle += this.speedAngle
}
// 开始左右摇摆
start() {
this.stop()
this.hidden = false
this.intervalNo = setInterval(() => {
this.update()
}, this.intervalTime)
}
// 停止摇摆
stop() {
this.hidden = true
if(this.intervalNo) {
clearInterval(this.intervalNo)
this.intervalNo = null
}
}
// 辅助函数:根据角度与斜边长,获取对边和直角边,从而获取到对应坐标(三角函数)
getPointPosition(angle, x, y, width) {
const radian = angle * Math.PI / 180
return {
x: Math.sin(radian) * width + x,
y: Math.cos(radian) * width + y
}
}
}
class Game {
constructor() {
// ...
this.initArrow()
this.animate()
// fixme: 测试效果
this.bar.start()
this.arrow.start()
}
// ...
// 初始化方向箭头
initArrow() {
const {ctx, mark} = this
this.arrow = new Arrow(ctx, mark)
}
// 渲染
render() {
// ...
// 渲染箭头
this.arrow.render()
}
animate() {
this.render()
requestAnimationFrame(() => {
this.animate()
})
}
}
事件监听器EventEmitter
很快就要到我们的主体逻辑球类Ball了,按照道理来说,我们需要编写大量的逻辑再球类Ball里,这势必会让主体变得高度耦合,增加逻辑的复杂度,降低模块的可维护性,于是我们这里引入了发布订阅模式,加入了事件监听器,这里实现的是一个简易的事件监听器。
class EventEmitter {
constructor() {
this._events = {}
}
on(eventName, fn) {
if (typeof fn !== 'function') {
throw new TypeError('The listener must be a function')
}
if (!this._events[eventName]) {
this._events[eventName] = [fn]
return
}
this._events[eventName].push(fn)
}
emit(eventName, ...args) {
if (!this._events[eventName]) {
return
}
this._events[eventName].forEach(fn => {
fn(...args)
})
}
}
球类Ball
终于到我们的核心了,球类Ball,球所要干的事,第一个是射出,第二个是滚动,其他功能就和球没有任何关系了,逻辑非常清晰,我们来拆解分析实现以下功能:
- 让球类
Ball继承于事件监听器EventEmitter, - 构造函数中需要传入点球点
Mark的实例对象,这是我们球初始的中心点,然后还要球的半径r,同样球需要水平加速度speedX与垂直加速度speedY,不过他们的初始值都为0。 - 提供一个
render函数,在canvas上渲染一个球,以球的坐标中心点,围绕半径画圆。 - 提供一个
update函数,该函数会根据水平与垂直加速度speedX``speedY,然球的中心点发生变化,让小球进行移动。 - 提供
start方法,开始定时器不断调用update方法,改变球的位置。 - 提供
stop方法,关闭定时器,每次start前都进行关闭,确保定时器不会累加。 - 提供一个方法
setSpeed,用以设置速度。 - 提供一个方法
getSpeedXByArrow,他会将箭头方向direction转换成水平加速度speedX。 - 提供一个方法
getSpeedYByForce,他会将力度条force转换成垂直加速度speedY。 - 提供一个
shoot方法,他会获取力度force与方向direction,这些值有射出时力度条与方向箭头提供,将其转换成水平与垂直加速度后,调用start方法,让小球不断移动,从而达到射门的效果。 - 小球不断地移动,肯定需要判断是否设计门口等逻辑,这里因为我们继承了事件监听器,我们可以作为发布者调用
emit方法,向我们的订阅者,告诉我们正在移动move。我们小球自身则不做任何逻辑判断。
class Ball extends EventEmitter{
constructor(ctx, mark, r) {
super()
this.ctx = ctx
this.mark = mark
this.r = r
this.intervalNo = null
this.intervalTime = 5
this.reset()
}
// 重置球回到原点
reset() {
this.stop()
const { x, y } = this.mark.getMarkPosition()
this.x = x
this.y = y
this.speedX = 0
this.speedY = 0
}
update() {
this.x += this.speedX
this.y += this.speedY
// 对外暴露move事件
this.emit('move')
}
// 设置水平与垂直加速度
setSpeed(speedX, speedY) {
this.speedX = speedX
this.speedY = speedY
}
// 渲染
render() {
const { ctx, r, x, y } = this
ctx.save()
ctx.beginPath()
ctx.arc(x, y, r, 0, 2 * Math.PI)
ctx.fill()
ctx.closePath()
ctx.restore()
}
// 射门
shoot(force, direction) {
const speedX = this.getSpeedXByDirection(direction)
const speedY = this.getSpeedYByForce(force)
this.setSpeed(speedX, speedY)
this.start()
}
// 根据力度获取Y轴动能,此处6为预估值可自行尝试调整
getSpeedYByForce(force) {
return -force / 6
}
// 根据方向获取X轴动能,此处12为预估值,可自行尝试调整
getSpeedXByDirection(direction) {
return direction / 12
}
start() {
this.stop()
this.intervalNo = setInterval(() => {
this.update()
}, this.intervalTime)
}
stop() {
if(this.intervalNo) {
clearInterval(this.intervalNo)
this.intervalNo = null
}
}
}
class Game {
// ...
this.initBall()
this.animate()
// fixme: 测试效果
this.bar.start()
this.arrow.start()
}
// 初始化球
initBall() {
const {ctx, mark} = this
const r = 25
this.ball = new Ball(ctx, mark, r)
}
// 渲染
render() {
// 渲染球
this.ball.render()
}
animate() {
this.render()
requestAnimationFrame(() => {
this.animate()
})
}
}
门将类Goalkeeper
门将要做的就是在球门前不断移动,来拦截球,当他拦截到球的时候,就会把球抱起来在实现门将的逻辑时,我始终在思考,门将拦截到球这个逻辑,是否在门将这个类中实现,思考了良久,加入这个逻辑,会让数据流不那么清晰,于是,得出结论,门将不考虑自己是否碰到球,但门将会自己可以“抱住球”。那么我们开始分析实现:
- 传入球门
Goal与球Ball,门将以球门的基准进行自身的渲染,获取中心点。(这里实现比较魔幻,大家大概看看就好) - 门将会左右移动,移动到球门最左边时,向右移动,移动到最右边是向左移动,这有点向箭头,同样有一个加速度
speedX正负切换 - 提供一个
update函数,该函数会根据水平加速度speedX``speedY,让门将左右移动。 - 提供
start方法,开始定时器不断调用update方法,门将开始左右移动。 - 提供
stop方法,关闭定时器,每次start前都进行关闭,确保定时器不会累加。 - 提供一个抓住球的方法
catchBall,门将也能抓住球啦。
为了方便测试,我们在
Game类的构造函数中调用门将实例对象的start方法,查看效果,但后续记得删除。
// 门将
class Goalkeeper {
constructor(ctx, goal, ball) {
this.ctx = ctx
this.goal = goal
this.ball = ball
this.intervalNo = null
this.intervalTime = 5
this.reset()
}
render() {
const { ctx } = this
ctx.save()
this.renderHead()
this.renderBody()
ctx.restore()
}
update() {
if(this.x <= this.goal.x || this.x >= this.goal.x + this.goal.width) {
this.speedX = -this.speedX
}
this.x += this.speedX
}
// 将中心点重置在门框中心下方
reset() {
this.speedX = 1
this.x = this.goal.x + this.goal.width / 2
this.y = this.goal.y + this.goal.height + 60
this.header = {
r: 20
}
this.body = {
neckHeight: 10, // 脖子高度
armWidth: 40, // 手臂长度
legHeight: 40, // 腿脚长度
}
// 这里计算门将的宽高,计算方式比较魔幻
this.width = this.body.armWidth * 2
this.height = this.header.r * 2 + this.body.neckHeight + this.body.legHeight
}
// 抱住球
catchBall() {
this.ball.x = this.x
this.ball.y = this.y
}
// 渲染头部
renderHead() {
const { header, ctx, x, y, body } = this
ctx.beginPath()
ctx.arc(x, y - header.r - body.neckHeight, header.r, 0, 2 * Math.PI)
ctx.stroke()
}
// 渲染身体
renderBody() {
const { body, ctx, x, y } = this
const { neckHeight, armWidth, legHeight } = body
ctx.beginPath()
// 渲染脖子
ctx.moveTo(x, y)
ctx.lineTo(x, y - neckHeight)
// 渲染胳膊
ctx.moveTo(x, y)
ctx.lineTo(x - armWidth, y)
ctx.lineTo(x + armWidth, y)
// 渲染腿
ctx.moveTo(x, y)
const legXA = Math.sin(30 * Math.PI / 180) * legHeight + x
const legXB = Math.sin(-30 * Math.PI / 180) * legHeight + x
const legY = Math.cos(30 * Math.PI / 180) * legHeight + y
ctx.lineTo(legXA, legY)
ctx.moveTo(x, y)
ctx.lineTo(legXB, legY)
ctx.stroke()
}
// 开始移动
start() {
this.stop()
this.intervalNo = setInterval(() => {
this.update()
}, this.intervalTime)
}
// 停止移动
stop() {
if(this.intervalNo) {
clearInterval(this.intervalNo)
this.intervalNo = null
}
}
}
class Game {
constructor() {
// ...
this.initGoalkeeper()
this.animate()
// fixme: 测试效果
// ...
this.goalkeeper.start()
}
// ...
// 初始化门将
initGoalkeeper() {
this.goalkeeper = new Goalkeeper(this.ctx, this.goal, this.ball)
}
// 渲染
render() {
// ...
// 渲染门将
this.goalkeeper.render()
}
animate() {
this.render()
requestAnimationFrame(() => {
this.animate()
})
}
}
裁判类Judge
球场上谁决定是否进球?谁决定球是否出界?谁决定门将是否碰到了球?谁决定可以开始点球?来了来了,他终于来了,我们引入了裁判这一概念,除了裁判,谁也无需考虑自身与其他模块之间的逻辑,只需要考虑自身逻辑与渲染,只有裁判需要考虑各个模块之间的逻辑,裁判需要实现的功能如下:
- 我们需要先将球、球场、球门、门将,引入进来
- 判断球是否出界:通过判断球自身坐标、半径与球场之间坐标、宽高的关系
- 判断是否进球:通过判断球自身坐标、半径与门口之间坐标、宽高的关系
- 判断门将是否碰到了球:通过判断球自身坐标、半径与门将之间坐标、宽高的关系
- 决定是否允许点球
- 同样的为了保持数据流清晰,我们这里也引入了事件监听器
EventEmitter,他会在出界、进球、碰到球时,分别对外暴露各自事件,至于外部如何处理,就不管裁判的事情了。
这里,我们需要完善Game类,来确保裁判的效果,我们将在下一接,再在Game类中实例化裁判类
class Judge extends eventEmitter{
constructor(ball, goal, field, goalkeeper) {
super()
this.ball = ball
this.goal = goal
this.field = field
this.goalkeeper = goalkeeper
// 是否是否允许射门
this.shootPermission = true
this.addEventListener()
}
addEventListener() {
// 当球开始移动时
this.ball.on('move', () => {
const hasEnter = this.hasBallEnterGoal()
const hasOuter = this.hasBallOuterField()
const hasCatch = this.hasGoalkeeperCatchBall()
if(hasEnter) {
this.emit('goal')
}
if(hasCatch) {
this.emit('catch')
}
if(hasOuter) {
this.emit('outer')
}
})
}
// 判断是否球进入门口
hasBallEnterGoal() {
const isEnterX = this.ball.x + this.ball.r > this.goal.x && this.ball.x + this.ball.r < this.goal.x + this.goal.width
const isEnterY = this.ball.y + this.ball.r > this.goal.y && this.ball.y + this.ball.r < this.goal.y + this.goal.height
return isEnterX && isEnterY
}
// 是否出界
hasBallOuterField() {
const isOuterX = this.ball.x - this.ball.r <= this.field.x || this.ball.x + this.ball.r >= this.field.x + this.field.width
const isOuterY = this.ball.y - this.ball.r <= this.field.y || this.ball.y + this.ball.r >= this.field.y + this.field.height
return isOuterX || isOuterY
}
// 门将是否抓住了球
hasGoalkeeperCatchBall() {
const isEnterX = this.ball.x + this.ball.r > this.goalkeeper.x && this.ball.x + this.ball.r < this.goalkeeper.x + this.goalkeeper.width
const isEnterY = this.ball.y + this.ball.r > this.goalkeeper.y && this.ball.y + this.ball.r < this.goalkeeper.y + this.goalkeeper.height
return isEnterX && isEnterY
}
// 获取是否有权限射门
getShootPermission() {
return this.shootPermission
}
// 请求裁判请求射门权限
requestShootPermission() {
this.shootPermission = true
}
// 取消射门曲线
cancelShootPermission() {
this.shootPermission = false
}
}
游戏类Game:剩余功能
我们已经将除了游戏类Game模块的其他所有功能都已经完成,万事俱备只欠东风,接下来我们要做的如下:
- 提供一个重置方法
reset,方便后续重新开始游戏。 - 首先要绑定事件
addEventListener:- 给
canvas绑定按下事件,使其在按下时,力度条开始滚动、箭头开始随机摆动、门将也开始移动。 - 给
canvas绑定抬起事件,使其在抬起是,获取当前力度值、箭头方向、并将这个值传递给球Ball,让其射出去shoot,让其不断滚动move。
- 给
- 其次要实例化裁判
Judge:- 实例化裁判
Judge,将球、球场、球门、门将传递进去,在球滚动move时,让其判断是否出界,是否进球、是否被门将抓住。 - 当进球时,裁判触发
goal,球与门将停止移动,日志显示:恭喜球进了! - 当球出界时,裁判触发
out,球与门将停止移动,日志显示:出界了! - 当球碰到裁判时,裁判触发
catch,球与门将停止移动,门将抓住球,日志显示:门将抓住了球!
- 实例化裁判
class Game {
constructor() {
// ...
this.initJudge()
this.animate()
this.addEventListener()
this.start()
}
// ...
// 初始化裁判
initJudge() {
this.judge = new Judge(this.ball, this.goal, this.field, this.goalkeeper)
this.judge.on('goal', () => {
this.log.setText('恭喜球进了!')
this.ball.stop()
this.goalkeeper.stop()
this.handleGameOver()
})
this.judge.on('out', () => {
this.log.setText('出界了!')
this.ball.stop()
this.goalkeeper.stop()
this.handleGameOver()
})
this.judge.on('catch', () => {
this.log.setText('门将抓住了球!')
this.ball.stop()
this.goalkeeper.catchBall()
this.goalkeeper.stop()
this.handleGameOver()
})
}
// ...
// 判断是否移动端
isMobile() {
let userAgentInfo = navigator.userAgent;
let Agents = ['Android', 'iPhone', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod'];
return !!Agents.find(i => userAgentInfo.includes(i));
}
// 绑定事件
addEventListener() {
// PC与移动分别使用不同的事件
const touchDown = this.isMobile() ? 'touchstart' : 'mousedown'
const touchUp = this.isMobile() ? 'touchend' : 'mouseup'
this.canvas.addEventListener(touchDown, () => {
const permission = this.judge.getShootPermission()
if(!permission) {
return
}
this.goalkeeper.start()
this.bar.start()
this.arrow.start()
// 防止移动端长按图片保存的情况
event.preventDefault()
})
this.canvas.addEventListener(touchUp, () => {
const permission = this.judge.getShootPermission()
if(!permission) {
return
}
const force = this.bar.getForce()
const direction = this.arrow.getDirection()
this.bar.stop()
this.arrow.stop()
this.ball.shoot(force,direction)
// 射门后,暂时取消射门权限
this.judge.cancelShootPermission()
})
}
// 游戏结束重新开始游戏
handleGameOver() {
setTimeout(() => {
this.reset()
}, 2000)
}
// 开始游戏
start() {
this.reset()
}
// 重置游戏数据
reset() {
// 向裁判获取射门权限
this.judge.requestShootPermission()
// 重置球的位置
this.ball.reset()
// 重置力度条
this.bar.reset()
// 重置方向箭头
this.arrow.reset()
// 重置文字
this.log.setText('长按足球开始游戏')
}
}
整体代码
这里建议大家将各个Class放进不同文件,然后分别引用。