H5 六邊形消除遊戲開發

EER發表於2018-08-10

試玩地址,目前只適配pc端。原始碼

六邊形遊戲的鼻祖應該是這個 hex-frvr,原作者開發用的是 pixi 遊戲引擎,本著快速開發的理念,本遊戲採用 cocos creator,UI 延用 hex-frvr。學習過程中,有借鑑各路實現。此原始碼僅供學習使用,謝謝。

預覽

H5 六邊形消除遊戲開發

功能介紹

六邊形遊戲本質是俄羅斯方塊,理解這個對接下來的開發會有很大的幫助。

本遊戲實現功能如下:

  • [x] 六邊形棋盤繪製、方塊隨機生成
  • [x] 方塊能否落入棋盤的判定
  • [x] 方塊消除與遊戲結束的判定
  • [x] 各種動畫效果
  • [x] 遊戲計分

cocos creator

在講遊戲開發思路前,建議先了解 cocos creator

必須瞭解的 API 有:

其中,Node、Event、Vec2,是此遊戲開發的重點。

開發思路

下面從功能逐一介紹開發思路。

棋盤繪製

棋盤用的是六角網格佈局,電子遊戲中六角網格的運用沒有方形網格那樣常見,先來簡單瞭解下六角網格。

六角網格

本文中討論的六角網格使用的都是正六邊形。六角網格最典型的朝向有兩種:水平方向( 頂點朝上 )與豎直方形( 邊線朝上 )。本遊戲用的是,頂點朝上的朝向。

H5 六邊形消除遊戲開發

細心的同學會發現,圖中有類似座標系的東西,稱之為軸座標。

軸座標

軸座標系,有時也叫做“梯形座標系”,是從立方座標系的三個座標中取兩個建立的座標系。由於我們有約束條件 x + y + z = 0,因此第三個座標其實是多餘的。軸座標適合用於地圖資料儲存,也適合用於作為面向玩家的顯示座標。類似立方座標,你也可以使用笛卡爾座標系中的加,減,乘,除等基本運算。

有許多種立方座標系,因此,也自然有許多種由其衍生的軸座標系。本遊戲,選用的是 q = x 以及 r = z 的情況。這裡 q 代表列而 r 表示行。

H5 六邊形消除遊戲開發

偏移座標是人們最先會想到的座標系,因為它能夠直接使用方形網格的笛卡爾座標。但不幸的是,偏移座標系中的一個軸總會顯得格格不入,並且最終會把問題變得複雜化。立方座標和軸座標則顯得相得益彰,演算法也更簡單明瞭,只是地圖儲存方面會略微變得複雜一點。所以,使用立方/軸座標系是較為簡單的。

從六角網格到畫素

大致瞭解了什麼是六角網格,接下來了解如何把六角網格轉換為畫素。

如果使用的軸座標,那麼可以先觀察下圖中示意的單位向量。在下圖中,箭頭 A→Q 表示的是 q 軸的單位向量而 A→R 是 r 軸的單位向量。畫素座標即 q_basis _ q + r_basis _ r。例如,B 點位於 (1, 1),等於 q 與 r 的單位向量之和。

H5 六邊形消除遊戲開發

在網格為 水平 朝向時,六邊形的 高度 為 高度 = size * 2. 相鄰六邊形的 豎直 距離則為 豎直 = 高度 * 3/4

六邊形的 寬度 為 寬度 = sqrt(3)/2 * 高度。相鄰六邊形的 水平 距離為 水平 = 寬度

對於本遊戲中,取棋盤中心點為,(0,0)。從已知的六角網格座標(正六邊形)以及六邊形的高度,就可以得到每個正六邊形的座標。可以得到如下畫素轉換程式碼:

  hex2pixel(hex, h) {
    let size = h / 2;
    let x = size * Math.sqrt(3) * (hex.q + hex.r / 2);
    let y = ((size * 3) / 2) * hex.r;
    return cc.p(x, y);
  }
複製程式碼

網格座標系生成

座標系轉畫素問題解決了,接下來,需要獲得本遊戲中六角網格佈局相應的座標系。

這個問題,本質是軸座標系統的地圖儲存。 8)

H5 六邊形消除遊戲開發

