H5 斗地主游戏后端教程(二)

2019/02/26

在这一节,将会给介绍算法模块的实现,即如何构造和分配牌、如何对牌的类型进行判断和比较。

1. 分配牌

我们将分配牌分成三个步骤,分别如下图所示:

构造牌

众所众知,斗地主规则的牌数为54张牌,其中1 ~ 2各四张,大小王两张。因此我们需要循环54构造出Card对象:

for (int i = 0; i < 54; i++) {
    int id = i + 1;
    Card card = new Card(id);
    card.setType(ConstructCard.getTypeById(id));      // 设置花色(type)
    card.setNumber(ConstructCard.getNumberById(id));  // 设置牌的数值(number)
    card.setGrade(ConstructCard.getGradeById(id));    // 设置牌的等级(grade)
    allCardList.add(card);                            // 添加到用于存储所有牌的列表
}

并且根据id分配不同的花色、数值和等级,具体的ConstructCard逻辑可以看ConstructCard.java。这一步比较自定义,也可以自己实现,就不详细讲解了。

洗牌

洗牌的逻辑非常简单,我们调用List接口的shuffle方法对所有牌列表进行乱序排序。为了保证牌的无序性,我们执行该方法1000次:

private void shuffle() {
    for (int i = 0; i < 1000; i++) {
        Collections.shuffle(allCardList);
    }
}

分牌

我们根据前面存放的allList,将所有牌分成三份给三个玩家,并留三张底牌存入topCards,等待分配给地主:

private void deal() {
    /* 分派给1号玩家17张牌 */
    for (int i = 0; i < 17; i++) {
        Card card = allCardList.get(i * 3);
        player1Cards.add(card);
    }
    /* 分派给2号玩家17张牌 */
    for (int i = 0; i < 17; i++) {
        Card card = allCardList.get(i * 3 + 1);
        player2Cards.add(card);
    }
    /* 分派给3号玩家17张牌 */
    for (int i = 0; i < 17; i++) {
        Card card = allCardList.get(i * 3 + 2);
        player3Cards.add(card);
    }
    /* 将剩余的三张牌添加到地主的牌当中 */
    topCards = allCardList.subList(51, 54);
    /* 将玩家的牌通过等级由小到大的排序 */
    Collections.sort(player1Cards);
    Collections.sort(player2Cards);
    Collections.sort(player3Cards);
}

这里涉及到牌的排序问题,熟悉集合特性的大伙儿都知道:通过sort()方法进行排序,底层会调用compareTo方法来比较两个对象的排序大小,因此我们需要实现Comparable接口,并重写Card对象的该方法,让列表根据grade等级进行排序。

这里我实现的排序方式是倒序,因为在玩家客户端展示的时候,牌会从左向右依次从小到大排序(根据通常情况下人的抓牌习惯),因此需要倒序排序:

public class Card implements Comparable<Card> {
    
    @Override
    public int compareTo(Card o) {
        return - Integer.compare(this.getGradeValue(), o.getGradeValue());
    }
}

除此之外,我们还提供了一个getCards方法,可以根据playerId来获取玩家分配到的牌:

public List<Card> getCards(int number) {
    switch (number) {
        case 1:
            return player1Cards;
        case 2:
            return player2Cards;
        case 3:
            return player3Cards;
    }
    return null;
}

2. 类型判断

熟悉斗地主规则的大伙儿都知道,斗地主中所有出牌的类型有:单张、对子、三张、三带一、三带一对、四带二、顺子、连对、飞机、飞机带翅膀、炸弹、王炸。我们为这些类型创建一个枚举类TypeEnum

public enum TypeEnum {
    
    SINGLE("单张"),
    PAIR("对子"),
    THREE("三张"),
    THREE_WITH_ONE("三带一"),
    THREE_WITH_PAIR("三带一对"),
    FOUR_WITH_TWO("四带二");
    STRAIGHT("顺子"),
    STRAIGHT_PAIR("连对"),
    AIRCRAFT_WITH_WINGS("飞机带翅膀"),
    AIRCRAFT("飞机"),
    BOMB("炸弹"),
    JOKER_BOMB("王炸"),
    
}

首先我们找出类型比较相似的出牌类似,如:对子、三张、炸弹。因为它们有一个共同的特性:所有的牌的等级(或者数值)都是相同的。唯一不同的就是张数不同,我们先定义一个公共函数,用于判断牌序列中所有的牌是否等级相同,以及用于判空的函数:

private static boolean isAllGradeEqual(List<Card> cards) {
    Card first = cards.get(0);
    for (int i = 1; i < cards.size(); i++) {
        if (!first.equalsByGrade(cards.get(i))) {
            return false;
        }
    }
    return true;
}

