cocos实现消消乐小游戏

cocos实现消消乐小游戏

前言

上一篇学习了两天入门cocos开发飞机大作战,接下来进阶实现一个消消乐游戏,相比飞机大作战来说难度会提升很多,需要了解游戏规则和实现基础的算法,实现棋子的布局、棋子的消除与生成。

体验

电脑端访问:cocos.open-art.cn/match-game/...

手机版本可以扫码二维码

消消乐基础规则

不同的消消乐游戏的规则略有不同,但总体上类似,以开心消消乐为例,三个及以上棋子横竖直线相连即可消除。不同关卡要求消除的类型不一样,在规定的步数内达到要求即可过关。剩余步数会随机触发特效棋子,产生大量自动消除分数。

除了基础规则消除以外,还存在特效消除规则

  • 四个相连会产生一个直线消除的棋子
  • L型和T型消除会产生一个爆炸棋子,爆炸范围以该棋子为中心,延伸到四周12个棋子范围
  • 直线五个相同的棋子相连,会产生活力鸟,与任意色块对调位置会消除全棋盘该棋子。
  • 交换相邻的特效色块会触发华丽效果,直线与爆炸特效交换,会使直线特效由原来的一行变成平行四行消除。

值得注意的地方是只有在交换棋子为中心匹配上才算符合规则,例如下图这种情况,交换后以交换棋子为中心,仅能匹配到直线消除,而不是T型。

正确消除T型的交换如下图所示:

实现消消乐基础算法

介绍

接下来使用二维数组模拟棋盘,用数字代表棋子,去实现交换棋子,匹配消除的算法。

例如有一个3*3的棋盘,存在四种动物,分别用1,2,3,4代表不同动物

在二维数组中展示为

js 复制代码
const chessBoard = [
  [1, 2, 3],
  [2, 4, 2],
  [1, 4, 3],
]

当交换了数字2(chessBoard[0][1])和数字4(chessBoard[1][1])后,符合直线匹配规则,对整行进行消除

js 复制代码
[
  [1,     4,    3  ],
  [null, null, null],
  [1,     4,    3  ],
]

随后需要将上方的棋子下落到null的位置

js 复制代码
[
  [null, null, null],
  [1,     4,    3  ],
  [1,     4,    3  ],
]

并且通过生成棋子去补齐空缺的棋盘

js 复制代码
[
  [2,     2,    1  ],
  [1,     4,    3  ],
  [1,     4,    3  ],
]

最终棋盘展示效果为

题目

为了检查执行效果的一致性,可以先不生成棋子进行补充,请你实现一个MatchGame类,并且完成交换棋子的方法,消除匹配棋子和下落。

js 复制代码
/**
 * 实现消消乐消除算法
 * 初始化一个二维数组,每个数字代表一个类型,当进行位置交换后,以位置交换为中心,消除三个及以上相连且相同的数字类型。
 */
const matchGame = new MatchGame([
  [1, 2, 3, 4],
  [2, 4, 2, 4],
  [2, 4, 3, 2],
  [1, 2, 2, 3],
]);

// 交换 [行,列]
matchGame.swapPiece([0, 1], [1, 1]);

console.table(matchGame.chessBoard);

/**
┌─────────┬──────┬──────┬──────┬───┐
│ (index) │  0   │  1   │  2   │ 3 │
├─────────┼──────┼──────┼──────┼───┤
│    0    │ null │ null │ null │ 4 │
│    1    │  1   │  4   │  3   │ 4 │
│    2    │  2   │  4   │  3   │ 2 │
│    3    │  1   │  2   │  2   │ 3 │
└─────────┴──────┴──────┴──────┴───┘
*/

实现基础类

新建一个MatchGame.js,编写MatchGame类,实现swapPiece方法,交换两个棋子比较简单,相互替换掉即可,执行题目用例后输出对应结果。

js 复制代码
class MatchGame {
  // 棋盘数据
  chessBoard = [];
  /**
   * 初始化
   * @param {number[][]} chessBoard
   */
  constructor(chessBoard) {
    this.chessBoard = chessBoard;
  }
  /**
   * 交换两个下标内容
   * @param {[number,number]} a
   * @param {[number,number]} b
   */
  swapPiece([row1, col1], [row2, col2]) {
    const temp = this.chessBoard[row1][col1];
    this.chessBoard[row1][col1] = this.chessBoard[row2][col2];
    this.chessBoard[row2][col2] = temp;
    console.log("交换后");
    console.table(this.chessBoard);
  }
}

