Cocos Creator 3D 案例《彈彈樂》技術實現分享

GJQI12發表於2020-09-30

《彈彈樂》是一款簡單的休閒物理彈跳類 3D 小遊戲,用手指輕輕划動螢幕來控制小球運動方向,跳中板心或是板邊可獲得不同分數,此外,留心獲取遊戲場景中設定的鑽石,可以為玩家增加更多分數。

 

 

Cocos 引擎開發工程師放空將分享這款3D 小遊戲最基礎的完整開發流程,各位開發者可以在閱讀學習本篇教程後,繼續發揮創造力,將這款簡單的 3D 小遊戲進行擴充開發,變成一款更有趣的、可對外發布的小遊戲。

 

 

   遊戲原始碼

 

https://github.com/cocos-creator/demo-ball

 

 

   結構說明

 

下圖是《彈彈樂》的草圖以及整體設計思路

 

 

在理出整體設計思路之後,就可以開始設計每個階段應該完成的目標,以便於提高程式碼編寫的效率。

 

以下是我劃分的每個階段的開發任務:

 

遊戲初始化

  • 跳板初始化

  • 螢幕事件監聽,小球與普通板塊彈跳計算

  • 提供相機跟隨介面

 

遊戲核心邏輯編寫

  • 跳板複用邏輯編寫

  • 小球與不同板塊彈跳計算

  • 遊戲開始與結束邏輯編寫

 

遊戲豐富

  • 新增鑽石以及吃磚石表現

  • 新增跳板表現

  • 增加小球粒子以及拖尾表現

 

初期設計完成後,我們就可以開始整個遊戲場景的搭建。

 

整個遊戲一共就一個場景,一個主程式 Game,負責管理所有分支管理的 Manager 以及事件的監聽和派發;多個分支 Manager,負責管理跳板建立擺放或遊戲頁面等;一個全域性配置模組,負責儲存遊戲中使用的配置;獨立物件的運作指令碼,負責自身行為運作。

 

 

 

   編寫遊戲內容邏輯

 

由於最終呈現出來的詳細步驟程式碼太多,就不一一演示了,今天主要針對每個流程的幾個關鍵部分做個說明。整個遊戲的製作流程主要分為以下幾點:

 

(1)跳板初始化

 

跳板初始化主要體現在 BoardManager 裡的 initBoard 和 getNextPos 兩個方法。在整個遊戲過程中,使用的板一共只有 5 個,後續的跳板生成都是通過複用的方式,不斷重新計算位置以及序號。跳板的生成也是嚴格根據上一個跳板的位置來計算,避免出現長距離位置偏移影響遊戲進行。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
getNextPos(board: Board, count: number, out ?: Vec3) {    const pos: Vec3 = out ? out.set(board.node.position) : board.node.position.clone();    const o = utils.getDiffCoeff(count, 1, 2);    pos.x = (Math.random() - .5) * Constants.SCENE_MAX_OFFSET_X * o;    if (board.type === Constants.BOARD_TYPE.SPRINT) {        pos.y += Constants.BOARD_GAP_SPRINT;        pos.x = board.node.position.x;    }
    if (board.type === Constants.BOARD_TYPE.SPRING) {        pos.y += Constants.BOARD_GAP_SPRING;    } else {        pos.y += Constants.BOARD_GAP;    }    return pos;}
getDiffCoeff(e: number, t: number, a: number) {    return (a * e + 1) / (1 * e + ((a + 1) / t - 1));}

 

(2)螢幕事件監聽,小球與普通板塊彈跳計算

 

跳板初始化後,開始做小球的彈跳。整個遊戲的入口函式都設定在 Game 類上,Game 又新增在 Canvas 節點上,因此,Game 類所掛載的節點作為全域性物件的事件監聽節點來使用最合適不過。因為主要接受該事件的物件是小球,所以,我們在小球裡做監聽的回撥。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
start () {    Constants.game.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);    Constants.game.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);    Constants.game.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
    this.updateBall();    this.reset();}
