使用cocos实现flappybird

打算梳理一下自己的游戏开发技能(虽然并没有什么技能),于是打算从易到难去实现一些比较完整的游戏案例。flappybird是一个比较精简的游戏,游戏玩法和规则都很简单,但是包含的内容比较丰富,能涉猎到不少知识点,因此用来入门是比较不错的。本文整理了使用 cocos creator 开发 flappy bird 的一些收获。

<!--more-->

整个项目的资源和代码都放在github

1. 梳理游戏逻辑

整个游戏只包含一个主界面,

  • 开始游戏后,鸟受重力影响会下降,点击屏幕可以让鸟向上飞行一段距离,鸟撞击到地面则游戏结束
  • 整个场景由右向左移动,每隔一段时间会生成上下两根管道,需要控制鸟通过管道中间的空白区域,鸟撞击到管道则游戏结束
  • 每通过一根管道,则积分+1,游戏目标是尽可能获得多的积分

可以看见,整个游戏的元素包括

  • 鸟,玩家控制的主角
  • 管道,上下成对出现,水平方向移动
  • 地面,水平方向移动
  • 分数

涉及到的知识点包括

  • 游戏流程,如开始、暂停、逻辑等
  • 物理系统,主要是竖直方向上的重力系统
  • 碰撞检测,鸟与管道、鸟与地面
  • 一些动画,鸟排翅动画、鸟向上飞行动画、地面水平移动动画
  • 预制体与对象池,需要随机生成管道,当管道从屏幕上消失之后可以回收复用
  • 组件封装,将不同的逻辑封装在不同的组件中

2. 初始化项目

首先将下载好的素材添加到项目中

然后创建节点,由于整个场景比较简单,因此只需要创建下面几个节点就行了

然后编写第一个脚本组件game.ts,这个脚本是整个游戏的入口,负责管理其他的脚本组件和游戏逻辑,然后将其作为script component添加到根节点Canvas上

game组件会维持对其他脚本组件的引用,这样就可以很方便地调用其他组件的方法

相关组件的作用会在下文一一道来。

3. 物理系统和碰撞系统

首先是在game.ts组件中启动物理系统

initPhysicsManager() {
    const instance = cc.director.getPhysicsManager()
    instance.enabled = true
    // instance.debugDrawFlags = 4 // 调试模式
    instance.gravity = this.gravity;
    const collisionManager = cc.director.getCollisionManager();
    collisionManager.enabled = true
}

然后为相关节点添加物理组件,比如为鸟添加RibidBody刚体和PhysicsBoxCollider物理碰撞组件

地面和管道同理设置。碰撞系统还需要编辑分组

勾选运行产生碰撞的分组,这里是鸟和管道、鸟和地面,注意管道和地面不需要勾选碰撞

在未开始游戏时,小鸟不受重力系统的影响,可以将this.gravity设置成cc.v2(0, 0),也可以将其RigidBody组件的type设置成static

4. 鸟

为了方便管理鸟的逻辑,单独创建一个bird.ts

由于需要处理碰撞结束游戏的逻辑,因此注册onBeginContact事件

@ccclass
export default class NewClass extends cc.Component {
    // 只在两个碰撞体开始接触时被调用一次
    onBeginContact(contact, selfCollider, otherCollider): void {
        this.game.gameOver()
    }
}

然后来看看鸟的两个动画,一个是拍翅,一个是点击向上飞行

其拍翅是一个纯粹的图片帧动画,一直循环,没有相关的逻辑,因此直接创建一个Animation clip,其模式为Loop, 设置成default clip,然后勾选play on load即可

点击的时候会向上飞行一段距离,使用action来实现简易的逻辑动画,

fly() {
    const body = this.node.getComponent(cc.RigidBody)
    // 飞行时关闭重力
    body.type = cc.RigidBodyType.Static
    this.node.angle = 30

    this.node.stopAction(this.lastAction)
    this.lastAction = cc.sequence(
        cc.sequence(
            cc.moveBy(this.jumpDuration, cc.v2(0, this.jumpHeight)).easing(cc.easeSineOut()),
            cc.callFunc(function (target) {
                // 重新开启重力
                body.type = cc.RigidBodyType.Dynamic
            }, this)
        ),
        cc.rotateTo(this.jumpDuration, 90).easing(cc.easeIn(1.2)), // 向下俯冲
    );

    this.node.runAction(this.lastAction)
}

然后注册相关的事件,在事件处理函数中调用bird.fly()

 this.node.on(cc.Node.EventType.MOUSE_DOWN, () => {
    this.bird.fly()
})

5. 管道

管道作为可以动态生成的节点,我们将它做成prefab

创建pipe.ts作为其脚本组件,负责控制管道的尺寸等逻辑

5.1. 移动

管道的移动比较简单,在update中修改x坐标即可,由于移动速度与地面保持一致,因此将速度这个变量提升到game组件负责维护


update(dt) {
    const speed = this.game.speed * dt
    this.node.x -= speed

    // 刚体需要手动同步
    this.topPipe.getComponent(cc.RigidBody).syncPosition(true);
    this.bottomPipe.getComponent(cc.RigidBody).syncPosition(true);

    if (!this.isPass && this.game && this.game.bird.node.x > this.node.x) {
        this.isPass = true
        this.game.passPipe()
        this.game.createPipe()
    }
}

5.2. 随机尺寸算法

整个游戏的核心机制就是操作小鸟安全通过管道,增加分数。那么管道的就是整个游戏最值得设计的地方,这里决定了整个游戏的难度

