安卓自定义view - 2048 小游戏

为了学习自定义 ViewGroup,正碰巧最近无意间玩了下 2048 的游戏,因此这里就来实现一个 2048 小游戏。想必很多人应该是玩过这个游戏的,如果没有玩过的可以下载玩一下。下图是我实现的效果。

2048 游戏规则

游戏规则比较简单,共有如下几个步骤:

  1. 向一个方向移动,所有格子会向那个方向移动
  2. 相同的数字合并,即相加
  3. 每次移动时,空白处会随机出现一个数字2或4
  4. 当界面不可移动时,即格子被数字填满,游戏结束,网格中出现 2048 的数字游戏胜利,反之游戏失败。

2048 游戏算法

算法主要是讨论上下左右四个方向如何合并以及移动,这里我以向左和向上来说明,而向下和向右就由读者自行推导,因为十分相似。

向左移动算法

先来看下面两张图,第一张是初始状态,可以看到网格中有个数字 2。在这里用二维数组来描述。它的位置应该是第2行第2列 。第二张则是它向左移动后的效果图,可以看到 2 已经被移动到最左边啦!

我们最常规的想法就是首先遍历这个二维数组,找到这个数的位置,接着合并和移动。所以第一步肯定是循环遍历。

int i;
for (int x = 0; x < 4; x++) {
for (int y = 0; y < 4; ) {
Model model = models[x][y];
int number = model.getNumber();
if (number == 0) {
y++;
continue;
} else {
// 找到不为零的位置. 下面就需要进行合并和移动处理

}
}
}


上面的代码非常简单,这里引入了 Model 类,这个类是封装了网格单元的数据和网格视图。定义如下:先不纠结视图的绘制,我们先把算法理清楚,算法搞明白了也就解决一大部分了,其他就是自定义 View 的知识。上述的过程就是,遍历整个网格,找到不为零的网格位置。


public class Model {

private int number;
/**
* 单元格视图.
*/

private CellView cellView;

public Model(int number, CellView cellView) {
this.number = number;
this.cellView = cellView;
}

public int getNumber() {
return number;
}

public void setNumber(int number) {
this.number = number;
}

public CellView getCellView() {
return cellView;
}

public void setCellView(CellView cellView) {
this.cellView = cellView;
}
}




让我们来思考一下,合并要做什么,那么我们再来看一张图。

从这张图中我们可以看到在第一行的最后两个网格单元都是2,当向左移动时,根据 2048 游戏规则,我们需要将后面的一个2 和前面的 2 进行合并(相加)运算。是不是可以推理,我们找到第一个不为零的数的位置,然后找到它右边第一个不为零的数,判断他们是否相等,如果相等就合并。算法如下:

int i;
for (int x = 0; x < 4; x++) {
for (int y = 0; y < 4; ) {
Model model = models[x][y];
int number = model.getNumber();
if (number == 0) {
y++;
continue;
} else {
// 找到不为零的位置. 下面就需要进行合并和移动处理
// 这里的 y + 1 就是找到这个数的右侧
for (i = y + 1; i < 4; i++) {
if (models[x][i].getNumber() == 0) {
continue;
} else if (models[x][y].getNumber() == models[x][i].getNumber()) {
// 找到相等的数
// 合并,相加操作
models[x][y].setNumber(
models[x][y].getNumber() + models[x][i].getNumber())

// 将这个数清0
models[x][i].setNumber(0);

break;
} else {
break;
}
}

// 防止陷入死循环,所以必须要手动赋值,将其跳出。
y = i;
}
}
}


通过上面的过程,我们就将这个数右侧的第一个相等的数进行了合并操作,是不是也好理解的。不理解的话可以在草稿纸上多画一画,多推导几次。

搞定了合并操作,现在就是移动了,移动肯定是要将所有数据的单元格都移动到左侧,移动的条件是,找到第一个不为零的数的坐标,继续向前找到第一个数据为零即空白单元格的位置,将数据覆盖它,并将后一个单元格数据清空。算法如下:

for (int x = 0; x < 4; x++) {
for (y = 0; y < 4; y++) {
if (models[x][y].getNumber() == 0) {
continue;
} else {
// 找到当前数前面为零的位置,即空格单元
for (int j = y; j >= 0 && models[x][j - 1].getNumber() == 0; j--) {
// 数据向前移动,即数据覆盖.
models[j - 1][y].setNumber(
models[j][y].getNumber())
// 清空数据
models[j][y].setNumber(0)
}
}
}
}

