用Java Swing实现Freecell(空当接龙)

时间:2022-05-02 03:24:00

 目录

引言

1 游戏规则

2 界面设计和大致逻辑

  2.1 界面设计

  2.2 大致逻辑

3 主要功能模块设计与实现

  3.1 主要思路
  3.2 主要工具类
  3.3 异常类
  3.4 游戏初始化模块
  3.5 头像左右变化模块
  3.6 自动移动到“回收单元”模块
  3.7 自动移动到“临时单元”模块
  3.8 拿牌模块
  3.9 放牌模块
  3.10 赢判断模块
  3.11 庆祝界面

---------------------------------------------------------------------------------------------------

引言

  实现了Freecell的大部分功能,包括最后很经典的掉牌的画面,但比较遗憾的是没有找到实现Windows中Freecell的发牌实现算法。我使用的随机洗牌然后发牌,有可能生成的是无解的死局,而Windows 中经典Freecell生成的所有牌局都能解开。

 

下文一些名词的说明:

  1. “临时单元”:左上角的四个单元,移牌时可以在其中临时放牌;

  2. “回收单元”:右上角的四个单元,在其中构建获胜所需的牌叠。

 

1 游戏规则

  空当接龙仅使用一副牌玩,共 52 张,牌的正面朝上,排成八列。 

  从每列排的底部拖动牌并按如下方式移动它们:

    1. 从列到临时单元。 每个临时单元一次只能放置一张牌;

    2. 从列到列(或从临时单元列)。在列中,卡片必须按降序(K到A)排放,红色和黑色交替出现;

    3. 从列到回收单元。每叠牌必须是相同的颜色,并升序(A到K)排放。

2 界面设计和大致逻辑

2.1 界面设计

  界面主要由“菜单栏”、“头像区域”和“玩牌区域”三个逻辑不同的区域组成,界面尽量模仿Freecell 的经典界面:

用Java Swing实现Freecell(空当接龙)

1.菜单栏

  (1)“Restart”,重新开始用户正在玩的这局,不生成新的牌局。方便用户多次挑战同一局。

  (2)“Renew”,重新生成一副牌,开始新的一局。

  (3)“Show Victory”,启动庆祝界面。方便开发者展示游戏功能时用。

  (4)“Exit”,退出游戏。

2.头像区域

  头像会随着用户鼠标的移动,而向左或向右“看”。

3.玩牌区域

  分为左上角的“临时单元”,右上角的“回收单元”和游戏界面下半部分的“列”部分。

 

  界面上所有的位置距离,都使用静态变量来表示。

  相关代码:

 1 public static final int WINDOW_WEITHT = 770;
 2 public static final int WINDOW_HEIGHT = 650; 3 public static final String WINDOW_TITLE = "FREECELL"; 4 public static final int COR_INTERVAL_1 = 21; 5 public static final int COR_INTERVAL_2 = 8; 6 public static final int COR_INTERVAL_3 = 30; 7 public static final int COR_INTERVAL_4 = 35; 8 public static final int COR_INTERVAL_5 = 15; 9 public static final int COR_INTERVAL_6 = COR_INTERVAL_1 10 + (Card.CARD_WIGHT * 4) 11 + (COR_INTERVAL_2 * 3) 12 + HeadPic.HEAD_PIC_SIZE 13 + COR_INTERVAL_3 * 2; 14 public static final int COR_INTERVAL_7 = COR_INTERVAL_4 15 + (Card.CARD_WIGHT * 8) 16 + (COR_INTERVAL_5 * 7); 17 public static final int COR_INTERVAL_8 = COR_INTERVAL_1 18 + (Card.CARD_WIGHT * 4) 19 + COR_INTERVAL_2 * 3 20 + COR_INTERVAL_3; 21 public static final int ROW_INTERVAL_1 = 17; 22 public static final int ROW_INTERVAL_2 = 40; 23 public static final int ROW_INTERVAL_3 = 135; 24 public static final int ROW_INTERVAL_4 = 28; 25 public static final int CHOOSED_TRANSLATION = 2; 26 public static final int HOVER_TRANSLATION = 1; 27 public static final int ROUND_ARC = 5; 28 public static final Color BG_COLOR = new Color(1, 127, 1);

  各静态变量在界面中的具体含义:

用Java Swing实现Freecell(空当接龙) 

 

2.2 大致逻辑

用Java Swing实现Freecell(空当接龙)

 

3 主要功能模块设计与实现

