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

本文,我们主要探讨页面开发遵循的思路 、游戏中的音频管理 与游戏页的开发包括基本玩法的实现原理与算法等等。

在此之前,我们先对上篇进行一下简单的回顾。

上篇回顾

上文我们已经实现了静态资源的加载与应用,并在画布上显示了加载页。我们先来捋一下流程:

graph TD 开始 --> 显示全局loading --> 初始化一个Pixi应用 --> 获取并初始化manifest --> 初始化完毕-加载preloadBundle资源 --> 加载完毕-后台预加载所有资源-隐藏全局loading-设置背景-显示加载页 --> 为显示下一页面做准备
  1. 开始,显示全局loading,这是在body::after上实现的loading动画。
  2. 紧接着,初始化一个Pixi应用并将对应的canvas元素添加到页面。
  3. 然后,通过fetch加载清单文件并通过pixi内置的方法Asset.init初始化清单文件。
  4. 待初始化完毕后,立即加载preload bundle,这是加载页和背景需要的静态资源列表。
  5. 待加载完毕后,将剩余资源放到后台加载。这时可以隐藏全局loading,取而代之的是显示加载页,同时设置路由容器背景。
  6. 开始加载下一页面资源,待加载完毕显示下一页面。

我们再来回顾一下主要的实现代码:

ts 复制代码
// app.ts
import { Application, Text } from "pixi.js";
import { isDev } from "./utils/is";

export const app = new Application<HTMLCanvasElement>({
    backgroundColor: 0xffffff,
    backgroundAlpha: 0,
    resolution: window.devicePixelRatio || 1,
})
// init pixi devtool if development
isDev() && (globalThis.__PIXI_APP__ = app);
ts 复制代码
// main.ts
async function init() {
    // add canvas element to body
    document.body.append(app.view);

    // Load assets
    await initAssets();

    // hide loading
    document.body.classList.add("loaded");

    // Add a persisting background shared by all screens
    navigation.setBackground(TiledBackground);

    // Show initial loading screen
    await navigation.showScreen(LoadScreen);

    // go to home screen
    navigation.showScreen(Homecreen);
}
ts 复制代码
// utils/assets.ts

/** Initialise and start background loading of all assets */
export async function initAssets() {
    // Load assets manifest
    assetsManifest = await fetchAssetsManifest('assets/assets-manifest.json');

    // Init PixiJS assets with this asset manifest
    await Assets.init({
        manifest: assetsManifest,
        basePath: 'assets',
        texturePreference: {
            resolution: 0.5
        }
    });

    // Load assets for the load screen
    await loadBundles(['preload']);

    // List all existing bundles names
    const allBundles = assetsManifest.bundles.map((item) => item.name);

    // Start up background loading of all bundles
    Assets.backgroundLoadBundle(allBundles);
}

可以看到,加载页的主要作用是等待下一页面的资源加载完毕。而这下一页面就是我们接下来需要需要实现的游戏首页

游戏首页的开发

注:所有页的UI绘制在下篇统一讲解

上文已经提及过,每一个Screen本质是一个pixi的Container的容器 。由于游戏首页主要以绘制及布局元素为主,关于所有页面的布局会在下篇统一讲解,因此,这里我贴一下主要代码并聊聊需要注意的几个点。

ts 复制代码
/** The first screen that shows up after loading */
export class HomeScreen extends Container {
    /** Assets bundles required by this screen */
    public static assetBundles = ['home', 'common'];
    
    constructor() {
        super();
        // draw materials of screen
    }
    
    /** Resize the screen, fired whenever window size changes  */
    public resize(width: number, height: number) { ... }
    
    /** Show screen with animations */
    public async show() { 
        // play bgm
        bgm.play('common/bgm-main.mp3', { volume: 0.7 });
        
        // others..
    }
    
    /** Hide screen with animations */
    public async hide() { ... }
    
    /** and so on */
}

所有页面都遵循的开发思路

  1. 在HomeScreen上我们定义了一个assetBundles 静态属性,表示HomeScreen所需的bundle列表。其中,common 指所有游戏页所通用的bundle,而home指当前页需要的bundle。从上文实现的路由管理器Navigation得知,这些bundle列表会在页面显示前被加载完毕。
  2. 一个页面的生命周期大概会经历以下阶段:constructor->prepare?->resize?->update?->show?->hide?。?表示Screen是否定义了该方法。
  • constructor:实例化一系列所需的UI元素,本质是一些pixi的Graphic,Sprite,Text元素,并将其添加到画布。
  • prepare:主要实现一些即将显示的准备工作。
  • resize:UI元素的布局与大小设置。show之前会被调用一次,在窗口大小改变时依然同步调用resize,以确保元素拥有正确的位置与大小。
  • update:该方法会被作为回调函数参数,传递app.ticker.add。
  • show:通过gsap将UI元素以动画的形式显现。
  • hide:隐藏当前页。伴随app.ticker.remove等动作。

