65.9K
CodeProject 正在变化。 阅读更多。
Home

扫雷 - Android 版扫雷游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (143投票s)

2010年9月29日

CPOL

14分钟阅读

viewsIcon

395069

downloadIcon

15421

Android版扫雷游戏。

Minesweeper - Startup screenshot Minesweeper - New game screenshot
扫雷 - 启动屏幕截图
扫雷 - 新游戏屏幕截图
Minesweeper - Game won screenshot Minesweeper - Game lost screenshot
扫雷 - 游戏获胜屏幕截图
扫雷 - 游戏失败屏幕截图

目录

引言

扫雷是一款单人游戏。游戏的目标是在不触雷的情况下清除雷区。扫雷不仅适用于Windows,还适用于其他平台(包括大多数Linux变体)。扫雷在Windows世界非常流行,自Windows 3.1起就一直捆绑在Windows中。

在本文中,我们将为Android创建一个扫雷游戏的克隆。我们将尝试实现Windows扫雷游戏中可用的大部分功能。本文面向**中高级**开发人员,并假定您熟悉Java和Android开发。

关于游戏

在扫雷中,我们面对的是一个方块网格,其中一些方块随机包含地雷。在我们的实现中,我们将限制为典型的**初级水平**实现。我们的实现中的行数和列数将为9,总雷数将为10。虽然将游戏扩展到中级、高级也很容易(只需更改我们代码中3个变量的值)。

好了,在本文中,我们不会过多谈论如何玩游戏,而是讨论在实现它(Windows版本)之前需要考虑的一些功能。

  1. 左键单击方块会打开方块。
  2. 右键单击方块可将方块标记为已标记(下方有地雷);已标记的方块可以标记为问号(怀疑有地雷),问号方块也可以取消标记。
  3. 第一个方块下方永远不会有地雷;这减少了猜第一个方块的痛苦。
  4. 如果一个未覆盖的方块是空白的,则会递归地打开附近的方块,直到打开一个带数字的方块;模拟涟漪效应。
  5. 单击已标记所有附近方块的地雷的方块的左右/中间按钮,将打开所有附近被覆盖的方块。
  6. 计时器在单击第一个方块时启动,而不是在新游戏选择后启动。

说够了,让我们开始动手吧。

一步一步来

解释或实现任何复杂系统的最佳方法是循序渐进。我们将在这里采用这种方法。首先,我们将讨论应用程序的GUI、布局和外观。我们还将讨论创建布局时使用的一些技术。之后,我们将讨论如何在Android应用程序中跟踪时间。接下来,我们将讨论鼠标、点击和触摸事件之间的区别以及我们将如何响应用户操作。最后一步将是实现完整的游戏以及《关于游戏》部分中讨论的大部分功能。

外观和感觉

让我们来谈谈设计扫雷游戏GUI的一些方面。我们将讨论整体应用布局以及创建游戏时使用的一些技术。

应用布局

对于扫雷,我们将使用一个TableLayout。我们在TableLayout中添加三行。

  1. 第一行包含三个用于计时器、新游戏按钮和地雷计数显示的列。对于计时器和地雷计数显示,我们使用了一个TextView。对于新游戏按钮,我们使用了一个ImageButton
  2. 第二行包含一个高度为50像素的空TextView。它只是作为顶部行和雷区之间的间隔。
  3. 此行包含另一个TableLayout,用于显示雷区。我们动态地向此TableLayout添加按钮行。

GUI Layout

布局的代码如下(为节省空间,已删除一些其他属性):

<?xml version="1.0" encoding="utf-8"?>
<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:stretchColumns="0,2"
  android:background="@drawable/back">
  <TableRow>
    <TextView
      android:id="@+id/Timer"
      android:layout_column="0"
      android:text="000" />
    <ImageButton android:id="@+id/Smiley"
      android:layout_column="1"
      android:background="@drawable/smiley_button_states"
      android:layout_height="48px"/>
    <TextView
      android:id="@+id/MineCount"
      android:layout_column="2"
      android:text="000" />
  </TableRow>
  <TableRow>
    <TextView
      android:layout_column="0"
      android:layout_height="50px"
      android:layout_span="3"
      android:padding="10dip"/>
  </TableRow>
  <TableRow>
    <TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
      android:id="@+id/MineField"
      android:layout_width="260px"
      android:layout_height="260px"
      android:gravity="bottom"
      android:stretchColumns="*"
      android:layout_span="3"
      android:padding="5dip" >
    </TableLayout>
  </TableRow>
