控制台
博客/开发者/用 Canvas 写游戏实录|Authing 开发者活动分享
用 Canvas 写游戏实录|Authing 开发者活动分享
Authing 官方2021-08-13阅读 725

撰稿人: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 开发者大家庭