爲了學習自定義 ViewGroup,正碰巧最近無心間玩了下 2048 的遊戲,所以這裏就來實現一個 2048 小遊戲。想必不少人應該是玩過這個遊戲的,若是沒有玩過的能夠下載玩一下。下圖是我實現的效果。git
遊戲規則比較簡單,共有以下幾個步驟:github
算法主要是討論上下左右四個方向如何合併以及移動,這裏我以向左和向上來講明,而向下和向右就由讀者自行推導,由於十分類似。算法
先來看下面兩張圖,第一張是初始狀態,能夠看到網格中有個數字 2。在這裏用二維數組來描述。它的位置應該是第2行第2列 。第二張則是它向左移動後的效果圖,能夠看到 2 已經被移動到最左邊啦!canvas
咱們最常規的想法就是首先遍歷這個二維數組,找到這個數的位置,接着合併和移動。因此第一步確定是循環遍歷。數組
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 類,這個類是封裝了網格單元的數據和網格視圖。定義以下:bash
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;
}
}
複製代碼
先不糾結視圖的繪製,咱們先把算法理清楚,算法搞明白了也就解決一大部分了,其餘就是自定義 View 的知識。上述的過程就是,遍歷整個網格,找到不爲零的網格位置。dom
讓咱們來思考一下,合併要作什麼,那麼咱們再來看一張圖。ide
從這張圖中咱們能夠看到在第一行的最後兩個網格單元都是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;
}
}
}
複製代碼
經過上面的過程,咱們就將這個數右側的第一個相等的數進行了合併操做,是否是也好理解的。不理解的話能夠在草稿紙上多畫一畫,多推導幾回。post
搞定了合併操做,如今就是移動了,移動確定是要將全部數據的單元格都移動到左側,移動的條件是,找到第一個不爲零的數的座標,繼續向前找到第一個數據爲零即空白單元格的位置,將數據覆蓋它,並將後一個單元格數據清空。算法以下:
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 能被配置。那麼就要自定義屬性。
這裏只是提供了設置網格單元行列數,其實這裏我我只取兩個值的最大值做爲行列的值。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="Play2048Group">
<attr name="row" format="integer"/>
<attr name="column" format="integer"/>
</declare-styleable>
</resources>
複製代碼
能夠看到將傳入的 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();
}
}
複製代碼
由於整個網格有一個個網格單元組成,其中每個網格單元都是一個 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);
}
}
複製代碼
因爲網格是行列數都相等,則寬和高都相等。那麼全部的寬加起來除以 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);
}
複製代碼
佈局稍微麻煩點,主要是在換行處的計算有點繞。首先咱們找一下何時是該換行了,若是是 4 * 4 的 16 宮格,咱們能夠知道每一行的開頭應該是 0、四、八、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);
}
}
複製代碼
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);
}
}
複製代碼
以上就是未帶數據源的宮格繪製過程,接下來開始接入數據源來動態改變宮格的數據啦!
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;
... 省略部分代碼.....
}
複製代碼
生成的隨機數據必須在空白的單元格上。
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--;
}
}
複製代碼
算法是咱們前面推導的,最後調用 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);
}
複製代碼
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();
}
});
}
複製代碼
寫到這兒就寫完了, 至於要看完整代碼的請點擊此處。 總結一下吧!其實核心就是上下左右的合併和移動算法。至於自定義 ViewGroup,這裏並不複雜,但也將自定義 view 的幾個流程包含進來了。