不管是哪个页面甚至是一个UI元素,都遵循上面的思路。下面附上首页最终效果图及主要标注的预览:

音频管理

在上文提及的HomeScreen的show方法中我们通过bgm.play('common/bgm-main.mp3', { volume: 0.7 });播放了一段背景音乐。本小节将介绍一下游戏中的音频管理。

两种音频类型:BGM与SFX

在游戏中,我们有2种类型的音频文件:背景音乐与声音特效。其中背景音乐贯穿每个页面并具有循环功能。声音特效是在消除,弹出,爆炸等时刻播放的一段时间很短的音效。为了分别控制两种音频的音量大小,我们通过实现BGMSFX两个类来分别管理背景音乐与声音特效。

ts 复制代码
import { PlayOptions, Sound, sound } from '@pixi/sound';
import gsap from 'gsap';
import { Assets } from 'pixi.js';

class BGM {
    /** Alias of the current music being played */
    public currentAlias?: string;
    /** Current music instance being played */
    public current?: Sound;
    /** Current volume set */
    private volume = 1;

    /** Play a background music, fading out and stopping the previous, if there is one */
    public async play(alias: string, options?: PlayOptions) {
        // Do nothing if the requested music is already being played
        if (this.currentAlias === alias) return;

        // Fade out then stop current music
        if (this.current) {
            const current = this.current;
            gsap.killTweensOf(current);
            gsap.to(current, { volume: 0, duration: 1, ease: 'linear' }).then(() => {
                current.stop();
            });
        }

        // Find out the new instance to be played
        // this.current = sound.find(alias);
        this.current = Assets.cache.get(alias);

        // Play and fade in the new music
        this.currentAlias = alias;
        this.current.play({ loop: true, ...options });
        this.current.volume = 0;
        gsap.killTweensOf(this.current);
        gsap.to(this.current, { volume: this.volume, duration: 1, ease: 'linear' });
    }

    /** Get background music volume */
    public getVolume() {
        return this.volume;
    }

    /** Set background music volume */
    public setVolume(v: number) {
        this.volume = v;
        if (this.current) this.current.volume = this.volume;
    }
}

class SFX {
    /** Current volume set */
    private volume = 1;

    /** Play an one-shot sound effect */
    public play(alias: string, options?: PlayOptions) {
        const volume = this.volume * (options?.volume ?? 1);
        sound.play(alias, { ...options, volume });
    }

    /** Get sound effects volume */
    public getVolume() {
        return this.volume;
    }

    /** Set sound effects volume */
    public setVolume(v: number) {
        this.volume = v;
    }
}

/** Shared background music controller */
export const bgm = new BGM();

/** Shared sound effects controller */
export const sfx = new SFX();
  1. 通过BGM可以看到,在处理音乐背景时,一次只循环播放同一个音频文件,如果切换新的背景音乐,则通过gsap使当前音乐渐渐消隐,新音乐渐渐显现。而为了处理音乐的渐隐渐显,我们需要查找音频对应的Sound实例。由于我们进入首页前,我们已经加载完毕common bundle,其中就有我们需要的所有音频文件,因此,我们可以通过 Assets.cache.get(name) 获取对应的Sound实例。也可以通过 sound.find(name) 查找并获取对应的Sound实例。同时BGM内部提供了私有volume的进行音量控制,这点与SFX相同。

Asset.cache

  1. 与BGM不同的是,短音效不需要循环播放,也不需要渐隐渐显,因此我们直接通过sound.play播放音频即可。无需查找对应音频的Sound实例。

两种音频处理方式:Sound与SoundLibrary

值得注意的是,在import { PlayOptions, Sound, sound } from '@pixi/sound'中的sound并不是Sound的实例,而是SoundLibrary的一个实例,用于统筹管理和控制多个Sound实例。

如果你仅需要播放一个音频文件,通过Sound提供的from方法即可创建一个Sound实例,实例提供了单个音频的播放、暂停、恢复、音量,循环控制、事件监听等等属性与方法,简单示例如下:

ts 复制代码
import { PlayOptions, Sound } from '@pixi/sound';

(async () => {
    const sound = Sound.from("https://pixijs.io/sound/examples/resources/sprite.mp3");
    // return a Promise if the sound has not yet loaded.
    const webAudioInstance = await Promise.resolve(sound.play());
    webAudioInstance.on("progress", (progress) => {
        console.log("播放进度: ", progress);
    });
})()

