Canvas实战:2D点球大战

2,420 阅读15分钟

引言

因为最近要举办世界杯的缘故,刚好自己也在学习canvas,一直没做过canvas的游戏与动画,于是决定尝试自己实现一个2d射门的小游戏,不过因为自己UI不是特别好,所以实现了一个《简易版》,以下内容全部为自己独立思考,实现的过程中,受益良多,希望这篇文章对大家有所帮助。

需求分析

整体需求类似于一个点球大战,长按进行蓄力射门,需求拆解如下:

  • 包含一个球。
  • 包含一个球场,球碰到球场边界则算作出界。
  • 包含一个球门,球进入球门则算进球。
  • 包含一个门将,门将会自动左右移动,阻止进球。
  • 包含一个蓄力条,控制球的力度。
  • 包含一个方向条,控制球射出的方向。
  • 包含一个展示日志,用以提示用户信息。

功能实现

在我思考的过程中,我决定把各个功能挨个实现,先从一些基础的且耦合低的功能入手,整体功能使用Class去实现。

游戏类Game

这里我们首先需要一个整体的游戏类Game去驱动整个游戏,游戏整体流程都需要在Game中实现,Game首先要负责的就是初始化canvas并设置整体的宽widthheightGame后续的功能我们再依次实现。

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

  1. 球场类Field主要的责任是负责渲染整个场地,通过Game传递过来的坐标与宽度等信息,Field包含一个render方法,可以设置边框作为球场线并设置场地为白色。
  2. 之后我们再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()

image.png

球门类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()
    }
}

image.png

日志类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()
    }
}

image.png

力度条类Bar

前几个功能都是静态的绘制,接下里的流程会稍显复杂,这里我们需要实现的是一个力度条的功能,我们将功能拆解为一下几个步骤:

  • 构造函数中会被传入上下文、位置信息、大小信息用以渲染
  • 会拥有一个force变量表示当前力度,力度值为0~100
  • 提供一个render函数,根据当前位置、大小、力度进行渲染。
  • 提供setForcegetForcereset函数,用以设置、获取、重置力度。
  • 提供一个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()
        })
    }
}

bar.gif

点球点类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()
        })
    }
}

arrow.gif

事件监听器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()
        })
    }
}

image.png

门将类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()
        })
    }
}

goalkeeper.gif

裁判类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放进不同文件,然后分别引用。