/*
交换后
┌─────────┬───┬───┬───┬───┐
│ (index) │ 0 │ 1 │ 2 │ 3 │
├─────────┼───┼───┼───┼───┤
│    0    │ 1 │ 4 │ 3 │ 4 │
│    1    │ 2 │ 2 │ 2 │ 4 │
│    2    │ 2 │ 4 │ 3 │ 2 │
│    3    │ 1 │ 2 │ 2 │ 3 │
└─────────┴───┴───┴───┴───┘
/*

匹配算法

如何去匹配是消消乐里相对复杂的地方,交换棋子后,存在2,2,2相连,这里使用十字扩散法去匹配符合条件的棋子。

以交换的棋子为中心,向左右扩散,将匹配的棋子插入数组中,当循环完成判断是否大于等于3,若满足条件则执行消除。

向上下匹配也是一样的方式

了解了如何匹配,接下来实现一个检查消除方法checkAndRemoveMatchesAt,用一个matches变量存储待消除的下标,匹配完成后,统一消除存储的棋子。

js 复制代码
   swapPiece([row1, col1], [row2, col2]) {
     const temp = this.chessBoard[row1][col1];
     this.chessBoard[row1][col1] = this.chessBoard[row2][col2];
     this.chessBoard[row2][col2] = temp;
     console.log("交换后");
     console.table(this.chessBoard);
     this.checkAndRemoveMatchesAt([this.chessBoard[row1][col1], temp]);
   }
   /**
   * 检查消除
   * @param {[number,number][]} pos  // 检查坐标
   */
  checkAndRemoveMatchesAt(pos) {
    let matches = [];
    for (let [row, col] of pos) {
      // 横向匹配
      let rows = this.checkMatch(row, col, true);
      // 纵向匹配
      let cols = this.checkMatch(row, col, false);
      matches = matches.concat(cols, rows);
    }
    // 消除
    for (let [row, col] of matches) {
      this.chessBoard[row][col] = null;
    }
    console.log("消除后")
    console.table(this.chessBoard)
  }

在具体的checkMatch方法中实现横纵向的匹配,当满足条件时把匹配的下标结果返回。

js 复制代码
/**
   * 检查单个棋子
   * @param {number} row  行
   * @param {number} col  列
   * @param {boolean} horizontal  平行
   */
  checkMatch(row, col, horizontal) {
    const matches = [[row, col]];
    const current = this.chessBoard[row][col];
    let i = 1;
    if (horizontal) {
      // 往左遍历
      while (col - i >= 0 && this.chessBoard[row][col - i] === current) {
        matches.push([row, col - i]);
        i++;
      }
      i = 1;
      // 往右遍历
      while (
        col + i < this.chessBoard[row].length &&
        this.chessBoard[row][col + i] === current
      ) {
        matches.push([row, col + i]);
        i++;
      }
    } else {
      // 往上
      while (row - i >= 0 && this.chessBoard[row - i][col] === current) {
        matches.push([row - i, col]);
        i++;
      }
      i = 1;
      // 往下
      while (
        row + i < this.chessBoard.length &&
        this.chessBoard[row + i][col] === current
      ) {
        matches.push([row + i, col]);
        i++;
      }
    }
    return matches.length >= 3 ? matches : [];
  }

/**
交换后
┌─────────┬───┬───┬───┬───┐
│ (index) │ 0 │ 1 │ 2 │ 3 │
├─────────┼───┼───┼───┼───┤
│    0    │ 1 │ 4 │ 3 │ 4 │
│    1    │ 2 │ 2 │ 2 │ 4 │
│    2    │ 2 │ 4 │ 3 │ 2 │
│    3    │ 1 │ 2 │ 2 │ 3 │
└─────────┴───┴───┴───┴───┘
消除后
┌─────────┬──────┬──────┬──────┬───┐
│ (index) │  0   │  1   │  2   │ 3 │
├─────────┼──────┼──────┼──────┼───┤
│    0    │  1   │  4   │  3   │ 4 │
│    1    │ null │ null │ null │ 4 │
│    2    │  2   │  4   │  3   │ 2 │
│    3    │  1   │  2   │  2   │ 3 │
└─────────┴──────┴──────┴──────┴───┘
*/

棋子下落

从左到右,从下到上,去遍历数组,当遇到空值时,向上统计空值数量,直到遇到棋子时,交换空位和棋子的位置。

js 复制代码
  /**
   * 向下移动棋子
   */
  movePiecesDown() {
    for (let col = this.chessBoard[0].length - 1; col >= 0; col--) {
      let nullCount = 0;
      for (let row = this.chessBoard.length - 1; row >= 0; row--) {
        const piece = this.chessBoard[row][col];
        if (piece === null) {
          nullCount++;
        } else if (nullCount > 0) {
          this.chessBoard[row + nullCount][col] = this.chessBoard[row][col];
          this.chessBoard[row][col] = null;
        }
      }
    }
    console.log('移动后棋子')
    console.table(this.chessBoard);
  }
/**
移动后棋子
┌─────────┬──────┬──────┬──────┬───┐
│ (index) │  0   │  1   │  2   │ 3 │
├─────────┼──────┼──────┼──────┼───┤
│    0    │ null │ null │ null │ 4 │
│    1    │  1   │  4   │  3   │ 4 │
│    2    │  2   │  4   │  3   │ 2 │
│    3    │  1   │  2   │  2   │ 3 │
└─────────┴──────┴──────┴──────┴───┘
*/