但如果你有大量音频,建议使用SoundLibrary进行管理。

ts 复制代码
import { sound } from '@pixi/sound';

// sound is a instance of SoundLibrary
sound.add('bgm-game', 'path/bgm-game.mp3');
sound.add('bgm-main', 'path/bgm-main.mp3');
sound.play('bgm-game');
sound.play('bgm-main');

sound.stopAll();
sound.pauseAll();
sound.muteAll();
sound.exists(name);
// and so on...

游戏页的介绍

概览

取名Match3,缘由来自游戏的基本玩法:在棋盘的垂直或水平方向上拖动棋子,匹配至少3个相连同色的棋子,将其消除。

页面元素的绘制与布局

游戏元素的绘制与布局放在下篇统一讲解,这里简单看下实现逻辑。重点注意游戏核心类this.match3

ts 复制代码
// screens/GameScreen.ts

export class GameScreen extends Container {
    constructor() {
        super();

        this.gameContainer = new Container();
        this.addChild(this.gameContainer);

        // 棋盘
        this.shelf = new Shelf();
        this.gameContainer.addChild(this.shelf);

        // 分数
        this.score = new GameScore();
        this.addChild(this.score);

        // 大锅
        this.cauldron = new Cauldron(true);
        this.addChild(this.cauldron);

        // 倒计时
        this.timer = new GameTimer();
        this.cauldron.addContent(this.timer);

        // Ready...GO
        this.countdown = new GameCountdown();
        this.addChild(this.countdown);

        // 最后5秒倒计时
        this.overtime = new GameOvertime();
        this.addChild(this.overtime);

        // game over
        this.timesUp = new GameTimesUp();
        this.addChild(this.timesUp);
        
        // 游戏核心类
        this.match3 = new Match3();
        this.gameContainer.addChild(this.match3);
    }
    prepare() {
        const match3Config = match3GetConfig({
            rows: getUrlParamNumber('rows') ?? 4,
            columns: getUrlParamNumber('columns') ?? 4,
            tileSize: getUrlParamNumber('tileSize') ?? 50,
            freeMoves: getUrlParam('freeMoves') !== null,
            duration: getUrlParamNumber('duration') ?? 60,
            mode: (getUrlParam('mode') as Match3Mode) ?? userSettings.getGameMode(),
        });
        
        // the entry of game core
        this.match3.setup(match3Config);
    }
}

游戏的核心实现原理

  1. 获取配置(行列数、棋子大小与类型等等)。

  2. 生成二维数组,遍历二维数组在棋盘上绘制相应的棋子。

  3. 棋子移动:是否是有效移动?交换位置:退回原位。

  4. 开启一轮异步任务队列:

    start

    4-1. 更新分数;

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

    4-3. 应用重力;

    4-4. 随机填充网格;

    4-5. 是否有新的匹配或者有空位置?重复步骤4:end。

实际上,游戏的本质是一个二维数组的变化过程。举个例子,假设从第2步开始,生成二维数组如下:

js 复制代码
// 初始化的棋盘,每个数字代表一个棋子
this.grid = [
    [3, 1, 4, 3],
    [4, 3, 3, 1],
    [3, 1, 1, 2],
    [1, 3, 2, 1],
]

// 用户触发第1行第4列向下移动之后
this.grid = [
    [3, 1, 4, 1],
    [4, 3, 3, 3],
    [3, 1, 1, 2],
    [1, 3, 2, 1],
]

// 4-1. 更新分数,grid不变

// 4-2. 消除棋子,空位置用0表示
this.grid = [
    [3, 1, 4, 1],
    [4, 0, 0, 0],
    [3, 1, 1, 2],
    [1, 3, 2, 1],
]

// 4-3. 应用重力:上层棋子填充空位置
this.grid = [
    [3, 0, 0, 0],
    [4, 1, 4, 1],
    [3, 1, 1, 2],
    [1, 3, 2, 1],
]

// 4-4. 随机填充棋盘空白位置,这里假设是1,2,3填充了0,0,0
this.grid = [
    [3, 1, 2, 3],
    [4, 1, 4, 1],
    [3, 1, 1, 2],
    [1, 3, 2, 1],
]

// 4-5. 有新的匹配(1,1,1),开启新一轮循环,直至棋盘没有新的匹配也没有空位置
// 重复步骤4.1 - 4.5

下面我们看看以上流程中的应用到的核心算法。

核心算法

暂时约定,当前共有4种不同颜色的棋子,每个棋子的类型为索引下标+1。

