免费注册,打造高效身份管理
博客/开发者/用 Canvas 写游戏实录|Authing 开发者活动分享
用 Canvas 写游戏实录|Authing 开发者活动分享
Authing 官方2021.08.13阅读 808

撰稿人:Authing 邓家旺


我想先和大家从一个非常有趣的游戏说起:康威生命游戏。这是一种相当独特的无人游戏,它几乎全程不需要玩家参与,只需要布好初始的图像,就可以静静地观察游戏运行,如果我们能根据规则,找到规律,就会发现游戏中的网格可以组成各种复杂的结构。


例如在游戏中,有一种结构叫做高斯帕滑翔机,每四个回合向右下方平移一格:

 

 

或者是轻型飞船,可以在4个回合向右移动两格,是生命游戏里最快的运动速度,可以理解为这个世界的“光速”。

 

 

除了平移运动,甚至还可以创造出更加复杂的繁殖结构:高斯帕滑翔机枪,每14个回合就发射一架高斯帕滑翔机。

 

 

更不可思议的是,你甚至可以递归地用生命游戏来模拟另一个生命游戏,进而创造无穷巨大的结构。
这个游戏非常有意思,我们研究一下「生命游戏」在程序上是如何写出来的。


这个游戏本身很简单,本质上是两件事:


1.画格子,默认白色,允许用户操作绘制黑色(有生命)
2.根据规则,自动计算下一回合的格子状态,让格子自动刷新

所以我们要先做的第一件事是画格子,画格子有两种思路。


第一种是绘制线条,让线条在视觉上组成网格:


 

第二种是根据四个顶点连线,直接画「格子」:

 

 

稍作分析,会发现这个游戏基本不会对“线”有改动,所以我们就按第二个方案来绘制格子,这样我们就可以很方便地拿到格子的坐标,标记它的生命也非常简单,布尔值就行。


实现这个游戏在程序上要怎么做,我们需要思考几个问题:


1.怎么用程序实现矩形、多个矩形
2.怎么用程序计算下一回合的数值
3.怎么用程序根据数值,绘制新一轮的图像
4.最好能响应用户操作,交互(可以绑定用户的交互操作)


我们用最简单的 html + css + js 来实现,不用依赖任何库就可以做到这件事,事情分成几个阶段做,第一阶段最简单,画 1 个格子。

 

<canvas id="id-canvas" width="800" height="600"></canvas>

<script>
    const domCanvas = document.querySelector('#id-canvas')
    const context = domCanvas.getContext('2d')
    context.fillRect(100, 100, 40, 40)
</script>

 

只要能看到最基本的一个格子,就说明绘图是 OK 的。我们继续第二阶段:画很多格子。

 

const domCanvas = document.querySelector('#id-canvas')
const context = domCanvas.getContext('2d')

let { width, height } = domCanvas
let grids = initData(width, height, 40)
for (let i of grids) {
    context.strokeStyle = 'rgba(0, 0, 0, 1)'
    context.strokeRect(i.x, i.y, 40, 40)
}

// initData 不是重点,返回值是格子数组,结构大致如下 
[
    {
        row: number,
        col: number,
        x: number,
        y: number,
        life: boolean,
    },
    ......
]

 

由于画很多格子需要在视觉上可辨认,所以我们就不填充格子了,而是画格子的边框,然后来一个 for 循环即可,这一步也很简单。


我们继续第三阶段:让 1 个格子运动起来。

 

let x = 100
let y = 100
let fps = 30
setInterval(() => {
    let { width, height } = domCanvas
    let gridLength = 40
    context.clearRect(0, 0, width, height)
    if (x < width - gridLength) {
        context.fillRect((x += 10), y, gridLength, gridLength)
    } else {
        context.fillRect(x, y, gridLength, gridLength)
    }
}, 1000 / fps)

 

这里的 fps 是指每秒刷新多少次,我们称为 “帧”。可以看到,格子每帧向右移动 10 个像素,并在碰到边界时停下。如果我们能让一个格子按我们所想的方式动起来,那么就能让所有格子都如此。

