Android 谜题求解器






4.87/5 (17投票s)
Puzzles Solver 是一款用于玩和解决谜题的 Android 应用程序。

引言
Puzzles Solver 是我一段时间前上传到 Android Market 的应用程序。该应用程序的灵感来自 CodeProject 上的一篇旧文章。在那篇文章中,我介绍了一些著名的谜题,并提供了一个计算机解谜的机制。这些谜题是用 Java Applets 实现的,因此很容易移植到 Android 平台。该应用程序提供了以下游戏:
- 八皇后问题:玩家需要在国际象棋棋盘上放置 8 个皇后,使得任意两个皇后都不能互相攻击。
- 马踏棋盘:让马走遍棋盘的所有方格,然后回到起点。
- 俄罗斯跳棋:跳过棋子以移除它们。移除所有棋子,只剩最后一个。
该应用程序为所有游戏提供了变体。它还可以为您解谜。不使用预先存储的解决方案。相反,应用程序会尝试通过应用简单的暴力搜索算法来即时计算解决方案。
在本文中,我想介绍我在开发应用程序时所做的所有设计决策和遵循的模式。我希望读者能够从中受益,并在自己的应用程序中重新使用它们。
UI 流程图
下图展示了应用程序活动的线框图。在开始设计新应用程序时,最好先草绘出应用程序的外观。这将有助于您及早发现用户界面挑战。它还有助于将视觉组件映射到 Java 类。

在本文的上下文中,线框图有助于展示一些重要的用户界面模式。该应用程序可以快速启动,因此不需要启动屏幕。应用程序立即从主屏幕开始,该屏幕仅显示一行按钮。该屏幕提供了应用程序功能的视觉线索(开始一个谜题、查看分数、获取帮助),并允许用户通过一两次触摸进行访问。当然,您可以将第一个屏幕设计得比一行按钮更有趣,但几乎所有移动应用程序都应该使所有重要功能在第一个屏幕上易于访问,这一点几乎不会例外。第一个屏幕还提供了一个菜单,但这并非必需。菜单提供与菜单相同的功能。只有关于框隐藏在其中。您可以将此类功能隐藏在菜单中,以节省屏幕空间,避免分散用户注意力。
从主屏幕到谜题和分数的过渡(每个谜题都有不同的分数屏幕)是通过列表完成的。按下按钮后,将出现一个可用谜题列表。另一种解决方案是使用单独的屏幕(由新活动实现)来呈现所有选项。如果有许多游戏选择(例如在线游戏、计时和非计时模式、关卡选择),这将是理想的选择。
游戏引擎
游戏引擎基于我早在 2004 年在 CodeProject 上写的一篇文章。在同一个应用程序中支持多个谜题的想法是创建一个实现所有通用功能的抽象
类。对于每个单独的谜题,都有一个抽象
类的具体实现。以下是抽象
类中实现的 solve
方法的摘录(为演示目的略有修改)。
while(!searched_all && movesMade() != movesTableSize) {
if (!findNextMove()) {
searched_all = !goBack();
while(!searched_all &&
movesMade() == 0) {
searched_all = !goBack();
}
}
}
这个 while
循环是求解器算法的核心。各个谜题实现了其中调用的方法,即 movesMade()
、goBack()
和 findNextMove()
。这实际上是模板方法设计模式的一种形式。
抽象
Puzzle
类还用于为单个 Puzzle
类的调用者提供接口。具体实现仅在初始化谜题时出现。以下类图展示了应用程序的主要类,即 PuzzleActivity
、PuzzleView
和 Puzzle
。