ts 复制代码
const pieces = ['piece-dragon', 'piece-frog', 'piece-newt', 'piece-snake']

const types=[1, 2, 3, 4]

Grid的初始化算法

初始化算法除了满足随机取用类型填充二维数组外,还应满足初始化后的grid没有任何匹配,即在水平或垂直方向上,没有一组至少3个相连的同色棋子。

实现思路

基本思路:从上到下,从左往右开始遍历grid,对于每一个位置,随机获取一个type,如果该type满足水平向左或垂直向上方向上3连同色,则需要重新生成。同时,用一个数组excludeList记录再次获取时需要排除的类型的集合。

编码实现

ts 复制代码
// Match3Utility.ts
export type Match3Type = number;
export type Match3Grid = Match3Type[][];
export type Match3Position = { row: number; column: number };

/* init grid with gived types  */
export function match3CreateGrid(rows = 6, columns = 6, types: Match3Type[]) {
    const grid: Match3Grid = [];

    for (let r = 0; r < rows; r++) {
        for (let c = 0; c < columns; c++) {
            let type = match3GetRandomType(types);

            const excludeList: Match3Type[] = [];

            while (matchPreviousTypes(grid, { row: r, column: c }, type)) {
                excludeList.push(type);
                type = match3GetRandomType(types, excludeList);
            }

            if (!grid[r]) grid[r] = [];

            grid[r][c] = type;
        }
    }

    return grid as Match3Grid;
}

export function match3GetRandomType(types: Match3Type[], exclude?: Match3Type[]) {
    let list = [...types];

    if (exclude) {
        list = types.filter((type) => !exclude.includes(type));
    }

    const index = Math.floor(Math.random() * list.length);

    return list[index];
}

function matchPreviousTypes(grid: Match3Grid, position: Match3Position, type: Match3Type) {
    // Check if previous horizontal positions are forming a match
    const horizontal1 = grid?.[position.row]?.[position.column - 1];
    const horizontal2 = grid?.[position.row]?.[position.column - 2];
    const horizontalMatch = type === horizontal1 && type === horizontal2;

    // Check if previous vertical positions are forming a match
    const vertical1 = grid?.[position.row - 1]?.[position.column];
    const vertical2 = grid?.[position.row - 2]?.[position.column];
    const verticalMatch = type === vertical1 && type === vertical2;

    return horizontalMatch || verticalMatch;
}

Grid中查找所有匹配的算法

即获取包括水平方向和垂直方向上的所有3连同色列表,比如以下grid含有2个匹配:[[1,1,1,1], [1,1,1]]

ts 复制代码
|01|01|01|01|
|01|01|02|04|
|01|02|04|01|
|04|04|03|03|

实现思路

基本思路:分别计算水平和垂直方向上的匹配列表,最后将两者concat。

编码实现

ts 复制代码
export function match3GetMatches(grid: Match3Grid, matchSize = 3) {
    const allMatches = [
        ...match3GetMatchesByOrientation(grid, matchSize, 'horizontal'),
        ...match3GetMatchesByOrientation(grid, matchSize, 'vertical'),
    ];

    return allMatches;
}

我们先来看看水平方向上的计算方式,其中的原委纵使千言万语不及读者自身画图举例对照理解。值得注意的是,当前行遍历结束或者当前类型不等于上一次类型,都要判断currentMatch的长度是否大于等于3,是则收集到最终结果matches数组中。还有就是,遍历每一行我们都需要一个"干净的环境",因此,需要将lastType与currentMatch重置。

ts 复制代码
export type Match3Orientation = 'horizontal' | 'vertical';

function match3GetMatchesByOrientation(
    grid: Match3Grid,
    matchSize: number,
    orientation: Match3Orientation,
) {
    const matches: Match3Position[][] = [];
    const rows = grid.length;
    const columns = grid[0].length;
    let lastType: undefined | number = undefined;
    let currentMatch: Match3Position[] = [];

    for (let row = 0; row < rows; row++) {
        for (let column = 0; column < columns; column++) {
            const type = grid[row][column];

            if (type && type === lastType) {
                currentMatch.push({ row, column });
            } else {
                if (currentMatch.length >= matchSize) {
                    matches.push(currentMatch);
                }
                currentMatch = [{ row, column }];
                lastType = type;
            }
        }

        if (currentMatch.length >= matchSize) {
            matches.push(currentMatch);
        }

        lastType = undefined;
        currentMatch = [];
    }

    return matches;
}

垂直方向的匹配逻辑与水平方向的一致。水平方向是从上到下,从左往右,垂直方向上则是从左往右,从上到下。