第四阶段:让所有格子动起来,闪烁。

 

let gridLength = 40
let { width, height } = domCanvas
let grids = initData(width, height, gridLength)
let fps = 2
setInterval(() => {
    context.clearRect(0, 0, width, height)
    for (let i of grids) {
        i.life = Math.random() > 0.5
        if (i.life === true) {
            context.fillStyle = 'gray' // 稍微灰色一点会让眼睛舒服些
            context.fillRect(i.x, i.y, gridLength, gridLength)
        } else {
            context.strokeStyle = 'gray'
            context.strokeRect(i.x, i.y, gridLength, gridLength)
        }
    }
}, 1000 / fps)

 

随机操作所有格子填充灰色或不填充,就已经做到了“让所有格子动起来”。


最后,第五阶段,让格子根据规则运动。

 

// 先说明一下规则:
// 如果一个生命周围的生命少于 2 个,它在回合结束时死亡。
// 如果一个生命周围的生命超过 3 个,它在回合结束时死亡。
// 如果一个死格子周围有 3 个生命,它在回合结束时获得生命。
// 如果一个生命周围有 2 个或 3 个生命,它在回合结束时保持原样。

let gridLength = 40
let { width, height } = domCanvas
let grids = initData(width, height, gridLength)
let fps = 10
addGosper(grids, 2, 2)
window.grids = grids
setInterval(() => {
    context.clearRect(0, 0, width, height)
    let gridsCopyed = JSON.parse(JSON.stringify(window.grids))
    for (let i of gridsCopyed) {
        if (i.life === true) {
            context.fillStyle = 'gray'
            context.fillRect(i.x, i.y, gridLength, gridLength)
        } else {
            context.strokeStyle = 'gray'
            context.strokeRect(i.x, i.y, gridLength, gridLength)
        }
        let nextLife = getNextRoundLife(i)
        i.life = nextLife
    }
    window.grids = gridsCopyed
}, 1000 / fps)

// initData 前面已经说明过了
// addGosper 就是提前标记其中几个格子为存活状态
// getNextRoundLife 见下面的实现,本质上是计算各格子周围的生命数量决定下回合状态

const getGridLife = (row, col) => {
    // 检查这个格子的生命状态
    if (!window.grids) {
        return false
    }
    let grid = grids.find((i) => i.row === row && i.col === col)
    return grid ? grid.life : false
}

const getNextRoundLife = (grid) => {
    let lifeAround = 0
    let { row, col } = grid

    // 上面三个
    lifeAround += getGridLife(row - 1, col - 1)
    lifeAround += getGridLife(row, col - 1)
    lifeAround += getGridLife(row + 1, col - 1)

    // 左右两个
    lifeAround += getGridLife(row - 1, col)
    lifeAround += getGridLife(row + 1, col)

    // 下面三个
    lifeAround += getGridLife(row - 1, col + 1)
    lifeAround += getGridLife(row, col + 1)
    lifeAround += getGridLife(row + 1, col + 1)

    if (grid.life === false) {
        if (lifeAround === 3) {
            return true
        }
    }
    if (grid.life === true) {
        if (lifeAround === 2 || lifeAround === 3) {
            return true
        }
    }
    return false
}

 

目前为止,我们已经成功让游戏实现了,如果我们接着加入 Event 事件,加入细节优化,比如帧率调整、格子大小调整、颜色、这个游戏就可以直接玩了。


但是还不行!目前这份代码问题太大:


· 使用了全局 window 对象,这不好,一旦添加功能容易炸。


· 我们用的 setInterval API 其实并不能动态改变帧率,比较好的方式是两种:用 settimeout 递归、用 requestAnimationFrame 递归,并且也不应该自己管理这个绘制,应该托管起来。


· 我们的绘制、更新操作都是手动的,这会增加很多开发成本,一旦对象多起来,场景丰富起来,就会要写非常多的代码来负责绘制,不容易做抽象,也不容易修改维护。