到这里题目已经解决了,但真实消消乐场景还需要补充棋子,并且重新匹配下落棋子补充棋子是否符合消除条件。

补充棋子并消除

补充棋子很简单,遍历棋盘如果是null就随机生成棋子补充

js 复制代码
 /**
   * 重新填充和检查棋子
   */
  refillAndCheck() {
    for (let row = 0; row < this.chessBoard.length; row++) {
      for (let col = 0; col < this.chessBoard[row].length; col++) {
        if (this.chessBoard[row][col] === null) {
          this.chessBoard[row][col] = this.getRandomPiece();
        }
      }
    }
    console.log("补充后的棋子");
    console.table(this.chessBoard);
  }

 // 随机获取棋子
  getRandomPiece() {
    // 1-5为例
    return Math.floor(Math.random() * 5) + 1;
  }

/**
...
补充后的棋子
┌─────────┬───┬───┬───┬───┐
│ (index) │ 0 │ 1 │ 2 │ 3 │
├─────────┼───┼───┼───┼───┤
│    0    │ 1 │ 2 │ 2 │ 4 │
│    1    │ 1 │ 4 │ 3 │ 4 │
│    2    │ 2 │ 4 │ 3 │ 2 │
│    3    │ 1 │ 2 │ 2 │ 3 │
└─────────┴───┴───┴───┴───┘
*/

再次去匹配修改过后的棋子,包含下落的棋子补充的棋子,但前面并没有记录这两种棋子,需要修改一下代码,记录下两种棋子,并且重新匹配。

修改movePiecesDown移动棋子方法,使用movedPos存储移动的棋子下标,并且返回出去

js 复制代码
  /**
   * 向下移动棋子
   */
  movePiecesDown() {
    const movedPos = [];
    for (let col = this.chessBoard[0].length - 1; col >= 0; col--) {
      let nullCount = 0;
      for (let row = this.chessBoard.length - 1; row >= 0; row--) {
        const piece = this.chessBoard[row][col];
        if (piece === null) {
          nullCount++;
        } else if (nullCount > 0) {
          this.chessBoard[row + nullCount][col] = this.chessBoard[row][col];
          this.chessBoard[row][col] = null;
          movedPos.push([row + nullCount, col]);
        }
      }
    }
    console.log("移动后棋子");
    console.table(this.chessBoard);
    return movedPos;
  }

修改refillAndCheck重新填充方法,将补充后的棋子下标也插入其中并返回

js 复制代码
/**
   * 重新填充和检查棋子
   */
  refillAndCheck() {
    const movedPos = [];

    for (let row = 0; row < this.chessBoard.length; row++) {
      for (let col = 0; col < this.chessBoard[row].length; col++) {
        if (this.chessBoard[row][col] === null) {
          this.chessBoard[row][col] = this.getRandomPiece();
          movedPos.push([row, col]);
        }
      }
    }

    console.log("补充后的棋子");
    console.table(this.chessBoard);

    return movedPos;
  }

调用两个方法,能够拿到补充棋子下落棋子 下标,接下来修改匹配算法checkAndRemoveMatchesAt,只要存在修改过的棋子就重复调用匹配算法。

js 复制代码
 /**
   * 检查消除
   * @param {[number,number][]} pos  // 检查坐标
   */
  checkAndRemoveMatchesAt(pos) {
    let matches = [];
    for (let [row, col] of pos) {
      // 横向匹配
      let cols = this.checkMatch(row, col, true);
      // 纵向匹配
      let rows = this.checkMatch(row, col, false);
      matches = matches.concat(cols, rows);
    }
    if (matches.length < 1) return;
    // 消除
    for (let [row, col] of matches) {
      this.chessBoard[row][col] = null;
    }
    console.log("消除后");
    console.table(this.chessBoard);
    const movedPos = [...this.movePiecesDown(), ...this.refillAndCheck()];
    if (movedPos.length > 0) {
      this.checkAndRemoveMatchesAt(movedPos);
      console.log("再次消除");
      console.table(this.chessBoard);
    }
  }
/**
.....
再次消除
┌─────────┬───┬───┬───┬───┐
│ (index) │ 0 │ 1 │ 2 │ 3 │
├─────────┼───┼───┼───┼───┤
│    0    │ 3 │ 2 │ 1 │ 4 │
│    1    │ 1 │ 4 │ 2 │ 4 │
│    2    │ 2 │ 4 │ 3 │ 2 │
│    3    │ 1 │ 2 │ 2 │ 3 │
└─────────┴───┴───┴───┴───┘
*/

最终js基础版本的消消乐就算是实现了,这是整个消消乐的核心部分,其他功能都是围绕这些功能进行扩展。

Cocos实现消消乐

使用到的素材:链接: pan.baidu.com/s/1pEhAYcHi... 提取码: tpew

在cocos中实现消消乐的消除算法与前面基本一致,只是需要额外的控制ui层面的内容。

布局方案

在cocos中有多种布局的方案能够实现消消乐的布局,可以使用layout中的grid布局,也可以使用代码通过计算坐标进行生成。