</TableLayout>

使用外部字体

对于计时器和地雷计数显示,我们使用了一个外部字体。我们使用了LCD mono字体(在资源部分指定)。在Android中使用外部字体非常容易,这是一个两步过程:

  1. 在项目的assets文件夹下创建一个fonts文件夹。将TTF(True Type Font)文件复制到fonts文件夹。
  2. 通过调用createFromAsset并传入TTF文件名来创建一个Typeface对象,并将TextViewTypeface设置为此对象。执行此操作的代码如下:
  3. private TextView txtMineCount;
    private TextView txtTimer;
    txtMineCount = (TextView) findViewById(R.id.MineCount);
    txtTimer = (TextView) findViewById(R.id.Timer);
    
    // set font style for timer and mine count to LCD style
    Typeface lcdFont = Typeface.createFromAsset(getAssets(),
        "fonts/lcd2mono.ttf");
    txtMineCount.setTypeface(lcdFont);
    txtTimer.setTypeface(lcdFont);

使用样式

在我们的扫雷应用程序中,新游戏按钮上的笑脸在按钮被点击时会变成一个紧张的笑脸。基本上,我们希望在按钮处于按下状态(紧张的笑脸)时使用不同的图像,在正常状态(笑脸)时使用另一个图像。为了实现这一点,我们使用了样式。效果如下:

New Game button states

使用样式也是一个两步过程:

  1. 创建一个新的XML文件(样式定义文件),该文件指定要用于相应按钮状态的图像。例如,在按下状态下,我们希望使用名为surprise的图像;在正常状态下,我们希望使用名为smile的图像。当然,这两张图像都已复制到我们的res/drawable文件夹中。样式文件如下:
  2. <?xml version="1.0" encoding="utf-8"?>
    <selector xmlns:android="http://schemas.android.com/apk/res/android">
      <item android:state_focused="true" 
        android:state_pressed="false" 
        android:drawable="@drawable/smile" />
      <item android:state_focused="true" 
        android:state_pressed="true"
        android:drawable="@drawable/surprise" />
      <item android:state_focused="false" 
        android:state_pressed="true"
        android:drawable="@drawable/surprise" />
      <item android:drawable="@drawable/smile" />
    </selector>
  3. 更新/添加新游戏按钮的background属性,并将其值设置为上面创建的样式文件。更新后的ImageButton代码如下:
  4. <ImageButton android:id="@+id/Smiley"
      android:layout_column="1"
      android:background="@drawable/smiley_button_states"
      android:layout_height="48px"/>

向TableLayout动态添加行

动态添加方块(Block是派生自Button类并添加了支持实现功能的类)的工作方式与我们的预期或期望的工作方式相同。我们可以将其分为以下步骤:

  1. 创建一个TableRow对象并设置其布局参数。
  2. 将方块(扩展按钮)添加到上面创建的行对象中。
  3. 使用findViewById方法获取TableLayout(雷区)的实例。
  4. 将上面创建的行添加到TableLayout

最终代码如下:

private TableLayout mineField; // table layout to add mines to

private Block blocks[][]; // blocks for mine field

public void onCreate(Bundle savedInstanceState)
{
  ...
  mineField = (TableLayout)findViewById(R.id.MineField);
}

