Android五子棋小游戏之UI篇

最近一直在学习Android自定义View方面的知识,正好看到一个讲解制作五子棋小游戏的案例,遂学习一番,记录下学习过程,帮助那些有需要的人。

首先放上效果图:


五子棋小游戏

下面我将带领大家一步步完成这个五子棋小游戏。

一、创建自定义View类及定义成员变量

首先我们先定义一个类WuziqiPanel,让该类继承自View,并在类中定义一些成员变量,便于我们后面使用,而且在我们需要显示五子棋的布局文件中引入该自定义View。

WuziqiPanel.java文件

public class WuziqiPanel extends View{
    //棋盘宽度
    private int mPanelWidth;
    //棋盘格子的行高(声明为int会造成由于不能整除而造成的误差较大)
    private float mLineHeight;
    //棋盘最大行列数(其实就是棋盘横竖线的个数)
    private int MAX_LINE_NUM = 10;

    //定义画笔绘制棋盘格子
    private Paint mPaint = new Paint();
    //定义黑白棋子Bitmap
    private Bitmap mWhitePiece;
    private Bitmap mBlackPiece;

    //棋子的缩放比例(行高的3/4)
    private float pieceScaleRatio = 3 * 1.0f / 4;

    //存储黑白棋子的坐标
    private ArrayList<Point> mWhiteArray = new ArrayList<>();
    private ArrayList<Point> mBlackArray = new ArrayList<>();
    //哪方先下子
    private boolean isWhiteFirst = true;

    //游戏是否结束
    private boolean isGameOver;
    //确定赢家
    private boolean isWhiteWinner = false;
    //游戏结束监听
    private OnGameOverListener onGameOverListener;
}

activity_main.xml文件中引入该自定义View

activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/main_bg"
    tools:context="com.codekong.wuziqi.activity.MainActivity">

    <com.codekong.wuziqi.view.WuziqiPanel
        android:id="@+id/id_wuziqi_panel"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_centerInParent="true" />
</RelativeLayout>

注意:自定义View的引入必须使用完整路径

二、定义构造函数并初始化设置

这一步我们首先要书写构造函数,并且在构造函数中初始化一些设置。比如初始化画笔以及将棋子图片转为bitmap
这里我们在三个参数的构造方法中调用两个参数的构造方法,又在两个参数的构造方法中调用一个参数的构造方法。

这里简单解释一下。一个参数的构造方法是我们在new出一个组件的时候调用;两个参数的构造方法是我们在XML中使用自定义View时调用;三个参数的构造方法是我们自定义View中使用了自定义属性的时候调用;所以我们按上面的写法就可以覆盖到这三种情况。

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

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

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

/**
 * 初始化设置
 */
private void init() {
    //初始化画笔
    mPaint.setColor(0x88000000);
    //设置抗锯齿
    mPaint.setAntiAlias(true);
    //设置防抖动
    mPaint.setDither(true);
    //设置为空心(画线)
    mPaint.setStyle(Paint.Style.STROKE);

    //初始化棋子
    mWhitePiece = BitmapFactory.decodeResource(getResources(), R.drawable.icon_white_piece);
    mBlackPiece = BitmapFactory.decodeResource(getResources(), R.drawable.icon_black_piece);
}

三、测量

测量几乎是自定义View必须要经历的步骤,由于我们要先绘制棋盘,所以我们必须先测量出我们需要的数据。
我们在onMeasure()方法中拿到屏幕宽高,然后在onSizeChanged()中获得棋盘的宽度,计算出棋盘的行高。接着根据行高缩放棋子大小,使其显示大小合适。

/**
 * 测量
 * @param widthMeasureSpec
 * @param heightMeasureSpec
 */
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);

    int width = Math.min(widthSize, heightSize);

    //此处的逻辑判断是处理当我们自定义的View被嵌套在ScrollView中时,获得的测量模式
    // 会是UNSPECIFIED
    // 使得到的widthSize或者heightSize为0
    if (widthMode == MeasureSpec.UNSPECIFIED){
        width = heightSize;
    }else if (heightMode == MeasureSpec.UNSPECIFIED){
        width = widthSize;
    }
    //调用此方法使我们的测量结果生效
    setMeasuredDimension(width, width);
}
/**
 * 当宽高发生变化时回调此方法
 * @param w
 * @param h
 * @param oldw
 * @param oldh
 */
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    //此处的参数w就是在onMeasure()方法中设置的自定义View的大小
    //计算出棋盘宽度和行高
    mPanelWidth = w;
    mLineHeight = mPanelWidth * 1.0f / MAX_LINE_NUM;

    //将棋子根据行高变化
    int pieceWidth = (int) (pieceScaleRatio * mLineHeight);
    mWhitePiece = Bitmap.createScaledBitmap(mWhitePiece, pieceWidth, pieceWidth, false);
    mBlackPiece = Bitmap.createScaledBitmap(mBlackPiece, pieceWidth, pieceWidth, false);
}