两种都尝试了一下,个人喜欢使用代码进行计算,会更加灵活,grid布局会自动调整在棋盘中的位置,需要注意插入的顺序。

Grid布局

Layout文档:docs.cocos.com/creator/man...

要使用Grid布局,只需将Layout组件添加到一个容器节点上或者直接新建一个UI组件,然后将棋盘元素作为容器节点的子节点。根据游戏需求,调整Layout组件的配置信息,文档中每个属性介绍得很清楚,将Type切换为GRID,并且根据子元素位置调整Padding LeftPadding RightPadding TopPadding BottomSpacing XSpacing Y等属性值,系统会自动根据设置的间距来排列子节点。

需要注意的是,Grid布局仅负责排列子节点,不会调整子节点的大小。因此,在使用Grid布局时,需要确保所有子节点的大小一致。

代码计算

使用代码计算,不需要使用特定的布局组件,可以使用节点并通过编写脚本来创建棋盘元素(例如消消乐中的方块)并设置它们的位置。

只需要知道初始位置和棋子之间的间距,通过计算能够获得不同坐标的棋子位置,已知原点在棋盘的中间,左上角棋子中心的坐标为(-240,240),横向方向由x+间隔*个数,能够计算出任意横向棋子的坐标,纵向棋子也是同样的方式。

生成棋盘

创建棋盘控制类

首先,创建一个名为ContentControl.ts的新脚本文件,用于实现棋盘控制类。

typescript 复制代码
import { _decorator, Component, instantiate, Node, Prefab } from "cc";
const { ccclass, property } = _decorator;

@ccclass("ContentControl")
export class ContentControl extends Component {
  // ...
}

定义棋盘控制类属性

定义棋盘元素、棋盘尺寸、间距和初始坐标等属性。

typescript 复制代码
@property({ type: [Prefab] })
public chessPieces: Prefab[] = []; // 棋子预设

@property
public boardWidth: number = 6; // 棋盘宽度(列数)

@property
public boardHeight: number = 6; // 棋盘高度(行数)

@property
public spacing: number = 96; // 棋盘元素之间的间距

@property
public x: number = -240; // 初始x坐标

@property
public y: number = 240; // 初始y坐标

// 棋盘节点
chessBoard: Node[][] = [];

创建棋盘

编写generateBoard方法创建棋盘,根据设定的宽高,循环创建棋子,并且插入到棋盘当中,chessBoard的作用是存储棋子,能够在棋盘中映射的信息。

typescript 复制代码
generateBoard() {
  // 创建空节点
  this.chessBoard = Array.from({ length: this.boardHeight }, () =>
    Array.from({ length: this.boardWidth }, () => null)
  );

  for (let i = 0; i < this.boardHeight; i++) {
    for (let j = 0; j < this.boardWidth; j++) {
      this.chessBoard[i][j] = this.generatePiece(i, j);
    }
  }
}

创建棋子

编写generatePiece方法用于根据行列索引创建棋子。

typescript 复制代码
generatePiece(i: number, j: number) {
  const piece = this.getRandomChessPiece();
  const [x, y] = this.getPiecePosition(i, j);
  piece.setPosition(x, y);
  this.node.addChild(piece);
  return piece;
}

获取棋子坐标

编写getPiecePosition方法用于根据行列索引获取棋子坐标。

typescript 复制代码
getPiecePosition(i: number, j: number): number[] {
  return [this.x + j * this.spacing, this.y - i * this.spacing];
}

随机选择棋子预制件

编写getRandomChessPiece方法用于随机选择一个棋子预制件。

typescript 复制代码
getRandomChessPiece(): Node {
  // 生成一个随机数,范围为 [0, 棋子预制件数组的长度)
  const randomIndex = Math.floor(Math.random() * this.chessPieces.length);
  // 使用随机数作为索引,从数组中选择一个棋子预制件
  const randomChessPiece = this.chessPieces[randomIndex];
  const piece = instantiate(randomChessPiece);
  return piece;
}

初始化棋盘

start方法中调用generateBoard方法生成棋盘。

typescript 复制代码
start() {
  this.generateBoard();
}

挂载脚本

ContentControl脚本添加到一个空节点上,并且新增一个UITransform组件用于后面的判断触摸位置,将棋子的预制件拖拽到chessPieces属性上。

调整属性

不同背景和棋盘大小需要根据情况调整boardWidthboardHeightspacingx,y属性的值。

运行游戏,会看到一个由代码生成的棋盘。

交换棋子

