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閱讀 229,362評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,013評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,346評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,421評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,146評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,534評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,585評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,767評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,318評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,074評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,258評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,828評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,486評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,916評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,156評論 1 290
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,993評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,234評論 2 375

推薦閱讀更多精彩內容