onTouchStart(touch: Touch, event: EventTouch){    this.isTouch = true;    this.touchPosX = touch.getLocation().x;    this.movePosX = this.touchPosX;}
onTouchMove(touch: Touch, event: EventTouch){    this.movePosX = touch.getLocation().x;}
onTouchEnd(touch: Touch, event: EventTouch){    this.isTouch = false;}

然後,小球根據一定比例的換算來做實際移動距離的計算。在 update 裡每幀根據衝刺等狀態對小球進行 setPosX,setPosY 調整,小球的上升與下降是通過擬重力加速減速來實現。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
// Constantsstatic BALL_JUMP_STEP = [0.8, 0.6, 0.5, 0.4, 0.3, 0.2, 0.15, 0.1, 0.05, 0.03]; // 正常跳躍步長static BALL_JUMP_FRAMES = 20; // 正常跳躍幀數
//Ball_tempPos.set(this.node.position);_tempPos.y += Constants.BALL_JUMP_STEP[Math.floor(this._currJumpFrame / 2)];this.node.setPosition(_tempPos);

 

(3)提供相機跟隨介面

 

相機的位置移動不是由自身來操控的,而是根據小球當前的位置來進行實時跟蹤。因此,相機只需要調整好設定介面,按照一定脫離距離去跟隨小球即可。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
update() {    _tempPos.set(this.node.position);    if (_tempPos.x === this._originPos.x && _tempPos.y === this._originPos.y) {        return;    }
    // 橫向位置誤差糾正    if (Math.abs(_tempPos.x - this._originPos.x) <= Constants.CAMERA_MOVE_MINI_ERR) {        _tempPos.x = this._originPos.x;        this.setPosition(_tempPos);    } else {        const x = this._originPos.x - _tempPos.x;        _tempPos.x += x / Constants.CAMERA_MOVE_X_FRAMES;        this.setPosition(_tempPos);    }
    _tempPos.set(this.node.position);    // 縱向位置誤差糾正    if (Math.abs(_tempPos.y - this._originPos.y) <= Constants.CAMERA_MOVE_MINI_ERR) {        _tempPos.y = this._originPos.y;        this.setPosition(_tempPos);    } else {        const y = this._originPos.y - _tempPos.y;        if (this.preType === Constants.BOARD_TYPE.SPRING) {            _tempPos.y += y / Constants.CAMERA_MOVE_Y_FRAMES_SPRING;            this.setPosition(_tempPos);        } else {            _tempPos.y += y / Constants.CAMERA_MOVE_Y_FRAMES;            this.setPosition(_tempPos);        }    }}

 

 

   核心邏輯

 

整個遊戲的節奏控制其實都是通過小球來的,小球通過彈跳位置決定什麼時候開始新板的生成,小球在遊戲過程中的得分決定了板子後續生成的豐富性(比如長板或者彈簧板)以及小球的死亡以及復活決定了遊戲的狀態等等;最後通過 UI 配合來完成遊戲開始結束復活的介面引導互動操作。

 

(1)跳板複用邏輯編寫

 

保持場景中的跳板就是初始化的數量,所以需要提前度量好板塊間的最小距離。那麼,螢幕最下方的板塊在什麼時機開始複用到螢幕最上方呢?舉個例子:假設當前場景的板上限是 5 塊,在陣列裡的順序就是 0 - 4,按前面說的所有板在全顯示的情況下是會均勻分佈的,因此,螢幕的分割板就是在中間板的 2 號板,因此只要超過了 2,就代表小球已經跳過的螢幕的一半,這個時候就要開始清理無用的板了。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
for (let i = this.currBoardIdx + 1; i >= 0; i--) {    const board = boardList[i];
    // 超過當前跳板應該彈跳高度,開始下降    if (this.jumpState === Constants.BALL_JUMP_STATE.FALLDOWN) {        if (this.currJumpFrame > Constants.PLAYER_MAX_DOWN_FRAMES || this.currBoard.node.position.y - this.node.position.y > Constants.BOARD_GAP + Constants.BOARD_HEIGTH) {            Constants.game.gameDie();            return;        }
        // 是否在當前檢測的板上        if (this.isOnBoard(board)) {            this.currBoard = board;            this.currBoardIdx = i;            this.activeCurrBoard();            break;        }    }}
// 當超過中間板就開始做板複用for (let l = this.currBoardIdx - Constants.BOARD_NEW_INDEX; l > 0; l--) {    this.newBoard();}

 

(2)小球與不同板塊彈跳計算

 

上面的製作過程中,我們已經實現了在普通板上小球是一個乒乓球狀態,那麼遇到彈簧板或者衝刺板的時候,也可以用類似邏輯結構來繼續補充不同板子的不同處理。這裡的實現因為結構已定較為簡單,就不再多做說明,只需要在全域性資料類里加上相應的相同配置即可。

 

(3)遊戲開始與結束邏輯編寫

遊戲開始以及結束都是通過 UI 介面來實現。定義一個 UIManager 管理類來管理當前 UI 介面,所有的 UI 開啟與關閉都通過此管理類來統一管理,點選事件的響應都直接回撥給遊戲主迴圈 Game 類。

以上部分就基本完成了整個遊戲的邏輯部分。

 

 

   遊戲豐富

 

接下來豐富一下游戲的真實表現力。

 

(1)新增鑽石以及吃磚石表現

 

因為遊戲內的跳板數量限制,因此,我們可以大方的給每個跳板配置 5 個鑽石,通過隨機概率決定鑽石的顯示。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
if (this.type === Constants.BOARD_TYPE.GIANT) {    for (let i = 0; i < 5; i++) {        this.diamondList[i].active = true;        this.hasDiamond = true;    }} else if (this.type === Constants.BOARD_TYPE.NORMAL || this.type === Constants.BOARD_TYPE.DROP) {    if (Math.random() > .7) {        this.diamondList[2].active = true;        this.hasDiamond = true;    }}

既然有了鑽石,那吃鑽石的時候,肯定也要有些表示,那就是掉落一些粒子來增加表現。由於遊戲設計過程中如果有很多對頻繁的建立和銷燬的話,對效能其實是很不友好的,因此,提供一個物件池在一款遊戲中是必不可少。

在這裡,我們就可以把散落的粒子存放在物件池裡進行復用。在這款遊戲的設計過程中,小球部分的計算量是很頻繁的,特別是在每幀需要更新的地方,想要去做效能優化的同學可以根據物件池的概念對小球裡的一些向量進行復用。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
getNode(prefab: Prefab, parent: Node) {    let name = prefab.data.name;    this.dictPrefab[name] = prefab;    let node: Node = null;    if (this.dictPool.hasOwnProperty(name)) {        //已有對應的物件池        let pool = this.dictPool[name];        if (pool.size() > 0) {            node = pool.get();        } else {            node = instantiate(prefab);        }    } else {        //沒有對應物件池,建立他!        let pool = new NodePool();        this.dictPool[name] = pool;
        node = instantiate(prefab);    }
    node.parent = parent;    return node;}
putNode(node: Node) {    let name = node.name;    let pool = null;    if (this.dictPool.hasOwnProperty(name)) {        //已有對應的物件池        pool = this.dictPool[name];    } else {        //沒有對應物件池,建立他!        pool = new cc.NodePool();        this.dictPool[name] = pool;    }
    pool.put(node);}

 

(2)新增跳板表現、增加小球粒子以及拖尾表現

 

其實這兩點功能都基本類似,都是增加一些波動、拖尾粒子等來豐富表現,在這裡就不過多說明,具體的表現都寫在了 Board 類和 Ball 類相對應關鍵字的方法裡。

 

(3)增加音效和音樂

 

因為是基礎教程,遊戲內的表現也不是很多,所以就選取了按鈕被點選的音效和背景音樂來做效果。

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
playSound(play = true) {    if (!play) {        this.audioComp.stop();        return;    }
    this.audioComp.clip = this.bg;    this.audioComp.play();}
playClip() {    this.audioComp.playOneShot(this.click);}

 

以上就是本教程的全部內容,接下來看一下執行結果吧。

 

————/ END /————

 

相關文章