不会做翻牌子游戏?看这篇就够了

4,915 阅读13分钟

前言

通过本文你能得到什么

  1. 学习到翻牌游戏的核心数据结构和算法,并能够轻松移植到其他平台、框架或引擎
  2. 理解游戏开发的复杂性并学习如何应对和拆解
  3. 高效开发HTML5游戏,同时又能得到高可维护性的代码
  4. 学习到老牌过渡动画库@tweenjs/tween.js的实战经验
  5. 学习到优秀的WebGL渲染引擎PixiJS的实战经验。
  6. 在线演示完整的GitHub代码助力学习效率

游戏规则

我们先通过一个gif动画来了解一下这个游戏:

flipgame.gif

规则比较简单:

  1. 开始游戏时,你有几秒的时间记忆卡面的图案
  2. 之后,所有卡片将翻转到卡背状态
  3. 用户点击卡背进行翻转匹配,完全匹配后游戏胜利

界面渲染库

这个游戏我用了基于WebGLPixiJS作为基础的渲染代码库。PixiJS的优势在于它比较轻量而且快速,用它来创建表现力丰富的游戏和应用是一个还不错的选择。当然,你也可以使用其他的渲染方法或者代码库,甚至你也可以拿DOM来实现渲染。

在线演示和代码

这是项目的在线演示,你可以马上上手玩一玩: wildfirecode.com/flipgame/in…

这是完整的代码的Github仓库,你可以参考一下实现细节: github.com/wildfirecod…

复杂度拆解

为了降低程序的复杂度,我们将从以下四个层次对代码进行拆解:

  1. 数据结构和算法
  2. 视图结构
  3. 用户交互
  4. 过渡动画

数据结构和算法

什么代码属于数据结构和算法范畴呢?数据结构和算法代码应该是可轻松移植的。

如果我们要用3D渲染库three.js把游戏改写成3D的,或者是你想用ReactPixi(基于PixiJSReact Renderer)来改写,那么这些数据和算法代码应该可以不经过修改就能直接使用。

好的数据和算法设计能够使得代码更可读、更可维护。

翻牌子游戏有以下几个数据和算法:

  1. 获取所有卡牌随机的初始卡面纹理
  2. 根据用户点击的坐标获取对应的卡片
  3. 在卡面朝上且未配对的卡片中找出可配对的卡片

下面我们来一一详细描述。

获取所有卡牌随机的初始卡面图案

这里我们使用一维数组来存储这个数据。假如,游戏是3行4列且卡面纹理图案类型有5种,那么最终的数据可能会是这样的结构:

[
 3,0,3,2,
 1,2,0,4,
 4,3,1,3
]

下面有一个生成这个数据算法的简单版本。你也可以做一下优化,使其包含所有的5种卡牌类型。

这里要注意的是:shuffle洗牌方法使用了比较优秀的费舍尔-耶茨洗牌算法

/**
 * 费舍尔-耶茨洗牌方法
 */
export const shuffle = (array: number[]) => {
    const length = array == null ? 0 : array.length
    if (!length) {
        return []
    }
    let index = -1
    const lastIndex = length - 1
    const result = array.concat();
    while (++index < length) {
        const rand = index + Math.floor(Math.random() * (lastIndex - index + 1))
        const value = result[rand]
        result[rand] = result[index]
        result[index] = value
    }
    return result
}

const getNextPairType = (textureTypeNumbers: number) => {
    return Math.floor(Math.random() * textureTypeNumbers)
}
/**
 * 获取所有卡牌初始的卡面纹理
 * @param textureTypeNumber 卡面纹理类型的数量
 * @param cardNumber 卡片总数
 */
export const getRandomTypeList = (textureTypeNumbers: number, cardNumbers: number) => {
    let cardTypeList = [];
    for (let index = 0; index < cardNumbers / 2; index++) {
        const nextType = getNextPairType(textureTypeNumbers);
        cardTypeList.push(nextType, nextType);
    }
    cardTypeList = shuffle(cardTypeList);
    return cardTypeList
}

根据用户点击的坐标获取对应卡片索引

数据层面,这里所有卡片我们希望用一维数组来存储,这会使得卡片索引变得方便。

因为所有卡片会添加到一个PixiJS Conainer当中,刚好Container的children属性能够满足我们的需求,我们就不需要创建额外的数组了。

那么根据用户点击的坐标获取对应卡片索引方法也是比较简单的。我们先根据坐标获取到点击的卡片的行和列,然后再计算出index(索引)。

注意这里有个前提:使用Conainer添加卡片的时候需要从左至右、横向优先。