下面是核心算法,由于顶部管道高度h1 + 空白区域gap + 底部管道的高度h2 + 地面高度 = 游戏场景高度,那么只要知道了h1、gap、h2三个值中的两个,则剩下的那个值都可以求得,

根据这个公式和锚点,随机h1和gap,就可以得到h2

const random = (min, max) => {
    return Math.random() * (max - min) + min
}
initPos() {
    this.node.setPosition(cc.v2(360, 0));

    const groundH = 64
    const screenH = 960

    let gap = random(200, 300) // 可以通过的区域
    let h1 = random(200, 400) // 顶部管道高度,通过顶部管道和可通过区域,就可以计算出底部管道的高度
    // let gap = 150
    // let h1 = 300

    let y1 = (screenH / 2 - h1) + h1 / 2

    this.topPipe.node.height = h1
    this.topPipe.node.y = y1

    // h1 + h2 + gap = screenH - groundH
    let h2 = screenH - groundH - h1 - gap

    // y2 + h2/2 = screenH/2 - groundH
    let y2 = screenH / 2 - groundH - h2 / 2

    this.bottomPipe.node.height = h2
    this.bottomPipe.node.y = -y2
}

5.3. 管道生成器

由于管道是动态创建的,在游戏运行时动态创建节点比较影响性能,一般的解决方案是使用节点池nodePool,由于flappy bird是竖版游戏,屏幕中一般不会超过3个管道,因此只需要将超过屏幕的管道进行回收,就不用无限制动态创建管道了

创建一个pipePool.ts脚本组件封装节点池的逻辑,然后将该组件也挂载到根节点Canvas上面

@ccclass
export default class NewClass extends cc.Component {

    @property(cc.Prefab)
    pipePrefab: cc.Prefab

    pool: cc.NodePool = null
    nodeList: cc.Node[]
    game: Game

    init(game: Game) {
        this.game = game
        this.nodeList = []
        this.initPool()
    }


    initPool() {
        this.pool = new cc.NodePool();
        let initCount = 3; // 最大节点,3个
        for (let i = 0; i < initCount; ++i) {
            let node = cc.instantiate(this.pipePrefab); // 创建节点
            this.pool.put(node); // 通过 put 接口放入对象池
            this.nodeList.push(node)
        }
    }

    // 从节点池获取一个节点
    createPipe() {
        let pipe = null
        if (this.pool.size() > 0) {
            pipe = this.pool.get()
        } else {
            pipe = cc.instantiate(this.pipePrefab)
        }
        pipe.getComponent("pipe").init(this.game)

        return pipe
    }

    // 回收节点,在合适的时机调用
    reusePipe(pipe: cc.Node) {
        this.pool.put(pipe)
    }

    reset() {
        this.nodeList.forEach(node => {
            this.reusePipe(node)
        })
    }
}

当某个管道的不再屏幕中展示时,就可以回收了

// pipe.ts
update(dt) {
    // ... 其他逻辑

    // 回收
    if (this.isPass && this.node.x <= -400) {
        this.game.poolMng.reusePipe(this.node)
    }

}

6. 地面

地面需要展示一个一直移动的动画,最开始本来使用Animation实现的,由于需要跟管道的移动顺序保持一致,后来决定用代码实现,创建一个ground.ts组件

// 初始化滴管
initGround() {
    for (let i = -18; i <= 18; ++i) {
        let tile = cc.instantiate(this.groundTile);
        tile.x = i * 37
        this.node.addChild(tile)
    }
}

update(dt) {
    // 地面移动动画
    const speed = this.game.speed * dt
    this.node.x -= speed
    if (this.node.x < -320) {
        this.node.x += 320 - 18.5
    }
}

其大概思路类似于无缝滚动背景图那样,使用两张背景图,当一张背景图离开屏幕时,重新设置其x位置实现,这种做法在某些时候会导致有几帧动画不太连贯,可以优化。

7. 游戏结果

在游戏进行中,每通过一个管道,对应分数会+1并进行展示,在游戏结束后,会弹出结束面板。这一部分都放在result.ts组件中进行处理,主要是一些label节点的内容替换

// result.ts
addScore() {
    this.updateScore(this.score + 1)
}


updateScore(num: number) {
    this.score = num
    this.scoreLabel.string = this.score.toString()
}

showResult() {
    this.maxScore = Math.max(this.maxScore, this.score)

    this.scoreLabel.node.active = false


    this.node.active = true
    this.node.zIndex = 9999

    this.currentScoreLabel.string = this.score.toString()
    this.bestScoreLabel.string = this.maxScore.toString()

    this.scoreLabel.node.active = false
}

在游戏game.ts组件中,只要在相关的逻辑节点调用result组件执行相关方法就行了

// game.ts
passPipe() {
    this.result.addScore()
}

gameOver() {
    this.isOver = true

    console.log('碰到障碍,游戏结束')
    this.result.showResult()

    cc.director.pause()
}

这里的逻辑比较简单,但是可以看出通过组件来封装逻辑的一些便利性,每个组件只负责自己的职责,然后暴露出一些接口供其他组件调用接口。

8. 小结

整个项目的资源和代码都放在github上面了,希望能对想做游戏的你有所帮助。

整个开发过程中比较平稳,虽然还是遇见了一些问题,比如“刚体不随父节点一起移动”(可以使用syncPosition(true),参考讨论,或者尝试移动摄像机替代移动游戏节点x。),所幸能找到相关的回答,也没有浪费多少时间。

cocos虽然不是最适合做游戏的引擎,但是用来入门游戏开发还是很不错的。从比较简单的游戏入手,一步一步学习,总有一天可以做出心仪的游戏(对我自己说