交换棋子需要监听触摸位置,进行判断移动方向,交换相应棋子,监听触摸我能想到的两种方案

  1. 每个棋子挂载监听方法,监听到移动后,根据移动方向,触发棋盘节点的交换方法

    优点:

    • 每个棋子都可以独立处理触摸事件,逻辑相对简单。
    • 棋子之间互不影响,易于管理和调试。

    缺点:

    • 如果棋盘很大,每个棋子都挂载监听方法可能导致性能问题。
    • 棋子之间的交互逻辑可能需要通过棋盘节点进行传递,可能导致代码结构复杂。
  2. 棋盘上挂载监听方法,通过监听触摸位置,并且计算出触摸的棋子,根据移动的方向,交换棋子。

    优点:

    • 只需在棋盘节点上挂载一个监听方法,性能较好。
    • 棋子之间的交互逻辑可以在棋盘节点中统一处理,便于管理和调试。

    缺点:

    • 需要通过触摸位置计算出触摸的棋子,逻辑相对复杂。
    • 如果棋盘很大,计算触摸棋子的过程可能会有性能问题。

结论: 从性能和代码结构的角度来看,推荐使用方案2:棋盘上挂载监听方法。这样可以减少监听方法的数量,提高性能,同时也便于管理和调试棋子之间的交互逻辑。虽然计算触摸位置和触摸棋子的逻辑相对复杂,但这可以通过优化代码实现来解决。而且,对于消消乐这类游戏,棋盘大小通常不会非常大,计算触摸棋子的性能问题应该是可以接受的。

监听触摸

输入事件系统文档:docs.cocos.com/creator/man...

监听开始触摸,但目前没办法拿到触摸的哪个棋子,需要通过其他方式获取。

ts 复制代码
 start() {
    this.generateBoard();
    this.onMove();
  }

  onMove() {
    input.on(Input.EventType.TOUCH_START, this.onBoardTouchStart, this);
  }

  // 触摸开始
  onBoardTouchStart(event: EventTouch) {
    console.log("event", event);
  }

获取触摸棋子下标

通过触摸的坐标需要知道触摸了哪个棋子,随后再进行判断移动方向进行交换操作。

目前我能想到的两种获取方案

  1. 通过物理引擎,检查触摸位置的点在哪个棋子内,遍历棋盘寻找对应的棋子下标。

优点:不需要取出所有节点的位置进行判断包含关系,但依然需要遍历棋盘才能知道存储的下标位置,实现相对来说容易

缺点:需要引入物理引擎,而在消消乐中其实是不需要物理引擎的。

检查给的点在哪些碰撞体内api文档:docs.cocos.com/creator/3.8...

  1. 每次触摸棋子,只能拿到触摸位置,通过遍历棋盘节点,取出每个棋子的位置信息,有了棋子的位置,可以通过api进行计算棋子是否包含触摸位置坐标,如果触摸坐标包含在棋子的范围内,就说明触摸的这个棋子。

实现根据触摸位置找到对应棋子需要了解一些名词和api

(1)世界坐标:

世界坐标是一个三维坐标系,所有在场景中的对象都可以使用世界坐标来描述它们在整个场景中的位置。世界坐标系的原点(0, 0, 0)通常是场景的中心点。在世界坐标系中,一个对象的坐标与其父对象和其他对象无关,它表示的是对象在整个场景中的绝对位置。在 Cocos Creator 3.x 中,可以使用 getWorldPosition 方法来获取一个节点的世界坐标。

(2)屏幕坐标系

屏幕坐标系是一个二维坐标系,用于描述设备屏幕上的位置。屏幕坐标系的原点(0,0)通常位于设备屏幕的左下角,x 轴从左到右增加,y 轴从下到上增加。屏幕坐标系通常用于处理用户界面(UI)元素,例如按钮、文本框等。它与游戏中的对象和场景无关,只表示元素在设备屏幕上的位置。

(3)局部坐标:

局部坐标是相对于某个节点(通常是父节点)的坐标系。一个节点的局部坐标表示它相对于其父节点的位置。局部坐标系的原点(0, 0, 0)是其父节点的锚点。局部坐标会受到父节点的位置、缩放和旋转的影响。在 Cocos Creator 3.x 中,可以使用 getPosition 方法来获取一个节点的局部坐标。

总结一下,世界坐标是描述一个对象在整个场景中的绝对位置,而局部坐标是描述一个对象相对于其父节点的位置。在实际开发中,我们需要根据需求来选择使用世界坐标还是局部坐标。同时,Cocos Creator 3.x 提供了一系列方法(如 convertToNodeSpaceAR)来在世界坐标和局部坐标之间进行转换。

简单绘制了一个图来表示世界坐标和局部坐标的关系,相同的一个青蛙元素,在世界坐标系下x,y为(133,1040),而这个青蛙元素相对于棋盘来说,他的局部坐标是(-240,240)。

getUILocation

获取的是触摸事件发生时的屏幕坐标,在处理 UI 事件时,event.getUILocation() 返回一个包含 xy 属性的对象,表示触摸点在屏幕坐标系中的位置。我们通常将屏幕坐标视为世界坐标。这是因为屏幕坐标表示的是触摸事件在整个屏幕上的绝对位置,与游戏中的节点和其他对象无关。

convertToNodeSpaceAR

用于将一个世界坐标系下的点转换为节点(Node)局部坐标系下的点(相对于锚点)的方法。这个方法在处理节点间坐标转换时非常有用,例如需要将一个节点放置在另一个节点的某个位置时。