private static boolean isEmpty(List<Card> cards) {
    if (cards == null) return true;
    return cards.size() == 0;
}

单张

只需满足牌序列张数为1即可:

public static boolean isSingle(List<Card> cards) {
    if (isEmpty(cards)) return false;
    return cards.size() == 1;
}

对子/三张/炸弹

三个类型满足所有牌等级相等,而对子、三张、炸弹分别还需要满足牌序列张数为:234

public static boolean isPair(List<Card> cards) {
    if (isEmpty(cards) || cards.size() != 2) return false;
    return isAllGradeEqual(cards);
}

public static boolean isThree(List<Card> cards) {
    if (isEmpty(cards) || cards.size() != 3) return false;
    return isAllGradeEqual(cards);
}

public static boolean isBomb(List<Card> cards) {
    if (isEmpty(cards) || cards.size() != 4) return false;
    return isAllGradeEqual(cards);
}

三带一

三单一有两种情况,一种是带的牌较小,只需要保证:第一张牌与第二张牌、第三章牌相等。如下图所示:

TIM截图20190225123811.png

另一种情况是带的牌较大,只需要保证:第二张牌与第三章牌、第四张牌相等。如下图所示:

TIM截图20190225124042.png

判断的算法逻辑为:

public static boolean isThreeWithOne(List<Card> cards) {
    if (isEmpty(cards) || cards.size() != 4) return false;
    // 防止该算法将炸弹判定为三带一
    if (isAllGradeEqual(cards)) return false;

    cards.sort(new CardSortComparable());
    /* 是3带1,并且被带的牌在牌头 */
    if (cards.get(0).equalsByGrade(cards.get(1)) && cards.get(0).equalsByGrade(cards.get(2)))
        return true;
    /* 是3带1,并且被带的牌在牌尾 */
    else if (cards.get(3).equalsByGrade(cards.get(1)) && cards.get(3).equalsByGrade(cards.get(2)))
        return true;
    else
        return false;
}

三带一对

三带一对同样也有两种情况,一种也是被带的牌(对子)较小,在牌的末尾。这种情况下满足第一张牌等级与第二张牌和第三张牌等级相等,并且,第四张牌与第五张牌等级相同。如下图所示:

TIM截图20190225124628.png

第二种情况是被带的牌(对子)较大,在牌的首部。这种情况下满足第三章牌的等级与第四张牌和第五张牌等级相同,并且第一张牌与第二张牌等级相同。如下图所示:

TIM截图20190225124601.png

判断的算法逻辑为:

public static boolean isThreeWithPair(List<Card> cards) {
    if (isEmpty(cards) || cards.size() != 5) return false;
    cards.sort(new CardSortComparable());

    if (cards.get(0).equalsByGrade(cards.get(1)) && cards.get(0).equalsByGrade(cards.get(2)))
        return cards.get(3).equalsByGrade(cards.get(4));
    else if (cards.get(2).equalsByGrade(cards.get(3)) && cards.get(2).equalsByGrade(cards.get(4))
             && cards.get(3).equalsByGrade(cards.get(4)))
        return cards.get(0).equalsByGrade(cards.get(1));
    else
        return false;
}

四带二

判断四带二我们可以从0遍历到2,每次遍历都与后四张牌进行比较,如果这四张牌等级相同,那么即为四带二(因为可以忽略带的是两张单牌还是对子的情况):

public static boolean isFourWithTwo(List<Card> cards)  {
    if (isEmpty(cards) || cards.size() != 6) return false;
    cards.sort(new CardSortComparable());
    for (int i = 0; i < 3; i++) {
        int grade1 = cards.get(i).getGradeValue();
        int grade2 = cards.get(i + 1).getGradeValue();
        int grade3 = cards.get(i + 2).getGradeValue();
        int grade4 = cards.get(i + 3).getGradeValue();

        if (grade2 == grade1 && grade3 == grade1 && grade4 == grade1) {
            return true;
        }
    }
    return false;
}

顺子

顺子的判断并不难,需要注意斗地主出顺子的约束有:顺子最少为5张,并且顺子中最大的牌只能是A。然后从0遍历到cards.size - 1(因为最后一张牌不用作比较),每次遍历都判断与下一张牌等级相比是否为递增:

public static boolean isStraight(List<Card> cards) {
    if (isEmpty(cards) || cards.size() < 5) {  // 顺子不能小于5个
        return false;
    }
    cards.sort(new CardSortComparable());
    Card last = cards.get(cards.size() - 1);
    // 顺子最大的数只能是A
    if (CardGradeEnum.getIllegalGradeOfStraight().contains(last.getGrade())) { 
        return false;
    }
    /* 判断卡片数组是不是递增的,如果是递增的,说明是顺子 */
    for (int i = 0; i < cards.size() - 1; i++) {
        /* 将每一张牌和它的后一张牌对比,是否相差1 */
        Card cur = cards.get(i);
        Card next = cards.get(i + 1);
        if ((cur.getGradeValue() + 1) != next.getGradeValue()) {
            return false;
        }
    }
    return true;
}