到此向左移动算法完毕,接着就是向上移动的算法。

向上移动算法

有了向左移动的算法思维,理解向上的操作也就变得容易一些啦!首先我们先来看合并,合并的条件也就是找到第一个不为零的数,然后找到它下一行第一个不为零且相等的数进行合并。算法如下:

int i = 0;
for (int y = 0; y < 4; y++) {
for (x = 3; x >= 0; ) {
if (models[x][y].getNumber() == 0) {
continue;
} else {
for (i = x + 1; i < 4; i++) {
if (models[i][y].getNumber() == 0) {
continue;
} else if (models[x][y].getNumber() == models[i][y].getNumber()) {
models[x][y].setNumber(
models[x][y].getNumber() + models[i][y].getNumber();
)

models[i][y].setNumber(0);

break;
} else {
break;
}
}
}
}
}


移动的算法也类似,即找到第一个不为零的数前面为零的位置,即空格单元的位置,将数据覆盖并将后一个单元格的数据清空。

for (int x = 0; x < 4; x++) {
for (int y = 0; y < 4; y++) {
if (models[x][y].getNumber() == 0) {
continue;
} else {
for (int j = x; x >
0 && models[j - 1][y].getNumber() == 0; j--) {
models[j -1][y].setNumber(models[j][y].getNumber());

models[j][y].setNumber(0);
}
}
}
}


到此,向左移动和向上移动的算法就描述完了,接下来就是如何去绘制视图逻辑啦!

网格单元绘制

首先先忽略数据源,我们只是单纯的绘制网格,有人可能说了我们不用自定义的方式也能实现,我只想说可以,但是不推荐。如果使用自定义 ViewGroup,将每一个小的单元格作为单独的视图。这样扩展性更好,比如我做了对随机显示的单元格加上动画。

既然是自定义 ViewGroup, 那我们就创建一个类并继承 ViewGroup,其定义如下:

public class Play2048Group extends ViewGroup {

public Play2048Group(Context context) {
this(context, null);
}

public Play2048Group(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public Play2048Group(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

......
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
.....
}

}


我们要根据子视图的大小来测量容器的大小,在 onLayout 中摆放子视图。为了更好的交给其他开发者使用,我们尽量可以让 view 能被配置。那么就要自定义属性。

  1. 自定义属性

这里只是提供了设置网格单元行列数,其实这里我我只取两个值的最大值作为行列的值。













  1. 布局中加载自定义属性

可以看到将传入的 row 和 column 取大的作为行列数。

public Play2048Group(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Play2048Group);

try {
mRow = a.getInteger(R.styleable.Play2048Group_row, 4);
mColumn = a.getInteger(R.styleable.Play2048Group_column, 4);
// 保持长宽相等排列, 取传入的最大值
if (mRow > mColumn) {
mColumn = mRow;
} else {
mRow = mColumn;
}

init();

} catch (Exception e) {
e.printStackTrace();
} finally {
a.recycle();
}
}


  1. 网格子视图

因为整个网格有一个个网格单元组成,其中每一个网格单元都是一个 view, 这个 view 其实也就只是绘制了一个矩形,然后在矩形的中间绘制文字。考虑文章篇幅,我这里只截取 onMeasure 和 onDraw 方法。

 @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

// 我这里直接写死了,当然为了屏幕适配,这个值应该由外部传入的,
// 这里就当我留下的作业吧 😄
setMeasuredDimension(130, 130);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

// 绘制矩形.
canvas.drawRect(0, 0, getMeasuredWidth(), getMeasuredHeight(), mPaint);

// 如果当前单元格的数据不为0,就绘制。
// 如果为零,就使用背景的颜色作为画笔绘制,这么做就是为了不让它显示出来😳
if (!mNumber.equalsIgnoreCase("0")) {
mTextPaint.setColor(Color.parseColor("#E451CD"));
canvas.drawText(mNumber,
(float) (getMeasuredWidth() - bounds.width()) / 2,
(float) (getMeasuredHeight() / 2 + bounds.height() / 2), mTextPaint);
} else {
mTextPaint.setColor(Color.parseColor("#E4CDCD"));
canvas.drawText(mNumber,
(float) (getMeasuredWidth() - bounds.width()) / 2,
(float) (getMeasuredHeight() / 2 + bounds.height() / 2), mTextPaint);
}
}



  1. 测量容器视图