注意:如上面注释所写,由于我们不知道我们的自定义View将会被放在什么样的布局中,所以如果我们的五子棋盘被放在ScrollView中,我们测量到的宽或者高就会有一方为,就会使我们的测量失效,从而影响后面的绘制,所以我们必须处理这一种情况.

四、绘制棋盘

首先我们应该先绘制好我们要下棋的棋盘,我们此处准备横竖都画10条线来绘制我们的棋盘,其实此处的棋盘的横竖线的个数我们是可以修改的,棋子的大小也会随着棋盘格子的大小缩放,但是为了美观一些,我们此处采用横竖10条线.

大家可以先通过我下面的示意图来理解一下棋盘横竖线坐标的确定.

棋盘绘制
/**
 * 绘制棋盘
 * @param canvas
 */
private void drawBoard(Canvas canvas) {
    int w = mPanelWidth;
    float lineHeight = mLineHeight;

    for (int i = 0; i < MAX_LINE_NUM; i++) {
        int startX = (int) (lineHeight / 2);
        int endX = (int) (w - lineHeight / 2);

        int y = (int) ((0.5 + i) * lineHeight);
        //画横线
        canvas.drawLine(startX, y, endX, y, mPaint);
        //画竖线
        canvas.drawLine(y, startX, y, endX, mPaint);
    }
}

通过上面的步骤棋盘就算是绘制好了.

五、处理用户手势-下棋

上面我们绘制好了棋盘,接着我们就可以下棋啦,所以理所当然我们要开始处理用户的手势,开始下棋啦,所以我们要重写onTouchEvent().

这一步我们要做三件事:

1 . onTouchEvent()return true拦截手势事件我们自己处理

2 . 获得用户触摸的坐标并进行处理,处理为棋盘上的整数值坐标,并存储起来.

这一步我们调用一个自定义的函数getValidPoint()将用户点击的点的坐标转化为整数值.也就是说用户下子的时候不必精确点击到棋盘各自的交叉点上,而是只要在这个交叉点周围就可以了,我们只需要将其取整后除以我们格子的高度(行高),

简单解释一下,如下图1、2所指的箭头所示,由于我们的棋盘上下左右边距为0.5倍的行高,当我们手指在以棋盘顶点0.5倍的行高范围内点击,只要除以行高取整,就会得到该顶点坐标.比如我点击的坐标点为(0.75,0.82),在第一个圈范围内,此时行高为,除以1后取整得到(0,0)就是棋盘的顶点,就是我们需要落子的地方。
坐标解释
/**
 * 将用户点击的位置的Point转换为类似于(0,0)d的坐标
 * @param x
 * @param y
 * @return
 */
private Point getValidPoint(int x, int y) {
    return new Point((int) (x / mLineHeight), (int) (y / mLineHeight));
}

3 . 调用invalidate()方法进行界面重绘,绘制出用户所下的棋子

每次调用invalidate()方法就会调用onDraw()方法进行界面绘制,在该方法中先绘制棋盘,然后绘制用户所下的棋子,还要判断游戏结束.绘制棋子和判断游戏结束我会在后面的步骤中给出介绍.

4 . 将下棋权利交于另一方(白棋下完换黑棋)

这一步比较好处理,我们只需要将一个布尔变量isWhiteFirst取反,下一次就轮到另一种棋子下子了.

/**
 * 处理用户手势操作
 * @param event
 * @return
 */
@Override
public boolean onTouchEvent(MotionEvent event) {
    if (isGameOver) return false;

    int action = event.getAction();
    //手指抬起后处理
    if (action == MotionEvent.ACTION_UP){

        //拦截事件自己来处理
        int x = (int) event.getX();
        int y = (int) event.getY();
        Point point = getValidPoint(x, y);
        //首先判断所点击的位置是不是已经有棋子
        if (mWhiteArray.contains(point) || mBlackArray.contains(point)){
            return false;
        }
        //白棋先下
        if (isWhiteFirst){
            mWhiteArray.add(point);
        }else{
            mBlackArray.add(point);
        }
        //调用重绘
        invalidate();
        isWhiteFirst = !isWhiteFirst;
    }
    return true;
}
/**
 * 进行绘制工作
 * @param canvas
 */
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //绘制棋盘
    drawBoard(canvas);
    //绘制用户已经下的所有棋子
    drawPieces(canvas);
    //判断游戏是否结束
    checkGameOver();
}

