Bootstrap

FlappyBird-Cocos2d小游戏源码分享讲解

本文针对Cocos开源小项目FlappyBird游戏源码进行讲解
需要先下好cocos软件

然后获取游戏源码包-网盘获取

🆗让我们开始吧

1 导入源码包

项目是用Cocos Creator 3.8.2版本创建的(从package.json中可以看出来),关于版本兼容性有两点需要注意:一个是向上兼容,可以用更高版本的编辑器打开(比如3.8.3、3.8.4),但建议只用比3.8.2高一点的版本,比如3.8.x系列;第二是向下不兼容,不能用低版本编辑器打开(比如3.7.x或3.6.x),这样会导致项目无法正常运行或报错。

安装好正确的编辑器后,我们把解压的源码文件夹导入我们的项目,首先点击导航栏的“项目”: 

进入后页面是这样的,你可能会疑惑,怎么工作台没有游戏界面,因为导入的源码还在资源管理器中,没有搬到层级管理器中,相当于游戏还在仓库里没有放到台面上。

双击scenes文件夹里的Game场景文件把游戏场景打开,这样就能看到游戏的界面了。场景文件就是一个保存了游戏画面布局的数据文件,打开它时,Cocos会自动把这些数据转换成层级管理器中的实际游戏物件。我们可以先来运行玩一下。

我们先来了解一下游戏的流程,游戏开始时,小鸟在屏幕中间悬停。玩家点击屏幕,小鸟就会向上跳跃一下,不点击的话小鸟会自然下落。游戏中会不断有上下的管道从右向左移动,玩家需要通过点击屏幕控制小鸟穿过管道之间的缝隙,碰到管道或者掉到地上game over,显示分数。每成功通过一对管道就得一分,目标就是尽可能得高分。 

这个FlappyBird游戏的代码结构很清晰,GameManager管理整体游戏状态(准备、游戏中、结束),Bird类控制小鸟的跳跃和碰撞,MoveBg负责背景的无限滚动,再加上分数统计和音效系统,所有这些组件协同工作,就构成了一个完整的游戏循环。

2 项目结构

接着我们来了解一下FlappyBird游戏的代码结构,可以分为两大部分:

一、场景结构(上半部分):

1.Game是主场景,下面的Canvas包含了所有游戏元素

2.Camera负责游戏画面

3.Bg(背景)、PipeSpawn(管道生成)、Land(地面)是游戏场景元素

4.GameManager和Bird是核心游戏对象

5.三个UI界面(Ready准备/Gaming游戏中/GameOver结束)负责不同状态的显示

6.audioMgr管理所有音效

二、资源结构(下半部分):

1.animations存放动画文件

2.audios存放音效文件

3.prefabs存放预制体(可重用的游戏对象)

4.scenes存放场景文件

5.scripts存放所有代码脚本

6.textures存放图片资源

游戏采用了典型的Cocos游戏架构:场景层级(Hierarchy)包含了游戏运行时所需的所有节点组件,从相机、UI界面到游戏对象都以树状结构组织;资源系统(Assets)则存放了游戏开发所需的各类资源文件,包括动画、音频、预制体、场景、脚本和贴图等,形成了一个完整的游戏开发工程结构。

3 核心代码

游戏用的TypeScript(简称TS)语言编写,这也是Cocos Creator推荐的开发语言。TypeScript是JavaScript的超集,它在JavaScript的基础上增加了类型系统,让代码更容易维护和调试,从文件扩展名.ts就可以看出来。TypeScript的优势是可以在编写代码时就发现可能的错误,而不是等到运行时才发现,这对游戏开发来说特别重要。

下面列出FlappyBird的核心的脚本代码,都放在assets/scripts文件夹里面:

1.GameManager.ts:游戏的大脑,控制游戏状态(准备/游戏中/结束)、管理游戏进程和分数

2.Bird.ts:小鸟的控制器,处理点击跳跃、控制小鸟旋转,还有碰撞检测