ts 复制代码
convertToNodeSpaceAR(worldPoint: Vec3, out?: Vec3): Vec3
名称 类型 描述
worldPoint Vec3 世界坐标点。
out Vec3 转换后坐标。

返回值:Vec3 类型,表示转换后的节点局部坐标(相对于锚点)。

getBoundingBox

用于获取一个节点在局部坐标系下的轴对齐包围盒(Axis-Aligned Bounding Box,简称 AABB)。轴对齐包围盒是一个矩形盒子,用于描述节点在局部坐标系下的范围。它是轴对齐的,即其边与坐标轴平行。getBoundingBox 方法通常用于碰撞检测、节点范围判断等场景。

javascript 复制代码
getBoundingBox(): Rect

返回值:Rect 类型,表示节点在局部坐标系下的轴对齐包围盒。

Rect 用于表示二维矩形。它包含以下属性:

  • x:矩形左下角的 x 坐标。
  • y:矩形左下角的 y 坐标。
  • width:矩形的宽度。
  • height:矩形的高度。

Rect 类还提供了一些方法,如 intersects(检查两个矩形是否相交)、contains(检查一个矩形是否包含另一个矩形或点)等,这些方法在处理碰撞检测、节点范围判断等场景时非常有用。

具体实现

通过获取屏幕坐标转换成棋盘中的相对坐标,循环遍历棋子,使用Rect类中的api进行判断棋子是否包含该触摸的坐标,以实现获取触摸棋子的下标功能。

ts 复制代码
  swapBeforeIndex: number[] = null; // 交换之前下标
  swapAfterIndex: number[] = null; // 交换之后的下标
  startTouchPos: Vec2 = null;	// 开始触摸的位置
  // 触摸开始
  onBoardTouchStart(event: EventTouch) {
    // 获取鼠标按下的位置
    this.startTouchPos = event.getUILocation();
    // 根据鼠标按下的位置找到对应的棋子
    this.swapBeforeIndex = this.getPieceAtPosition(this.startTouchPos);
  }

  getPieceAtPosition(pos: Vec2 | null): number[] {
    /**
     * 1. 获取当前棋盘节点
     * 2. 遍历子节点,将点击的点坐标转换到当前棋盘节点坐标系中
     * 3. 判断子节点盒子是否包含点击的节点
     */
    // 获取当前棋盘节点
    const uiTransform = this.node.getComponent(UITransform);

    // 转换当前棋盘坐标系
    const { x, y } = uiTransform.convertToNodeSpaceAR(v3(pos.x, pos.y));

    // 遍历坐标 查看该棋子是否包含了点击的点
    for (let row = 0; row < this.chessBoard.length; row++) {
      for (let col = 0; col < this.chessBoard[row].length; col++) {
        const piece = this.chessBoard[row][col];
        const box = piece?.getComponent(UITransform).getBoundingBox();
        if (box?.contains(v2(x, y))) {
          return [row, col];
        }
      }
    }
    return;
  }

获取移动方向

已经能够通过触摸开始的坐标获取触摸的棋子了,接下来获取触摸后移动的方向决定了交换哪个棋子。

通过监听触摸移动,能够实时获取移动的坐标,通过计算移动坐标与开始坐标之前的差值进行判断移动方向。

ts 复制代码
 onMove() {
	// ...
    input.on(Input.EventType.TOUCH_MOVE, this.onBoardTouchMove, this);
  }
 onBoardTouchMove(event: EventTouch) {
    const target = this.getSwappingPieces(event);
     console.log('target', target)
  }

一共存在上下左右 四个方向,首先可以去判断左右移动方向,通过计算开始移动后x坐标移动前x坐标之间差值的绝对值,如果大于y坐标移动后移动前之间的差值绝对值,那说明往左右移动的幅度更大,取左右方向,判断条件 abs(moveX-startX) > abs(moveY-startY)

知道是左右方向后,通过移动后x坐标减去移动前的坐标如果是正数那说明是往右 移动,负数说明是往左移动。

而上下方向判断方式与左右方向一致,实现getSwappingPieces方法,根据最终移动的方向拿到需要交换的棋子下标target,

ts 复制代码
  // 获取需要交换的棋子下标
  getSwappingPieces(event: EventTouch) {
    if (!this.startTouchPos || !event || !this.swapBeforeIndex) {
      return null;
    }

    let target = null;
    const [row, col] = this.swapBeforeIndex;
    const threshold = 50; // 移动阈值
    const { x: startX, y: startY } = this.startTouchPos;
    const { x: moveX, y: moveY } = event.getUILocation();
    const diffX = moveX - startX;
    const diffY = moveY - startY;

    // 判断左右
    if (Math.abs(diffX) > Math.abs(diffY)) {
      if (diffX > threshold) {
        target = [row, col + 1];
      } else if (diffX < -threshold) {
        target = [row, col - 1];
      }
    } else {
      if (diffY > threshold) {
        target = [row - 1, col];
      } else if (diffY < -threshold) {
        target = [row + 1, col];
      }
    }

    // 边界判断
    if (!this.isWithinBounds(target, this.boardWidth, this.boardHeight)) {
      return null;
    }

    return target;
  }

  // 检查目标位置是否在棋盘边界内
  isWithinBounds(target, boardWidth, boardHeight) {
    return (
      target &&
      target[0] >= 0 &&
      target[0] < boardHeight &&
      target[1] >= 0 &&
      target[1] < boardWidth
    );
  }