六、绘制棋子

上一步我们已经把用户所点击的要下棋子的坐标存储在了ArrayList,这一步我们就将遍历这个ArrayList将黑白棋子绘制到棋盘上.

这里我们的一个变量pieceScaleRatio = 3/4,表示一个棋子长宽为3/4的行高,剩余的1/4的行高平均棋子的左右各留出1/8的行高,这样棋子距离左右边框的距离为1/8行高,棋子与棋子之间的间距为2*1/8=1/4行高.

/**
 * 绘制棋子
 * @param canvas
 */
private void drawPieces(Canvas canvas) {
    //绘制白棋子
    for (int i = 0, n = mWhiteArray.size(); i < n; i++) {
        Point whitePoint = mWhiteArray.get(i);
        //棋子之间的间隔为1/4行高
        canvas.drawBitmap(mWhitePiece,
                (whitePoint.x + (1 - pieceScaleRatio) / 2) * mLineHeight,
                (whitePoint.y + (1 - pieceScaleRatio) / 2) * mLineHeight, null);
    }
    //绘制黑棋子
    for (int i = 0, n = mBlackArray.size(); i < n; i++) {
        Point blackPoint = mBlackArray.get(i);
        //棋子之间的间隔为1/4行高,棋子距离左右边框的距离为1/8行高
        canvas.drawBitmap(mBlackPiece,
                (blackPoint.x + (1 - pieceScaleRatio) / 2) * mLineHeight,
                (blackPoint.y + (1 - pieceScaleRatio) / 2) * mLineHeight, null);
    }
}

七、判断游戏结束

经过上面的步骤,我们已经可以在棋盘上落子了,下面的任务就是我们要判断游戏是否结束.
这个游戏的规则比较简单,我们只要判断在上下左右斜对角线如果存在连续5个棋子是同一色,我们就可以判定胜负了.

我们专门定义一个工具类WuziqiUtil.java来进行判断

public class WuziqiUtil {
    //每行上最大的数目
    public static final int MAX_COUNT_IN_LINE = 5;

    /**
     * 检查是否五子连珠
     * @param points
     * @return
     */
    public static boolean checkFiveInLine(List<Point> points) {
        for (Point p: points) {
            int x = p.x;
            int y = p.y;

            boolean win = checkHorizontal(x, y, points);
            if (win) return true;
            win = checkVertical(x, y, points);
            if (win) return true;
            win = checkLeftDiagonal(x, y, points);
            if (win) return true;
            win = checkRightDiagonal(x, y, points);
            if (win) return true;
        }
        return false;
    }

    /**
     * 判断x, y位置的棋子是否横向五个一致
     * @param x
     * @param y
     * @param points
     * @return
     */
    public static boolean checkHorizontal(int x, int y, List<Point> points) {
        int count = 1;
        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x - i, y))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;

        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x + i, y))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;
        return false;
    }

    /**
     * 判断x, y位置的棋子是否竖向五个一致
     * @param x
     * @param y
     * @param points
     * @return
     */
    public static boolean checkVertical(int x, int y, List<Point> points) {
        int count = 1;
        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x, y - i))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;

        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x, y + i))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;
        return false;
    }

    /**
     * 判断x, y位置的棋子是否左斜向上五个一致
     * @param x
     * @param y
     * @param points
     * @return
     */
    public static boolean checkLeftDiagonal(int x, int y, List<Point> points) {
        int count = 1;
        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x - i, y + i))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;

        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x + i, y - i))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;
        return false;
    }

    /**
     * 判断x, y位置的棋子是否右斜向下五个一致
     * @param x
     * @param y
     * @param points
     * @return
     */
    public static boolean checkRightDiagonal(int x, int y, List<Point> points) {
        int count = 1;
        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x - i, y - i))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;

        for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
            if (points.contains(new Point(x + i, y + i))){
                count ++;
            }else {
                break;
            }
        }

        if (count == MAX_COUNT_IN_LINE) return true;
        return false;
    }
}

然后我们只要在在自定义View类中的checkGameOver()方法中进行调用就可以判断游戏是否结束

/**
 * 检查游戏是否结束
 */