const getPositionByGlobalPoint = (point: number[], cellWidth: number, cellHeight: number) => {
    const [x, y] = point;
    const col = Math.floor(x / cellWidth);
    const row = Math.floor(y / cellHeight);
    return [col, row]
}

const getIndex = (position: number[], maxCol: number) => {
    const [col, row] = position;
    return col + row * maxCol
}

/**
 * 根据全局的位置获取网格格子的索引
 * @param globalPoint 全局的位置
 */
export const getGridCellIndex = (globalPoint: number[], cellWidth: number, cellHeight: number, maxCol: number) => {
    return getIndex(
        getPositionByGlobalPoint(globalPoint, cellWidth, cellHeight),
        maxCol
    );
}

在卡面朝上且未配对的卡片中找出可配对的卡片

数据层面,卡面朝上且未配对的卡片是个一维数组,我们把它设为userCards: Container[]

我们每次从userCards拿出一个卡片和剩余的卡片进行对比,如果配对成功,那么移除掉配对的卡片并保存到新的数组,直至userCards中没有卡片时结束。

/**
 * 在卡面朝上且未配对的卡片中找出可配对的卡片
 * @param userCards 卡面朝上且未配对的卡片
 * @param match 配对检测方法
 */
export const getMatchedCards = (userCards: any[], match: (card0, card1) => boolean) => {
    const result: any[] = [];
    userCards = userCards.concat();//深拷贝
    while (userCards.length > 0) {
        const card0 = userCards.pop();//如果本轮没有找到,那么说明没有可匹配元素,直接抛弃
        for (let i = 0; i < userCards.length; i++) {
            const card1 = userCards[i];
            if (match(card0, card1)) { //判断是否配对
                userCards.splice(i, 1); //匹配了之后需要移除,并且退出本轮for循环                
                result.push(card0, card1)
                break;
            }
        }
    }
    return result;
}

至此,所有的数据结构和算法都已经设计实现完毕。

值得一提的是,这部分代码,我们没有依赖任何其他任何外部库,所以,他们是方便测试、维护和移植的。

接下来我们来考虑视图结构的设计。

视图结构

对于视图结构的概念,我是从W3C Web标准对于结构的描述中获得的灵感。

我建议先创建静态的用户界面,即先专注于编写不包含交互和动画的代码,这样能够提升我们的开发效率。

创建结构

我们知道,网页的结构是用HTML来描述的,那么同样地,我们也可以把基于PixiJS游戏的结构通过XML来作描述:

export const gameStructure =
    `<Grid x="72.5" cols="{cols}" rows="{rows}" cellWidth="{cellWidth}" cellHeight="{cellHeight}">
        <Card>
            <Back pivotX = "72.5" texture="back"/>
            <Front pivotX = "72.5"/>
        </Card>
    </Grid>`;

详细说明一下这段XML:

  • Card 一张卡片包含两个部分:卡面和卡背。我们用 <Card> 标签描述卡片,用和<Back><Front>来描述卡背和卡面,另外<Card>还包裹了<Back><Front>
  • Back 因为动画需求,我们要通过pivotX属性来设置动画锚点。另外因为卡背的纹理是固定的,所以我们通过texture(纹理图案)属性来设置纹理。
  • Front pivotX属性的设置和Back标签类似。和Back不同的是,卡面是随机的,所以这里没有设置texture属性。
  • Grid 因为卡片(Card)是按照网格来布局的,所以我们用网格(Grid)来描述。网格具有4个属性:cols表示卡片存在多少列、rows表示卡片存在多少行、cellWidth表示卡片或者网格的宽度、cellHeight表示卡片或者网格的高度。

我写了一个简单的xml parser代码库用来把游戏结构的XML转化为显示对象树,最后再添加到舞台(stage)中。这是xml parser的使用代码:

import { parseXML,initParser } from "xml-pixi";
const MAX_COL = 4;
const MAX_ROW = 3;
const CARD_SIZE = [145, 193];
const ASSETS = {
    'back': 'http://wildfirecode.com/objects/flipgame/back.png',
    'frontList': [
        'http://wildfirecode.com/objects/flipgame/lajiao.png',
        'http://wildfirecode.com/objects/flipgame/caomei.png',
        'http://wildfirecode.com/objects/flipgame/xigua.png',
        'http://wildfirecode.com/objects/flipgame/hamigua.png',
        'http://wildfirecode.com/objects/flipgame/boluo.png',
    ]
}
initParser(ASSETS);
const gridView = parseXML(gameStructure,
        { cols: MAX_COL, rows: MAX_ROW, cellWidth: CARD_SIZE[0], cellHeight: CARD_SIZE[1] }); //parseXML返回的是显示对象树
