【新手教程】【羊了个羊】

给棋子添加花色

棋子目前没有花色。因此,我们給棋子增加花色。
首先我们的花色资源目录为:
assets/Match3/yang/SpriteFrames/pai/

这里有一个文件夹关键字 SpriteFrames,这个文件夹关键字用来识别动态加载的图片资源(这是我自己定义的,不是引擎定义的)。然后通过自动化脚本生成配置文件: assets/GScript/auto/SpriteFramesCfg.ts

先提供一个 API setDisplay(style: number) 来设置棋子的花色.

/** 
 * !!! 本文件由下方脚本自动生成,请勿手动修改.
 * !!! /Users/z/Desktop/CCYang/_tool/nodejs/src/app/gen_res_cfg/gen.ts
 */
import { BL } from "db://assets/GScript/core/modules/res/ResConst";
export class SpriteFramesCfg {
    // -------- Match3BN --------
    static pai = (key: string | number) => BL(`yang/SpriteFrames/pai/${key}/spriteFrame`, "Match3BN");
}

目前,共提供了 30 个资源,因此其 key 为 pai-1, pai-2, …, pai-30

首先在 ResLoader.ts 中补充资源收集的通用接口:

/** 新增通用接口 */
addAsset(bUrl: IBundleUrl, type: Constructor<Asset> | null) {
    this.toLoadAssets.push({
        type: type,
        bUrl: bUrl
    });
    return this;
}

然后补充特殊接口 addSpriteFrame,并修改旧的 addUI 接口中增加预制体的部分:

addSpriteFrame(bUrl: IBundleUrl) { return this.addAsset(bUrl, SpriteFrame) }

addUI<UI extends Component>(uiClass: Constructor<UI>) {
    this.addAsset(getUIClassBUrl(uiClass), Prefab);
    if (typeof uiClass['R'] === "function") {
        (uiClass['R'] as Function).call(uiClass, this);
    }
    return this;
}

现在我们可以在 Match3UI.ts 的资源收集接口中添加棋子花色的资源,以预加载好所有的花色资源

@ccclass('Match3UI')
export class Match3UI extends Component {
    static R(loader: ResLoader) {
        loader.addUI(Match3ZiUE);
        for (let i = 1; i <= 30; ++i) {
            loader.addSpriteFrame(SpriteFramesCfg.pai(`pai-${i}`))
        }
    }
}

在 ResManager.ts 中补充获取资源的方法:

getAsset<T extends Asset>(bUrl: IBundleUrl, type?: Constructor<T> | null): T {
    let assetBundle = assetManager.getBundle(bUrl.b);
    return assetBundle.get(bUrl.l, type)
}

在 Match3ZiUE.ts 中新增设置牌面花色的 API:

setDisplay(style: number) {
    let spriteFrame = gtr.res.getAsset(SpriteFramesCfg.pai(`pai-${style}`), SpriteFrame);
    this.sprite.spriteFrame = spriteFrame;
}

(备注:因为之前没有设计好命名,导致其使用起来不是很方便。实际命名从 0-29 会好用很多,这里就不修改了。)

最后,我们在 Match3UI.ts 中先统一设置花色为 1

ziUE.setDisplay(1)

结果符合预期:

引入棋盘

棋子目前是直接添加到 Match3UI 的界面节点中的。所以其位置目前是发生的偏移。因此,我们需要添加一个节点,作为棋盘。
这个棋盘的锚点为 (0, 0),整个棋盘居中显示。

当然,我们会遇到棋盘高度过高的情况,因此需要在屏幕中心引入一个缩放节点,以方便后续对整个棋盘进行缩放处理。

正式操作:
1、首先在屏幕中心引入一个 scale 节点,其宽高都为0;
2、scale 节点下添加棋盘,其尺寸为 714x1200 (714=102 * 7 1200 = 120 * 10); 位置为(-357,-600),这样棋盘中写和scale重合(后续缩放就方便了)。

然后我们修改之前添加的棋子节点的父节点为这个 board 节点,为了引入这个 board 节点,又需要定义一个board属性。整个类似的操作重复很多次了,这里不展示过程了。直接上结果:

@property(Node) private board: Node = null;