private void checkGameOver() {
    //检查是否五子连珠
    boolean whiteWin = WuziqiUtil.checkFiveInLine(mWhiteArray);
    boolean blackWin = WuziqiUtil.checkFiveInLine(mBlackArray);
    if (whiteWin || blackWin){
        isGameOver = true;
        isWhiteWinner = whiteWin;

        //String msg = isWhiteWinner ? "白子获胜" : "黑子获胜";
        //Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show();
        onGameOverListener.gameOver(isWhiteWinner);
    }
}

或许已经有人发现了,我上面还声明了一个游戏结束的监听,对的,我们作为一个自定义View当然要将游戏的胜负结果通过回调函数返回给使用者,让其自行处理.

/**
 * 游戏结束回调监听
 */
public interface OnGameOverListener{
   void gameOver(boolean isWhiterWinner);
}
/**
 * 设置游戏结束回调监听
 * @param onGameOverListener
 */
public void setOnGameOverListener(OnGameOverListener onGameOverListener){
    this.onGameOverListener = onGameOverListener;
}

到这里看起来我们的五子棋小游戏已经开发完成了,但是一个负责的程序员怎么可能满足于此呢,我们还要让我们的游戏更健壮.

八、防止游戏被回收

我们想象一个场景,当我们正在玩五子棋小游戏,正到关键时刻,来电话了,这时候我们去接电话,这时候我们的小游戏就相当于处于后台,假如这时候手机内存不足,那我们在后台的小游戏就可能被内存回收了,当我们打完电话,发现棋盘上一个棋子都没啦,是不是很伤心,为了解决这个问题,我们就需要重写onSaveInstanceState()方法和onRestoreInstanceState()方法来保存和恢复我们的游戏状态.

我们可以通过旋转屏幕模拟出上面提到的情况,旋转屏幕就会触发上面两个函数.

/**
 * 防止内存不足活动被回收
 */
private static final String INSTANCE = "instance";
private static final String INSTANCE_GAME_OVER = "instance_game_over";
private static final String INSTANCE_WHITE_ARRAY = "instance_white_array";
private static final String INSTANCE_BLACK_ARRAY = "instance_black_array";


@Override
protected Parcelable onSaveInstanceState() {
    Bundle bundle = new Bundle();
    bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
    bundle.putBoolean(INSTANCE_GAME_OVER, isGameOver);
    bundle.putParcelableArrayList(INSTANCE_WHITE_ARRAY, mWhiteArray);
    bundle.putParcelableArrayList(INSTANCE_BLACK_ARRAY, mBlackArray);
    return bundle;
}

@Override
protected void onRestoreInstanceState(Parcelable state) {
    if (state instanceof Bundle){
        Bundle bundle = (Bundle) state;
        isGameOver = bundle.getBoolean(INSTANCE_GAME_OVER);
        mWhiteArray = bundle.getParcelableArrayList(INSTANCE_WHITE_ARRAY);
        mBlackArray = bundle.getParcelableArrayList(INSTANCE_BLACK_ARRAY);
        super.onRestoreInstanceState(bundle.getParcelable(INSTANCE));
        return;
    }
    super.onRestoreInstanceState(state);
}

好了,这样我们的游戏就健壮了不少.

九、再来一局

一个游戏怎么可以只玩一次呢,所以我们这里还需要向使用者保留一个再来一局的方法.

/**
 * 重新开始,再来一局
 */
public void restart(){
    mBlackArray.clear();
    mWhiteArray.clear();
    isGameOver = false;
    isWhiteWinner = false;
    //重绘
    invalidate();
}

十、使用该自定义View

定义已经全部完成了,现在使用就非常简单了.

activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/main_bg"
    tools:context="com.codekong.wuziqi.activity.MainActivity">

    <com.codekong.wuziqi.view.WuziqiPanel
        android:id="@+id/id_wuziqi_panel"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_centerInParent="true" />
</RelativeLayout>

MainActivity.java文件

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        WuziqiPanel panel = (WuziqiPanel) findViewById(R.id.id_wuziqi_panel);
        panel.setOnGameOverListener(new WuziqiPanel.OnGameOverListener() {
            @Override
            public void gameOver(boolean isWhiterWinner) {
                //处理胜负结果
            }
        });
    }
}

十一、结语

游戏的介绍就到此为止了,希望可以帮助到需要的人.
源代码已经在Github开源,开源地址: https://github.com/codekongs/WuZiQi
欢迎大家start和fork
下集预告:五子棋小游戏之AI篇,通过算法实现人机对战。

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

推荐阅读更多精彩内容