Android小游戏 之《数字华容道》

[toc]

0 背景

最近看《最强大脑》,看到其中的“数字华容道”这个小游戏挺有意思,于是萌生了自己写一个的想法,正好结合之前的文章《Android开发艺术探索》第4章 View的工作原理 ,顺便复习一下。
GitHub链接:https://github.com/LittleFogCat/Shuzihuarongdao

szhrd

说做就做。
经过一夜的粗制滥造,初版已经完成,现在复盘一下详细过程。

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的mSizeXmSizeY属性。

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中完成的。流程如下:

  1. 进入界面,开始生成棋局,同时读取本地高分榜;
  2. 生成棋局完成,开始记录游戏时间;
  3. 棋局完成,记录结束时间,计算游戏用时;
  4. 比对本地最佳成绩和本次成绩,计算是否打破记录及保存;
  5. 如果进入最佳成绩榜,输入姓名并保存。

总的来说,逻辑简单清晰。

6 作弊&后记

自己开发的自然是需要作弊功能了!暂且不表。

由于只用了一个晚上完成,所以还很粗糙,很多功能不够完善,而且也没做适配和测试,难免会有bug存在。主要是把思路记录下来,方便以后自己和他人做个参考。

数字华容道GitHub地址:https://github.com/LittleFogCat/Shuzihuarongdao

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,245评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,749评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,960评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,575评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,668评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,670评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,664评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,422评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,864评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,178评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,340评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,015评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,646评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,265评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,494评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,261评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,206评论 2 352

推荐阅读更多精彩内容

  • 滋养女人的四宝: “鲜花、水、音乐、月光” 女人活出智慧,有品味的同时一定要“有趣” 清晨正蹲MT,不知怎的突发奇...
    一滴_ddb5阅读 214评论 0 0
  • 甘德礼(关键词——等待详细解释)持续原创分享第60天,约练第15次。 首先祝所有的老师们节日快乐!祝你们在接下来的...
    华南帝虎阅读 313评论 0 0
  • 世界人口60多亿。一生有:80*365=29200天,平均每天可以遇到1000个人左右。 一辈子遇到人的总数:29...
    S超然物外阅读 354评论 0 0
  • 问路 一日,我出去玩,有一个人转来转去,迷路了。看见我在路边玩耍,我就走过去,手摸着他的头,问:“小朋友,这是什么...
    顾家乐1020阅读 848评论 7 5