安卓自定义view - 2048 小游戏

为了学习自定义 ViewGroup,正碰巧最近无心间玩了下 2048 的游戏,所以这里就来实现一个 2048 小游戏。想必不少人应该是玩过这个游戏的,若是没有玩过的能够下载玩一下。下图是我实现的效果。git

2048 游戏规则

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

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

2048 游戏算法

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

向左移动算法

先来看下面两张图,第一张是初始状态,能够看到网格中有个数字 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 能被配置。那么就要自定义属性。

  1. 自定义属性

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

<?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>

复制代码
  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、四、八、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();
            }
        });
    }

复制代码

写到这儿就写完了, 至于要看完整代码的请点击此处。 总结一下吧!其实核心就是上下左右的合并和移动算法。至于自定义 ViewGroup,这里并不复杂,但也将自定义 view 的几个流程包含进来了。

相关文章
相关标签/搜索