在 PuzzleActivity
的 onCreate
方法中,初始化了一个 PuzzleView
实例。同时创建一个 Puzzle
对象并将其注入 PuzzleView
。
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
Bundle bundle = intent.getExtras();
gameType = bundle.getInt("GameType", 0);
switch(gameType) {
case 0:
puzzle = new Q8Puzzle(this);
break;
case 1:
puzzle = new NumberSquarePuzzle(this);
break;
case 2:
puzzle = new SoloPuzzle(this);
break;
}
timeCounter = new TimeCounter();
puzzle.init();
puzzleView = new PuzzleView(this);
puzzleView.setPuzzle(puzzle);
puzzleView.setTimeCounter(timeCounter);
setContentView(puzzleView);
timerHandler = new Handler();
replayHandler = new Handler();
scoresManager = ScoresManagerFactory.getScoresManager(getApplicationContext());
}
这是 Puzzle
具体实现唯一出现的地方。负责服务用户界面的 PuzzleView
通过调用 Puzzle.draw
和 Puzzle.onTouchEvent
方法来完成工作。如果需要添加新谜题,只需添加一个新的谜题 case
语句即可。
在文章的第二个版本中,我添加了对 Solo 谜题创建自定义棋盘的支持。这导致添加了一些特殊处理。
case R.id.custom_boards:
if (puzzle instanceof SoloPuzzle) {
if (soloPuzzleRepository != null &&
soloPuzzleRepository.getCustomBoardsCount() > 0) {
Intent editIntent = new Intent
("gr.sullenart.games.puzzles.SOLO_EDIT_BOARDS");
startActivity(editIntent);
}
else {
SoloCustomBoardActivity.showDirections = true;
Intent addIntent = new Intent
("gr.sullenart.games.puzzles.SOLO_ADD_BOARD");
startActivity(addIntent);
}
}
然而,也可以扩展基类以支持自定义棋盘。毕竟,添加自定义棋盘是许多谜题的常见功能。这样,特殊处理就消失了。
支持多屏幕
由于 Android 运行在各种设备上,因此应用程序能够支持不同的屏幕尺寸和密度非常重要。官方文档提供了关于屏幕支持过程的详细指南。这是一个不断发展的文档,因为随着 SDK 每个新版本的发布,都会增加新的屏幕尺寸和密度。
除了那里描述的实践之外,我将在本文中介绍一种使用图块绘制用户界面的技术。图块是小的矩形图像(例如 80x80 或 100x100 像素)。用户界面是通过水平和垂直重复这些图像构建的。这项技术利用了 Android 中可以轻松动态调整图像大小的事实。因此,一组图块可以支持各种屏幕尺寸以及纵向和横向方向。不过,我应该指出,这项技术适用于类似谜题的游戏或其他应用程序,其中用户界面的变化很慢。对于变化迅速的用户界面,使用它可能效率不高。这项技术还提供了一种支持应用程序主题的方法。
让我们从使用图块在布局中创建背景开始。这和定义一个可绘制的 XML 一样简单:
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="AndroidPuzzlesSolver/@drawable/bg_tile"
android:tileMode="repeat"
android:dither="true" />
然后在布局 XML 中:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="vertical"
android:background="@drawable/background">
这解决了使用布局创建用户界面时的问题。然而,对于谜题和简单的游戏,通常在 View 的 onDraw
方法中绘制所有内容。调用以下方法将在 onDraw
开始时在画布上绘制背景:
private void drawBackgroundRepeat(Canvas canvas, Bitmap bgTile) {
float left = 0, top = 0;
float bgTileWidth = bgTile.getWidth();
float bgTileHeight = bgTile.getWidth();
while (left < screenWidth) {
while (top < screenHeight) {
canvas.drawBitmap(bgTile, left, top, null);
top += bgTileHeight;
}
left += bgTileWidth;
top = 0;
}
}
为了使用图像作为图块来绘制跨多种屏幕尺寸的用户界面,需要进行一些调整。下图展示了屏幕的组织结构。虚线表示背景图块。由于这只是相同图像的重复,并且不需要在边缘显示完整图像,因此无需调整大小。实线表示必须放置游戏图块以形成游戏棋盘的位置。