由于网格是行列数都相等,则宽和高都相等。那么所有的宽加起来除以 row, 所有的高加起来除以 column 就得到了最终的宽高, 不过记得要加上边距。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int width = 0;
int height = 0;

int count = getChildCount();

MarginLayoutParams layoutParams =
(MarginLayoutParams)getChildAt(0).getLayoutParams();

// 每一个单元格都有左边距和上边距
int leftMargin = layoutParams.leftMargin;
int topMargin = layoutParams.topMargin;

for (int i = 0; i < count; i++) {
CellView cellView = (CellView) getChildAt(i);
cellView.measure(widthMeasureSpec, heightMeasureSpec);

int childW = cellView.getMeasuredWidth();
int childH = cellView.getMeasuredHeight();

width += childW;
height += childH;
}

// 需要加上每个单元格的左边距和上边距
setMeasuredDimension(width / mRow + (mRow + 1) * leftMargin,
height / mRow + (mColumn + 1) * topMargin);
}


  1. 布局子视图(网格单元)

布局稍微麻烦点,主要是在换行处的计算有点绕。首先我们找一下什么时候是该换行了,如果是 4 * 4 的 16 宫格,我们可以知道每一行的开头应该是 0、4、8、12,如果要用公式来表示的就是: temp = mRow * (i / mRow), 这里的 mRow 为行数,i 为索引。

我们这里首先就是要确定每一行的第一个视图的位置,后面的视图就好确定了, 下面是推导过程:

第一行: 
网格1:
left = lefMargin;
top = topMargin;
right = leftMargin + width;
bottom = topMargin + height;

网格2:
left = leftMargin + width + leftMargin
top = topMargin;
right = leftMargin + width + leftMargin + width
bottom = topMargin + height

网格3:
left = leftMargin + width + leftMargin + width + leftMargin
right = leftMargin + width + leftMargin + width + leftMargin + width

...
第二行:
网格1:
left = leftMargin
top = topMargin + height
right = leftMargin + width
bottom = topMargin + height + topMargin + height

网格2:
left = leftMargin + width + leftMargin
top = topMargin + height + topMargin
right = leftMargin + width + lefMargin + width
bottom = topMargin + height + topMargin + height


上面的应该很简单的吧,这是根据画图的方式直观的排列,我们可以归纳总结,找出公式。

除了每一行的第一个单元格的 left, right 都相等。 其他的可以用一个公式来总结:

left = leftMargin * (i - temp + 1) + width * (i - temp)
right = leftMargin * (i - temp + 1) + width * (i - temp + 1)

可以随意带数值进入然后对比画图看看结果,比如(1, 1) 即第二行第二列。

temp = row * (i / row) => 4 * 1 = 4

left = leftMargin * (5 - 4 + 1) + width * (5 - 4)
= leftMargin * 2 + width

right = leftMargin * (5 - 4 + 1) + width * (5 - 4 + 1)
= lefMargin * 2 + width * 2

和上面的手动计算完全一样,至于为什么 i = 5 那是因为 i 循环到第二行的第二列为 5


除了第一行第一个单元格其他的 top, bottom 可以用公式:

top = height * row + topMargin * row + topMargin
bottom = height * (row + 1) + topMargin(row + 1)


@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
for (int i = 0; i < count; i++) {
CellView cellView = (CellView) getChildAt(i);
MarginLayoutParams layoutParams = (MarginLayoutParams) cellView.getLayoutParams();
int leftMargin = layoutParams.leftMargin;
int topMargin = layoutParams.topMargin;

int width = cellView.getMeasuredWidth();
int height = cellView.getMeasuredHeight();

int left = 0, top = 0, right = 0, bottom = 0;

// 每一行开始, 0, 4, 8, 12...
int temp = mRow * (i / mRow);
// 每一行的开头位置.
if (i == temp) {
left = leftMargin;
right = width + leftMargin;
} else {
left = leftMargin * (i - temp + 1) + width * (i - temp);
right = leftMargin * (i - temp + 1) + + width * (i - temp + 1);
}

int row = i / mRow;
if (row == 0) {
top = topMargin;
bottom = height + topMargin;
} else {
top = height * row + topMargin * row + topMargin;
bottom = height * (row + 1) + (row + 1) * topMargin;
}

cellView.layout(left, top, right, bottom);
}
}


  1. 初始数据