private void showMineField()
{
  for (int row = 1; row < numberOfRowsInMineField + 1; row++)
  {
    TableRow tableRow = new TableRow(this);  
    tableRow.setLayoutParams(new LayoutParams((blockDimension + 2 * blockPadding) * 
        numberOfColumnsInMineField, blockDimension + 2 * blockPadding));

    for (int column = 1; column < numberOfColumnsInMineField + 1; column++)
    {
      blocks[row][column].setLayoutParams(new LayoutParams(  
          blockDimension + 2 * blockPadding,  
          blockDimension + 2 * blockPadding)); 
      blocks[row][column].setPadding(blockPadding, blockPadding, 
             blockPadding, blockPadding);
      tableRow.addView(blocks[row][column]);
    }
    mineField.addView(tableRow,new TableLayout.LayoutParams(  
        (blockDimension + 2 * blockPadding) * numberOfColumnsInMineField, 
         blockDimension + 2 * blockPadding));  
  }
}

设置扫雷图标

更改应用程序图标(此图标显示在主屏幕和启动器窗口中,通常称为启动器图标)很简单。它很简单,因为项目已提供一个默认图标,名为icon.png,位于res/drawable文件夹中。更改/更新/替换项目提供的icon.png,更改将在启动器窗口中反映出来。有关Android图标设计的更多详细信息,请阅读图标设计指南

Minesweeper icon in Launcher Window

多分辨率图像

众所周知,Android支持并运行在各种设备上。设备在屏幕尺寸、纵横比、分辨率、密度和像素支持方面可能有所不同。为了支持所有这些设备,我们必须根据每种设备定制图像。现在,这确实不是可行的。Google推荐的方法是为三种通用屏幕密度(即低DPI、中DPI和高DPI)提供一组单独的图像。图像应复制到res/drawable-hdpires/drawable-mdpires/drawable-ldpi文件夹,分别对应不同的密度。如果我们希望Android负责图像调整(这并非总是最佳选择),那么我们只需要创建一组图像(我们在扫雷中采用了这种方法),并将它们复制到res/drawable-nodpi文件夹。有关更多信息,请阅读支持多屏

测量时间

开发游戏时,最重要的一点是跟踪时间。在Java世界中,使用java.util.Timerjava.util.TimerTask是时间跟踪场景的标准方法。唯一令人烦恼的是,这样做会创建一个新线程。在某些情况下,这可能不是我们想要的。Android为这种情况提供了更好的解决方案。我们可以为此目的使用android.os.Handler类。

使用Handler代替Timer

Handler可以有两种使用方式:一种是通过向Handler发送消息并在收到消息时执行特定操作,另一种是通过Handler安排一个Runnable对象。我们在扫雷中使用了第二种方法。使用Handler的好处是它与创建者线程的线程/消息队列相关联。在此处阅读有关Handler的更多信息。实现Handler的代码与以下类似:

private Handler timer = new Handler();
private int secondsPassed = 0;

public void startTimer()
{
  if (secondsPassed == 0) 
  {
    timer.removeCallbacks(updateTimeElasped);
    // tell timer to run call back after 1 second
    timer.postDelayed(updateTimeElasped, 1000);
  }
}

public void stopTimer()
{
  // disable call backs
  timer.removeCallbacks(updateTimeElasped);
}

// timer call back when timer is ticked
private Runnable updateTimeElasped = new Runnable()
{
  public void run()
  {
    long currentMilliseconds = System.currentTimeMillis();
    ++secondsPassed;
    txtTimer.setText(Integer.toString(secondsPassed));

    // add notification
    timer.postAtTime(this, currentMilliseconds);
    // notify to call back after 1 seconds
    // basically to remain in the timer loop
    timer.postDelayed(updateTimeElasped, 1000);
  }
};

处理用户操作

在扫雷中,我们实现了Click和Long Click事件的监听器。Click事件用于模拟鼠标左键点击,Long Click事件用于模拟鼠标右键点击。我们没有为同一功能实现Touch事件,但也可以通过Touch事件实现相同的功能,但这样只会限制在支持触摸的设备上。相反,在支持触摸的设备上,Click和Long Click事件也可以通过触摸触发,而在不支持触摸的设备上,这些事件可以通过轨迹球或Enter键生成。

理解鼠标点击和手机点击事件

Java提供了实现鼠标按钮点击/按下/释放、鼠标移动/拖动、鼠标进入/退出以及滚轮事件支持的功能。在Android中,没有鼠标悬停(进入/退出)事件和滚轮事件的概念。Android仅提供点击和触摸功能。点击可以是点击或长按。点击事件不支持拖动功能。触摸事件提供拖动功能。