3.MoveBg.ts:背景移动系统,控制背景和地面的无限滚动、实现视差效果

4.PipeSpawner.ts:管道生成器,生成和控制管道、设置管道间距和速度

5.UI文件夹下的:GameReadyUI.ts是准备界面,GameOverUI.ts是结束界面

6.AudioMgr.ts:音频管理器,控制游戏音效和背景音乐

上面这些脚本构成了游戏的核心逻辑系统,每个脚本都负责特定的功能下面我挑几个代码(讲解注释)

A:GameManager.ts

主要逻辑是通过管理游戏状态来控制小鸟、背景、管道、UI显示和音效,同时实时更新分数。

import { _decorator, AudioClip, Component, Game, Label, Node } from 'cc';
import { Bird } from './Bird';
import { MoveBg } from './MoveBg';
import { PipeSpawner } from './PipeSpawner';
import { GameReadyUI } from './UI/GameReadyUI';
import { GameData } from './GameData';
import { GameOverUI } from './UI/GameOverUI';
import { AudioMgr } from './AudioMgr';
const { ccclass, property } = _decorator;

// 定义游戏状态枚举(状态分为:准备中、游戏中、游戏结束)
enum GameState {
    Ready,     // 准备中
    Gaming,    // 游戏中
    GameOver   // 游戏结束
}

@ccclass('GameManager')
export class GameManager extends Component {

    // 单例模式,让其他地方可以直接通过GameManager.inst()访问
    private static _inst: GameManager = null;
    public static inst() {
        return this._inst;
    }

    // 游戏的移动速度(比如背景滚动的速度)
    @property
    moveSpeed: number = 100;

    // 小鸟的控制组件
    @property(Bird)
    bird: Bird = null;

    // 背景和地面的移动组件
    @property(MoveBg)
    bgMoving: MoveBg = null;
    @property(MoveBg)
    landMoving: MoveBg = null;

    // 管道生成器
    @property(PipeSpawner)
    pipeSpawner: PipeSpawner = null;

    // 各种UI:游戏开始前的准备UI、游戏中的UI、游戏结束UI
    @property(GameReadyUI)
    gameReadyUI: GameReadyUI = null;
    @property(Node)
    gamingUI: Node = null;
    @property(GameOverUI)
    gameOverUI: GameOverUI = null;

    // 显示分数的标签组件
    @property(Label)
    scoreLabel: Label = null;

    // 背景音乐和游戏结束音效
    @property(AudioClip)
    bgAudio: AudioClip = null;
    @property(AudioClip)
    gameOverAudio: AudioClip = null;

    // 当前游戏状态,初始是准备状态
    curGS: GameState = GameState.Ready;

    // 当组件加载时会调用,初始化GameManager实例
    onLoad() {
        GameManager._inst = this; // 把当前实例赋值给静态变量,供其他地方调用
    }

    // 游戏开始时会调用的逻辑
    protected start(): void {
        this.transitionToReadyState(); // 切换到准备状态
        AudioMgr.inst.play(this.bgAudio, 0.1); // 播放背景音乐,音量是0.1
    }

    // 切换到“准备状态”的逻辑
    transitionToReadyState() {
        this.curGS = GameState.Ready; // 设置当前状态为准备中
        this.bird.disableControl(); // 禁用小鸟的控制,防止玩家操作
        this.bgMoving.disableMoving(); // 停止背景滚动
        this.landMoving.disableMoving(); // 停止地面滚动
        this.pipeSpawner.pause(); // 暂停生成管道
        this.gamingUI.active = false; // 隐藏游戏中的UI
        this.gameOverUI.hide(); // 隐藏游戏结束的UI
        this.gameReadyUI.node.active = true; // 显示准备状态的UI
    }

