使用cocos实现flappybird
打算梳理一下自己的游戏开发技能(虽然并没有什么技能),于是打算从易到难去实现一些比较完整的游戏案例。flappybird是一个比较精简的游戏,游戏玩法和规则都很简单,但是包含的内容比较丰富,能涉猎到不少知识点,因此用来入门是比较不错的。本文整理了使用 cocos creator 开发 flappy bird 的一些收获。
整个项目的资源和代码都放在github。
梳理游戏逻辑
整个游戏只包含一个主界面,
- 开始游戏后,鸟受重力影响会下降,点击屏幕可以让鸟向上飞行一段距离,鸟撞击到地面则游戏结束
- 整个场景由右向左移动,每隔一段时间会生成上下两根管道,需要控制鸟通过管道中间的空白区域,鸟撞击到管道则游戏结束
- 每通过一根管道,则积分+1,游戏目标是尽可能获得多的积分
可以看见,整个游戏的元素包括
- 鸟,玩家控制的主角
- 管道,上下成对出现,水平方向移动
- 地面,水平方向移动
- 分数
涉及到的知识点包括
- 游戏流程,如开始、暂停、逻辑等
- 物理系统,主要是竖直方向上的重力系统
- 碰撞检测,鸟与管道、鸟与地面
- 一些动画,鸟排翅动画、鸟向上飞行动画、地面水平移动动画
- 预制体与对象池,需要随机生成管道,当管道从屏幕上消失之后可以回收复用
- 组件封装,将不同的逻辑封装在不同的组件中
初始化项目
首先将下载好的素材添加到项目中
然后创建节点,由于整个场景比较简单,因此只需要创建下面几个节点就行了
然后编写第一个脚本组件game.ts
,这个脚本是整个游戏的入口,负责管理其他的脚本组件和游戏逻辑,然后将其作为script component添加到根节点Canvas上
game组件会维持对其他脚本组件的引用,这样就可以很方便地调用其他组件的方法
相关组件的作用会在下文一一道来。
物理系统和碰撞系统
首先是在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
鸟
为了方便管理鸟的逻辑,单独创建一个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()
})
管道
管道作为可以动态生成的节点,我们将它做成prefab
创建pipe.ts
作为其脚本组件,负责控制管道的尺寸等逻辑
移动
管道的移动比较简单,在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()
}
}
随机尺寸算法
整个游戏的核心机制就是操作小鸟安全通过管道,增加分数。那么管道的就是整个游戏最值得设计的地方,这里决定了整个游戏的难度
下面是核心算法,由于顶部管道高度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
}
管道生成器
由于管道是动态创建的,在游戏运行时动态创建节点比较影响性能,一般的解决方案是使用节点池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)
}
}
地面
地面需要展示一个一直移动的动画,最开始本来使用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位置实现,这种做法在某些时候会导致有几帧动画不太连贯,可以优化。
游戏结果
在游戏进行中,每通过一个管道,对应分数会+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()
}
这里的逻辑比较简单,但是可以看出通过组件来封装逻辑的一些便利性,每个组件只负责自己的职责,然后暴露出一些接口供其他组件调用接口。
小结
整个项目的资源和代码都放在github上面了,希望能对想做游戏的你有所帮助。
整个开发过程中比较平稳,虽然还是遇见了一些问题,比如“刚体不随父节点一起移动”(可以使用syncPosition(true)
,参考讨论,或者尝试移动摄像机替代移动游戏节点x。),所幸能找到相关的回答,也没有浪费多少时间。
cocos虽然不是最适合做游戏的引擎,但是用来入门游戏开发还是很不错的。从比较简单的游戏入手,一步一步学习,总有一天可以做出心仪的游戏(对我自己说
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。