private void init() {
models = new Model[mRow][mColumn];
cells = new ArrayList<>(mRow * mColumn);

for (int i = 0; i < mRow * mColumn; i++) {
CellView cellView = new CellView(getContext());
MarginLayoutParams params = new MarginLayoutParams(
LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);

params.leftMargin = 10;
params.topMargin = 10;
cellView.setLayoutParams(params);

Model model = new Model(0, cellView);
cells.add(model);

addView(cellView, i);
}
}


以上就是未带数据源的宫格绘制过程,接下来开始接入数据源来动态改变宫格的数据啦!

动态改变数据

  1. 初始化数据源,随机显示一个数据 2
private void init() {
... 省略部分代码.....

int i = 0;
for (int x = 0; x < mRow; x++) {
for (int y = 0; y < mColumn; y++) {
models[x][y] = cells.get(i);
i++;
}
}

// 生成一个随机数,初始化数据.
mRandom = new Random();
rand = mRandom.nextInt(mRow * mColumn);
Model model = cells.get(rand);
model.setNumber(2);
CellView cellView = model.getCellView();
cellView.setNumber(2);

// 初始化时空格数为总宫格个数 - 1
mAllCells = mRow * mColumn - 1;

// 程序动态变化这个值,用来判断当前宫格还有多少空格可用.
mEmptyCells = mAllCells;


... 省略部分代码.....
}


  1. 计算随机数生成的合法单元格位置

生成的随机数据必须在空白的单元格上。

 private void nextRand() {
// 如果所有宫格被填满则游戏结束,
// 当然这里也有坑,至于怎么发现,你多玩几次机会发现,
// 这个坑我就不填了,有兴趣的可以帮我填一下😄😄
if (mEmptyCells <= 0) {
findMaxValue();
gameOver();
return;
}

int newX, newY;

if (mEmptyCells != mAllCells || mCanMove == 1) {
do {
// 通过伪随机数获取新的空白位置
newX = mRandom.nextInt(mRow);
newY = mRandom.nextInt(mColumn);
} while (models[newX][newY].getNumber() != 0);

int temp = 0;

do {
temp = mRandom.nextInt(mRow);
} while (temp == 0 || temp == 2);

Model model = models[newX][newY];
model.setNumber(temp + 1);
CellView cellView = model.getCellView();
cellView.setNumber(model.getNumber());
playAnimation(cellView);

// 空白格子减1
mEmptyCells--;
}
}


  1. 向左移动

算法是我们前面推导的,最后调用 drawAll() 绘制单元格文字, 以及调用 nextRand() 生成新的随机数。

public void left() {
if (leftRunnable == null) {
leftRunnable = new Runnable() {
@Override
public void run() {
int i;
for (int x = 0; x < mRow; x++) {
for (int y = 0; y < mColumn; ) {
Model model = models[x][y];
int number = model.getNumber();
if (number == 0) {
y++;
continue;
} else {
// 找到不为零的位置. 往后找不为零的数进行运算.
for (i = y + 1; i < mColumn; i++) {
Model model1 = models[x][i];
int number1 = model1.getNumber();
if (number1 == 0) {
continue;
} else if (number == number1) {
// 如果找到和这个相同的,则进行合并运算(相加)。
int temp = number + number1;
model.setNumber(temp);
model1.setNumber(0);

mEmptyCells++;
break;
} else {
break;
}
}

y = i;
}
}
}

for (int x = 0; x < mRow; x++) {
for (int y = 0; y < mColumn; y++) {
Model model = models[x][y];
int number = model.getNumber();
if (number == 0) {
continue;
} else {
for (int j = y; (j > 0) && models[x][j - 1].getNumber() == 0; j--) {
models[x][j - 1].setNumber(models[x][j].getNumber());
models[x][j].setNumber(0);

mCanMove = 1;
}
}
}
}

drawAll();
nextRand();
}
};
}

mExecutorService.execute(leftRunnable);
}

  1. 随机单元格动画
private void playAnimation(final CellView cellView) {
mainHandler.post(new Runnable() {
@Override
public void run() {
ObjectAnimator animator = ObjectAnimator.ofFloat(
cellView, "alpha", 0.0f, 1.0f);
animator.setDuration(300);
animator.start();
}
});
}


代码下载:i1054959069-simple-2048-games-master.zip

1 个评论

这个demo跑不起来啊

要回复文章请先登录注册