app.stage.addChild(gridView);//将显示对象树添加到stage

至此,我们只用了数行JavaScript代码就完成了游戏结构的创建。

总的来说,XML是描述用户界面的优秀方案,它能够给代码带来很好的可读性和可维护性。

操作结构

有了视图结构,就要考虑结构的操作了。那么,游戏中有哪些结构操作呢?

  1. 初始化所有牌子的卡面图案
  2. 翻转单个牌子到正面或背面
  3. 翻转所有的牌子到正面或背面 接下来,我们将对这些操作一一进行描述。这里需要你对PixiJS的API要有一定的熟悉程度。

初始化所有牌子的卡面图案

const initCardType = (girdView: Container) => {
    const typeList = getRandomTypeList(ASSETS.frontList.length, MAX_COL * MAX_ROW);
    girdView.children.forEach(
        async (cardView: Container, index) => {
            const [back, front] = cardView.children as Sprite[];
            const type = typeList[index];
            front.texture = await Texture.fromURL(ASSETS.frontList[type]);
        }
    );
}

翻转单个牌子到正面或背面

enum FLIP_TYPE {
    FRONT = 'FRONT',
    BACK = 'BACK',
}
const flipCardTo = (type: FLIP_TYPE, cardView: Container) => {
    const toFront = type == FLIP_TYPE.FRONT;
    const [back, front] = cardView.children;
    back.scale.x = 1;
    front.scale.x = 1;
    back.visible = !toFront;
    front.visible = toFront;
}

翻转所有的牌子到正面或背面

const flipAllCardTo = (type: FLIP_TYPE, gridView: Container) => {
    gridView.children.forEach( (cardView: Container) => flipCardTo(type, cardView) )
}

至此所有的视图结构相关的代码已经编写完毕。

总结一下,这时你可以写一些测试代码对结构操作方法进行测试。因为编写代码的过程比较聚焦,所以整体效率是有保障的。

这是一些测试代码。

flipCardTo(FLIP_TYPE.FRONT, gridView.children[0]);//翻转第一张牌子到正面
flipCardTo(FLIP_TYPE.BACK, gridView.children[0]);//翻转第一张牌子到背面
flipAllCardTo(FLIP_TYPE.BACK,gridView)//翻转所有到背面
flipAllCardTo(FLIP_TYPE.FRONT,gridView)//翻转所有到正面

接下来,我们要开始聚焦整个程序的第三个部分:用户交互。

用户交互

好的交互设计可以带来流畅的用户体验。一般来说,单点是比较推荐,这是游戏的交互方式。

如果没有交互,那游戏就变成了”动画片“了,用户也没有什么参与感。 但是交互也会给程序带来一定的复杂度,因为你需要考虑到所有的交互场景,而且还要添加额外的程序状态来存储用户交互状态。

让我们一步一步来。

添加点击交互

我们先添加点击后翻转以及短暂延迟后翻转回退的交互。