理解手机点击和手机触摸事件

当用户触摸控件(在触摸模式下)或使用导航键或轨迹球聚焦于控件并按下合适的“enter”键或按下轨迹球时,就会生成点击事件。当用户执行被视为触摸事件的操作时,会调用触摸事件,包括在屏幕上的按下、释放或任何手势(在控件边界内)。

理解手机点击和手机长按事件

当用户触摸控件(在触摸模式下)或使用导航键或轨迹球聚焦于控件并按下合适的“enter”键或按下轨迹球时,就会生成点击事件。当用户触摸并按住控件(在触摸模式下)或使用导航键或轨迹球聚焦于控件并按下并按住合适的“enter”键或按住轨迹球(一秒钟)时,就会生成长按事件。

模拟左右键

Android不支持中间按钮点击事件;为了实现此功能,我们直接使用了长按事件。如果长按一个带有数字的已打开方块,则触发相关功能。这部分代码如下:

blocks[row][column].setOnLongClickListener(new OnLongClickListener()
{
  public boolean onLongClick(View view)
  {
    // simulate a left-right (middle) click
    // if it is a long click on an opened mine then
    // open all surrounding blocks
    if (!blocks[currentRow][currentColumn].isCovered() 
        && (blocks[currentRow][currentColumn].getNumberOfMinesInSorrounding() > 0) && !isGameOver)
    {
      int nearbyFlaggedBlocks = 0;
      for (int previousRow = -1; previousRow < 2; previousRow++)
      {
        for (int previousColumn = -1; previousColumn < 2; previousColumn++)
        {
          if (blocks[currentRow + previousRow][currentColumn + previousColumn].isFlagged())
          {
            nearbyFlaggedBlocks++;
          }
        }
      }

      // if flagged block count is equal to nearby mine count
      // then open nearby blocks
      if (nearbyFlaggedBlocks == blocks[currentRow][currentColumn].getNumberOfMinesInSorrounding())
      {
        for (int previousRow = -1; previousRow < 2; previousRow++)
        {
          for (int previousColumn = -1; previousColumn < 2; previousColumn++)
          {
            // don't open flagged blocks
            if (!blocks[currentRow + previousRow][currentColumn + previousColumn].isFlagged())
            {
              // open blocks till we get numbered block
              rippleUncover(currentRow + previousRow, currentColumn + previousColumn);

              // check status of game
              ...
            }
          }
        }
      }

      // as we no longer want to judge this gesture so return
      // not returning from here will actually trigger other action
      // which can be marking as a flag or question mark or blank
      return true;
    }

    // if clicked block is enabled, clickable or flagged
    ...
    }

    return true;
  }
});

禁用按钮的事件

如果按钮被禁用,我们无法接收该按钮的事件。为了克服此限制,我们将按钮标记为禁用并更改其背景。实际上,按钮从未被禁用,它始终保持启用状态。这样,按钮仍然是启用的,但表现得像被禁用了,因此可以接收事件。

决定策略

让我们专注于游戏最重要的方面:实现它。在本节中,我们将逐一讨论《关于游戏》部分讨论的一些功能(其中一个已在上面的《模拟左右键》部分讨论过)。

完整周期

扫雷游戏最重要的部分是处理用户操作。它始于等待和接收用户输入并对其进行适当处理。我相信,与其用文字解释,不如用图示来表示。毕竟,俗话说得好,一图胜千言。整个周期可以用这个流程图来概括:

第一次点击开始处理

正如我们已经描述过的,计时器应该在第一次点击(打开第一个方块)时启动,而不是在新游戏按钮点击时启动。这对于保持对时间的适当控制确实是必要的。为了做到这一点,我们创建一个布尔变量,并且一旦收到Click事件,就检查该变量并启动Handler,然后翻转变量的值。这部分代码如下:

private boolean isTimerStarted; // check if timer already started or not

blocks[row][column].setOnClickListener(new OnClickListener()
{
  @Override
  public void onClick(View view)
  {
    // start timer on first click
    if (!isTimerStarted)
    {
      startTimer();
      isTimerStarted = true;
    }
    ...
  }
});