修改后可以发现移动触摸后会触发多次打印

交换棋子存储位置

拿到了当前对象和目标对象,就可以对两个对象的位置进行交换,一个是需要交换在棋盘中存储数组的位置,另一个是需要通过交换动画修改棋子的位置信息。

消费目标对象位置信息swapBeforeIndex,使用后清除掉以免二次触发。

ts 复制代码
 onBoardTouchMove(event: EventTouch) {
    const target = this.getSwappingPieces(event);
    if (target) {
      this.swapPiece(this.swapBeforeIndex, target);
      this.swapBeforeIndex = null;
    }
  }

swapPiece方法和前面js实现的消消乐算法的类似,进行一个二维数组的棋子交换

ts 复制代码
  swapPiece([row1, col1]: number[], [row2, col2]: number[]) {
    const temp = this.chessBoard[row1][col1];
    this.chessBoard[row1][col1] = this.chessBoard[row2][col2];
    this.chessBoard[row2][col2] = temp;
  }

交换动画

交换动画需要使用到cocos中的缓动系统,有封装好的api可以使用,非常方便。使用缓动api分别设置两个棋子的位置,并且在执行完成后,触发回调。

添加回调原因是因为需要添加一个交换锁,在交换时锁住防止动画未执行完成再次触发交换位置,会出现棋盘混乱的问题。

ts 复制代码
  // 交换动画
  swapAnimation(a: Node, b: Node, callback?: () => void) {
    if (!a || !b) return;
    const speed = 0.2;
    const aPos = new Vec3(a.position.x, a.position.y);
    const bPos = new Vec3(b.position.x, b.position.y);

    const swapAPromise = new Promise((resolve) => {
      tween(a)
        .to(speed, { position: bPos })
        .call(() => {
          resolve(true);
        })
        .start();
    });

    const swapBPromise = new Promise((resolve) => {
      tween(b)
        .to(speed, { position: aPos })
        .call(() => {
          resolve(true);
        })
        .start();
    });

    Promise.allSettled([swapAPromise, swapBPromise]).then(() => {
      callback?.();
    });
  }

随后在swapPiece交换棋子方法中,触发交换动画方法,并且添加上锁。

ts 复制代码
  swapPiece([row1, col1]: number[], [row2, col2]: number[]) {
    this.isSwap = true;
    const temp = this.chessBoard[row1][col1];
    this.chessBoard[row1][col1] = this.chessBoard[row2][col2];
    this.chessBoard[row2][col2] = temp;
    this.swapAnimation(
      this.chessBoard[row1][col1],
      this.chessBoard[row2][col2],
      () => {
        this.isSwap = false;
      }
    );
  }

设置了锁还需要使用它,在getSwappingPieces方法中,判断条件添加上交换锁isSwap,正在交换时停止触发。

ts 复制代码
 getSwappingPieces(event: EventTouch) {
    if (!this.startTouchPos || !event || !this.swapBeforeIndex || this.isSwap) {
      return null;
    }
    // ...
 }

最后实现效果

相同棋子退回

交换的过程中会发现一个问题,交换两个相同棋子,依然能够正常交换,会导致系统默认存在相连棋子时,随意交换相同棋子即可消除,这不是我们想要的,我们需要的是新增的棋子相连进行消除,而不是已有的棋子。

在这里需要判断条件,如果交换的两个棋子相同,那么就再次交换回去。

对交换方法swapPiece进行修改,新增条件判断棋子是否相同,相同则再次交换,否者进行匹配棋子。

ts 复制代码
  onBoardTouchMove(event: EventTouch) {
    if (!this.swapBeforeIndex) return;
    const target = this.getSwappingPieces(event);
    const [row, col] = this.swapBeforeIndex;
    if (target) {
      this.swapPiece([row, col], target, (isSame: boolean) => {
        if (isSame) {
          this.swapPiece([row, col], target);
        } else {
          const isMatch = this.checkAndRemoveMatchesAt([[row, col], target]);
          if (!isMatch) this.swapPiece([row, col], target);
        }
      });
      this.swapBeforeIndex = null;
    }
  }
  swapPiece(
    [row1, col1]: number[],
    [row2, col2]: number[],
    callback?: (isSame: boolean) => void
  ) {
    if (!this.chessBoard[row1][col1] || !this.chessBoard[row2][col2]) return;
    this.isSwap = true;
    const temp = this.chessBoard[row1][col1];
    this.chessBoard[row1][col1] = this.chessBoard[row2][col2];
    this.chessBoard[row2][col2] = temp;
    this.swapAnimation(
      this.chessBoard[row1][col1],
      this.chessBoard[row2][col2],
      () => {
        this.isSwap = false;
        if (
          this.chessBoard[row1][col1].name === this.chessBoard[row2][col2].name
        ) {
          callback?.(true);
        } else {
          callback?.(false);
        }
      }
    );
  }