ts 复制代码
for (let column = 0; column < columns; column++) {
    for (let row = 0; row < rows; row++) {
        const type = grid[row][column];
        // ...
    }
}

将两个方向整合在一起后,最终代码如下:

ts 复制代码
function match3GetMatchesByOrientation(
    grid: Match3Grid,
    matchSize: number,
    orientation: Match3Orientation,
) {
    // 逻辑不变...

    const primary = orientation === 'horizontal' ? rows : columns;
    const secondary = orientation === 'horizontal' ? columns : rows;

    for (let p = 0; p < primary; p++) {
        for (let s = 0; s < secondary; s++) {
            // On horizontal 'p' is row and 's' is column, vertical is opposite
            const row = orientation === 'horizontal' ? p : s;
            const column = orientation === 'horizontal' ? s : p;
            const type = grid[row][column];

            // 逻辑不变...
        }

        // 逻辑不变...
    }

    return matches;
}

Grid的重力算法

消除完成后,每个位置的类型都会变为0,表示空位置。重力算法是指所有非空位置的棋子往下沉,直至最后一行或正下方是非空的。

第一版:实现基本逻辑

实现思路

从下往上,从左往右遍历,如果当前位置在最后一行,直接跳过,因为最后一行无法应用重力。最后判断如果当前位置非空并且下方位置为空,则两者交换位置达到下沉目的。

编码实现

其中一些辅助函数在后面贴出。

ts 复制代码
export function match3ApplyGravity(grid: Match3Grid) {
    const rows = grid.length;
    const columns = grid[0].length;
    for (let r = rows - 1; r >= 0; r--) {
        for (let c = 0; c < columns; c++) {
            let position = { row: r, column: c };
            let belowPosition = { row: r + 1, column: c };

            // Skip if position below is out of bounds
            if (!match3IsValidPosition(grid, belowPosition)) continue;

            let currentType = match3GetPieceType(grid, position);
            let belowType = match3GetPieceType(grid, belowPosition);
            
            if (currentType !== 0 && belowType === 0) {
                match3SwapTypeInGrid(grid, position, belowPosition);
            }
        }
    }
}

第二版:完善逻辑

很明显,第一版是有缺陷的,因为没有考虑交换之后,下方仍然有空位置的情况。为此,我们加上while循环,只要下方不超出边界且是空位置,就让非空位置往下沉。

ts 复制代码
export function match3ApplyGravity(grid: Match3Grid) {
    const rows = grid.length;
    const columns = grid[0].length;
    for (let r = rows - 1; r >= 0; r--) {
        for (let c = 0; c < columns; c++) {
            let position = { row: r, column: c };
            let belowPosition = { row: r + 1, column: c };

            if (!match3IsValidPosition(grid, belowPosition)) continue;

            let currentType = match3GetPieceType(grid, position);
            let belowType = match3GetPieceType(grid, belowPosition);
            
            // Keep moving the piece down if position below is valid and empty
            while (match3IsValidPosition(grid, belowPosition) && (belowType === 0 && currentType !== 0)) {
                match3SwapTypeInGrid(grid, position, belowPosition);
                position = { ...belowPosition };
                belowPosition.row += 1;
                currentType = match3GetPieceType(grid, position);
                belowType = match3GetPieceType(grid, belowPosition);
            }
        }
    }
}
ts 复制代码
export function match3IsValidPosition(grid: Match3Grid, position: Match3Position) {
    const rows = grid.length;
    const cols = grid[0].length;
    return (
        position.row >= 0 && position.row < rows && position.column >= 0 && position.column < cols
    );
}

export function match3SwapTypeInGrid(
    grid: Match3Grid,
    positionA: Match3Position,
    positionB: Match3Position,
) {
    const typeA = match3GetPieceType(grid, positionA);
    const typeB = match3GetPieceType(grid, positionB);

    // Only swap pieces if both types are valid (not undefined)
    if (typeA !== undefined && typeB !== undefined) {
        match3SetPieceType(grid, positionA, typeB);
        match3SetPieceType(grid, positionB, typeA);
    }
}

export function match3GetPieceType(grid: Match3Grid, position: Match3Position) {
    return grid?.[position.row]?.[position.column];
}

export function match3SetPieceType(grid: Match3Grid, position: Match3Position, type: number) {
    grid[position.row][position.column] = type;
}

第三版:收集变化

在棋子应用重力过程中,会伴随有下落的动画,因此我们需要记录起始位置与最终位置,在它们之间应用补间动画。每一个变化记录是一个数组,数组的第一项是起始位置from,第二项是终点位置to。