连对

连对的判断与顺子类似,但是连对(连续的对子)必须大于或等于3个,也就是不能小于6张牌,并且最大的连对数只能为A。判断的主要逻辑为,遍历牌序列,判断第i张牌是否与下一张牌相等,且是否与下一张牌的下一张牌等级递增1:

public static boolean isStraightPair(List<Card> cards) {
    /* 连对的牌必须满足大于6张牌,而且必须是双数 */
    if (isEmpty(cards) || cards.size() < 6 || cards.size() % 2 != 0) {
        return false;
    }
    cards.sort(new CardSortComparable());
    Card last = cards.get(cards.size() - 1);
    // 连对最大的数只能是A
    if (CardGradeEnum.getIllegalGradeOfStraight().contains(last.getGrade())) { 
        return false;
    }
    for (int i = 0; i < cards.size(); i += 2) {
        Card current = cards.get(i);   // 当前牌
        Card next = cards.get(i + 1);  // 下一张牌
        if (i == cards.size() - 2) { // 判断牌尾的两张牌是否相等
            if (!current.equalsByGrade(next)) {
                return false;
            }
            break;  // 跳出循环,因为不需要和下一个连对数进行比较
        }
        Card nextNext = cards.get(i + 2);  // 下一张牌的下一张牌
        if (!current.equalsByGrade(next)) { // 判断是否和下一牌的牌数相同
            return false;
        }
        /* 判断当前的连对数是否和下一个连对数递增1,如果是,则满足连对的出牌规则 */
        if ((current.getGradeValue() + 1) != nextNext.getGradeValue()) {
            return false;
        }
    }
    return true;
}

王炸

因为大小王等级和数值都不相同,因此我们只能通过相加牌的等级数来判断是否是王炸

private static int JOKER_BOMB_NUMBER_SUM = CardNumberEnum.BIG_JOKER.getValue() + CardNumberEnum.SMALL_JOKER.getValue();

public static boolean isJokerBomb(List<Card> cards) {
    if (isEmpty(cards) || cards.size() != 2) return false;
    int numberSum = 0;
    for (Card card : cards) {
        numberSum += card.getNumberValue();
    }
    return numberSum == JOKER_BOMB_NUMBER_SUM;
}

实现判断各个类型的方法之后,我们可以提供一个方法,传入一个牌序列并返回该序列的出牌类型。尽量将常出牌类型判断放在前面,提高命中率:

public static TypeEnum getCardType(List<Card> cards) {
    TypeEnum type = null;
    if (cards != null && cards.size() != 0) {
        if (TypeJudgement.isSingle(cards)) {
            type = TypeEnum.SINGLE;
        } else if (TypeJudgement.isPair(cards)) {
            type = TypeEnum.PAIR;
        } else if (TypeJudgement.isThree(cards)) {
            type = TypeEnum.THREE;
        } else if (TypeJudgement.isThreeWithOne(cards)) {
            type = TypeEnum.THREE_WITH_ONE;
        } else if (TypeJudgement.isThreeWithPair(cards)) {
            type = TypeEnum.THREE_WITH_PAIR;
        } else if (TypeJudgement.isStraight(cards)) {
            type = TypeEnum.STRAIGHT;
        } else if (TypeJudgement.isStraightPair(cards)) {
            type = TypeEnum.STRAIGHT_PAIR;
        } else if (TypeJudgement.isFourWithTwo(cards)) {
            type = TypeEnum.FOUR_WITH_TWO;
        } else if (TypeJudgement.isBomb(cards)) {
            type = TypeEnum.BOMB;
        } else if (TypeJudgement.isJokerBomb(cards)) {
            type = TypeEnum.JOKER_BOMB;
        } else if (TypeJudgement.isAircraft(cards)) {
            type = TypeEnum.AIRCRAFT;
        } else if (TypeJudgement.isAircraftWithWing(cards)) {
            type = TypeEnum.AIRCRAFT_WITH_WINGS;
        }
    }
    return type;
}

3. 大小比较

前面我们实现了牌序列的类型判断,因此在已知两个牌序列的类型情况下,它们的大小比较将比较容易。比较特殊的是单张、对子、三张、炸弹等情况,都是只需要判断第一张牌大小即可。其他的出牌类型大小比较也不难:

  • 三带一:比较第二张牌即可;
  • 四带二:比较第三张牌即可;
  • 顺子、连对:比较最后一张牌即可;

该方法的代码有点长就不贴了,详细的逻辑可以看GradeComparison.java 中的方法。