· 格子生命的判定和绘图的逻辑不应该混合到一起,它们一个是数据状态,一个是绘图操作,应该拆分开。


· 我们没有把“格子”这个物体单独抽象出来,这不好。

 

要处理这些问题,我们需要换一个思维方式。在编程上采取更好的实现,将代码组织好,进一步设计自动刷新的模型,让程序接管更多人手动操作的部分。

 

 

const __main_v2 = () => {
    let domCanvas = document.querySelector('canvas')
    let context = domCanvas.getContext('2d')

    // 1,场景初始化,托管 canvas 和 context
    // 托管自动刷新渲染的 rendering
    let scene = new Scene()
    scene.registerCanvas({
        canvas: domCanvas,
        context: context,
    })
    scene.registerContinuousRendering()
    scene.setFps(2)

    // 2,抽离一个单独的 Life 物体(组件),自己负责自己的逻辑工作
    let lifeGame = new LifeGame(context)
    scene.registerObject(lifeGame)    
}

 

理论上,和“整个游戏场景”有关的代码就只有这些:获取上下文、初始化场景、为场景添加物体。


至于物体到了场景里具体如何表现,只需要我们提前写好物体即可,换句话说,说这和场景本身是分离的。那么来看看和场景无关的 LifeGame 的实现:

 

class Grid {
    constructor(row, col, x, y, life) {
        this.row = row
        this.col = col
        this.x = x
        this.y = y
        this.life = life ? life : false
    }
}

class LifeGame {
    constructor(context = null, config = {}) {
        this.context = context ? context : null

        // life game 需要有很多属性,比如自己的初始化格子树
        this.gridLength = config.gridLength ? config.gridLength : 40
        this.containerWidth = config.width ? config.width : 800
        this.containerHeight = config.height ? config.height : 600
        this.grids = this.initGrids()

        // 手动添加一架高斯帕滑翔机,当然也可以不添加
        this.addGosper(2, 2)
    }

    copyGrids() {
        return JSON.parse(JSON.stringify(this.grids))
    }

    addGosper(startRow, startCol) {
        let { grids } = this
        let start = grids.find((i) => i.row === startRow && i.col === startCol)
        start.life = true

        let g2 = grids.find((i) => i.row === startRow + 1 && i.col === startCol + 1)
        g2.life = true 
        let g3 = grids.find((i) => i.row === startRow + 2 && i.col === startCol + 1)
        g3.life = true 

        let g4 = grids.find((i) => i.row === startRow + 0 && i.col === startCol + 2)
        g4.life = true 
        let g5 = grids.find((i) => i.row === startRow + 1 && i.col === startCol + 2)
        g5.life = true 
    }

    initGrids = () => {
        let { containerWidth, containerHeight, gridLength } = this
        let xLen = containerWidth / gridLength
        let yLen = containerHeight / gridLength
        let grids = []
        for (let i = 0; i < xLen; i++) {
            let row = []
            for (let j = 0; j < yLen; j++) {
                let g = new Grid(i, j, i * gridLength, j * gridLength, false)
                row.push(g)
            }
            grids.push(row)
        }
        return grids.flat(Infinity)
    }

    getNextRoundLife(grid) {
        let lifeAround = 0
        let { row, col } = grid

        lifeAround += this.getGridLife(row - 1, col - 1)
        lifeAround += this.getGridLife(row, col - 1)
        lifeAround += this.getGridLife(row + 1, col - 1)

        lifeAround += this.getGridLife(row - 1, col)
        lifeAround += this.getGridLife(row + 1, col)

        lifeAround += this.getGridLife(row - 1, col + 1)
        lifeAround += this.getGridLife(row, col + 1)
        lifeAround += this.getGridLife(row + 1, col + 1)

        if (grid.life === false) {
            if (lifeAround === 3) {
                return true
            }
        }
        if (grid.life === true) {
            if (lifeAround === 2 || lifeAround === 3) {
                return true
            }
        }
        return false
    }