匹配棋子

与前面使用js实现算法基本一致,仅需要修改一下部分代码。

交换过后,需要匹配交换的两个棋子,是否符合消除条件,符合则消除匹配到所有棋子,消除后清除棋子,并且补充棋子,补充完成还需要再次匹配清除。

检查棋子是否匹配并且删除

与前面js基本一致,只是新增了删除棋盘中的节点 this.node.removeChild

ts 复制代码
  /**
   * 检查消除
   * @param {[number,number][]} pos  // 检查坐标
   */
  checkAndRemoveMatchesAt(pos): boolean {
    let matches = [];
    for (let [row, col] of pos) {
      // 横向匹配
      let cols = this.checkMatch(row, col, true);
      // 纵向匹配
      let rows = this.checkMatch(row, col, false);
      matches = matches.concat(cols, rows);
    }
    if (matches.length < 1) return;
    // 消除
    for (let [row, col] of matches) {
      this.node.removeChild(this.chessBoard[row][col]);
      this.chessBoard[row][col] = null;
    }
    const movedPos = [...this.movePiecesDown(), ...this.refillAndCheck()];
    if (movedPos.length > 0) {
      this.checkAndRemoveMatchesAt(movedPos);
    }
    return true;
  }

checkMatch也是一样,但判断条件变化了,不是直接判断节点,而是判断节点下的name是否相等

TS 复制代码
  checkMatch(row, col, horizontal) {
    const matches = [[row, col]];
    const current = this.chessBoard[row][col].name;
    let i = 1;
    if (horizontal) {
      // 往左遍历
      while (col - i >= 0 && this.chessBoard[row][col - i].name === current) {
        matches.push([row, col - i]);
        i++;
      }
   // ....
   }

下落棋子

实现一个下落动画方法,设置指定节点到指定位置。

ts 复制代码
 // 下坠动画
  downAnimation(node: Node, [x, y]: number[], callback?: () => void) {
    tween(node)
      .to(0.2, { position: new Vec3(x, y) })
      .call(callback)
      .start();
  }

修改movePiecesDown下落棋子方法,仅需要在需要下落时,补充一个下落动画执行

ts 复制代码
  movePiecesDown() {
  // ....
  } else if (nullCount > 0) {	
    this.downAnimation(
         this.chessBoard[row][col],
         this.getPiecePosition(row + nullCount, col)
    );
  // ...
  }

补充棋子

修改refillAndCheck方法,修改创建棋子调用generatePiece,并且执行动画

ts 复制代码
  refillAndCheck() {
  // ....
      if (this.chessBoard[row][col] === null) {
          this.chessBoard[row][col] = this.generatePiece(-(row + 1), col);
          movedPos.push([row, col]);
          this.downAnimation(
            this.chessBoard[row][col],
            this.getPiecePosition(row, col)
          );
        }
    // ....
  }

执行匹配方法

ts 复制代码
  onBoardTouchMove(event: EventTouch) {
    if (!this.swapBeforeIndex) return;
    const target = this.getSwappingPieces(event);
    const [row, col] = this.swapBeforeIndex;
    if (target) {
      this.swapPiece([row, col], target, (isSame: boolean) => {
        if (isSame) {
          this.swapPiece([row, col], target);
        } else {
          const isMatch = this.checkAndRemoveMatchesAt([[row, col], target]);
          if (!isMatch) this.swapPiece([row, col], target);
        }
      });
      this.swapBeforeIndex = null;
    }
  }

实现效果

通过预览,可以看到到这里实现了一个基础的消消乐功能。

总结

源代码仓库:github.com/yiqia/match...

在开始开发前,所遇到的问题挺多,例如如何布局,如何交换位置,如何判断滑动方向,如何匹配和消除等等,每一步都是一边查阅文档,一边实现,有的并不是一次性就能实现,也有遇到一些bug后修改代码解决,通过这次消消乐实现,了解了消消乐的基本规则和特效消除规则,了解了游戏的设计思路和实现技巧,首先通过js的方式,实现了交换棋子、匹配消除棋子和下落的算法,解决了核心问题后,实现cocos就相对容易很多,在cocos编写中,学习了在cocos中的布局方式,交换棋子所涉及到的相关api使用,最终实现了一个基础版本的消消乐,并且可以在此基础上进行扩展和优化,例如需要完善消除爆炸动画,添加音效,一些扩展功能等,打造出更丰富、更有趣的消消乐游戏

相关推荐
weixin_3823952339 分钟前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__1 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.1 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~2 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华3 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子5 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅5 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni6 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学7 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端