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 Left
、Padding Right
、Padding Top
、Padding Bottom
、Spacing X
、Spacing 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
属性上。
调整属性
不同背景和棋盘大小需要根据情况调整boardWidth
、boardHeight
和spacing
,x
,y
属性的值。
运行游戏,会看到一个由代码生成的棋盘。
交换棋子
交换棋子需要监听触摸位置,进行判断移动方向,交换相应棋子,监听触摸我能想到的两种方案
-
每个棋子挂载监听方法,监听到移动后,根据移动方向,触发棋盘节点的交换方法
优点:
- 每个棋子都可以独立处理触摸事件,逻辑相对简单。
- 棋子之间互不影响,易于管理和调试。
缺点:
- 如果棋盘很大,每个棋子都挂载监听方法可能导致性能问题。
- 棋子之间的交互逻辑可能需要通过棋盘节点进行传递,可能导致代码结构复杂。
-
棋盘上挂载监听方法,通过监听触摸位置,并且计算出触摸的棋子,根据移动的方向,交换棋子。
优点:
- 只需在棋盘节点上挂载一个监听方法,性能较好。
- 棋子之间的交互逻辑可以在棋盘节点中统一处理,便于管理和调试。
缺点:
- 需要通过触摸位置计算出触摸的棋子,逻辑相对复杂。
- 如果棋盘很大,计算触摸棋子的过程可能会有性能问题。
结论: 从性能和代码结构的角度来看,推荐使用方案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);
}
获取触摸棋子下标
通过触摸的坐标需要知道触摸了哪个棋子,随后再进行判断移动方向进行交换操作。
目前我能想到的两种获取方案
- 通过物理引擎,检查触摸位置的点在哪个棋子内,遍历棋盘寻找对应的棋子下标。
优点:不需要取出所有节点的位置进行判断包含关系,但依然需要遍历棋盘才能知道存储的下标位置,实现相对来说容易
缺点:需要引入物理引擎,而在消消乐中其实是不需要物理引擎的。
检查给的点在哪些碰撞体内api文档:docs.cocos.com/creator/3.8...
- 每次触摸棋子,只能拿到触摸位置,通过遍历棋盘节点,取出每个棋子的位置信息,有了棋子的位置,可以通过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()
返回一个包含 x
和 y
属性的对象,表示触摸点在屏幕坐标系中的位置。我们通常将屏幕坐标视为世界坐标。这是因为屏幕坐标表示的是触摸事件在整个屏幕上的绝对位置,与游戏中的节点和其他对象无关。
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;
}
}
实现效果
通过预览,可以看到到这里实现了一个基础的消消乐功能。
总结
在开始开发前,所遇到的问题挺多,例如如何布局,如何交换位置,如何判断滑动方向,如何匹配和消除等等,每一步都是一边查阅文档,一边实现,有的并不是一次性就能实现,也有遇到一些bug后修改代码解决,通过这次消消乐实现,了解了消消乐的基本规则和特效消除规则,了解了游戏的设计思路和实现技巧,首先通过js的方式,实现了交换棋子、匹配消除棋子和下落的算法,解决了核心问题后,实现cocos就相对容易很多,在cocos编写中,学习了在cocos中的布局方式,交换棋子所涉及到的相关api使用,最终实现了一个基础版本的消消乐,并且可以在此基础上进行扩展和优化,例如需要完善消除爆炸动画,添加音效,一些扩展功能等,打造出更丰富、更有趣的消消乐游戏