    // 切换到“游戏进行状态”的逻辑
    transitionToGamingState() {
        this.curGS = GameState.Gaming; // 设置当前状态为游戏中

        this.bird.enableControl(); // 启用小鸟的控制,让玩家可以操作
        this.bgMoving.enableMoving(); // 开始背景滚动
        this.landMoving.enableMoving(); // 开始地面滚动
        this.pipeSpawner.start(); // 开始生成管道
        this.gameReadyUI.node.active = false; // 隐藏准备状态的UI
        this.gamingUI.active = true; // 显示游戏中的UI
    }

    // 切换到“游戏结束状态”的逻辑
    transitionToGameOverState() {
        if (this.curGS == GameState.GameOver) return; // 如果已经是游戏结束状态,直接返回
        this.curGS = GameState.GameOver; // 设置当前状态为游戏结束

        this.bird.disableControlNotRGD(); // 禁用小鸟的控制,但保留重力等效果
        this.bgMoving.disableMoving(); // 停止背景滚动
        this.landMoving.disableMoving(); // 停止地面滚动
        this.pipeSpawner.pause(); // 暂停生成管道
        this.gamingUI.active = false; // 隐藏游戏中的UI

        // 显示游戏结束UI,并更新分数
        this.gameOverUI.show(GameData.getScore(), GameData.getBestScore()); 
        GameData.saveScore(); // 保存当前分数
        AudioMgr.inst.stop(); // 停止背景音乐
        AudioMgr.inst.playOneShot(this.gameOverAudio); // 播放游戏结束音效
    }

    // 增加分数的逻辑
    addScore(count: number = 1) {
        GameData.addScore(); // 更新分数
        this.scoreLabel.string = GameData.getScore().toString(); // 把新的分数显示在屏幕上
    }
}

B:Bird.ts

通过触摸屏幕控制小鸟飞起,管理小鸟的旋转与物理运动,碰撞管道或地面触发游戏结束,穿越管道得分。

import { _decorator, Animation, animation, AudioClip, Collider2D, Component, Contact2DType, Input, input, IPhysics2DContact, Node, RigidBody2D, SkelAnimDataHub, Vec2, Vec3 } from 'cc';
import { Tags } from './Tags';
import { GameManager } from './GameManager';
import { AudioMgr } from './AudioMgr';
const { ccclass, property } = _decorator;

@ccclass('Bird')
export class Bird extends Component {

    private rgd2D: RigidBody2D = null; // 小鸟的物理刚体组件

    @property
    rotateSpeed: number = 30; // 小鸟旋转的速度

    @property(AudioClip)
    clickAudio: AudioClip = null; // 小鸟点击时播放的音效

    private _canControl: boolean = false; // 是否可以控制小鸟的状态

    // 在组件加载时执行
    onLoad() {
        // 监听触摸事件,当触摸屏幕时调用 onTouchStart 方法
        input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);

        // 获取并注册碰撞事件的回调
        let collider = this.getComponent(Collider2D);
        if (collider) {
            // 开始接触时调用 onBeginContact
            collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
            // 结束接触时调用 onEndContact
            collider.on(Contact2DType.END_CONTACT, this.onEndContact, this);
        }