3.1 主要思路

  用不同的类分别模拟“牌”、“扑克”(包含52张牌)、“临时单元”、“回收单元”、“头像”和“列”等。

  用GameLook类实现游戏的界面。监听用户鼠标对游戏面板的点击并适时重绘界面,让用户的操作实时更新在界面上。

  Logic类实现了游戏的主要逻辑。用工具类将用户的鼠标点击位置的x、y值转换为在游戏中的逻辑位置,并将位置传入Logic类。在Logic类中,根据现有情况判断出用户的操作意图,并实现用户的操作。

  程序结构图:

 用Java Swing实现Freecell(空当接龙)

3.2 主要工具类

  我将游戏中多次会用到的一些东西,都提取出来,封装成工具类。让后续代码实现中各部分的逻辑关系更加简洁明了。

3.2.1 Position类

  因为游戏中需要多次用到对“位置”的表示,所以我将牌的位置表示提炼出来做成了一个单独的Position类。Position类主要包含了对各种位置的计算及表达方式。

用Java Swing实现Freecell(空当接龙)

  Position类中主要封装了三个属性:area、order和cardOrder。area属性用不同的数字来代表不同的区域(0代表空白区域,1代表临时单元,2代表回收单元,3代表列);order代表该位置在该类区域的序号;区域如果是“列”,则再用cardOrder属性标明目标牌是目标列的第几张牌。

  因为游戏中每一个区域相对面板最左上角的位置都是固定的,所以可以通过对目标点相对面板最左上角的x、y值,计算出目标点在游戏中的哪一区域中。如果目标点的区域是的是列的话,再由每一张牌之间的相对位置计算出是目标点在该列的哪一张牌上。

  对于目标点位置的解析,是空当接龙游戏代码实现中最精华的一个部分。将用户对界面的操作,转化为对各种逻辑对象的操作。

  以下是对目标点位置解析的关键代码:

public static Position calculatePosition(int x, int y) {
    Position position = new Position(BLANK, -1, -1);
    Rectangle rect;
    if (y < GameLook.ROW_INTERVAL_3) {
        if (x < GameLook.COR_INTERVAL_6) {
            for (int i = 0; i < Logic.TEMP_AREA_COUNT; i++) {
                int rectX = GameLook.COR_INTERVAL_1
                        + (Card.CARD_WIGHT + GameLook.COR_INTERVAL_2) * i;
                rect = new Rectangle(rectX, GameLook.ROW_INTERVAL_1,
                        Card.CARD_WIGHT, Card.CARD_HEIGHT);
                if (rect.contains(x, y)) {
                    position = new Position(Position.TEMP_AREA, i, -1);
                    break;
                }
            }
        } else {
            // 此处判断recycle area中position的代码
            // 和上述判断temp area中position的代码类似,略
        }
    } else {
        if (x >= GameLook.COR_INTERVAL_4 && x <= GameLook.COR_INTERVAL_7) {
            int colOrder = (x - GameLook.COR_INTERVAL_4)
                    / (Card.CARD_WIGHT + GameLook.COR_INTERVAL_5);

            for (int i = 0; i < (Game.logic.getColList()[colOrder].size() - 1); i++) { // 1到倒数第二张牌
                int rectX = GameLook.COR_INTERVAL_4 
                        + colOrder * (Card.CARD_WIGHT + GameLook.COR_INTERVAL_5);
                int rectY = GameLook.ROW_INTERVAL_3
                        + (i * Column.currentCardInterval);
                rect = new Rectangle(rectX, rectY, Card.CARD_WIGHT,    GameLook.ROW_INTERVAL_4);
                if (rect.contains(x, y)) {
                    position = new Position(Position.COL, colOrder, i);
                    break;
                }
            }
            
            if (position.getArea() == BLANK) { // 最后一张牌            
                int rectX = GameLook.COR_INTERVAL_4
                        + colOrder * (Card.CARD_WIGHT + GameLook.COR_INTERVAL_5);
                int rectY = GameLook.ROW_INTERVAL_3
                        + onnegativeInt(Game.logic.getColList()[colOrder].size() - 1) * GameLook.ROW_INTERVAL_4;
                rect = new Rectangle(rectX, rectY, Card.CARD_WIGHT,    Card.CARD_HEIGHT);
                if (rect.contains(x, y)) {
                    position = new Position(Position.COL, colOrder, (Game.logic.getColList()[colOrder].size() - 1));
                }
            }
        }
    } 
    return position;
}

3.2.2 OperateCards类

  用OperateCards类来表示正在被用户操作的牌组。