    getGridLife(row, col) {
        let grid = this.grids.find((i) => i.row === row && i.col === col)
        return grid && grid.life ? 1 : 0
    }

    update() {
        let gs = this.copyGrids()
        gs.forEach((g) => {
            let nextLife = this.getNextRoundLife(g)
            g.life = nextLife
        })
        this.grids = gs
    }

    draw() {
        if (!this.context) {
            return
        }
        const { context, grids, gridLength } = this
        for (let i of grids) {
            if (i.life === true) {
                context.fillStyle = 'black'
                context.fillRect(i.x, i.y, gridLength, gridLength)
            } else {
                context.strokeStyle = 'black'
                context.strokeRect(i.x, i.y, gridLength, gridLength)
            }
        }
    }
}

 

目前的组织方式是使用了一个 Scene 对象,Scene 对象提供了注册上下文的方法,提供了自动渲染,并且允许添加物体,交给 Scene 来处理。


我们在外部层面不再关心物体是如何被画出来的,我们只知道造出一个符合标准的“物体”,然后添加到场景里,它就一定会被画出来。

 

class Scene {
    constructor() {
        this.fps = 10
        this.pause = false
        this.canvas = null
        this.context = null
        this.objects = [] // 所有物体
    }

    registerCanvas(props) {
        this.canvas = props.canvas
        this.context = props.context
    }

    registerContinuousRendering() {
        let { canvas, context } = this
        setTimeout(() => {
            if (this.pause == true) {
                return
            }
            context.clearRect(0, 0, canvas.width, canvas.height)
            this.update()
            this.draw()
            this.registerContinuousRendering()
        }, 1000 / this.fps)
    }

    registerObject(obj) {
        this.objects.push(obj)
    }

    setFps(fps){
        this.fps = fps
    }

    update() {
        for (let i of this.objects) {
            i.update()
        }
    }

    draw() {
        for (let i of this.objects) {
            i.draw()
        }
    }
}

 

我们造出来的标准物体就是 LifeGame,它非常简单,其他方法我都是拷贝的 V1 版本,只有一个地方有区别,数据更新和绘制的逻辑被拆分开,变成了两个方法:update 和 draw,只要有这两个方法,他们就会被 scene 自动处理。因此,我们专心在这两个方法里决定好自己被画成什么样就行了。


最终游戏看起来像这样:我们提前添加一架滑翔机,然后让程序自动运行就好。

 

 

生命游戏很简单,我们讲到这里,如果我们运用上这个思路,只需要不停地往场景里添加物体,就很容易做出来一个真正的游戏,例如看下面这段伪代码做的事:

 

let scene = new Scene()
scene.registerContinuousRendering()
scene.setFps(2)

let cloud = new Cloud()
cloud.x -= random(0, 10) // 0-10 之间随机一个数字
scene.registerObject(cloud)

let monster = new Monster()
monster.hp = 100
scene.registerObject(monster)

let hero = new Hero()
hero.attack = 25
hero.hp = 10
scene.registerObject(hero)


scene.registerEventsPressKey('j', () => {
    hero.jump()
})
scene.registerEventsPressKey('i', () => {
    hero.chop()
})

 

这就构建出了一个有背景(云朵)、有怪物(和生命值)、有主角的游戏,并且通过绑定键盘事件,在按下 “j” 的时候会跳跃,在按下 “i” 的时候会挥剑攻击的游戏。


只要我们提前写好 Cloud, Monster, Hero 对象,我们就可以在业务层很简单地做到这些事。这就让游戏可以完全按照我们的兴趣被创造。


本文根据 Authing 开发者活动的演讲内容整理,有兴趣的读者可以尝试一下,最后祝大家周末愉快~

文章作者

avatar

Authing 官方

0

文章总数

authing blog rqcode
关注 Authing 公众号
随时随地发现更多内容
authing blog rqcode
添加 Authing 小助手
加入 Authing 开发者大家庭
authing
If needed, please add the identity consultant on WeChat.
authing
authing
Download the Authing token and experience fast login authentication!
Free Trial
Online
Phone