例如,Solo 谜题屏幕是用以下图块绘制的。所有图像都是使用Gimp生成的。
类型 | 木纹主题 | 大理石主题 |
---|---|---|
棋盘图块 | ![]() |
![]() |
孔洞图块 | ![]() |
![]() |
棋盘上的棋子 | ![]() |
![]() |
选中的棋子 | ![]() |
![]() |
选中的孔洞 | ![]() |
![]() |
首先,让我们看看 ImageResizer
类。
public class ImageResizer {
private Matrix matrix;
public void init(int oldWidth, int oldHeight, float newWidth, float newHeight) {
float scaleWidth = newWidth / oldWidth;
float scaleHeight = newHeight / oldHeight;
matrix = new Matrix();
matrix.postScale(scaleWidth, scaleHeight);
}
public Bitmap resize(Bitmap bitmap) {
if (matrix == null) {
return bitmap;
}
int width = bitmap.getWidth();
int height = bitmap.getHeight();
Bitmap resizedBitmap = Bitmap.createBitmap(bitmap, 0, 0,
width, height, matrix, true);
return resizedBitmap;
}
}
这是一个实用类,提供了一种动态调整位图大小的方法。该类的实例需要在提供原始位图和调整大小后的位图的尺寸后进行初始化。该类在谜题 View 的 onSizeChanged
方法中使用。每当屏幕尺寸发生变化时(例如,发生方向更改或首次显示屏幕时),都会调用 onSizeChanged
方法。在此方法中,从应用程序资源中加载图块图像。根据主题,加载不同的图像。然后将加载的位图调整大小以适应现有屏幕,并将其保留在内存中。图像在每个谜题的 draw
方法的画布上绘制。
public void onSizeChanged(int w, int h) {
super.onSizeChanged(w, h);
int boardSize = (boardRows > boardColumns) ? boardRows : boardColumns;
if (w < h) {
tileSize = (w - 10) / boardSize;
}
else {
tileSize = (h - 10) / boardSize;
}
offsetX = (screenWidth - tileSize*boardColumns)/2;
offsetY = (screenHeight - tileSize*boardRows)/2;
imageResizer = new ImageResizer();
if (theme.equals("marble")) {
emptyImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.marble_tile);
tileImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.wood_sphere);
tileSelectedImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.golden_sphere);
}
else {
emptyImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.wood_tile);
tileImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.glass);
tileSelectedImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.glass_selected);
}
freePosImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.hole);
freePosAllowedMoveImage = BitmapFactory.decodeResource(context.getResources(),
R.drawable.hole_move);
imageResizer.init(emptyImage.getWidth(), emptyImage.getHeight(),
tileSize, tileSize);
emptyImage = imageResizer.resize(emptyImage);
freePosImage = imageResizer.resize(freePosImage);
tileImage = imageResizer.resize(tileImage);
tileSelectedImage = imageResizer.resize(tileSelectedImage);
freePosAllowedMoveImage = imageResizer.resize(freePosAllowedMoveImage);
}
弹出窗口
在文章的第三个修订版中,我用一个更漂亮的弹出窗口替换了简单的列表谜题选择器。您可以在下图比较这两个选择器的区别。

