[toc]
0 背景
最近看《最強(qiáng)大腦》,看到其中的“數(shù)字華容道”這個(gè)小游戲挺有意思,于是萌生了自己寫一個(gè)的想法,正好結(jié)合之前的文章《Android開發(fā)藝術(shù)探索》第4章 View的工作原理 ,順便復(fù)習(xí)一下。
GitHub鏈接:https://github.com/LittleFogCat/Shuzihuarongdao

說做就做。
經(jīng)過一夜的粗制濫造,初版已經(jīng)完成,現(xiàn)在復(fù)盤一下詳細(xì)過程。
0.1 游戲介紹
在4x4的方格棋盤中,擺放了115一共十五個(gè)棋子。玩家需要在最短時(shí)間內(nèi),移動(dòng)棋子將115按順序排列好。
1 結(jié)構(gòu)
本文app結(jié)構(gòu)很簡單,分為三個(gè)界面:目錄,游戲,高分榜。分別對應(yīng)的是MenuAcitivity、GameActivity、HighScoreActivity。其中MenuActivity為主界面。
2 定義棋盤和棋子
新建棋盤類BoardView,繼承自ViewGroup。在xml文件中直接加入BoardView即可。
新建棋子類CubeView,繼承自TextView。
1.0 棋子
棋子只包含一個(gè)數(shù)字,所以簡單的繼承自TextView即可。由于我們還需要比對棋子是否在正確的位置,所以我們還需要給每個(gè)棋子加上數(shù)字和位置屬性。
public class CubeView extends android.support.v7.widget.AppCompatTextView {
// ...
private Position mPosition;
private int mNumber;
public void setNumber(int n) {
mNumber = n;
setText(String.valueOf(n));
}
public int getNumber() {
return mNumber;
}
public Position getPosition() {
return mPosition;
}
public void setPosition(Position position) {
this.mPosition = position;
}
}
這里,我們定義了一個(gè)類Position,用于描述棋子在棋盤中的位置。
class Position {
int sizeX; // 總列數(shù)
int sizeY; // 總行數(shù)
int x; // 橫坐標(biāo)
int y; // 縱坐標(biāo)
public Position() {
}
Position(int sizeX, int sizeY) {
this.sizeX = sizeX;
this.sizeY = sizeY;
}
public Position(int sizeX, int sizeY, int x, int y) {
this.sizeX = sizeX;
this.sizeY = sizeY;
this.x = x;
this.y = y;
}
Position(Position orig) {
this(orig.sizeX, orig.sizeY, orig.x, orig.y);
}
/**
* 移動(dòng)到下一個(gè)位置
*/
boolean moveToNextPosition() {
if (x < sizeX - 1) {
x++;
} else if (y < sizeY - 1) {
x = 0;
y++;
} else {
return false;
}
return true;
}
@Override
public String toString() {
return "Position{" +
"x=" + x +
", y=" + y +
'}';
}
}
我們參考Android系統(tǒng)屏幕坐標(biāo)系,以棋盤左上角為零點(diǎn),每向右一格橫坐標(biāo)加一,每向下一格縱坐標(biāo)加一。如圖:

接下來,我們開始定義棋盤View:BoardView,這也是這個(gè)游戲的重頭戲。
2.1 棋盤屬性
首先,考慮需要添加哪些屬性。由于時(shí)間關(guān)系,我這里只加入了棋盤尺寸。
在style.xml文件中加入:
<declare-styleable name="BoardView">
<attr name="sizeH" format="integer" />
<attr name="sizeV" format="integer" />
</declare-styleable>
其中sizeH為棋盤列數(shù),sizeV為棋盤行數(shù)。(默認(rèn)4x4大小,以下文中均以4x4為例)
分別對應(yīng)BoardView的mSizeX和mSizeY屬性。
2.2 排列棋子
首先我們新建一個(gè)cube_view.xml,作為單顆棋子的布局。在BoardView的構(gòu)造方法中,我們使用LayoutInflater將總共15顆棋子加載出來,并指定它們的位置,逐一保存在mChildren數(shù)組中。
public class BoardView extends ViewGroup {
// ...
private CubeView[] mChildren;
private void init() {
mChildSize = mSizeX * mSizeY - 1;
mChildren = new CubeView[mChildSize];
Position p = new Position(mSizeX, mSizeY);
for (int i = 0; i < mChildSize; i++) {
final CubeView view = (CubeView) LayoutInflater.from(getContext()).inflate(R.layout.cube_view, this, false);
view.setPosition(new Position(p));
view.setOnClickListener(v -> moveChildToBlank(view));
addView(view);
p.moveToNextPosition();
mChildren[i] = view;
}
mBlankPos = new Position(mSizeX, mSizeY, mSizeX - 1, mSizeY - 1);
}
}
最后,我們記錄了沒有棋子的空格所在位置mBlankPos。這個(gè)位置很關(guān)鍵,因?yàn)槲覀冎蟮牡牟僮髦卸际菄@這個(gè)空格來的。
measure和layout的過程很簡單,這里由于是自己使用,假定寬高都是定值。因?yàn)橹八械腃ubeView都沒有定義寬高,默認(rèn)是0,所以在onMeasure中,我們使用BoardView的寬除以列數(shù),高除以行數(shù),得到每顆棋子的寬高并給其賦值。這樣處理雖然很粗放,但是只是試玩的話并沒有什么影響。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int w = getMeasuredWidth();
int h = getMeasuredHeight();
mChildWidth = w / mSizeX;
mChildHeight = h / mSizeY;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
CubeView v = (CubeView) getChildAt(i);
if (v == null) {
continue;
}
LayoutParams lp = v.getLayoutParams();
lp.width = mChildWidth;
lp.height = mChildHeight;
v.setLayoutParams(lp);
v.setTextSize(TypedValue.COMPLEX_UNIT_PX, mChildWidth / 3);
}
}
我是按照從左往右、從上往下的方式依次排列棋子,并且沒有考慮棋子的margin屬性,所以onLayout很簡單:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
CubeView v = (CubeView) getChildAt(i);
Position p = v.getPosition();
int left = p.x * mChildWidth;
int top = p.y * mChildHeight;
int right = left + mChildWidth;
int bottom = top + mChildHeight;
v.layout(left, top, right, bottom);
}
}
至此,棋子在棋盤中就已經(jīng)排列好了。
3 生成棋局
一開始的時(shí)候,我考慮的是,生成1~15的不重復(fù)隨機(jī)數(shù),然后依次給CubeView賦值即可。即:
/**
* 用于生成不重復(fù)的隨機(jī)數(shù)
*
* @deprecated 可能會(huì)生成不可解的情況
*/
public class RandomNoRepeat {
private List<Integer> mRandomArr;
/**
* 在一串連續(xù)整數(shù)中取隨機(jī)值
*
* @param first 連續(xù)整數(shù)的第一個(gè)
* @param size 連續(xù)整數(shù)的數(shù)量
*/
RandomNoRepeat(int first, int size) {
mRandomArr = new ArrayList<>();
for (int i = first; i < size + first; i++) {
mRandomArr.add(i);
}
Collections.shuffle(mRandomArr);
}
int nextInt() {
if (mRandomArr == null || mRandomArr.isEmpty()) {
return 0;
}
int i = mRandomArr.get(0);
mRandomArr.remove(0);
return i;
}
}
雖然看起來是能行得通的,但是在實(shí)際的游戲過程中,遇到了非常嚴(yán)重的問題,那就是會(huì)出現(xiàn)無解的死局,也就是說無論如何都不可能解出來的棋局。經(jīng)過網(wǎng)上搜索之后證實(shí)了這個(gè)bug的存在,而且市面上流傳的該類app很多都是有這個(gè)bug的!所以這個(gè)辦法就被廢棄掉了,得想一個(gè)新的方法。
由于必須是按照順序放置然后打亂的棋局才能保證有解,不能隨機(jī)亂放置,所以我就模擬手動(dòng)打亂,寫了一個(gè)新的棋局生成器:
public class BoardGenerator {
private static final int LEFT = 0;
private static final int UP = 1;
private static final int RIGHT = 2;
private static final int DOWN = 3;
private int[][] mBoard;
private int mSizeX;
private int mSizeY;
private int mBlankX;
private int mBlankY;
/**
* @param sizeX 列數(shù)
* @param sizeY 行數(shù)
*/
public BoardGenerator(int sizeX, int sizeY) {
mSizeX = sizeX;
mSizeY = sizeY;
mBoard = new int[sizeY][sizeX];
generate();
}
public void generate() {
int totalCount = mSizeX * mSizeY - 1;
int temp = 1;
for (int i = 0; i < mSizeY; i++) {
for (int j = 0; j < mSizeX; j++) {
mBoard[i][j] = temp;
temp++;
}
}
mBlankX = mSizeX - 1;
mBlankY = mSizeY - 1;
for (int i = 0; i < 10000; i++) {
moveRandomly();
}
while (mBlankX != mSizeX - 1) {
moveToRight(mBlankY, mBlankX);
mBlankX++;
}
while (mBlankY != mSizeY - 1) {
moveToDown(mBlankY, mBlankX);
mBlankY++;
}
if (mListener != null) {
mListener.onGenerated(mBoard);
}
}
private void moveRandomly() {
int r = RandomUtil.randomInt(0, 4);
switch (r) {
case LEFT:
if (moveToLeft(mBlankY, mBlankX)) {
mBlankX--;
}
break;
case UP:
if (moveToUp(mBlankY, mBlankX)) {
mBlankY--;
}
break;
case RIGHT:
if (moveToRight(mBlankY, mBlankX)) {
mBlankX++;
}
break;
case DOWN:
if (moveToDown(mBlankY, mBlankX)) {
mBlankY++;
}
break;
}
}
private void exchange(int a1, int b1, int a2, int b2) {
int temp = mBoard[a1][b1];
mBoard[a1][b1] = mBoard[a2][b2];
mBoard[a2][b2] = temp;
}
private boolean moveToLeft(int a, int b) {
if (b > 0) {
exchange(a, b, a, b - 1);
return true;
} else {
return false;
}
}
private boolean moveToRight(int a, int b) {
if (b < mSizeX - 1) {
exchange(a, b, a, b + 1);
return true;
} else {
return false;
}
}
private boolean moveToUp(int a, int b) {
if (a > 0) {
exchange(a, b, a - 1, b);
return true;
} else {
return false;
}
}
private boolean moveToDown(int a, int b) {
if (a < mSizeY - 1) {
exchange(a, b, a + 1, b);
return true;
} else {
return false;
}
}
private OnGeneratedListener mListener;
public void setOnGeneratedListener(OnGeneratedListener l) {
mListener = l;
}
public interface OnGeneratedListener {
void onGenerated(int[][] board);
}
}
原理很簡單,因?yàn)榭崭竦奈恢檬俏ㄒ坏模敲次覀儼芽崭竦纳舷伦笥宜膫€(gè)棋子隨機(jī)找出一個(gè),與空格互換位置,也就模擬了一次手動(dòng)點(diǎn)擊。當(dāng)點(diǎn)擊的次數(shù)足夠多時(shí)(這里循環(huán)了10000次),就可以看做是已經(jīng)打亂的棋盤了。
最后把生成好的棋盤,保存在一個(gè)二維數(shù)組中即可。
(因?yàn)橛袀€(gè)10000次的循環(huán),我擔(dān)心時(shí)間過長,于是將其放在線程中執(zhí)行,但是后來我覺得自己多此一舉了。)
然后,在BoardView中定義一個(gè)setData方法,來把生成好的棋局裝進(jìn)來:
public void setData(List<Integer> data) {
for (int i = 0; i < mChildSize; i++) {
CubeView child = (CubeView) getChildAt(i);
child.setNumber(data.get(i));
}
}
這樣,就完成了棋局的生成。
4 游戲過程
游戲過程基本是極簡的。
在初始化方法中(2.1),我們給每個(gè)棋子都定義了點(diǎn)擊事件,模擬真實(shí)場景。具體來講,就是當(dāng)我們點(diǎn)擊一個(gè)棋子的時(shí)候:如果棋子在空格周圍,則將棋子移動(dòng)到空格處;反之,則不進(jìn)行任何操作。(如果設(shè)置滑動(dòng)同理)
這樣我們的Position類就派上用場了。
在2.1的init()方法中,我們有這么一句:
view.setOnClickListener(v -> moveChildToBlank(view));
即是,當(dāng)我們點(diǎn)擊了其中一個(gè)棋子時(shí),會(huì)觸發(fā)moveChildToBlank(view)方法。這個(gè)方法的目的正是上面所說。
public void moveChildToBlank(CubeView child) {
Position childPos = child.getPosition();
Position dstPos = mBlankPos;
if (childPos.x == dstPos.x && Math.abs(childPos.y - dstPos.y) == 1 ||
childPos.y == dstPos.y && Math.abs(childPos.x - dstPos.x) == 1) {
child.setPosition(dstPos);
child.setX(dstPos.x * mChildWidth);
child.setY(dstPos.y * mChildHeight);
mBlankPos = childPos;
mStepCounter.add();
}
checkPosition();
}
在移動(dòng)棋子之后,我們需要檢查一下是否是正確排列的順序,如果是的話,那么表明游戲完成。
5 高分榜
首先創(chuàng)建HighScore類,包含姓名,用時(shí),步數(shù),時(shí)間。
public class HighScore {
public long useTime;
public long time;
public String name = "匿名";
public int useStep;
}
高分榜使用SharedPreferences+Gson,將一個(gè)List<HighScore>轉(zhuǎn)換為json形式保存在本地。
最佳成績的記錄是在GameActivity中完成的。流程如下:
- 進(jìn)入界面,開始生成棋局,同時(shí)讀取本地高分榜;
- 生成棋局完成,開始記錄游戲時(shí)間;
- 棋局完成,記錄結(jié)束時(shí)間,計(jì)算游戲用時(shí);
- 比對本地最佳成績和本次成績,計(jì)算是否打破記錄及保存;
- 如果進(jìn)入最佳成績榜,輸入姓名并保存。
總的來說,邏輯簡單清晰。
6 作弊&后記
自己開發(fā)的自然是需要作弊功能了!暫且不表。
由于只用了一個(gè)晚上完成,所以還很粗糙,很多功能不夠完善,而且也沒做適配和測試,難免會(huì)有bug存在。主要是把思路記錄下來,方便以后自己和他人做個(gè)參考。
數(shù)字華容道GitHub地址:https://github.com/LittleFogCat/Shuzihuarongdao