        // 获取小鸟的物理刚体组件
        this.rgd2D = this.getComponent(RigidBody2D);
    }

    // 在组件销毁时执行,移除事件监听
    onDestroy() {
        input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); // 移除触摸事件监听

        let collider = this.getComponent(Collider2D);
        if (collider) {
            // 移除碰撞事件的回调
            collider.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
            collider.off(Contact2DType.END_CONTACT, this.onEndContact, this);
        }
    }

    // 当触摸屏幕时调用,控制小鸟飞起来
    onTouchStart() {
        if (this._canControl == false) return; // 如果不能控制小鸟,直接返回
        this.rgd2D.linearVelocity = new Vec2(0, 10); // 给小鸟一个向上的速度
        this.node.angle = 30; // 设置小鸟的角度
        AudioMgr.inst.playOneShot(this.clickAudio); // 播放点击音效
    }

    // 每一帧都会调用,用来更新小鸟的角度(旋转)
    protected update(dt: number): void {
        if (this._canControl == false) return; // 如果不能控制小鸟,直接返回
        this.node.angle -= this.rotateSpeed * dt; // 根据旋转速度更新小鸟角度
        if (this.node.angle < -60) {
            this.node.angle = -60; // 限制小鸟最大旋转角度为-60度
        }
    }

    // 启用小鸟的控制
    public enableControl() {
        this.getComponent(Animation).enabled = true; // 启动动画
        this.rgd2D.enabled = true; // 启用物理刚体
        this._canControl = true; // 设置可以控制小鸟
    }

    // 禁用小鸟的控制
    public disableControl() {
        this.getComponent(Animation).enabled = false; // 禁用动画
        this.rgd2D.enabled = false; // 禁用物理刚体
        this._canControl = false; // 设置不能控制小鸟
    }

    // 禁用小鸟的控制,但保留物理刚体(主要用于游戏结束时)
    public disableControlNotRGD() {
        this.getComponent(Animation).enabled = false; // 禁用动画
        this._canControl = false; // 设置不能控制小鸟
    }

    // 碰撞开始时的回调函数
    onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        console.log(otherCollider.tag); // 打印碰撞物体的标签
        // 如果碰到地面或者管道,游戏结束
        if (otherCollider.tag === Tags.LAND || otherCollider.tag === Tags.PIPE) {
            GameManager.inst().transitionToGameOverState(); // 切换到游戏结束状态
        }
    }

    // 碰撞结束时的回调函数
    onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        // 如果小鸟通过了管道的中间区域,得分
        if (otherCollider.tag === Tags.PIPE_MIDDLE) {
            GameManager.inst().addScore(); // 增加分数
        }
    }

}

C:MoveBg.ts

主要用来实现游戏中的背景平滑滚动效果。通过两个背景交替移动,避免了背景结束后出现空白

import { _decorator, Component, Node } from 'cc';
import { GameManager } from './GameManager';
const { ccclass, property } = _decorator;

@ccclass('MoveBg')
export class MoveBg extends Component {

    @property(Node)
    target1ToMove: Node = null; // 第一个需要移动的目标节点(背景)
    @property(Node)
    target2ToMove: Node = null; // 第二个需要移动的目标节点(背景)

    private moveSpeed: number = 100; // 背景移动的速度
    private _canMoving: boolean = false; // 控制背景是否可以移动

    // 在游戏开始时设置背景移动的速度
    start() {
        this.moveSpeed = GameManager.inst().moveSpeed; // 获取游戏管理器中的移动速度
    }

    // 每一帧都执行,用来更新背景的位置
    update(deltaTime: number) {

        // 如果不能移动背景,就直接返回
        if (this._canMoving == false) return;
        
        const moveDistance = this.moveSpeed * deltaTime; // 计算背景每帧应该移动的距离

        // 获取第一个背景的当前位置并更新位置
        let p1 = this.target1ToMove.getPosition();
        this.target1ToMove.setPosition(p1.x - moveDistance, p1.y);

        // 获取第二个背景的当前位置并更新位置
        let p2 = this.target2ToMove.getPosition();
        this.target2ToMove.setPosition(p2.x - moveDistance, p2.y);

        // 如果第一个背景移动出屏幕(x小于-730),则将其位置重新设置到第二个背景的右边
        if (p1.x < -730) {
            p2 = this.target2ToMove.getPosition();
            this.target1ToMove.setPosition(p2.x + 728, p2.y); // 让第一个背景出现在第二个背景的右边
        }
        
        // 如果第二个背景移动出屏幕(x小于-730),则将其位置重新设置到第一个背景的右边
        if (p2.x < -730) {
            p1 = this.target1ToMove.getPosition();
            this.target2ToMove.setPosition(p1.x + 728, p1.y); // 让第二个背景出现在第一个背景的右边
        }

    }

    // 启动背景的移动
    public enableMoving() {
        this._canMoving = true; // 允许背景移动
    }

    // 停止背景的移动
    public disableMoving() {
        this._canMoving = false; // 禁止背景移动
    }
}