用Java Swing实现Freecell(空当接龙)

  当用户每次拿起牌或牌组时,完成以下操作:1.将拿起的牌或牌组放入OperateCards对象中;2.记录拿起该牌或牌组的位置;3.将拿起牌或牌组设置为选中状态。

  当每次用户放下牌或牌组时,完成以下操作:1.清空OperateCards对象;2.将记录牌或牌组拿起位置的属性opCardFromPosition重置;3.将牌或牌组的设置为未选中状态。

  以上操作都封装在OperateCards类的相应方法中。

  以下是用户拿牌时,OperateCards类中添加牌组的关键代码:

public void add(Card card, Position ps) {
    this.cardList.add(card);
    this.opCardsFromPosition = ps;
    
    this.setCardListChoose(true);
}

public void add(ArrayList<Card> cardList, Position ps) {
    this.cardList.addAll(cardList);
    this.opCardsFromPosition = ps;

    this.setCardListChoose(true);
}

  以下是用户放牌时,OperateCards类中相应方法的关键代码:

public void removeAll()    {
    if (!isEmpty()) {
        setCardListChoose(false);
        
        this.cardList = new ArrayList<Card>();
        this.opCardsFromPosition = null;
    }
}

 3.3 异常类

  游戏中当提取某个对象的时候,常遇到提取对象有可能为空的情况。比如提取某列的最后一张牌时,如下列代码所示:

private boolean canRecycle(Card judgeCard, boolean forceRecycle) {
    Card recAreaLastCard = recycleAreaList[judgeCard.getColor()].getLastCard();

    //
}

  此时从回收单元提取的recAreaLastCard对象有可能为null,如果对该对象进行后续处理的话,程序会报错。

  当然可以不使用异常类,只在每次要调用方法对牌组进行操作的时候检查要操作的牌组是否为空。但是我在具体代码实现中,会发现,因为要对牌组进行操作的时候非常多,很多时候很有可能会忘记对null进行处理,导致程序老是莫名其妙的出错。

  所以我在设计对每个牌组要进行的操作时,就对有可能出错的情况强制使用Java的异常报错机制,预先定义好对每类牌组进行不同操作时可能出现的所有异常情况,每次我要对牌组进行操作的时候都会被强制处理所有的可能出现的错误。

  避免了非硬性处理(每次程序员手动处理null等异常情况)下,程序可能会隐含的错误。

  程序中设计的异常类有: 1. RecycleAreaIsNullException类;2. ColumnIsNullException类;3. TempAreaIsNullException类。

3.4 游戏初始化模块

用Java Swing实现Freecell(空当接龙)

  游戏初始化需要初始化“临时单元”、“回收单元”和“列”,并对游戏的一些基本属性进行重置。

  是否生成一副新的牌,取决于用户选择的操作是“Restart”还是“Renew”。“Renew”会生成一副新牌并进行洗牌,然后发牌。“Restar”则不生成新的扑克牌,将现有的扑克牌进行发牌,以方便用户在解局过程中陷入死局时重新尝试解局。

  在代码实现上,通过传递一个布尔值newPoker来判断是否要生成一副新牌。

  以下是初始化操作的主要代码:

public void init(boolean newPoker) {
    this.win = false;
    
    if (opCardList != null) this.resetOP();
    this.opCardList = new OpereateCards();
    
    this.initTempAreas();
    this.initRecAreas();
    this.initCols();
    
    if (newPoker) this.poker = new Poker();
    this.deal();
}

  用户有两个渠道开始新的牌局:1.游戏刚被打开时;2.用户鼠标左键单击菜单栏上“Renew”选项时。

  用户可以通过鼠标左键单击菜单栏上“Restart”选项来重新开始现有牌局。

  以下是游戏刚被打开时,自动开始新的牌局的代码:

public Game() {    
    logic = new Logic(); // 在Logic的构造方法中会调用init(true)
    //
}

  以下是对菜单栏中 “renew”和“restart”选项监听的代码:

class MenuLis implements ActionListener {
    public void actionPerformed(ActionEvent e) {
        if (e.getSource() == restarItem) {
            Game.logic.init(false);
            Game.gameLook.repaint();
        } else if (e.getSource() == renewItem) {
            Game.logic.init(true);
            Game.gameLook.repaint();
        } else if (e.getSource() == showWinItem) {
            Game.logic.setWin(true);
        } else if (e.getSource() == exitItem) {
            System.exit(0);
        }
        //
    }
}

3.5 头像左右变化模块

  当用户的鼠标位置在面板左边时,头像朝左;当用户的鼠标位置在面板右边时,头像朝右。通过对鼠标移动事件的监听,捕捉到鼠标当前所在位置的x值,再通过Logic类计算出该位置是在面板左边还是右边,并设置HeadPic类中的toward属性。

  再通过repaint绘制出在该位置下,头像应有的朝向。

  以下是监听鼠标移动事件的主要代码:

public void mouseMoved(MouseEvent e) {
    int x = e.getX();
    int y = e.getY();
    Position ps = Position.calculatePosition(x, y);
    
    //

    Game.logic.calculateHeadToward(x);    
    Game.gameLook.repaint();
}

  以下是Logic类中对当前鼠标是在面板左边还是右边计算的主要代码:

public void calculateHeadToward(int x) {
    if (x < (GameLook.WINDOW_WEITHT / 2)) {
        headPic.setToward(HeadPic.LEAF);
    } else {
        headPic.setToward(HeadPic.RIGHT);
    }
}

  以下是HeadPic类中头像绘制的主要代码:

public void paint(Graphics g) {
    img = new ImageIcon("image/headPic" + toward + ".jpg").getImage();
g.drawImage(img,
    GameLook.COR_INTERVAL_8, GameLook.ROW_INTERVAL_2,
        HEAD_PIC_SIZE, HEAD_PIC_SIZE, null);
}

3.6 自动移动到“回收单元”模块

  用户有两种途径完成自动移动到“回收单元”操作:1.在空白区域右击;2.完成一次放牌;3.完成自动移动到回收单元操作。

  以下是监听鼠标点击事件的主要代码:

public void mouseClicked(MouseEvent e) {
    int x = e.getX();
    int y = e.getY();
    Position ps = Position.calculatePosition(x, y);

    // 鼠标左键 BUTTON1、右键BUTTON3
    if (e.getButton() == MouseEvent.BUTTON3) {
        if (ps.getArea() == Position.BLANK) {
            Game.logic.autoRecycle();
        }
    } else if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 1) {
        // 在Logic中判断出是放牌操作时,执行自动回收操作
        // Logic类中具体代码略
    } else if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 2) {
        if (ps.getArea() == Position.COL) {
            //
            Game.logic.autoRecycle();
        }
    }

    Game.gameLook.repaint();
}

  “临时单元”和“列”中的牌都有可能能够被回收。

  执行自动回收的时候,还要考虑到:现有暴露的牌被回收上去后,新暴露出的牌也有可能能够被回收。所以使用循环结构反复执行自动回收操作,当没有可回收的牌时结束循环。

  如果满足自动回收的条件,则:1.删除原有区域的牌;2.将牌加入到回收单元中;3.改变stillCanRecycle状态。

  以下是自动回收的关键代码:

public void autoRecycle() {
    Card card;
    boolean stillCanRecycle = true;
    
    while (stillCanRecycle) {
        stillCanRecycle = false;
        
        for (int i = 0; i < TEMP_AREA_COUNT; i++) {
            try {
                card = tempAreaList[i].getCard();
                if (canRecycle(card, false)) {
                    tempAreaList[i].remove();
                    recycleAreaList[card.getColor()].add(card);
                    stillCanRecycle = true;
                }
            } catch (TempAreaIsNullException e) {
                // do nothing
            }
        }
        
        for (int i = 0; i < COL_COUNT; i++) {
            try {
                card = colList[i].getLastCard();
                if (canRecycle(card, false)) {
                    colList[i].removeLast();
                    recycleAreaList[card.getColor()].add(card);
                    stillCanRecycle = true;
                }
            } catch (ColumnIsNullException e) {
                // do nothing
            }
        }
    }
    //
}

  目标判断牌能被自动回收的条件有两个:1. 同目标判断牌花色相同的recycle are区域的点数和目标判断牌的点数是否相匹配;2.在临时单元和列中同目标判断牌花色相异的牌中,有没有比目标判断牌点数更小的牌(因为这些牌有可能会需要借助目标判断牌来完成移动和重叠)。

  同时,当目标判断牌是A时,则自动回收;当目标判断牌点数是2,且对应花色的回收区域中已有A时,则自动回收(因为所有的A都会自动被回收,所以2不会被需要)。

  但用户在解局的过程中有时又需要将“被需要”牌强制移动到回收单元来完成游戏,此时就不需要判断在临时单元和列中同目标判断牌花色相异的牌中,有没有比目标判断牌点数更小的牌。此时由用户手动拿起目标牌后鼠标左键单击回收单元完成“强制”回收操作。代码实现上,用forceRecycle变量来记录用户是否要强制回收目标牌。

  以下是判断目标判断牌能否被回收的关键代码:

private boolean canRecycle(Card judgeCard, boolean forceRecycle) {
    Card recAreaLastCard;
    try {
        recAreaLastCard = recycleAreaList[judgeCard.getColor()].getLastCard();
    } catch (RecycleAreaIsNullException e) {
        if (judgeCard.getValue() == 0) {
            return true;
        } else {
            return false;
        }
    }

    if (recAreaLastCard.getValue() == (judgeCard.getValue() - 1)) {
        if (forceRecycle) {
            return true;
        } else if (judgeCard.getValue() == 1) {
            return true;
        } else {
            int firstJudgeColor = (judgeCard.getColor() + 1) % 2;
            int secondJudgeColor = (judgeCard.getColor() + 1) % 2 + 2;
            if (thisColorStillNeedsThisCard(firstJudgeColor, judgeCard)
                  || thisColorStillNeedsThisCard(secondJudgeColor, judgeCard)) {
                return false;
            } else {
                return true;
            }
        }
    } else {
        return false;
    }
}

  以下是判断目标判断牌是否被需要的关键代码:

private boolean thisColorStillNeedsThisCard(int color, Card judgeCard) {
    try {
        Card thisRecAreaLastCard = recycleAreaList[color].getLastCard();
        if ((judgeCard.getValue() - 1) > thisRecAreaLastCard.getValue()) {
            return true;
        } else {
            return false;
        }
    } catch (RecycleAreaIsNullException e) {
        return true;
    }
}

  具体实现中,虽然是为了验证在临时单元和列中同目标判断牌花色相异的牌中,有没有比目标判断牌点数更小的牌,但是因为需要判断的区域有两个,写出来代码会很冗杂。逆向考虑,直接判断和目标判断牌花色相异的回收单元中是否有花色比目标判断牌点数更大的牌即可。

3.7  自动移动到“临时单元”模块

  捕捉用户在“列”的鼠标左键双击事件,来触发自动移动到临时单元操作。

  监听用户在列的鼠标左键右击事件关键代码如下:

public void mouseClicked(MouseEvent e) {
    int x = e.getX();
    int y = e.getY();
    Position ps = Position.calculatePosition(x, y);

if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 2) {
        if (ps.getArea() == Position.COL) {
            Game.logic.autoPutInTempArea(ps);
            //
        }
    }
    //
}

  只要有空余的临时单元,就可以将该列的最后一张牌自动移动到临时单元操作。

  以下是自动移动到临时单元的关键代码:

public void autoPutInTempArea(Position ps) {
    try {
        Column col = colList[ps.getOrder()];
        Card card = col.getLastCard();
        for (int i = 0; i < TEMP_AREA_COUNT; i++) {
            if (tempAreaList[i].isEmpty()) {
                tempAreaList[i].add(card);
                col.removeLast();
                break;
            }
        }
    } catch (ColumnIsNullException e) {
        // do nothing
    }
}

3.8  拿牌模块

  当用户鼠标左键单击临时单元或列中的牌或牌组时,且此时OpereateCards对象为空,则判断用户正在进行“拿牌”操作。

  以下是监听鼠标左键单击事件的主要代码:

public void mouseClicked(MouseEvent e) {
    int x = e.getX();
    int y = e.getY();
    Position ps = Position.calculatePosition(x, y);
    if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 1) {
        GameLook.logic.click(ps);
    }
    //
}

  根据拿牌位置的不同,分为“从临时单元中拿牌”和“从列中拿牌”。

  以下是判断拿牌位置的关键代码:

public void click(Position ps) {    
    if (opCardList.isEmpty()) {
        if (ps.getArea() == Position.TEMP_AREA) {
            takeUpFromTempArea(ps);
        } else if (ps.getArea() == Position.COL) {
            takeUpFromCol(ps);
        } else {
            // do nothing
        }
    } else {
        //
    }        
}

3.8.1 从临时单元中拿牌

  只要用户点击的目标临时单元里有牌,即可完成拿牌操作。

  以下是从临时单元拿牌的关键代码:

private void takeUpFromCol(Position ps) {
    if (canTakeUp(ps)) {
        try {
           ArrayList<Card> cardList = new ArrayList<Card>();
           for(int i = ps.getCardOrder();i < colList[ps.getOrder()].size();i++) {
               cardList.add(colList[ps.getOrder()].getCard(i));
           }
           opCardList.add(cardList, ps);
        } catch (ColumnIsNullException e) {
           // do nothing
        }
    }
}

3.8.2 从列中拿牌

  从列中拿牌需要判断用户想要拿起的牌下面是否有叠压的牌。如果没有则完成拿牌操作。

  以下是从列中拿牌的关键代码:

private void takeUpFromCol(Position ps) {
    if (canTakeUp(ps)) {
        try {
           ArrayList<Card> cardList = new ArrayList<Card>();
           for(int i = ps.getCardOrder();i < colList[ps.getOrder()].size();i++) {
               cardList.add(colList[ps.getOrder()].getCard(i));
           }
           opCardList.add(cardList, ps);
        } catch (ColumnIsNullException e) {
           // do nothing
        }
    }
}

  以下是判断是否有叠压的关键代码:

private boolean canTakeUp(Position ps) {        
    try {
        boolean canTakeUp = true;
        
        Column col = colList[ps.getOrder()];
        Card card = col.getCard(ps.getCardOrder());            
        int colorKey = card.getColor() % 2;
        int valueKey = card.getValue();
        for (int i = ps.getCardOrder(); i < col.size(); i++) {
            if (col.getCard(i).getValue() != valueKey
                    || (col.getCard(i).getColor() % 2) != colorKey) {
                canTakeUp = false;
                break;
            }
            colorKey = (colorKey + 1) % 2;
            valueKey--;
        }
        
        return canTakeUp;
    } catch (ColumnIsNullException e) {
        return false;
    }
}

3.9 放牌模块

  当用户鼠标左键单击临时单元、回收单元或列时,且此时OpereateCards对象不为空,则判断用户正在进行“放牌”操作。

  以下是监听鼠标左键单击事件的主要代码:

public void mouseClicked(MouseEvent e) {
    int x = e.getX();
    int y = e.getY();
    Position ps = Position.calculatePosition(x, y);
    if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 1) {
        Game.logic.click(ps);
    }
    //
}

  根据放牌位置的不同,分为“放入临时单元”、“放入回收单元”和“放入列”。

  以下是判断放牌位置的关键代码:

public void click(Position ps) {        
    if (opCardList.isEmpty()) { ////
    } else { //
        if (ps.getArea() == Position.TEMP_AREA) {
            putInTempArea(ps);
        } else if (ps.getArea() == Position.RECTCLE_AREA) {
            putInRecycleArea(ps);
        } else if (ps.getArea() == Position.COL) {
            putInCol(ps);
        } else {
            // do nothing
        }
        //
    }
}

3.9.1  放入“临时单元”

  将目标牌组放入临时单元要满足两个条件:1.目标牌组只有一张牌;2.目标临时单元为空。

  以下是放入临时单元的关键代码:

private void putInTempArea(Position ps) {
    if (opCardList.size() == 1 && tempAreaList[ps.getOrder()].isEmpty()) {
        tempAreaList[ps.getOrder()].add(opCardList.getCard(0));
        removeOPFromCard();            
        resetOP();
    }
}

3.9.2 放入“回收单元”

  将目标牌组放入回收单元要满足两个条件:1.目标牌组里只有一张牌;2.目标牌的花色和值满足放入回收单元的条件。

  此时不需要再判断在临时单元和列中同目标判断牌花色相异的牌中,有没有比目标判断牌点数更小的牌,因为这个操作是用户的强制放入回收单元操作。

  以下是放入回收单元的关键代码:

private void putInRecycleArea(Position ps) {
    Card c = opCardList.getCard(0);
    if (opCardList.size() == 1 && canRecycle(c, true)) {
        recycleAreaList[ps.getOrder()].add(c);
        removeOPFromCard();
        resetOP();
    }
}

3.9.3  放入“列”

  将目标牌组放入列中需要满足两个条件:1.目标牌组中牌的数目在目前空当允许移动的最大牌数以内;2.目标牌组的值和花色符合放入目标列末尾的条件。

  以下是放入列的关键代码:

private void putInCol(Position ps) {
    if (moveCardNumIsAviliable(ps) && colorAndSizeIsAvailable(ps)) {
        colList[ps.getOrder()].add(opCardList.getCardList());
        removeOPFromCard();
        resetOP();
    }
}

  目前空当允许的最大牌数的计算公式是:设当前空的临时单元数目为m,空列(该列下面没有牌)为n,如果是将牌组移动到某个空列,则n的数目减1。

  则:可移动牌数上限 用Java Swing实现Freecell(空当接龙)

  以下是判断目标牌组中牌的数目是否在目前空当允许移动的最大牌数以内的关键代码:

private boolean moveCardNumIsAviliable(Position ps) {
    int emptyTempAreaCount = 0;
    for (int i = 0; i < TEMP_AREA_COUNT; i++) {
        if (tempAreaList[i].isEmpty()) emptyTempAreaCount++;
    }
    
    int emptyColCount = 0;
    for (int i = 0; i < COL_COUNT; i++) {
        if (colList[i].size() == 0) emptyColCount++;
    }
    
    if (colList[ps.getOrder()].size() == 0) emptyColCount--;
    
    int avaliableCount = (emptyTempAreaCount + 1) * (int)(Math.pow(2, emptyColCount));
    
    return (opCardList.size() <= avaliableCount)? true: false;
}

  判断目标牌组的值和花色能否放入目标列末尾,只需要判断目标牌组的第一张牌和目标列的最后一张牌能否叠放在一起即可。

  以下是判断目标牌组的值和花色能否放入目标列的关键代码:

private boolean colorAndSizeIsAvailable(Position ps) {
    try {
        Card colLastcard = colList[ps.getOrder()].getLastCard();
        Card opFirstCard = opCardList.getCard(0);
        
        if ((colLastcard.getColor() % 2) != (opFirstCard.getColor() %2)
                && (colLastcard.getValue() - 1) == opFirstCard.getValue()) {
            return true;
        } else {
            return false;
        }
        
    } catch (ColumnIsNullException e) {
        return true;
    }
}

  此外,当列中的牌数过多时,当前牌间距下游戏面板可能会显示不下所有的牌。此时,程序会自动计算合适的牌间距,来让所有的牌都呈现在游戏面板上。当牌数变少,在原始间距下列中所有的牌也能全部显示在游戏面板上时,将牌间距恢复为初始值。

  以下是自动计算牌间距的代码:

private void calculateCardInterval() {
    int currentColHeight = (currentCardInterval * (size() - 1)) + Card.CARD_HEIGHT;
    if (currentColHeight > MAX_COL_HEIGHT) {
        currentCardInterval = (MAX_COL_HEIGHT - Card.CARD_HEIGHT) / (size() - 1);
    } else {
        currentCardInterval = MAX_CARD_INRERVAL;
    }
}

3.10 赢判断模块

  检查“回收单元”里牌的总数,当数目等于52时,游戏胜利。

  以下是赢判断的关键代码:

private void calculateWin() {
    int recCardCount = 0;
    for (int i = 0; i < RECYCLE_AREA_COUNT; i++) {
        recCardCount += recycleAreaList[i].size();
    }
    if (recCardCount == Poker.CARD_COUNT) win = true;
}

  游戏中,有两张种途径会改变回收单元里牌的数目,所以也就有两种途径触发赢判断:1.每一次放牌时;2.每一次自动回收到回收单元时。又每一次放牌也会触动自动回收到回收单元的操作,所以只需要在自动回收到回收单元时触发赢判断即可。

  以下是每次自动回收到回收单元时,触发赢判断的关键代码:

public void autoRecycle() {
    //
    calculateWin();
}

3.11 庆祝界面

用Java Swing实现Freecell(空当接龙)

  有两种途径进入庆祝界面:1.游戏成功时;2.用户手动点击菜单栏的“Show victory”选项时。

  启动庆祝界面的代码如下:

public void paint(Graphics g) {
    //
    
    if (Game.logic.isWin()) {
        new WinFatherThread(this.getGraphics());
        
        //
    }
}

  庆祝界面中每一张牌都在独立地做类平抛运动,在牌触底之后反弹,到达顶点后再下落,触底之后再反弹,周而复始,直到牌“跳”出游戏面板。

  整体的效果看来是,每一组牌面值相同的牌按照顺序往下“掉落”,过了一段时间下一组牌面值相同的牌再往下“掉落”。

3.11.1 物理模型

  假定将物体以一定的初速度沿水平方向抛出,不考虑空气阻力,物体在重力的作用下做平抛运动。庆祝界面运动分解表:

用Java Swing实现Freecell(空当接龙)

  示意图:

用Java Swing实现Freecell(空当接龙)

  物体在触底后反弹,竖直运动方向改变,重力加速度和物体运动方向相反,做减速运动。当竖直方向的分速度减为0时,到达顶点,竖直运动方向再一次改变,重力加速度和物体运动方向相同,物体做加速运动。

  在物体每一次触底反弹时,加大物体的加速度,让物体在下一轮弹跳的速度变化越来越剧烈,以实现如下效果:1.让物体每一次达到的最高点都比上一次低;2.从视觉上给人一种物体朝用户方向跳跃前进的错觉。

3.11.2 庆祝效果代码实现

  代码上我用多线程实现庆祝界面。每一张牌都是一个独立的子线程,由一个父线程按照一定时间顺序轮流启动52个子线程。父线程启动子线程的的主要代码如下:

public void run() {
    int x;
    Image img;
    try {
        for (int i = Poker.VALUE_COUNT; i > 0 ; i--) {
            for (int j = 0; j < Poker.COLOR_COUNT; j++) {
                //
                
                x = GameLook.COR_INTERVAL_6 + (Card.CARD_WIGHT + GameLook.COR_INTERVAL_2) * j;
                img = new ImageIcon("image/poker/" + i + "-" + (j + 1) + ".gif").getImage();
                new WinSonThread(g, img, x);
                Thread.sleep(250);
            }
            Thread.sleep(7000);
        }
        
        //
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

  在每一个子线程中,设定物体的初速度为0,初始x、y值为相应回收单元所在位置最左上角的x、y值。

  在每个相同的时间间隔内,子线程需要做的事情有:1.计算物体竖直方向上应该移动的距离和水平方向上应该移动的距离,进而计算出此时物体的x、y值,并按照计算出的x、y值将牌画在面板上的相应位置上;2.判断物体的y值是否超出游戏面板的最大高度,超出面板最大高度时,判断物体的运动方向改变,并调整相应数值;3.计算出v值,当v值为0时,判断物体的运动方向改变,并调整相应数值。

  子线程中实现图像类平抛运动跳跃前进的代码如下:

public void run() {
    int xInterval = (int)(Math.random() * 3 + 2);
    int y0 = GameLook.ROW_INTERVAL_1;
    int y = y0;
    int maxY = 495;
    int yInterval = 0;
    double a = 9.8;
    int aInterval = (int)(Math.random() * 8 + 2);
    double v;
    double v0 = 0;
    int count = 0;
    int power = 0;
    
    while (true) {
        //
        
        try {
            count++;
            g.drawImage(img, x, y, 71, 96, null);
            v =  Math.pow(-1, power) * a * (count * 0.2) + v0;
            x -= xInterval;    
            yInterval = (int) (v0 * (count * 0.2) + 0.5 * (Math.pow(-1, power) * a) * (count * 0.2) * (count * 0.2));
            y = (int) (y0 + Math.pow(-1, power) * yInterval);
            
            if (y >= maxY) {
                y = maxY;
                a = a + aInterval;                
                y0 = y;
                v0 = v;
                count = 0;
                power = 1;
            }
            if (v <= 0) {
                y0 = y;
                v0 = 0;
                count = 0;
                power = 0;
            }  
            Thread.sleep(30);                
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

3.11.3 退出庆祝界面代码

  有两种途径退出庆祝界面:1.庆祝界面中所有的牌都“跳”下后;2.在庆祝界面,用户手动鼠标左键点击游戏面板并选择相应选项时。

  在显示庆祝界面,监听用户鼠标左键点击事件的代码如下:

class MouseLisWhenWin  extends MouseAdapter {
    public void mouseClicked(MouseEvent e) {
        if (e.getButton() == MouseEvent.BUTTON1) {
            chooseTheNextStep ();
        }
    }            
}

  用户选择下一步操作的代码如下:

public void chooseTheNextStep() {
    int response = JOptionPane.showConfirmDialog(null, "继续游戏?", "WIN", JOptionPane.YES_NO_CANCEL_OPTION);
    if (response == 0) {
        // 1. 停止庆祝界面的动画效果
        // 2. 重设游戏面板的监听及相应属性
        // 3. 完成游戏初始化,并生成一副新牌,用户继续新的一局游戏
        // 具体代码略
    } else if (response == 1) {
        // 1. 停止庆祝界面的动画效果
        // 2. 重设游戏面板的监听及相应属性
        // 具体代码略
    } else if (response == 2) {
        // 退出游戏。具体代码略
    }
}

  因为庆祝界面的动画效果是由一个父线程和若干子线程的不断运行实现的,所以停止所有线程则停止了庆祝界面的动画效果。我使用一个退出标志stop来终止线程,为保证同时终止所有正在运行的线程,我将终止标志设置成一个公开的静态变量,所有的线程都访问该变量来判断是否停止线程。

  同时在子线程中,如果牌的当前位置的x值已经小于-100,则自动结束该子线程。

  父线程中终止线程的关键代码:

public void run() {
    //

    for (int i = Poker.VALUE_COUNT; i > 0 ; i--) {
        for (int j = 0; j < Poker.COLOR_COUNT; j++) {
            if (stop) return;
            //
        }
}

  子线程中使用退出标志终止线程的关键代码:

public void run() {
    //
    while (true) {
        if (WinFatherThread.stop) break;
        if (x < -100) break;
        //
    }
}