對半徑為 N 的六邊形佈局,當N = max(abs(x), abs(y), abs(z),有 first_column[r] == -N - min(0, r)。最後你訪問的會是 array[r][q + N + min(0, r)]。然而,由於我們可能會把一些 r < 0 的位置作為起點,因此我們也必須偏移行,有 array[r + N][q + N + min(0, r)]

如本遊戲中,棋盤為邊界六邊形個數為 5 的六角網格佈局,生成的座標系儲存程式碼如下:

  setHexagonGrid() {
    this.hexSide = 5;
    this.hexSide--;
    for (let q = -this.hexSide; q <= this.hexSide; q++) {
      let r1 = Math.max(-this.hexSide, -q - this.hexSide);
      let r2 = Math.min(this.hexSide, -q + this.hexSide);
      for (let r = r1; r <= r2; r++) {
        let col = q + this.hexSide;
        let row = r - r1;
        if (!this.hexes[col]) {
          this.hexes[col] = [];
        }
        this.hexes[col][row] = this.hex2pixel({ q, r }, this.tileH);
      }
    }
  }
複製程式碼

邊界個數為 6 的六角網格佈局,六邊形總數為 61。接著,只需要遍歷新增背景即可完成棋盤的繪製。

  setSpriteFrame(hexes) {
    for (let index = 0; index < hexes.length; index++) {
      let node = new cc.Node('frame');
      let sprite = node.addComponent(cc.Sprite);
      sprite.spriteFrame = this.tilePic;
      node.x = hexes[index].x;
      node.y = hexes[index].y;
      node.parent = this.node;
      hexes[index].spriteFrame = node;
      this.setShadowNode(node);
      this.setFillNode(node);
      this.boardFrameList.push(node);
    }
  }
複製程式碼

至此,棋盤繪製結束。

方塊隨機生成

方塊的形狀可以千變萬化,先來看下本遊戲事先約定的 23 種形狀。

在前面六角網格的知識基礎上,實現這 23 種形狀並不難。只需要約定好每個形狀對應的軸座標。

H5 六邊形消除遊戲開發

程式碼配置如下:

const Tiles = [
  {
    type: 1,
    list: [[[0, 0]]]
  },
  {
    type: 2,
    list: [
      [[1, -1], [0, 0], [1, 0], [0, 1]],
      [[0, 0], [1, 0], [-1, 1], [0, 1]],
      [[0, 0], [1, 0], [0, 1], [1, 1]]
    ]
  },
  {
    type: 3,
    list: [
      [[0, -1], [0, 0], [0, 1], [0, 2]],
      [[0, 0], [1, -1], [-1, 1], [-2, 2]],
      [[-1, 0], [0, 0], [1, 0], [2, 0]]
    ]
  },
  {
    type: 4,
    list: [
      [[0, 0], [0, 1], [0, -1], [-1, 0]],
      [[0, 0], [0, -1], [1, -1], [-1, 1]],
      [[0, 0], [0, 1], [0, -1], [1, 0]],
      [[0, 0], [1, 0], [-1, 0], [1, -1]],
      [[0, 0], [1, 0], [-1, 0], [-1, 1]]
    ]
  },
  {
    type: 5,
    list: [
      [[0, 0], [0, 1], [0, -1], [1, -1]],
      [[0, 0], [1, -1], [-1, 1], [-1, 0]],
      [[0, 0], [1, -1], [-1, 1], [1, 0]],
      [[0, 0], [1, 0], [-1, 0], [0, -1]],
      [[0, 0], [1, 0], [-1, 0], [0, 1]]
    ]
  },
  {
    type: 6,
    list: [
      [[0, -1], [-1, 0], [-1, 1], [0, 1]],
      [[-1, 0], [0, -1], [1, -1], [1, 0]],
      [[0, -1], [1, -1], [1, 0], [0, 1]],
      [[-1, 1], [0, 1], [1, 0], [1, -1]],
      [[-1, 0], [-1, 1], [0, -1], [1, -1]],
      [[-1, 0], [-1, 1], [0, 1], [1, 0]]
    ]
  }
];
複製程式碼

由於沒有涉及方塊出現的概率,這裡就簡單粗暴地用 random 來實現方塊隨機生成。

const getRandomInt = function(min, max) {
  let ratio = cc.random0To1();
  return min + Math.floor((max - min) * ratio);
};
複製程式碼

網格和方塊都搞定了,蠻喜歡這種簡單的 UI 風格,非常適合遊戲開發的入門學習。接下來處理遊戲互動邏輯。

方塊落入棋盤邏輯

方塊與棋盤之間的互動關係是 Drag 與 Drop ,在 cocos creator 中暫時沒發現有 Drag 相關的元件,目前是通過 touch 事件來模擬。在方塊 touchmove 的過程,需要處理兩件事,第一,檢測拖拽過程中方塊是否與棋盤有交叉,就是遊戲裡所謂的 碰撞檢測,cc 有提供相應的碰撞元件,但不夠靈活,因為我們要得到的是方塊與棋盤重合關係(ps:並不需要完全重合),所以還是用指令碼來模擬實現,cc 為此提供了很多 API,主要都與 vec2 有關。第二,檢測方塊是否可以落入棋盤。

碰撞檢測 (重合判定)

方塊與棋盤其實都是由正六邊形組合而成,這裡有種比較簡單地方式來判斷兩者是否有重合部分,即判斷兩個六邊形圓心的距離,當小於設定值,則認為有重合。

這邊簡單起見,特意將棋盤與方塊的父節點的座標系原點設為同一個(中心點)。cocos 座標系可參考這篇

由於方塊是相對於它的父級中心點定位,而它的父級是相對於 Canvas 定位,因此可以通過 cc.pAdd(this.node.position, tile.position) 來獲取方塊相對於棋盤原點的座標值。接著遍歷棋盤內六邊形座標值,來檢查拖拽進入的六邊形與棋盤哪些存在重合關係。相關程式碼如下:

  checkCollision(event) {
    const tiles = this.node.children; // this.node 為 方塊的父級,拖拽改變的是這個節點的座標
    this.boardTiles = []; // 儲存棋盤與方塊重合部分。
    this.fillTiles = []; // 儲存方塊當前重合的部分。
    for (let i = 0; i < tiles.length; i++) {
      const tile = tiles[i];
      const pos = cc.pAdd(this.node.position, tile.position); // pAdd 是cc早期提供的 api,可用 vec2 中向量相加替換
      const boardTile = this.checkDistance(pos);
      if (boardTile) {
        this.fillTiles.push(tile);
        this.boardTiles.push(boardTile);
      }
    }
  },
  checkDistance(pos) {
    const distance = 50;
    const boardFrameList = this.board.boardFrameList;
    for (let i = 0; i < boardFrameList.length; i++) {
      const frameNode = boardFrameList[i];
      const nodeDistance = cc.pDistance(frameNode.position, pos);
      if (nodeDistance <= distance) {
        return frameNode;
      }
    }
  },
複製程式碼

在拖拽過程,實時儲存棋盤有重合關係的六邊形,用於判定方塊是否可以落入棋盤

落子判定

只要方塊的個數與棋盤所在區域可填充部分(棋盤裡面沒有方塊)數目一致,則認為可以落子。

checkCanDrop() {
    const boardTiles = this.boardTiles; // 當前棋盤與方塊重合部分。
    const fillTiles = this.node.children; // 當前拖拽的方塊總數
    const boardTilesLength = boardTiles.length;
    const fillTilesLength = fillTiles.length;

    // 如果當前棋盤與方塊重合部分為零以及與方塊數目不一致,則判定為不能落子。
    if (boardTilesLength === 0 || boardTilesLength != fillTilesLength) {
      return false;
    }

    // 如果方塊內以及存在方塊,則判定為不能落子。
    for (let i = 0; i < boardTilesLength; i++) {
      if (this.boardTiles[i].isFulled) {
        return false;
      }
    }

    return true;
  },
複製程式碼

落子提示

得到落入與否的判定值後,需要給使用者可以落子的提示。這邊的一個做法是,在生成棋盤之前就給每個棋盤格子節點新建一個 name 為 shadowNode 的子節點。接著只需要修改符合條件的節點的spriteFrame為當前拖拽方塊的spriteFrame,同時降低透明度即可。程式碼如下:

  dropPrompt(canDrop) {
    const boardTiles = this.boardTiles;
    const boardTilesLength = boardTiles.length;
    const fillTiles = this.fillTiles;

    this.resetBoardFrames();
    if (canDrop) {
      for (let i = 0; i < boardTilesLength; i++) {
        const shadowNode = boardTiles[i].getChildByName('shadowNode');
        shadowNode.opacity = 100;
        const spriteFrame = fillTiles[i].getComponent(cc.Sprite).spriteFrame;
        shadowNode.getComponent(cc.Sprite).spriteFrame = spriteFrame;
      }
    }
  }
複製程式碼

落入邏輯

至此,方塊的 touchmove 事件新增完畢。接下來,需要做的是,拖拽結束後的相關邏輯處理。

兩種情況,方塊可以落入,與方塊不能落入。前面已經獲取了是否可以落入的判定。那接下來就是新增相應的處理。

可以落入的情況需要做的是在棋盤新增對應方塊,方塊新增結束後重新隨機生成新的方塊。不可以落入則讓拖拽的方塊返回原位置。

在新增方塊上用了跟之前說到的落入提示類似的方法,給棋盤內每個格子節點下新增一個名為 fillNode 的節點,方塊落入都跟這個節點有關。

  tileDrop() {
    this.resetBoardFrames();
    if (this.checkCanDrop()) {
      const boardTiles = this.boardTiles;
      const fillTiles = this.fillTiles;
      const fillTilesLength = fillTiles.length;

      for (let i = 0; i < fillTilesLength; i++) {
        const boardTile = boardTiles[i];
        const fillTile = fillTiles[i];
        const fillNode = boardTile.getChildByName('fillNode');
        const spriteFrame = fillTile.getComponent(cc.Sprite).spriteFrame;

        boardTile.isFulled = true;
        fillNode.getComponent(cc.Sprite).spriteFrame = spriteFrame;
        this.resetTile();
      }
      this.board.curTileLength = fillTiles.length;
      this.board.node.emit('dropSuccess');
    } else {
      this.backSourcePos();
    }
    this.board.checkLose();
  }
複製程式碼

消除邏輯

棋盤有了,也可以判斷方塊是否可以落入棋盤。接下來要做的就是消除邏輯的處理,之前說,六邊形消除遊戲就是俄羅斯方塊的衍生版,其實就是多了幾個消除方向,來看張圖:

H5 六邊形消除遊戲開發

如果把這個棋盤看成陣列,即從左斜方向依次新增 [0,1,2.....],最終可以得到如下消除規則:

const DelRules = [
  //左斜角
  [0, 1, 2, 3, 4],
  [5, 6, 7, 8, 9, 10],
  [11, 12, 13, 14, 15, 16, 17],
  [18, 19, 20, 21, 22, 23, 24, 25],
  [26, 27, 28, 29, 30, 31, 32, 33, 34],
  [35, 36, 37, 38, 39, 40, 41, 42],
  [43, 44, 45, 46, 47, 48, 49],
  [50, 51, 52, 53, 54, 55],
  [56, 57, 58, 59, 60],

  //右斜角
  [26, 35, 43, 50, 56],
  [18, 27, 36, 44, 51, 57],
  [11, 19, 28, 37, 45, 52, 58],
  [5, 12, 20, 29, 38, 46, 53, 59],
  [0, 6, 13, 21, 30, 39, 47, 54, 60],
  [1, 7, 14, 22, 31, 40, 48, 55],
  [2, 8, 15, 23, 32, 41, 49],
  [3, 9, 16, 24, 33, 42],
  [4, 10, 17, 25, 34],

  //水平
  [0, 5, 11, 18, 26],
  [1, 6, 12, 19, 27, 35],
  [2, 7, 13, 20, 28, 36, 43],
  [3, 8, 14, 21, 29, 37, 44, 50],
  [4, 9, 15, 22, 30, 38, 45, 51, 56],
  [10, 16, 23, 31, 39, 46, 52, 57],
  [17, 24, 32, 40, 47, 53, 58],
  [25, 33, 41, 48, 54, 59],
  [34, 42, 49, 55, 60]
];
複製程式碼

規則有了,接著新增消除邏輯,直接看程式碼:

  deleteTile() {
    let fulledTilesIndex = []; // 儲存棋盤內有方塊的的索引
    let readyDelTiles = []; // 儲存待消除方塊
    const boardFrameList = this.boardFrameList;
    this.isDeleting = true; // 方塊正在消除的標識,用於後期新增動畫時,充當非同步狀態鎖
    this.addScore(this.curTileLength, true);

    // 首先獲取棋盤記憶體在方塊的格子資訊
    for (let i = 0; i < boardFrameList.length; i++) {
      const boardFrame = boardFrameList[i];
      if (boardFrame.isFulled) {
        fulledTilesIndex.push(i);
      }
    }

    for (let i = 0; i < DelRules.length; i++) {
      const delRule = DelRules[i]; // 消除規則獲取
      // 逐一獲取規則陣列與存在方塊格子陣列的交集
      let intersectArr = _.arrIntersect(fulledTilesIndex, delRule);
      if (intersectArr.length > 0) {
        // 判斷兩陣列是否相同,相同則將方塊新增到待消除陣列裡
        const isReadyDel = _.checkArrIsEqual(delRule, intersectArr);
        if (isReadyDel) {
          readyDelTiles.push(delRule);
        }
      }
    }

    // 開始消除
    let count = 0;
    for (let i = 0; i < readyDelTiles.length; i++) {
      const readyDelTile = readyDelTiles[i];
      for (let j = 0; j < readyDelTile.length; j++) {
        const delTileIndex = readyDelTile[j];
        const boardFrame = this.boardFrameList[delTileIndex];
        const delNode = boardFrame.getChildByName('fillNode');
        boardFrame.isFulled = false;
        // 這裡可以新增相應消除動畫
        const finished = cc.callFunc(() => {
          delNode.getComponent(cc.Sprite).spriteFrame = null;
          delNode.opacity = 255;
          count++;
        }, this);
        delNode.runAction(cc.sequence(cc.fadeOut(0.3), finished));
      }
    }

    if (count !== 0) {
      this.addScore(count);
      this.checkLose();
    }

    this.isDeleting = false;
  }
複製程式碼

遊戲結束邏輯

三個方塊都無法放入棋盤,則認為遊戲結束。

首先得到未填充的棋盤格子資訊,再將三個方塊逐一放入未填充區域判斷是否可以放入。程式碼如下:

  checkLose() {
    let canDropCount = 0;
    const tiles = this.node.children;
    const tilesLength = tiles.length;
    const boardFrameList = this.board.boardFrameList;
    const boardFrameListLength = boardFrameList.length;

    // TODO: 存在無效檢測的情況,可優化
    for (let i = 0; i < boardFrameListLength; i++) {
      const boardNode = boardFrameList[i];
      let srcPos = cc.p(boardNode.x, boardNode.y);
      let count = 0;
      if (!boardNode.isFulled) {
        // 過濾出未填充的棋盤格子
        for (let j = 0; j < tilesLength; j++) {
          let len = 27; // 設定重合判定最小間距

          // 將方塊移到未填充的棋盤格子原點,並獲取當前各方塊座標值
          let tilePos = cc.pAdd(srcPos, cc.p(tiles[j].x, tiles[j].y));

          // 遍歷棋盤格子,判斷方塊中各六邊形是否可以放入
          for (let k = 0; k < boardFrameListLength; k++) {
            const boardNode = boardFrameList[k];
            let dis = cc.pDistance(cc.p(boardNode.x, boardNode.y), tilePos);
            if (dis <= len && !boardNode.isFulled) {
              count++;
            }
          }
        }

        if (count === tilesLength) {
          canDropCount++;
        }
      }
    }

    if (canDropCount === 0) {
      return true;
    } else {
      return false;
    }
  }
複製程式碼

計分制

計分規則千變萬化,看你需求。一般方塊放入與消除均可加分。

  scoreRule(count, isDropAdd) {
    let x = count + 1;
    let addScoreCount = isDropAdd ? x : 2 * x * x;
    return addScoreCount;
  }
複製程式碼

致謝

專案屬於入門級別,初次接觸 cocos creator 遊戲開發,多數參考了網上一些六邊形開源遊戲。在此感謝開源,專案有融入自己的一些方法,比如處理六角網格那塊,但是消除規則,還需要接觸更多知識後才能完善。先寫這麼一篇入門級的,後續再深入,希望對一些像我一樣剛接觸遊戲開發的人能有一些幫助。後續可能會結合適當的例子,講一些,cocos creator 動畫,粒子系統,物理系統,webgl等。

原始碼

參考

相關文章