likes
comments
collection
share

探索Pixi.js的潜力:打造专业级网页游戏《消消乐》(下)

作者站长头像
站长
· 阅读数 12

消消乐游戏在线体验

前面介绍了游戏的基本玩法,本文我们聊聊游戏的高阶玩法--特殊元素

探索Pixi.js的潜力:打造专业级网页游戏《消消乐》(下)

在游戏中,为了获得更多的评分,我们应该优先考虑生成特殊元素,下面我们就来看看特殊元素的类型。

特殊元素的种类及相应的特效功能

  1. SpcialRow:弹出其所在行的所有棋子。
  2. SpcialColumn: 弹出其所在列的所有棋子。
  3. SpcialBlast:弹出指定范围的所有棋子。
  4. SpcialColor:弹出面板上出现次数最多的同色棋子。

特殊元素与普通元素的区别

1. 默认不会自动生成,每种类型都有相应的生成策略。

初始化面板时,不会生成特殊元素。它们的出现需要满足一定条件

SpcialRow:一次性在垂直方向上消除4个,就会在中间位置(Math.floor(match.length / 2)下同)生成一个special row。

SpcialColumn: 一次性在水平方向上消除4个,就会在中间位置生成一个special colum。

SpcialBlast:消除的方块有相交重叠,在相交位置生成一个special blast。

SpcialColor:水平或垂直方向上一次性消除至少5个,就会在中间位置生成一个special color。

实现特殊元素的生成

特殊元素的生成是消除过程中产生的,因此,我们回到循环任务中,在其中添加一项处理特殊元素的任务 processSpecialMatches

private async runProcessRound() {
    // Step #1 - Bump sequence number and update stats with new matches found

    // Step #2 - Process and clear all special matches
    this.queue.add(async () => {
        await this.processSpecialMatches();
    });

    // Step #3 - Process and clear remaining common matches

    // Step #4 - Move down remaining pieces in the grid if there are empty spaces in their columns

    // Step #5 - Create new pieces that falls from the to to fill up remaining empty spaces

    // Step #6 - Finish up this sequence round and check if it needs a re-run, otherwise stop processing
}

processSpecialMatches最终是为了调用每个特殊元素类实现的process方法。下面我们以Match3SpecialRow为例:

// special/Match3SpecialRow.ts

public async process(matches: Match3Position[][]) {
    let i = matches.length;
    while (i) {
        i--;
        const match = matches[i];
        if (match.length != 4) continue;
        if (match[0].column === match[1].column) {
            const middle = Math.floor(match.length / 2);
            const middlePosition = match[middle];
            await this.match3.board.popPieces(match);
            await this.match3.board.spawnPiece(middlePosition, this.pieceType);
        }
    }
}

可以看到,遍历所有matches,判断每一个match是否满足垂直方向上4连同色,是则在中间位置生成(spawnPiece)一个特殊元素,同时弹出所有匹配的棋子。

其中spawnPiece的核心就是在给定位置(middlePosition)生成一个给定类型(this.pieceType)的棋子,同时更新grid中middlePosition的type值。

点击特殊元素触发特效

生成棋子时,为其添加点击事件:

// Match3Board.ts
createPiece(position: Match3Position, pieceType: Match3Type) {
    // ...
    piece.onTap = (position) => this.match3.actions.actionTap(position);
}

在点击事件中,核心要做的就是弹出特殊元素,之后开启循环任务。

public async actionTap(position: Match3Position) {
    if (!this.match3.isPlaying()) return;
    // ...
    
    await this.match3.board.popPiece(piece);
    this.match3.process.start();
}

弹出棋子的过程中,我们会触发特殊元素的特效。

public async popPiece(position: Match3Position, causedBySpecial = false) {
    // ...
    
    await this.match3.special.trigger(type, position);
}

其中this.match3.special.trigger(type, position);就是调用每个特殊元素类实现的trigger方法。这里仍以SpecialRow为例,我们看看它触发的功能特效:即弹出所在行的所有棋子。其中popPieces会遍历所有位置,为每一个位置应用popPiece方法。

// special/SpecialRow.ts

public async trigger(pieceType: Match3Type, position: Match3Position) {
    // 检查棋子类型是不是SpecialRow类型
    if (pieceType !== this.pieceType) return;

    const columns = this.match3.board.columns;
    const list: Match3Position[] = [];
    for (let i = 0; i < columns; i++) {
        list.push({ row: position.row, column: i });
    }

    await this.match3.board.popPieces(list, true);
}

拖动特殊元素触发特效

除了点击特殊元素触发它的功能特效外,我们还可以像拖动普通元素一样,拖动特殊元素来触发功能特效。不同的是,拖动之后,如果from与to位置有一者是特殊元素,那就视为有效移动,从而触发功能特效。核心实现如下:

private validateMove(from: Match3Position, to: Match3Position) {
    const typeOfFrom = match3GetPieceType(this.match3.board.grid, from);
    const typeOfTo = match3GetPieceType(this.match3.board.grid, to);
    const specialFrom = this.match3.special.isSpecial(typeOfFrom);
    const specialTo = this.match3.special.isSpecial(typeOfTo);

    // Always allow move that either or both are special pieces
    if (specialFrom || specialTo) return true;

    // Clone current grid so we can manipulate it safely
    const tempGrid = match3CloneGrid(this.match3.board.grid);

    // Swap type in the temporary cloned grid
    match3SwapTypeInGrid(tempGrid, from, to);

    // Get all matches created by this move in the temporary grid
    const newMatches = match3GetMatches(tempGrid, [from, to]);

    // Only validate moves that creates new matches
    return newMatches.length >= 1;
}

可以看到,if (specialFrom || specialTo) return true;只要from或to的位置是特殊元素,即视为有效移动。对于普通元素,我们是通过计算grid中是否有匹配判断有效移动的。

最后,弹出对应的元素,而弹出的过程中就会触发功能特效,实现如下:

private async swapPieces(pieceA: Match3Piece, pieceB: Match3Piece) {
    // ...
    const valid = this.validateMove(positionA, positionB);

    if (!valid) {
        // Revert pieces to their original position if move is not valid
        // ...
    } else if (
        this.match3.special.isSpecial(match3GetPieceType(this.match3.board.grid, positionA))
        && this.match3.special.isSpecial(match3GetPieceType(this.match3.board.grid, positionB))
    ) {
        // Pop both if A and B are special
        await this.match3.board.popPieces([positionA, positionB]);
    } else if (
        this.match3.special.isSpecial(match3GetPieceType(this.match3.board.grid, positionA))
    ) {
        // Pop piece A if is special
        await this.match3.board.popPiece(positionA);
    } else if (
        this.match3.special.isSpecial(match3GetPieceType(this.match3.board.grid, positionB))
    ) {
        // Pop piece B if is special
        await this.match3.board.popPiece(positionB);
    }
}

以上便是特殊元素的实现思路与核心代码。

总结

最后我们再来回顾一下整个游戏的实现过程,不管咋样,请记住:游戏的过程就是二维数组的变化过程。

  1. 获取配置,比如grid的行数,列数,游戏难度(决定普通棋子类型的数目,数目越多,匹配的难度就越大)
  2. 根据配置,初始化一个grid即二维数组。
  3. 根据二维数组绘制棋盘以及棋盘每个位置上的棋子。为棋子添加点击,拖拽事件。
  4. 向上/下/左/右拖动棋子,判断是否是有效移动,是有效移动,且from或to是特殊元素,弹出并触发其功能特效;不是有效移动,棋子归为原位。
  5. 开启循环任务(见下文)处理消除过程,直至没有匹配且没有空位置。
  6. 游戏时间到。如果此时循环任务还没结束,等待其处理完毕,再结束游戏。

循环任务

start(lock)

5-1. 更新分数;

5-2. 弹出所有匹配到的棋子;

5-3. 应用重力;

5-4. 填充网格;

5-5. 是否有新的匹配或者有空位置?继续步骤5-1 ~ 5-5:end(unlock)

值得注意的是lock与unlock。因为在循环任务执行期间,用户是可以再次拖动或点击特殊元素触发消除的。此时,如果是lock状态,就没有必要再开启新的循环了,而是交给当前的循环处理。也就是说,新触发的消除仅仅是改变了grid的状态,消除过程还是在当前循环中进行。

还有一点值得注意的是,循环任务是可以暂停的

class AsyncQueue {
    private readonly queue: AsyncQueueFn[] = [];
    private paused = false;
    private processing = false;
    
    public async add(fn: AsyncQueueFn, autoStart = true) {
        this.queue.push(fn);
        if (autoStart) await this.process();
    }
    
    public async process() {
        if (this.processing) return;
        this.processing = true;
        while (this.queue.length) {
            if (this.paused) {
                await waitFor(0.1);
            } else {
                const fn = this.queue.shift();
                if (fn) await fn();
            }
        }
        this.processing = false;
    }
}

export function waitFor(delayInSecs = 1): Promise<void> {
    return new Promise((resolve) => {
        setTimeout(() => resolve(), delayInSecs * 1000);
    });
}

核心在于当pause为true,通过await waitFor(0.1);让出主线程,让它得以处理其它任务。在之后适当时机我们可以将pause置为false,从而恢复循环任务。

至此,本专栏结束,感谢阅读。往期文章:

展望

游戏其他玩法:比如可以使用可消除次数代替倒计时,让玩家有更多时间思考棋子的最佳移动;使用道具;设置关卡,在通过easy模式后再开放上层模式;开发其它特殊元素以及特殊元素相互增强威力等等,丰富游戏的趣味性。