async start() {
    console.log(`主玩法界面`)
    let ziArr = [
        [0, 0],
        [1, 1],
        [2, 2]
    ]
    ziArr.forEach(zi => {
        let ziUE = gtr.ui.instantiate(Match3ZiUE);
        ziUE.node.setParent(this.board);
        ziUE.init();
        ziUE.node.setPosition(zi[0] * 102, zi[1] * 120)
        ziUE.setDisplay(1)
    })
}

运行后发现棋子偏移了半个格子,原因是棋子的锚点在棋子中心,所以实际位置还要向右上偏移半个给子:

async start() {
    console.log(`主玩法界面`)
    let ziArr = [
        [0, 0],
        [1, 1],
        [2, 2]
    ]
    ziArr.forEach(zi => {
        let ziUE = gtr.ui.instantiate(Match3ZiUE);
        ziUE.node.setParent(this.board);
        ziUE.init();
        ziUE.node.setPosition((zi[0] + 0.5) * 102, (zi[1] + 0.5) * 120)
        ziUE.setDisplay(1)
    })
}

再次运行后,符合预期!

棋子位置的定义

之前的回帖中,棋子定义是

let ziArr = [
    [0, 0],
    [1, 1],
    [2, 2]
]

用来表示
1、第 1 行,第 1 列
2、第 2 行,第 2 列
3、第 3 行,第 3 列

这样的定义,并不能表达遮挡关系。所以应该使用更细粒度的表达。

let ziArr = [
    [0, 0],
    [6, 6],
    [12, 12]
]

我们修改棋子的预制体,让其锚点在左下角(0,0),同时引入一个节点在棋子中间位置(51, 60)


然后其加载代码更改为:

let ziArr = [
    [0, 0],
    [6, 6],
    [12, 12]
]
ziArr.forEach(zi => {
    let ziUE = gtr.ui.instantiate(Match3ZiUE);
    ziUE.node.setParent(this.board);
    ziUE.init();
    ziUE.node.setPosition(zi[0] * 17, zi[1] * 20)
    ziUE.setDisplay(1)
})

这样,我们重新定义了棋子位置。

引入 json 配置文件来表达关卡数据

现在在 assets/Match3/yang/文件夹下新增文件夹 Jsons,Jsons 文件夹下新建 level 文件夹,再创建 0.json 表示第一关数据。

其数据内容如下:

{
    "zis": [
        [9, 42, 1], [18, 42, 1], [27, 42, 1]
        ,[9, 30, 2], [18, 30, 1], [27, 30, 2]
        ,[9, 18, 1], [18, 18, 2], [27, 18, 3]

        ,[9, 39, 3], [18, 39, 3], [27, 39, 3]
        ,[9, 28, 2], [18, 28, 3], [27, 28, 2]
        ,[9, 17, 1], [18, 17, 2], [27, 17, 3]
    ]
}

这里的数据如 [9, 42, 1] 对应一个棋子,9 表示第 9 列,42 表示第 42 行, 1 表示的是棋子花色。(计数都是从 0 开始计算)
由于为json 文件配置了特定的路径模式。因此自动化脚本为我们自动生成了 json 配置 GScript/auto/JsonsCfg.ts 文件:

/** 
 * !!! 本文件由下方脚本自动生成,请勿手动修改.
 * !!! /Users/z/Desktop/CCYang/_tool/nodejs/src/app/gen_res_cfg/gen.ts
 */
import { BL } from "db://assets/GScript/core/modules/res/ResConst";
export class JsonsCfg {
    static level = (key: string | number) => BL(`yang/Jsons/level/${key}`, "Match3BN");
}

这样我们就能通过加载 json 数据后,创建第一关数据了。

async start() {
    console.log(`主玩法界面`)
    let jsonAsset = await gtr.res.loadAssetAsync(JsonsCfg.level(0), JsonAsset);
    const levelJson = jsonAsset.json as {
        zis: [col: number, row: number, style: number][]
    };

    levelJson.zis.forEach(zi => {
        let ziUE = gtr.ui.instantiate(Match3ZiUE);
        ziUE.node.setParent(this.board);
        ziUE.init();
        ziUE.node.setPosition(zi[0] * 17, zi[1] * 20)
        ziUE.setDisplay(zi[2])
    })
}