新的弹出窗口在美学上更具吸引力。它还使谜题选择更快,因为它出现在比用户首次触摸的点更近的地方,并且不会使整个屏幕变灰。它还可以轻松实现动画。要创建弹出窗口,您需要从布局 XML 文件开始,就像处理对话框或视图一样。为布局定义背景很重要,因为 Android 的 PopupWindow
默认不提供背景。在 Java 代码中,您可以使用以下代码显示 PopupWindow
。
private void showGameSelectPopup(View parentView) {
LayoutInflater inflater = (LayoutInflater)
getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View layout = inflater.inflate(R.layout.puzzle_select, null, false);
gameSelectPopupWindow = new PopupWindow(this);
gameSelectPopupWindow.setTouchInterceptor(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
gameSelectPopupWindow.dismiss();
gameSelectPopupWindow = null;
return true;
}
return false;
}
});
gameSelectPopupWindow.setWidth(WindowManager.LayoutParams.WRAP_CONTENT);
gameSelectPopupWindow.setHeight(WindowManager.LayoutParams.WRAP_CONTENT);
gameSelectPopupWindow.setTouchable(true);
gameSelectPopupWindow.setFocusable(true);
gameSelectPopupWindow.setOutsideTouchable(true);
gameSelectPopupWindow.setContentView(layout);
// Set click listeners by calling layout.findViewById()
// layout.findViewById(R.id.queens_select).setOnClickListener();
int [] location = new int [] {0, 0};
parentView.getLocationInWindow(location);
int width = parentView.getWidth()/2;
int x = location[0] - width/2;
int y = location[1] + parentView.getHeight();
gameSelectPopupWindow.setAnimationStyle(R.style.AnimationPopup);
gameSelectPopupWindow.showAtLocation(layout, Gravity.NO_GRAVITY, x, y);
}
@Override
public void onStop() {
if (gameSelectPopupWindow != null) {
gameSelectPopupWindow.dismiss();
}
super.onStop();
}
show
方法接受一个视图作为参数,该视图是启动操作的 Button
或其他项目。这允许将 PopupWindow
相对于用户首次单击的点进行定位。最初,我们通过膨胀布局来创建 PopupWindow
的视图。然后创建 PopupWindow
对象并进行相应配置。我们使用父视图的 getLocationInWindow()
、getWidth()
和 getHeight()
方法来正确设置 Popup
的位置。最后,我们设置动画样式并显示 Popup
窗口。如果用户单击窗口外部,它将被关闭。为了避免窗口泄露,当活动停止时,我们需要检查 popup
是否可见并手动关闭它。例如,如果 Popup
可见时方向发生更改,就会发生这种情况。
另一个棘手的问题是动画样式。这必须在 values
文件夹中的 styles.xml 文件中定义,并具有以下形式:
<resources>
<style name="AnimationPopup">
<item name="@android:windowEnterAnimation">@anim/popup_show</item>
<item name="@android:windowExitAnimation">@anim/popup_hide</item>
</style>
</resources>
保存分数
为了存储用户的分数,您需要一个持久化机制。Android 平台的持久化选项在文档中进行了描述。在这些选项中,最适合存储分数的是 SQLite 数据库。以下语句展示了我使用的表架构。
private static final String DATABASE_CREATE =
"create table Scores (_id integer primary key autoincrement, " +
"game text not null, category text not null,
player text not null, date text, " +
"score integer not null);";
为了将数据库与其余代码分离,我创建了一个 ScoresManager
类,该类提供了访问分数的方法。
public class ScoresManager {
public boolean addScore(String game, String group, String player, int score);
public boolean isHighScore(String game, int score);
public List<Score> getScores(String game);
public List<Score> getScoresByGroup(String group);
}
我应该指出,Android 上有许多开源分数库。您可以找到经过充分测试的库,它们提供了丰富的功能,包括与 Web 分数系统的集成。如果不是为了学习如何与 SQLite 数据库交互,最好使用其中一个来满足您的评分需求。
设置
另一个持久化需求是存储用户的选择。由于每个谜题都有许多变体,因此应用程序能够记住用户上次玩过的游戏版本会很好。您可以使用 SharedPreferences
在 Android 中轻松实现这一点。
SharedPreferences
在每个谜题的单独 XML 文件中定义。Puzzle
抽象
类定义了 configure
方法。具体类实现此方法以读取用户设置。
public boolean configure(SharedPreferences preferences)
Android 提供了 PreferenceActivity
,您可以非常轻松地扩展它以创建用于编辑设置的简单用户界面。
public class PuzzleOptionsActivity extends PreferenceActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
Bundle bundle = intent.getExtras();
int gameResources = bundle.getInt("GameResources", 0);
addPreferencesFromResource(gameResources);
}
}
当然,这并非利用 SharedPreferences
的唯一方法。您可以创建一个精美的用户界面,并使用 Java 代码读写 SharedPreferences
。在 Puzzles Solver 中,我使用了一个 PreferencesScreen
,但实际上并没有显示任何内容,而是存储用户的姓名。当用户获得高分时,询问她输入姓名的祝贺屏幕会预先填充以前使用的姓名。
本地化
该应用程序支持英语、德语和希腊语。本地化元素包括应用程序的文本(存储在 values、values-de 和 values-el 文件夹中的 strings.xml 文件中)、应用程序的徽标(存储在 drawable、drawable-de、drawable-el 文件夹中)以及应用程序的帮助页面,该页面以 HTML 页面的形式存储在 assets 文件夹中(稍后详细介绍)。
帮助
提供应用程序使用说明非常重要。当然,应用程序应该易于使用,界面直观,这样用户就可以在不阅读帮助文件的情况下开始使用应用程序。这不是没有良好帮助页面的理由。一些用户可能不熟悉您的游戏或应用程序的概念,需要一些指导。此外,您可能可以在帮助页面上提供一些技巧和窍门,即使是高级用户也会觉得有用。
我相信使用 WebView 显示 HTML 文件是在 Android 应用程序中实现帮助页面的理想解决方案。所需的 Java 代码量最少,然后您只需要 assets 文件夹中的一个或多个 HTML 文件。编写 HTML 帮助页面非常容易。设置文本样式和添加图像非常简单。您还可以重用描述您应用程序的现有页面。使用 HTML 作为帮助页面非常简单,这让我不禁想知道为什么许多应用程序仍然更喜欢使用其他帮助系统,例如在对话框中显示所有信息。
如果您想支持多种语言,那么您将需要不同版本的 HTML 页面。将所有页面放在 assets 文件夹中。然后在 strings.xml 中,为每种语言定义页面名称。
<string name="help_file_name">index.html</string>
在 WebView
中加载页面时,从资源中读取名称。
webview.loadUrl("file:///android_asset/" +
getResources().getString(R.string.help_file_name));
不显眼的广告
对于移动应用程序,尤其是免费分发的应用程序,展示某种形式的广告非常普遍。这些广告可以为开发者创收,同时仍允许其免费分发应用程序。有许多广告框架可供选择。对于 PuzzleSolver
,我选择了AdMob。无论您选择哪个框架,都必须确保广告不会分散用户的注意力。在游戏中,广告只能显示在辅助屏幕(游戏选择、分数、帮助)上,而不能显示在游戏屏幕上。广告不应分散用户玩游戏的注意力。
为了能够在多个 Activity 中显示广告,而无需重复相同的代码,我创建了一个名为 AdsManager
的实用类。AdsManager
实现 AdListener
并提供 addAdsView
方法,该方法设置发布者 ID、测试设备的 ID 并将请求添加到视图。
public class AdsManager implements AdListener {
private String publisherId = "your publisher id here";
public void addAdsView(Activity activity, LinearLayout layout) {
AdView adView;
int screenLayout = activity.getResources().getConfiguration().screenLayout;
if ((screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= 3) {
adView = new AdView(activity, AdSize.IAB_LEADERBOARD, publisherId);
}
else {
adView = new AdView(activity, AdSize.BANNER, publisherId);
}
layout.addView(adView);
AdRequest request = new AdRequest();
request.addTestDevice(AdRequest.TEST_EMULATOR);
request.addTestDevice("Your test device id here - Find the id in Log Cat");
adView.loadAd(request);
}
请注意,对不同屏幕尺寸进行了区分。对于小屏幕和普通屏幕(值 1 和 2),显示横幅广告。对于大屏幕和特大屏幕(值 3 和 4),显示排行榜广告。
此方法在显示广告的每个活动的 onCreate
方法中调用:
LinearLayout layout = (LinearLayout)findViewById(R.id.banner_layout);
(new AdsManager()).addAdsView(this, layout);
历史
- 2012 年 10 月
- 为 Solo 添加了对三角形棋盘和对角线移动的支持。
- 棋盘上的按钮(撤销、重玩、求解)。
- 当没有更多可用移动时,会出现一条消息。
- 2012 年 7 月:修复了 bug。新图标。使用最新版本的 Android 工具。Admob jar 包含在 libs 文件夹中。
- 2012 年 4 月:新的分数屏幕。对大屏幕提供更好的支持,包括 AdMob 的更改。一个新的 Solo 主题。在马的旅行中显示连接线的选项。消除了大多数 Lint 警告。
- 2012 年 1 月:添加了新的弹出窗口用于谜题选择。
- 2012 年 1 月:添加了对创建 Solo 自定义棋盘的支持。修复 bug。
- 文章的第一个版本