实例思路很简单,为每个位置提供一个初始值为false的hasChanged标识,满足while条件则将其更新为true;当while循环结束后,判断hasChanged是否为true,是则将最终的position(to)与当前遍历到的位置(from)收集到结果changes数组中,最终将changes返回出去。最终代码如下:

ts 复制代码
export function match3ApplyGravity(grid: Match3Grid) {
    const rows = grid.length;
    const columns = grid[0].length;
    const changes: Match3Position[][] = [];
    for (let r = rows - 1; r >= 0; r--) {
        for (let c = 0; c < columns; c++) {
            let position = { row: r, column: c };
            let belowPosition = { row: r + 1, column: c };
            let hasChanged = false;

            // Skip this one if position below is out of bounds
            if (!match3IsValidPosition(grid, belowPosition)) continue;

            // Retrive the type of the position below
            let belowType = match3GetPieceType(grid, belowPosition);
            let currentType = match3GetPieceType(grid, position);

            // Keep moving the piece down if position below is valid and empty
            while (match3IsValidPosition(grid, belowPosition) && (belowType === 0 && currentType !== 0)) {
                hasChanged = true;
                match3SwapTypeInGrid(grid, position, belowPosition);
                position = { ...belowPosition };
                belowPosition.row += 1;
                currentType = match3GetPieceType(grid, position);
                belowType = match3GetPieceType(grid, belowPosition);
            }

            if (hasChanged) {
                // Append a new change if position has changed [<from>, <to>]
                changes.push([{ row: r, column: c }, position]);
            }
        }
    }

    return changes;
}

Grid应用重力后的填充算法

应用重力后,我们的grid可能是如下样子的:

ts 复制代码
this.grid = [
    [3, 0, 0, 0],
    [4, 1, 4, 1],
    [3, 1, 1, 2],
    [1, 3, 2, 1],
]

现在我们需要使用随机的类型对空位置进行填充。相比较之前的算法,填充算法算是相对比较简单的。

实现思路

实现思路:使用初始化算法生成一个同等大小的temp grid,遍历原grid,如果当前位置为空,取temp grid相应位置的类型进行填充。

编码实现

ts 复制代码
export function match3FillUp(grid: Match3Grid, types: Match3Type[]) {
    const tempGrid = match3CreateGrid(grid.length, grid[0].length, types);
    
    const rows = grid.length;
    const columns = grid[0].length;
    const emptyPositions: Match3Position[] = [];
    for (let r = 0; r < rows; r++) {
        for (let c = 0; c < columns; c++) {
            if (!grid[r][c]) {
                grid[r][c] = tempGrid[r][c];
                emptyPositions.push({ row: r, column: c });
            }
        }
    }

    return emptyPositions;
}

值得注意的是,我们收集了空位列表emptyPositions,目的和重力应用中收集changes是一致的:为下落动画而服务。

游戏页的核心逻辑

倒计时

上文提到screen中定义的update方法会添加到app.ticker中,也就是说update会被不断地执行,底层使用的是requestAnimationFrame。此处的app.ticker.deltaMS指两帧之间的毫秒间隔。我们只要将其从0不断地累加,在大于给定的duration时停止即可。

ts 复制代码
// GameScreen.ts

/** Update the screen */
public update() {
    // update timer
    this.match3.update(app.ticker.deltaMS);
    // while remaining time < 10s, show countdown flash animation
    this.timer.updateTime(this.match3.timer.getTimeRemaining());
    // while remaining time <= 5, show number scale animation
    this.overtime.updateTime(this.match3.timer.getTimeRemaining());
}

以下是this.match3.update(app.ticker.deltaMS);的实际调用。当游戏页准备就绪,更改running为true,即可开启累加。而剩余的毫秒时间就是 this.duration - this.time,将其格式化后即可展示。

ts 复制代码
// Match3Timer.ts
public update(deltaMs: number) {
    if (!this.running) return;
    this.time += deltaMs;
    if (this.time >= this.duration) {
        this.stop(); // set running to false
        this.match3.onTimesUp?.(); // show 'Game over'
    }
}

public getTimeRemaining() {
    return this.duration - this.time;
}

使用棋子填充面板

上面我们介绍了grid,现在我们来看看如何根据grid绘制棋子。我们把目光焦距在Match3Board.

ts 复制代码
// Match3Board.ts