现在有一个问题: 这个关卡数据是怎么来的?
答案是:需要写编辑器生成。
但是,因为时间有限,写编辑器的功能,目前没有打算在这个系列教程里实现了。

总之,我们已经实现了关卡的加载了。

处理遮挡关系

目前,关卡已经加载了,但是被遮挡的棋子应该是暗色的。本节来实现这个功能。

如何判断一个棋子是被遮挡了呢?有很多办法来实现这个功能。这里我只讲其中一种:

我们知道每个棋子是占据 6x6 =36个小单元格的。我们为所有的单元格分配一个【棋子栈】(没错,就是后进先出的 stack)。
因此,我们在添加一个棋子的时候,就在这个棋子覆盖的 36 个栈,分别入栈这个棋子。
这样,判断一个棋子是否被遮挡,只需要遍历它所覆盖的棋子栈,如果有任意一个栈顶元素不是该棋子,说明该棋子是被覆盖的。

我们在 Match3UI.ts 的同级目录下新建 ZiStack.ts 文件:


其内容如下:

import { Match3ZiUE } from "./Match3ZiUE";

export class ZiStack {
    /** 用数组来实现栈 */
    private m_Stack: Match3ZiUE[] = [];

    /** 栈是否为空 */
    get empty(): boolean { return this.m_Stack.length === 0 }

    /** 获取栈顶元素 */
    get top(): Match3ZiUE | null {
        if (this.empty) {
            return null
        }
        return this.m_Stack[this.m_Stack.length - 1];
    }

    /** 添加的时候,放到数组最后一项 */
    push(zi: Match3ZiUE) {
        this.m_Stack.push(zi)
    }

    pop(): Match3ZiUE {
        return this.m_Stack.pop();
    }
}

修改 Match3ZiUE.ts 的初始化方法:

col: number = 0;
row: number = 0;
style: number = 0;
init(col: number, row: number, style: number) {
    this.col = col;
    this.row = row;
    this.style = style;
    this.node.setPosition(col * 17, row * 20);
    this.setDisplay(style);
}

/** 设置是否可点击(不可点击时变暗) */
setClickable(bClickable: boolean) {
    this.bg.color = bClickable ? Color.WHITE : new Color(120, 120, 120, 180);
    this.sprite.color = bClickable ? Color.WHITE : new Color(120, 120, 120, 180);
}

同时修改 Match3UI.ts 中的创建流程

/** 所有的棋子都会添加到此数组中一次 */
private m_ZiList: Match3ZiUE[] = []
private stacks: ZiStack[][] = []
addZi(ziUE: Match3ZiUE) {
    // 加到数组
    this.m_ZiList.push(ziUE);

    // 加到棋子栈栈
    for (let r = 0; r < 6; ++r) {
        for (let c = 0; c < 6; ++c) {
            this.stacks[ziUE.row + r][ziUE.col + c].push(ziUE);
        }
    }
}
/** 判断是否可点击 */
calcClickable(ziUE: Match3ZiUE): boolean {
    let stack: ZiStack = null;
    for (let r = 0; r < 6; ++r) {
        for (let c = 0; c < 6; ++c) {
            stack = this.stacks[ziUE.row + r][ziUE.col + c];
            if (!stack.empty && stack.top !== ziUE) {
                // 有一个棋子栈顶部不是该棋子,说明被遮挡,不可点击
                return false;
            }
        }
    }

    return true;
}

async start() {
    console.log(`主玩法界面`)
    // 初始化栈数据
    const COL = 7;
    const ROW = 10;
    const SPLIT = 6;
    for (let r = 0, rmax = ROW * SPLIT; r < rmax; ++r) {
        let row_stacks: ZiStack[] = [];
        this.stacks.push(row_stacks);
        for (let c = 0, cmax = COL * SPLIT; c < cmax; ++c) {
            row_stacks.push(new ZiStack())
        }
    }

    let jsonAsset = await gtr.res.loadAssetAsync(JsonsCfg.level(0), JsonAsset);
    const levelJson = jsonAsset.json as {
        zis: [col: number, row: number, style: number][]
    };

    levelJson.zis.forEach(zi => {
        let ziUE = gtr.ui.instantiate(Match3ZiUE);
        ziUE.node.setParent(this.board);
        ziUE.init(zi[0], zi[1], zi[2]);
        this.addZi(ziUE);
    })

    // 刷新一次所有子的显示
    this.m_ZiList.forEach(ziUE => {
        ziUE.setClickable(this.calcClickable(ziUE))
    })
}