D:PipeSpawner.ts

定时生成管道并随机设置位置,用于创建游戏中的障碍物,增强游戏的挑战性和动态性。

import { _decorator, Component, instantiate, math, Node, Prefab } from 'cc';
import { Pipe } from './Pipe';
const { ccclass, property } = _decorator;

@ccclass('PipeSpawner')
export class PipeSpawner extends Component {

    @property(Prefab)
    pipePrefab: Prefab = null; // 管道的预设模板

    @property
    spawnRate: number = 0.5; // 生成管道的间隔时间,单位是秒

    private timer: number = 0; // 计时器,用来累积时间
    private _isSpawning: boolean = false; // 判断是否正在生成管道

    // 每一帧都执行
    update(deltaTime: number) {
        if (this._isSpawning == false) return; // 如果没有开启生成管道,就什么都不做
        this.timer += deltaTime; // 每帧增加经过的时间
        if (this.timer > this.spawnRate) { // 如果时间超过了设定的间隔
            this.timer = 0; // 重置计时器
            const pipeInst = instantiate(this.pipePrefab); // 创建一个新的管道实例
            this.node.addChild(pipeInst); // 将管道添加到当前场景中

            const p = this.node.getWorldPosition(); // 获取当前节点的位置
            pipeInst.setWorldPosition(p); // 将管道放到相同的位置

            const y = math.randomRangeInt(-100, 200); // 随机生成一个Y坐标,范围从-100到200

            const pLoca = pipeInst.getPosition(); // 获取管道当前的坐标
            pipeInst.setPosition(pLoca.x, y); // 更新管道的位置,只改变Y坐标
        }
    }

    // 暂停生成管道
    public pause() {
        this._isSpawning = false; // 停止生成管道

        // 遍历所有生成的管道,并禁用它们
        const nodeArray = this.node.children;
        for (let i = 0; i < nodeArray.length; i++) {
            const pipe = nodeArray[i].getComponent(Pipe);
            if (pipe) {
                pipe.enabled = false; // 禁用管道的行为
            }
        }
    }

    // 开始生成管道
    public start() {
        this._isSpawning = true; // 开启管道生成
    }
}

E:GameReadyUI.ts

import { _decorator, Component, director, Label, Node } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('GameOverUI')
export class GameOverUI extends Component {

    @property(Label)
    curScoreLabel: Label = null; // 当前分数的标签
    @property(Label)
    bestScoreLabel: Label = null; // 最佳分数的标签

    @property(Node)
    newSprite: Node = null; // 新纪录图标(显示新纪录时才显示)

    @property([Node])
    medalArray: Node[] = []; // 奖牌数组,用来显示不同等级的奖牌

    // 显示游戏结束时的UI
    public show(curScore: number, bestScrore: number) {
        this.node.active = true; // 激活游戏结束UI
        this.curScoreLabel.string = curScore.toString(); // 显示当前分数
        this.bestScoreLabel.string = bestScrore.toString(); // 显示最佳分数

        // 如果当前分数超过最佳分数,显示新纪录图标
        if (curScore > bestScrore) {
            this.newSprite.active = true; // 显示新纪录图标
        } else {
            this.newSprite.active = false; // 不显示新纪录图标
        }

        // 根据当前分数决定显示哪一个奖牌
        const index = curScore / 10; // 分数除以10
        let indexInt = Math.floor(index); // 取整数部分
        if (indexInt > 3) {
            indexInt = 3; // 最多显示3个等级奖牌
        }
        this.medalArray[indexInt].active = true; // 显示对应等级的奖牌
    }

    // 隐藏游戏结束UI
    public hide() {
        this.node.active = false; // 隐藏游戏结束UI
    }

    // 按下“重新开始”按钮时,重新加载当前场景
    onPlayButtonClick() {
        director.loadScene(director.getScene().name); // 重新加载当前场景
    }
}

具体完整可看源码中内容,本文仅供参考学习。

;