第一次点击无雷

用户第一次点击时不应该遇到地雷,因为它使玩家不必猜测第一个方块。为了实现此功能,我们在第一次点击后放置地雷。我们在除用户刚刚点击的方块以外的其他方块中放置地雷。地雷是随机放置在方块中的(通过生成随机行和列号)。放置地雷后,我们更新所有方块附近的雷数。这部分代码如下:

private boolean areMinesSet; // check if mines are planted in blocks

blocks[row][column].setOnClickListener(new OnClickListener()
{
  @Override
  public void onClick(View view)
  {
    ...
    // set mines on first click
    if (!areMinesSet)
    {
      areMinesSet = true;
      setMines(currentRow, currentColumn);
    }
  }
});

private void setMines(int currentRow, int currentColumn)
{
  // set mines excluding the location where user clicked
  Random rand = new Random();
  int mineRow, mineColumn;

  for (int row = 0; row < totalNumberOfMines; row++)
  {
    mineRow = rand.nextInt(numberOfColumnsInMineField);
    mineColumn = rand.nextInt(numberOfRowsInMineField);
    if ((mineRow + 1 != currentColumn) || (mineColumn + 1 != currentRow))
    {
      if (blocks[mineColumn + 1][mineRow + 1].hasMine())
      {
        row--; // mine is already there, don't repeat for same block
      }
      // plant mine at this location
      blocks[mineColumn + 1][mineRow + 1].plantMine();
    }
    // exclude the user clicked location
    else
    {
      row--;
    }
  }

  int nearByMineCount;

  // count number of mines in surrounding blocks 
  ...
}

打开方块的涟漪效果

当用户打开一个方块时,用户应该得到下一步的提示。如果打开的方块是空的/空白的,用户就无法做出下一步的猜测或决定。为了避免这种情况,我们打开附近的方块并递归地继续打开它们,直到我们遇到一个带有数字的方块。这就产生了涟漪效应。递归打开方块(涟漪效果)的代码如下:

private void rippleUncover(int rowClicked, int columnClicked)
{
  // don't open flagged or mined rows
  if (blocks[rowClicked][columnClicked].hasMine() || 
         blocks[rowClicked][columnClicked].isFlagged())
  {
    return;
  }

  // open clicked block
  blocks[rowClicked][columnClicked].OpenBlock();

  // if clicked block have nearby mines then don't open further
  if (blocks[rowClicked][columnClicked].getNumberOfMinesInSorrounding() != 0 )
  {
    return;
  }

  // open next 3 rows and 3 columns recursively
  for (int row = 0; row < 3; row++)
  {
    for (int column = 0; column < 3; column++)
    {
      // check all the above checked conditions
      // if met then open subsequent blocks
      if (blocks[rowClicked + row - 1][columnClicked + column - 1].isCovered()
          && (rowClicked + row - 1 > 0) && (columnClicked + column - 1 > 0)
          && (rowClicked + row - 1 < numberOfRowsInMineField + 1) 
          && (columnClicked + column - 1 < numberOfColumnsInMineField + 1))
      {
        rippleUncover(rowClicked + row - 1, columnClicked + column - 1 );
      } 
    }
  }
  return;
}

空白方块 -> 标记 -> 问号 -> 空白

我们还讨论了游戏的另一个方面,即标记方块为“已标记”、“问号”或再次清除标记。此功能实现起来很简单。当我们收到长按事件并且它不是左-右点击时,我们会检查方块的当前状态。如果方块是空白的,我们就将其标记为“已标记”(内部有地雷);如果已标记,我们可以将其标记为“问号”(怀疑有地雷);如果标记为“问号”,我们可以清除标记。我希望任何人都能明白,我们只需要几个条件语句。

