[toc]
0 背景
最近看《最强大脑》,看到其中的“数字华容道”这个小游戏挺有意思,于是萌生了自己写一个的想法,正好结合之前的文章《Android开发艺术探索》第4章 View的工作原理 ,顺便复习一下。
GitHub链接:https://github.com/LittleFogCat/Shuzihuarongdao
说做就做。
经过一夜的粗制滥造,初版已经完成,现在复盘一下详细过程。
0.1 游戏介绍
在4x4的方格棋盘中,摆放了115一共十五个棋子。玩家需要在最短时间内,移动棋子将115按顺序排列好。
1 结构
本文app结构很简单,分为三个界面:目录,游戏,高分榜。分别对应的是MenuAcitivity、GameActivity、HighScoreActivity。其中MenuActivity为主界面。
2 定义棋盘和棋子
新建棋盘类BoardView
,继承自ViewGroup。在xml文件中直接加入BoardView即可。
新建棋子类CubeView
,继承自TextView。
1.0 棋子
棋子只包含一个数字,所以简单的继承自TextView即可。由于我们还需要比对棋子是否在正确的位置,所以我们还需要给每个棋子加上数字和位置属性。
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;
}
}
这里,我们定义了一个类Position,用于描述棋子在棋盘中的位置。
class Position {
int sizeX; // 总列数
int sizeY; // 总行数
int x; // 横坐标
int y; // 纵坐标
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);
}
/**
* 移动到下一个位置
*/
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系统屏幕坐标系,以棋盘左上角为零点,每向右一格横坐标加一,每向下一格纵坐标加一。如图:
接下来,我们开始定义棋盘View:BoardView,这也是这个游戏的重头戏。
2.1 棋盘属性
首先,考虑需要添加哪些属性。由于时间关系,我这里只加入了棋盘尺寸。
在style.xml文件中加入:
<declare-styleable name="BoardView">
<attr name="sizeH" format="integer" />
<attr name="sizeV" format="integer" />
</declare-styleable>
其中sizeH为棋盘列数,sizeV为棋盘行数。(默认4x4大小,以下文中均以4x4为例)
分别对应BoardView的mSizeX
和mSizeY
属性。
2.2 排列棋子
首先我们新建一个cube_view.xml
,作为单颗棋子的布局。在BoardView的构造方法中,我们使用LayoutInflater将总共15颗棋子加载出来,并指定它们的位置,逐一保存在mChildren数组中。
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
。这个位置很关键,因为我们之后的的操作中都是围绕这个空格来的。
measure和layout的过程很简单,这里由于是自己使用,假定宽高都是定值。因为之前所有的CubeView都没有定义宽高,默认是0,所以在onMeasure中,我们使用BoardView的宽除以列数,高除以行数,得到每颗棋子的宽高并给其赋值。这样处理虽然很粗放,但是只是试玩的话并没有什么影响。
@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);
}
}
至此,棋子在棋盘中就已经排列好了。
3 生成棋局
一开始的时候,我考虑的是,生成1~15的不重复随机数,然后依次给CubeView赋值即可。即:
/**
* 用于生成不重复的随机数
*
* @deprecated 可能会生成不可解的情况
*/
public class RandomNoRepeat {
private List<Integer> mRandomArr;
/**
* 在一串连续整数中取随机值
*
* @param first 连续整数的第一个
* @param size 连续整数的数量
*/
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;
}
}
虽然看起来是能行得通的,但是在实际的游戏过程中,遇到了非常严重的问题,那就是会出现无解的死局,也就是说无论如何都不可能解出来的棋局。经过网上搜索之后证实了这个bug的存在,而且市面上流传的该类app很多都是有这个bug的!所以这个办法就被废弃掉了,得想一个新的方法。
由于必须是按照顺序放置然后打乱的棋局才能保证有解,不能随机乱放置,所以我就模拟手动打乱,写了一个新的棋局生成器:
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 列数
* @param sizeY 行数
*/
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);
}
}
原理很简单,因为空格的位置是唯一的,那么我们把空格的上下左右四个棋子随机找出一个,与空格互换位置,也就模拟了一次手动点击。当点击的次数足够多时(这里循环了10000次),就可以看做是已经打乱的棋盘了。
最后把生成好的棋盘,保存在一个二维数组中即可。
(因为有个10000次的循环,我担心时间过长,于是将其放在线程中执行,但是后来我觉得自己多此一举了。)
然后,在BoardView中定义一个setData方法,来把生成好的棋局装进来:
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),我们给每个棋子都定义了点击事件,模拟真实场景。具体来讲,就是当我们点击一个棋子的时候:如果棋子在空格周围,则将棋子移动到空格处;反之,则不进行任何操作。(如果设置滑动同理)
这样我们的Position类就派上用场了。
在2.1的init()
方法中,我们有这么一句:
view.setOnClickListener(v -> moveChildToBlank(view));
即是,当我们点击了其中一个棋子时,会触发moveChildToBlank(view)
方法。这个方法的目的正是上面所说。
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();
}
在移动棋子之后,我们需要检查一下是否是正确排列的顺序,如果是的话,那么表明游戏完成。
5 高分榜
首先创建HighScore类,包含姓名,用时,步数,时间。
public class HighScore {
public long useTime;
public long time;
public String name = "匿名";
public int useStep;
}
高分榜使用SharedPreferences+Gson,将一个List<HighScore>转换为json形式保存在本地。
最佳成绩的记录是在GameActivity中完成的。流程如下:
- 进入界面,开始生成棋局,同时读取本地高分榜;
- 生成棋局完成,开始记录游戏时间;
- 棋局完成,记录结束时间,计算游戏用时;
- 比对本地最佳成绩和本次成绩,计算是否打破记录及保存;
- 如果进入最佳成绩榜,输入姓名并保存。
总的来说,逻辑简单清晰。
6 作弊&后记
自己开发的自然是需要作弊功能了!暂且不表。
由于只用了一个晚上完成,所以还很粗糙,很多功能不够完善,而且也没做适配和测试,难免会有bug存在。主要是把思路记录下来,方便以后自己和他人做个参考。
数字华容道GitHub地址:https://github.com/LittleFogCat/Shuzihuarongdao