这样就完成了遮挡判定,我们看一下效果:

点击棋子逻辑

现在来为棋子添加点击事件。点击后的处理逻辑,我们暂时处理为移除该棋子。同时该棋子下方的所有棋子栈的栈顶棋子刷新可点击状态即可。

首先在棋子的初始化函数 init 中,增加一个参数,用来传递点击处理函数:

private m_ClickFunc: (zi: Match3ZiUE)=> any = null;
init(col: number, row: number, style: number, clickFunc: (zi: Match3ZiUE)=> any) {
    this.col = col;
    this.row = row;
    this.style = style;
    this.m_ClickFunc = clickFunc;
    this.node.setPosition(col * 17, row * 20);
    this.setDisplay(style);
}

再定义一个函数用于预制体绑定:

click() {
    this.m_ClickFunc(this);
}

接下来为 Match3ZiUE.prefab 的 root 节点添加一个 Button 组件,ClickEvents 修改为 1,然后绑定节点 Match3ZiUE 中的 Match3ZiUE 组件的 click 方法:

然后在 Match3UI.ts 中,新增移除引用的方法:

removeZi(ziUE: Match3ZiUE) {
    this.m_ZiList.splice(this.m_ZiList.indexOf(ziUE), 1);
    // 棋子栈栈顶肯定是该棋子
    for (let r = 0; r < 6; ++r) {
        for (let c = 0; c < 6; ++c) {
            this.stacks[ziUE.row + r][ziUE.col + c].pop();
        }
    }
}

初始化调用的时候,传入点击处理函数:

let clickFunc = (clickZi: Match3ZiUE) => {
    let isClickable = this.calcClickable(clickZi);
    if (!isClickable) return;
    let ziCol = clickZi.col;
    let ziRow = clickZi.row;
    this.removeZi(clickZi);
    // 目前表现是先销毁
    clickZi.node.destroy();
    // 下方刷新
    let stack: ZiStack = null;
    for (let r = 0; r < 6; ++r) {
        for (let c = 0; c < 6; ++c) {
            stack = this.stacks[ziRow + r][ziCol + c];
            if (!stack.empty) {
                stack.top.setClickable(this.calcClickable(stack.top));
            }
        }
    }
}
levelJson.zis.forEach(zi => {
    let ziUE = gtr.ui.instantiate(Match3ZiUE);
    ziUE.node.setParent(this.board);
    ziUE.init(zi[0], zi[1], zi[2], clickFunc);
    this.addZi(ziUE);
})

运行后表现符合预期。

1赞

收牌区

现在,我们需要一个收牌区域,用于在用户点击了棋子后,将棋子移动到该区域。

现在在 Match3UI.prefab 预制体中的 scale 节点下,新增节点 collect-area。其节点尺寸设置为 758x160。同时为 collect-area 添加 Sprite 组件,将图片 match3-receive.png 拖拽到 spriteFrame 属性上:

同时,为 scale 节点添加一个 Layout 组件,Type 选择 VERTICAL, Resize Mode 选择 CONTAINER,这样 scale 节点的尺寸,刚好就是棋盘 + 下方收集区的尺寸。

于是,对 scale 节点的缩放,就是对整个棋盘的缩放,这将在后面做屏幕适配时用到。
接下来,在 collect-area 下添加一个尺寸为 0 的空节点 zi-container,其坐标x修改为(-357, -60),这个位置,刚好是第一个棋子的位置。

上述的那些尺寸是怎么来的?因为我测试过效果:

接下来在 Match3UI.ts 中定义属性来绑定这个节点:

@property(Node) private collectArea: Node = null;

现在我们来修改一下之前点击棋子后的逻辑,将其中的销毁逻辑修改为移动到这个 collectArea 节点:

let clickFunc = (clickZi: Match3ZiUE) => {
    let isClickable = this.calcClickable(clickZi);
    if (!isClickable) return;
    let ziCol = clickZi.col;
    let ziRow = clickZi.row;
    this.removeZi(clickZi);
    // 目前表现是移动到 collectArea 节点下
    clickZi.node.setParent(this.collectArea, true);
    tween(clickZi.node)
        .to(0.15, { position: new Vec3(0, 0, 0) })
        .start()

    // 下方刷新
    let stack: ZiStack = null;
    for (let r = 0; r < 6; ++r) {
        for (let c = 0; c < 6; ++c) {
            stack = this.stacks[ziRow + r][ziCol + c];
            if (!stack.empty) {
                stack.top.setClickable(this.calcClickable(stack.top));
            }
        }
    }
}

点击后,棋子移动到第一个位置,符合预期!

收牌区占位问题

关于收牌区的第一个特点是,移动到目标位置前,玩家又点击了其它棋子,我们如何知道第一个位置已被占用,应该飞到第二个格子呢?

因此,我们需要定义一个数组,用来标记某个格子已经被棋子预定了(也就是棋子正朝这个格子飞去)

新增列表

private m_PlaceHolders: Match3ZiUE[] = [];

新增方法:

private m_PlaceHolders: Match3ZiUE[] = [];
private tryCollectingZi(clickZi: Match3ZiUE) {
    // 已经有 7 个了,不让继续收集
    if (this.m_PlaceHolders.length === 7) {
        return false;
    }
    // 没有 7 个
    // 1.先从后往前找,看看有没有同花色的棋子,有的话,就插入
    for (let i = this.m_PlaceHolders.length - 1; i >= 0; --i) {
        let zi = this.m_PlaceHolders[i];
        if (zi.style === clickZi.style) {
            // 插入到 zi 后面
            this.m_PlaceHolders.splice(i + 1, 0, clickZi);
            clickZi.moveToTargetIndex(this.collectArea, i + 1, () => {
                // 检测消除或者失败逻辑
                // TODO
            })
            // 后面的棋子如果正在做飞行动画,要改目标
            for (let j = i + 2; j < this.m_PlaceHolders.length; ++j) {
                this.m_PlaceHolders[j].changeMovingTargetIndex(j);
            }
            return true
        }
    }

    // 2.找不到花色,那么直接插入
    this.m_PlaceHolders.push(clickZi);
    clickZi.moveToTargetIndex(this.collectArea, this.m_PlaceHolders.length - 1, () => {
        // 检测消除或者失败逻辑
        // TODO
    })

    return true
}

这里还有几个方法没实现,但是我们已经把尝试收集一个棋子的逻辑写完了。

预览时为什么要先横屏才会进行屏幕适配,先竖屏无法自动适配,不知道是预览bug还是啥

跟着帖子做,clone 工程,切到对应提交上对比下。

棋子动画

棋子这边先设定其有 3 种的状态(在未消除前,棋子会在这几个状态迁移,因为进入消除状态后,不可迁移,所以没有必要在下方枚举中出现消除状态),因此在 Match3ZiUE.ts 中定义移动状态类型:

enum MovingType {
    // 无动画
    none = 0,
    // 从棋盘飞到收集区
    flying,
    // 收集区调整位置
    adjusting,
}

添加移动类型成员变量、移动目标、移动回调函数。然后这里我们简单的使用一维的平滑因子来取代速度这个二维向量:

private m_MovingType: MovingType = MovingType.none;
/** 移动到哪个目标 */
private m_TargetPosition: Vec3 = new Vec3(0, 0, 0);
/** 用平滑因子替换:二维的移动速度 */
private m_SmoothFactor: number = 0.2;
private m_FlyingCallback: Function = null;

实现从棋盘到收集区的 API,它设置相关变量,然后开启移动调度:

/** 调用此方法时,一定是从棋盘移动到收集区 */
moveToTargetIndex(collectArea: Node, index: number, cb: Function) {
    if (this.m_MovingType !== MovingType.none) {
        debugger;
    }
    this.m_MovingType = MovingType.flying;
    this.m_FlyingCallback = cb;
    // 目前表现是移动到 collectArea 节点下
    this.node.setParent(collectArea, true);
    /** 设置运动目标 */
    this.m_TargetPosition.set(index * 102, 0, 0);
    /** 开启调度控制飞行 */
    this.schedule(this.updateMoving, 0);
}

移动调度如下:

private updateMoving(dt: number) {
    let pos = this.node.position;
    // 到达目标位置
    if (Math.abs(this.m_TargetPosition.x - pos.x) + Math.abs(this.m_TargetPosition.y - pos.y) < this.m_SmoothFactor) {
        this.node.setPosition(this.m_TargetPosition.x, this.m_TargetPosition.y, pos.z);
        this.unschedule(this.updateMoving);
        let movingType = this.m_MovingType;
        this.m_MovingType = MovingType.none;
        if (movingType === MovingType.flying) {
            let flyingCallback = this.m_FlyingCallback;
            this.m_FlyingCallback = null;
            return flyingCallback();
        } else {
            return
        }
    }
    this.node.setPosition(new Vec3(pos.x + (this.m_TargetPosition.x - pos.x) * this.m_SmoothFactor, pos.y + (this.m_TargetPosition.y - pos.y) * this.m_SmoothFactor, pos.z));
}

接下来实现中途更改目标位置的 API:

changeMovingTargetIndex(index: number) {
    /** 设置运动目标位置 */
    this.m_TargetPosition.set(index * 102, 0, 0);
    switch (this.m_MovingType) {
        case MovingType.none:
            {
                // 说明是收牌区调整动画开启
                this.m_MovingType = MovingType.adjusting;
                /** 开启调度控制移动 */
                this.schedule(this.updateMoving, 0);
                break;
            }
        case MovingType.adjusting:
        case MovingType.flying:
            {
                // 在调整动画或者飞行的时候,又修改一次目标位置 -> 改目标位置即可(上方已执行)。
                break;
            }
    }
}

如果我们想测试各种用例下的实现是否正确,最好的办法就是将平滑因子改得很小很小,这样我们可以通过慢动作观察真实效果。

运行后,符合预期:

消除逻辑

一个要考虑的消除情况是这样的:加入我们点击了 2 个桃子,然后依次点击远处的桃子和近处的桃子。由于距离原因,后点的先到了,这时候如果在到的那一刻再决定哪些消除,就会出问题。因此要在点击的那一刻就标记好这个棋子关联的消除对象。

我们在点击处需要预先处理好要消除的对象,并进行标记,所以先在棋子类 Match3ZiUE 中新增标记变量:

isMarkEliminate: boolean = false;

然后修改点击收集的逻辑:

private tryCollectingZi(clickZi: Match3ZiUE) {
    // 已经有 7 个了,不让继续收集
    if (this.m_PlaceHolders.length === 7) {
        return false;
    }
    // 没有 7 个
    // 1.先从后往前找,看看有没有同花色的棋子,有的话,就插入
    for (let i = this.m_PlaceHolders.length - 1; i >= 0; --i) {
        let zi = this.m_PlaceHolders[i];
        if (zi.style === clickZi.style) {
            // 插入到 zi 后面
            this.m_PlaceHolders.splice(i + 1, 0, clickZi);
            // 标记当前子是否和前面 2 个子形成了消除
            let elimination: Match3ZiUE[] = [];
            if (!zi.isMarkEliminate && (i >= 1) && (this.m_PlaceHolders[i - 1].style === clickZi.style) && !this.m_PlaceHolders[i - 1].isMarkEliminate) {
                // 形成消除
                elimination.push(zi)
                elimination.push(this.m_PlaceHolders[i - 1])
                elimination.push(clickZi)
                // 标记消除
                elimination.forEach(it => it.isMarkEliminate = true);
            }
            clickZi.moveToTargetIndex(this.collectArea, i + 1, () => {
                // 检测消除或者失败逻辑
                if (elimination.length > 0) {
                    // 消除行为
                    elimination.forEach(it => {
                        this.m_PlaceHolders.splice(this.m_PlaceHolders.indexOf(it), 1);
                        it.node.destroy();
                    })
                }
            })
            // 后面的棋子如果正在做飞行动画,要改目标
            for (let j = i + 2; j < this.m_PlaceHolders.length; ++j) {
                this.m_PlaceHolders[j].changeMovingTargetIndex(j);
            }
            return true
        }
    }

    // 2.找不到花色,那么直接插入
    this.m_PlaceHolders.push(clickZi);
    clickZi.moveToTargetIndex(this.collectArea, this.m_PlaceHolders.length - 1, () => {
        // 检测消除或者失败逻辑
        // TODO
    })

    return true
}