const wait = (ms) => {
    return new Promise(resolve => setTimeout(resolve, ms));
};
const onUserClick = async (e: InteractionEvent) => {
    const gridView = e.target as Container;
    const { global } = e.data;
    const index = getGridCellIndex([global.x, global.y], CARD_SIZE[0], CARD_SIZE[1], MAX_COL);
    const clickedCard = gridView.children[index] as Sprite;
    flipCardTo(FLIP_TYPE.FRONT, clickedCard);
    await wait(500)
    flipCardTo(FLIP_TYPE.BACK, clickedCard);
    
const addUserInteraction = (gridView: Container) => {
    gridView.interactive = true;
    gridView.on('pointerdown', onUserClick);
}
addUserInteraction();

值得一提的是,至此,我们除了gridView变量都没有创建额外的应用状态。

这是好事情。维护过多的状态,会让我们的代码变得复杂、消耗我们额外的注意力。

但是,现在我们不得不要为程序添加其他状态了。

添加用户交互状态

需要添加的用户交互相关的状态有两个:

  1. 用户点击操作之后,当前卡面朝上且待配对的元素列表userCards。这里的关键是如果没有配对,回退翻转的时候需要从userCards里移除。换个角度说,如果没有卡片需要回退翻转,那么userCards应该是空数组。另外,userCards还有一个作用:在未完成回退翻转前,userCards内的卡片不能参与交互处理。
  2. 已经配对的元素列表matchedCards。我们用一维数组来表示,类型是Sprite。 它的作用有两个:一是判断游戏是否胜利,二是已经配对的元素不能再参与交互处理。 用户每次进行点击交互都要计算更新这两个状态。配对的元素要从userCards移除,然后加入matchedCards

让我们来更新一下onUserClick方法。

function includeCard(card, cardList) { return cardList.indexOf(card) != -1 }

function removeCard(card, cardList) {
    const index = cardList.indexOf(card);
    cardList.splice(index, 1)
}

let matchedCards: Container[] = [];
const userCards: Container[] = [];
const onUserClick = async (e: InteractionEvent) => {
    const gridView = e.target as Container;
    const { global } = e.data;
    const index = getGridCellIndex([global.x, global.y], CARD_SIZE[0], CARD_SIZE[1], MAX_COL);
    const clickedCard = gridView.children[index] as Sprite;//获取到点击位置对应的卡片

    if (includeCard(clickedCard, matchedCards)//已经配对的元素不能再参与交互处理
        || includeCard(clickedCard, userCards)) //在未完成回退翻转前,userCards内的卡片不能参与交互处理。
        return;

    userCards.push(clickedCard);//立刻存储,在之后的1秒内等待配对

    const currentMatchedCards = getMatchedCards(userCards, match);//在卡面朝上且未配对的卡片中找出可配对的卡片,每次点击都要处理

    matchedCards = matchedCards.concat(currentMatchedCards);//立刻存储配对卡片,currentMatchedCards可能为空

    currentMatchedCards.forEach(matchedCard => removeCard(matchedCard, userCards));//匹配的元素立刻从userCards移除

    flipCardTo(FLIP_TYPE.FRONT, clickedCard);//立刻翻转,没有动画

    if (isSuccess(matchedCards, gridView.children)) {
        wait(1000).then(() => alert('胜利了'));
    } else {
        await wait(1000);//这里的wait没有做中断处理,但是会让代码的可读性有一些提升
        if (!includeCard(clickedCard, matchedCards)) { //如果没有配对,就需要回退翻转,并且恢复交互
            flipCardTo(FLIP_TYPE.BACK, clickedCard);
            removeCard(clickedCard, userCards);
        }
    }
}

小心处理异步代码

值得一提的是,等待回退翻转的1000毫秒,属于异步执行代码。这里通过await语法使其看起来像同步执行代码,这会提升一定的代码可读性,让代码更容易理解。

做个总结,至此,整个游戏已经具备完整的可玩性了。

flipgame2.gif

接下来,我们给游戏添加一些过渡动画,来使得用户体验更加地流畅。

过渡动画

游戏中的过渡动画有以下2个:

  1. 翻转单个卡片到正面或背面
  2. 翻转所有的卡片到正面或背面

使用代码库@tweenjs/tween.js

这里的过渡代码库用的是@tweenjs/tween.js ,一个比较老牌的库。

下面是动画的实现:

import * as TWEEN from "@tweenjs/tween.js";

// Setup the animation loop.
function animate(time) {
    requestAnimationFrame(animate)
    TWEEN.update(time)
}
requestAnimationFrame(animate)

/** 翻转所有的卡片到正面或背面 */
export const playFipAllAnimation = (type: FLIP_TYPE, gridView: Container) => {
    return Promise.all(
        gridView.children.map(
            (child: Container) => playFlipAnimation(type, child))
    )
}
/** 翻转单个卡片到正面或背面 */
export const playFlipAnimation = (type: FLIP_TYPE, cardView: Container) => {
    return new Promise((resolve) => {
        const toFront = type == FLIP_TYPE.FRONT;
        const [back, front] = cardView.children;

        const DURATION = 300;

        back.visible = front.visible = true;
        back.scale.x = front.scale.x = 0;

        const callback = () => {
            back.visible = !toFront;
            front.visible = toFront;
            resolve(cardView);
        }

        const tweenBack = new TWEEN.Tween(back.scale);
        const tweenFront = new TWEEN.Tween(front.scale);
        if (toFront) {
            back.scale.x = 1;
            tweenBack.to({ x: 0 }, DURATION).start().onComplete(() => {
                tweenFront.to({ x: 1 }, DURATION).start().onComplete(callback);
            });
        } else {
            front.scale.x = 1;
            tweenFront.to({ x: 0 }, DURATION).start().onComplete(() => {
                tweenBack.to({ x: 1 }, DURATION).start().onComplete(callback);
            });
        }

    })
}

加入过渡动画并小心处理

加入动画看起来应该不难。好像用playFlipAnimation方法直接替换掉onUserClick中的flipCardTo方法就行了。

但是事情可能没那么简单:动画会带来额外的复杂度。这里要考虑在动画播放的过程中,如果和卡片进行交互,是否可能出现异常。这里动画有两种情况:

  1. 点击卡片后翻转到正面的动画。这里保证动画过程中,目标卡片不可被点击即可。当前的代码是支持这种交互禁止场景的。要考虑的就是延迟回退翻转的时间大于动画时间即可。
  2. 在第一种情况的基础上,如果没有配对,卡片会播放回退翻转的动画。此时,动画播放过程中,需要禁用目标卡片的交互,而且还不能和其他卡片进行匹配。当前的代码无法支持这种要求,所以我们需要填加一个额外的状态变量lockedCards。
const lockedCards: Container[] = [];

const onUserClick = async (e: InteractionEvent) => {
    ...
    
    if (includeCard(clickedCard, matchedCards)//已经配对的元素不能再参与交互处理
        || includeCard(clickedCard, userCards) //在未完成回退翻转前,userCards内的卡片不能参与交互处理。
        || includeCard(clickedCard, lockedCards)) //回退动画播放过程中,需要禁用目标卡片的交互,而且还不能和其他卡片进行匹配。
        return;

    ...

    playFlipAnimation(FLIP_TYPE.FRONT, clickedCard);//动画翻转。要考虑的就是延迟回退翻转的时间大于动画时间即可。

    if (isSuccess(matchedCards, gridView.children)) {
        wait(1000).then(() => alert('胜利了'));
    } else {
        await wait(1000);//这里的wait没有做中断处理,但是会让代码的可读性有一些提升
        if (!includeCard(clickedCard, matchedCards)) { //如果没有配对,就需要回退翻转,并且恢复交互
            playFlipAnimation(FLIP_TYPE.BACK, clickedCard).then(()=>{
                removeCard(clickedCard,lockedCards)
            });
            removeCard(clickedCard, userCards);
            lockedCards.push(clickedCard);
        }
    }
}

回顾

至此本文的主要内容已经告一段落了。下面我们通过增加游戏两个游戏需求来回顾并加强一下上文所说四个层次的概念。新增需求如下:

  1. 增加一个游戏道具,使用后可以自动高亮一组未配对的卡片。
  2. 增加另一个游戏道具,使用后可以自动翻转一组未配对的卡片。

数据结构和算法

我们先考虑数据结构和算法层面。这里需要增加一个算法,即找出所有未配对的卡片。这应该不难:从所有卡片中剔除matchedCardsuserCardslockedCards即可。

function includeCard(card, cardList) { return cardList.indexOf(card) != -1 }
export const getUnmatchedCard = (allCard: any[], matchedCards: any[], userCards: any[], lockedCards: any[]) => {
    return allCard.filter(
        card =>
            !includeCard(card, matchedCards) &&
            !includeCard(card, userCards) &&
            !includeCard(card, lockedCards)
    )
}

然后再结合已经写好的getMatchedCards方法就能找出需要高亮或者自动翻转的卡片。

视图结构

我们再考虑视图结构层面。这里需要增加高亮和清除高亮的方法。PixiJS官方收集了一些社区开发的滤镜,其中就包含发光滤镜(Glow Filter)。

import { GlowFilter } from "@pixi/filter-glow"
export const highlight = (card) => {
    card.filters = [new GlowFilter({ distance: 30, outerStrength: 2, color: 0x00ff00 })]
}
export const clearHighlight = (card) => {
    card.filters = []
}

用法如下:

export const highlightCardPairs = () => {
    const cardList = getUnmatchedCard(gridView.children, matchedCards, userCards, lockedCards);
    const matched = getMatchedCards(cardList, match);
    if (matched.length > 0) {
        const [card0, card1] = matched;
        highlight(card0);
        highlight(card1);
    }
}

用户交互

新需求里要求我们必须要入侵用户的交互数据,不过也不复杂,将配对卡片加入matchedCards数组即可。

export const autoFlip = () => {
    const cardList = getUnmatchedCard(gridView.children, matchedCards, userCards, lockedCards);
    const matched = getMatchedCards(cardList, match);
    if (matched.length > 0) {
        const [card0, card1] = matched;
        matchedCards.push(card0, card1);
        clearHighlight(card0);
        clearHighlight(card1);
        playFlipAnimation(FLIP_TYPE.FRONT, card0);//立刻翻转
        playFlipAnimation(FLIP_TYPE.FRONT, card1);//立刻翻转
    }
    if (isSuccess(matchedCards, gridView.children)) {
        wait(1000).then(() => alert('胜利了'));
    }
}

动画交互

这个需求里没有新的动画交互,所以就不考虑了。

ok最后我们看下运行效果:

flipgame3.gif

最后

至此,整篇文章就结束了。如果对你有帮助,希望能给个👍评论收藏三连!

欢迎关注互相交流,有问题可以评论留言,我会及时进行回复。