blocks[row][column].setOnLongClickListener(new OnLongClickListener()
{
  public boolean onLongClick(View view)
  {
    // simulate a left-right (middle) click
    // if it is a long click on an opened mine then
    // open all surrounding blocks
    ...

    // if clicked block is enabled, clickable or flagged
    if (blocks[currentRow][currentColumn].isClickable() && 
        (blocks[currentRow][currentColumn].isEnabled() || 
         blocks[currentRow][currentColumn].isFlagged()))
    {

      // for long clicks set:
      // 1. empty blocks to flagged
      // 2. flagged to question mark
      // 3. question mark to blank

      // case 1. set blank block to flagged
      if (!blocks[currentRow][currentColumn].isFlagged() && 
           !blocks[currentRow][currentColumn].isQuestionMarked())
      {
        blocks[currentRow][currentColumn].setBlockAsDisabled(false);
        blocks[currentRow][currentColumn].setFlagIcon(true);
        blocks[currentRow][currentColumn].setFlagged(true);
        minesToFind--; //reduce mine count
        updateMineCountDisplay();
      }
      // case 2. set flagged to question mark
      else if (!blocks[currentRow][currentColumn].isQuestionMarked())
      {
        blocks[currentRow][currentColumn].setBlockAsDisabled(true);
        blocks[currentRow][currentColumn].setQuestionMarkIcon(true);
        blocks[currentRow][currentColumn].setFlagged(false);
        blocks[currentRow][currentColumn].setQuestionMarked(true);
        minesToFind++; // increase mine count
        updateMineCountDisplay();
      }
      // case 3. change to blank square
      else
      {
        blocks[currentRow][currentColumn].setBlockAsDisabled(true);
        blocks[currentRow][currentColumn].clearAllIcons();
        blocks[currentRow][currentColumn].setQuestionMarked(false);
        // if it is flagged then increment mine count
        if (blocks[currentRow][currentColumn].isFlagged())
        {
          minesToFind++; // increase mine count
          updateMineCountDisplay();
        }
        // remove flagged status
        blocks[currentRow][currentColumn].setFlagged(false);
      }
      
      updateMineCountDisplay(); // update mine display
    }

    return true;
  }
});

每一步检查游戏输赢

是的,这一步非常重要;我们必须在每次点击后检查游戏的状态。这对于确保我们不遗漏任何点击或任何方块至关重要。如果我们点击一个下面有地雷的方块,我们就输了游戏。如果我们标记了所有有地雷的方块,我们就赢了游戏。这部分代码如下:

// check status of the game at each step
if (blocks[currentRow + previousRow][currentColumn + previousColumn].hasMine())
{
  // oops game over
  finishGame(currentRow + previousRow, currentColumn + previousColumn);
}

// did we win the game
if (checkGameWin())
{
  // mark game as win
  winGame();
}

private boolean checkGameWin()
{
  for (int row = 1; row < numberOfRowsInMineField + 1; row++)
  {
    for (int column = 1; column < numberOfColumnsInMineField + 1; column++)
    {
      if (!blocks[row][column].hasMine() && blocks[row][column].isCovered())
      {
        return false;
      }
    }
  }
  return true;
}

测试/如何玩

测试/玩游戏很简单,与在Windows上玩的方式类似。一些关键点需要提及:

  • 点击笑脸图标开始新游戏。
  • 点击或触摸方块以打开它。这相当于Windows上的左键点击。
  • 按住(一秒钟)方块以将其标记为“已标记”、“问号”或清除所有标记。这相当于Windows上的右键点击。
  • 按住(一秒钟)带数字的方块以打开所有被覆盖的方块(如果所有地雷都已标记)。这相当于Windows上的中间点击。
  • 没有标记方块的图标。标记方块用F符号表示。
  • 去吧,玩玩看,并给出您的反馈。

总结

在文章的范围内解释一个功能齐全的游戏并不容易。我已经尽力解释了扫雷游戏的工作原理以及如何处理游戏的一些重要部分。完整的游戏源代码已附上。我们将游戏局限于初级模式,但实现中级、专家和自定义模式也非常容易。这只需要对代码进行一些修改。请随意使用和扩展它。请提供您的反馈和建议。

资源

用于创建扫雷游戏GUI的图像属于其各自的所有者。图像来源如下:

历史

  • 2010年9月28日:初稿。
© . All rights reserved.