setup(config: Match3Config) {
    this.rows = config.rows;
    this.columns = config.columns;
    this.tileSize = config.tileSize;

    // The list of blocks (including specials) that will be used in the game
    const blocks = match3GetBlocks(config.mode);      

    this.typesMap = {};

    // Piece types will be defined according to their positions in the string array of blocks
    for (let i = 0; i < blocks.length; i++) {
        const name = blocks[i];
        const type = i + 1; // leave 0 for empty
        this.commonTypes.push(type);
        this.typesMap[type] = name;
    }

    // Create the initial grid state with commonTypes
    this.grid = match3CreateGrid(this.rows, this.columns, this.commonTypes);

    // Fill up the visual board with piece sprites
    match3ForEach(this.grid, (gridPosition: Match3Position, type: Match3Type) => {
        this.createPiece(gridPosition, type);
    });

    console.log("Initial Grid: \n" + match3GridToString(this.grid));
}

首先,根据游戏当前模式获取piece的类型列表,比如当前模式是普通模式,那么得到的列表就是['piece-dragon', 'piece-frog', 'piece-newt', 'piece-snake', 'piece-spider']将其遍历使用各自的索引+1作为自己的类型,最后得到的commonTypes就是:[1,2,3,4,5]。值得注意的是此处我们暂时忽略特殊类型,同时也可以看到,类型数目越多难度越大。

ts 复制代码
const blocks: Record<Match3Mode | 'special', string[]> = {
    /** Easy mode piece set */
    easy: ['piece-dragon', 'piece-frog', 'piece-newt', 'piece-snake'],
    /** Normal mode piece set */
    normal: ['piece-dragon', 'piece-frog', 'piece-newt', 'piece-snake', 'piece-spider'],
    /** Hard mode piece set */
    hard: ['piece-dragon', 'piece-frog', 'piece-newt', 'piece-snake', 'piece-spider', 'piece-yeti'],
    /** Special types that will be added to the game regardless the mode */
    special: [/*'special-row', 'special-column', 'special-blast', 'special-colour'*/],
};

有了commonTypes我们就可以使用上文讲到的Grid初始化算法 计算一个grid(即一个二维数组)。然后遍历grid,在grid的每个位置新建一个piece(一个继承于Container的Match3Piece实例)并将其添加到画布上。实际上,每个piece的本质也是一个pixi的Container容器,里面装了一张图片。同时存有类型,位置等等信息。

ts 复制代码
// Match3Board.ts

public createPiece(position: Match3Position, pieceType: Match3Type) {
    const name = this.typesMap[pieceType];
    const piece = pool.get(Match3Piece);
    // 根据grid位置计算对应的在面板上的位置(px)
    const viewPosition = this.getViewPositionByGridPosition(position);
    // piece.onMove = (from, to) => this.match3.actions.actionMove(from, to);
    piece.setup({
        name,
        type: pieceType,
        size: this.match3.config.tileSize,
        interactive: true,
    });
    piece.row = position.row;
    piece.column = position.column;
    piece.x = viewPosition.x;
    piece.y = viewPosition.y;
    this.pieces.push(piece);
    this.piecesContainer.addChild(piece);
    return piece;
}

移动棋子触发消除

在创建棋子的时候,为每个piece添加了pointerdown, pointermove, pointerup 事件。

  1. 计算鼠标拖动的距离;
  2. 大于10触发拖动;同时根据拖动的水平距离和垂直距离,计算出移动的方向:left/right/top/bottom。如果水平距离的绝对值大于垂直距离的绝对值,说明是左右移动;如果水平距离是负数,说明是向左,否则向右。判断向上/向下也是同理的。
  3. 计算出移动方向后,即可得出目标位置to,进行一次交换。
  4. 计算交换后的grid是否有匹配(即是否至少有一组水平或垂直至少3连同色)。其算法上文已经提及,但一定要注意的是,这里的匹配必须是from或to引起的,其它匹配不能算入。如果没有任何匹配,回退位置。
  5. 开启一轮异步任务队列:处理得分、弹出所有匹配的棋子,应用重力,填充棋盘,检查是否需要开启新的一轮。开启队列前锁住,队列任务执行结束后再进行解锁。

下面具体看看如何编码实现,首先是前4步,以下是核心代码

ts 复制代码
// Match3Piece.ts

/** Interaction mouse/touch down handler */
private onPointerDown = (e: FederatedPointerEvent) => {
    this.pressing = true;
    this.dragging = false;
    this.pressX = e.globalX;
    this.pressY = e.globalY;
};
/** Interaction mouse/touch move handler */
private onPointerMove = (e: FederatedPointerEvent) => {
    if (!this.pressing) return;

    const moveX = e.globalX - this.pressX;
    const moveY = e.globalY - this.pressY;
    const distanceX = Math.abs(moveX);
    const distanceY = Math.abs(moveY);
    const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);

    if (distance > 10) {
        this.dragging = true;
        const from = { row: this.row, column: this.column };
        const to = { row: this.row, column: this.column };

        if (distanceX > distanceY) {
            if (moveX < 0) {
                // Move left
                to.column -= 1;
                this.onMove?.(from, to); // this.match3.actions.actionMove
            } else {
                // Move right
                to.column += 1;
                this.onMove?.(from, to);
            }
        } else {
            if (moveY < 0) {
                // Move up
                to.row -= 1;
                this.onMove?.(from, to);
            } else {
                // Move down
                to.row += 1;
                this.onMove?.(from, to);
            }
        }
        this.onPointerUp();
    }
};
/** Interaction mouse/touch up handler */
private onPointerUp = () => {
    this.dragging = false;
    this.pressing = false;
};