新增的内容如图示部分:

即:
1、收集并标记消除棋子

// 标记当前子是否和前面 2 个子形成了消除
let elimination: Match3ZiUE[] = [];
if (!zi.isMarkEliminate && (i >= 1) && (this.m_PlaceHolders[i - 1].style === clickZi.style) && !this.m_PlaceHolders[i - 1].isMarkEliminate) {
    // 形成消除
    elimination.push(zi)
    elimination.push(this.m_PlaceHolders[i - 1])
    elimination.push(clickZi)
    // 标记消除
    elimination.forEach(it => it.isMarkEliminate = true);
}

和移动到目标后的消除表现逻辑

// 检测消除或者失败逻辑
if (elimination.length > 0) {
    // 消除行为
    elimination.forEach(it => {
        this.m_PlaceHolders.splice(this.m_PlaceHolders.indexOf(it), 1);
        it.node.destroy();
    })
}

运行后测试,表现符合预期。

1赞

失败逻辑判定

最后,我们要做一个失败逻辑判定:
当有棋子移动到棋盘的时候,如果收牌区已满(且没有标记消除的棋子),那么当前局就失败了。

修改以下内容,分别添加 checkLose 判定:

// 检测消除或者失败逻辑
if (elimination.length > 0) {
    // 消除行为
    elimination.forEach(it => {
        this.m_PlaceHolders.splice(this.m_PlaceHolders.indexOf(it), 1);
        it.node.destroy();
    })
} else {
    this.checkLose();
}

// 2.找不到花色,那么直接插入
this.m_PlaceHolders.push(clickZi);
clickZi.moveToTargetIndex(this.collectArea, this.m_PlaceHolders.length - 1, () => this.checkLose())

然后新增 checkLose 方法

private m_IsLose: boolean = false;
checkLose() {
    if (this.m_PlaceHolders.length < 7) return;
    for (let i = 0; i < this.m_PlaceHolders.length; ++i) {
        if (this.m_PlaceHolders[i].isMarkEliminate) {
            return
        }
    }
    // 失败
    if (this.m_IsLose) return;
    this.m_IsLose = true;
    console.log(`游戏失败弹窗`)
    // TODO 弹出失败窗口
}

这样我们完成了失败判定逻辑。最后,只要做一个失败界面,就完成了失败判定。

结束语

目前羊了个羊的这个教程,还有几个点没有细化:
0、收集区的棋子不能响应点击事件
1、失败弹窗
2、根据 viewSize 去缩放 Match3UI 中的 scale 节点,以方便后续道具栏的显示;
3、一些道具的使用
4、编辑器功能
5、自动化脚本

但是这些工作要么是比较简单,要么比较琐碎。和之前的工作没有太多本质的差别。再结合现在 Kimi、DeepSeek 等强大的人工智能,基本上不会有太大的问题。这里就不再重复演示了。

到这里算是给自己的承诺一个交代了。最后祝大家事业有成!

8赞

很强,感谢大佬分享 :+1:

1赞

感谢大佬分享

佬,Boost这里为何又写了loadBundle?用ResManager中写的loadBundleAsync可以吗?这里不是很明白。

因为 ResManager 在脚本子包中,如果 boost 引用了它,会导致脚本包被打包到主包里。这样在微信这样的平台,你主包就超过 4M 大小限制。

我看有的微信小游戏没有自己加健康游戏忠告,
是不是可以不用开发者自己加因为打开游戏的加载界面微信已经自己就加了一个了 :thinking:

好像都要加,备案审核的时候,必须要有截图。
审核过了可以不加,不过为了避免被骚扰,我都加。