下面实现onMove的核心逻辑:

ts 复制代码
// Find out view positions based on grid positions
const viewPositionA = this.match3.board.getViewPositionByGridPosition(positionA);
const viewPositionB = this.match3.board.getViewPositionByGridPosition(positionB);

// Validate move if it creates any matches
const valid = this.validateMove(positionA, positionB);

if (valid) {
    // If move is valid, swap types in the grid and update view coordinates
    // 1.update grid
    match3SwapTypeInGrid(this.match3.board.grid, positionA, positionB); 
    // 2.update position of two pieces
    pieceA.row = positionB.row;
    pieceA.column = positionB.column;
    pieceB.row = positionA.row;
    pieceB.column = positionA.column;
}

await Promise.all([
    pieceA.animateSwap(viewPositionB.x, viewPositionB.y),
    pieceB.animateSwap(viewPositionA.x, viewPositionA.y),
]);

if (!valid) {
    await Promise.all([
        pieceA.animateSwap(viewPositionA.x, viewPositionA.y),
        pieceB.animateSwap(viewPositionB.x, viewPositionB.y),
    ]);
}

最后就是最为关键的开启异步任务队列。

ts 复制代码
// Match3Process.ts

public async start() {
    if (this.processing || !this.match3.isPlaying()) return;
    this.processing = true; // lock
    this.runProcessRound();
}

private async runProcessRound() {
    this.queue.add(async () => {
        this.updateStats();
    });

    this.queue.add(async () => {
        // TODO 高阶玩法(下篇):处理特殊元素
        // await this.processSpecialMatches();
    });

    this.queue.add(async () => {
        await this.processRegularMatches();
    });

    this.queue.add(async () => {
        this.applyGravity();
    });

    this.queue.add(async () => {
        await this.refillGrid();
    });

    this.queue.add(async () => {
        this.processCheckpoint();
    });
}

private async processCheckpoint() {
    // Check if there are any remaining matches or empty spots
    const newMatches = match3GetMatches(this.match3.board.grid);
    const emptySpaces = match3GetEmptyPositions(this.match3.board.grid);
  
    if (newMatches.length || emptySpaces.length) {
        this.runProcessRound();
    } else {
        this.end();
    }
}
public async end() {
    if (!this.processing) return;
    this.processing = false; // unlock
    this.queue.clear();
    this.match3.onProcessComplete?.();
}

下面给出AsyncQueue的核心逻辑

ts 复制代码
// utils/asyncUtil.ts

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

export class AsyncQueue {
    private readonly queue: AsyncQueueFn[] = [];
    private processing = false;

    /** Check if the queue is processing */
    public isProcessing() {
        return this.processing;
    }

    public async add(fn: AsyncQueueFn, autoStart = true) {
        this.queue.push(fn);
        if (autoStart) await this.process();
    }

    /** Run the execution queue one by one, awaiting each other. */
    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;
    }
}

总结

以上就是本文所有内容。主要讲解了:

  1. 游戏首页的实现,其中应该着重关注每个页面所遵循的开发思路,即constructor到hide期间,各个生命周期的主要职责。
  2. 游戏中的音频管理。主要有Sound和SoundLibrary模式,其中Sound提供了单音频控制,SoundLibrary提供了多音频统筹管理。
  3. 最后就是游戏的基本玩法的实现原理与思路了。游戏本质就是二维数组变化的过程。因此思路上是:初始化grid,根据grid绘制棋盘;为每个棋子添加监听事件,移动后判断是否是有效移动,是,则开启循环队列,否则退回原位。

附:github项目地址

展望

下文会给大家带来游戏的高阶玩法----特殊元素

  • SpcialRow:弹出其所在行的所有方块。
  • SpcialColumn: 弹出其所在列的所有方块。
  • SpcialBlast:弹出指定范围的所有方块。
  • SpcialColor:弹出面板上出现次数最多的同色方块。
相关推荐
恋猫de小郭5 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅11 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606112 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了12 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅12 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅13 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment13 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅13 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端