CocosCreator热更新(v1.10.2)

creator生成的项目是纯粹的数据驱动而非代码驱动,一切皆为数据,包括脚本、图片、音频、动画等,而数据驱动需要一个入口,就是main.js,而setting.js描述了整个资源的配置文件,我们自己编写的脚本,在编译后统一存放在了project.js中。热更新根据这个原理,在服务器存放游戏资源,通过服务端与本地的manifest进行对比,把差异文件下载到某个文件夹里面,在入口文件main.js设置搜索路径为更新的文件夹,这样达到热更新的目的。

新建Hall工程

VersionTip用来提示更新,UpdateTip用来显示更新进度,FinishTip用来提示完成更新并重启。


image.png

热更新脚本:

onst hotResDir = "AllGame/Hall"; //更新的资源目录

export class HotUpdateUtil {

    private static am;
    private static isUpdating = false;
    private static checkUpdateListener;
    private static updateListener;
    private static manifestUrl;

    private static checkNewVersionListener: Function;
    private static updateProgressListener: Function;
    private static updateErrorListener: Function;
    private static updateFinishListener: Function;

    public static init(manifestUrl: cc.Asset) {

        if (!cc.sys.isNative) return;

        this.manifestUrl = manifestUrl;
        let storagePath = ((jsb.fileUtils ? jsb.fileUtils.getWritablePath() : '/') + hotResDir);
        this.am = new jsb.AssetsManager("", storagePath, this.versionCompareHandle);

        if (!cc.sys.ENABLE_GC_FOR_NATIVE_OBJECTS) {
            this.am.retain();
        }

        this.am.setVerifyCallback(function (path, asset) {

            var compressed = asset.compressed;
            var expectedMD5 = asset.md5;
            var relativePath = asset.path;
            var size = asset.size;
            if (compressed) {
                cc.log("Verification passed : " + relativePath);
                return true;
            }
            else {
                cc.log("Verification passed : " + relativePath + ' (' + expectedMD5 + ')');
                return true;
            }
        });

        if (cc.sys.os === cc.sys.OS_ANDROID) {
            // Some Android device may slow down the download process when concurrent tasks is too much.
            // The value may not be accurate, please do more test and find what's most suitable for your game.
            this.am.setMaxConcurrentTask(2);
            cc.log("Max concurrent tasks count have been limited to 2");
        }
    }

    //版本对比
    private static versionCompareHandle(versionA, versionB): number {

        var vA = versionA.split('.');
        var vB = versionB.split('.');
        for (var i = 0; i < vA.length; ++i) {
            var a = parseInt(vA[i]);
            var b = parseInt(vB[i] || 0);
            if (a === b) {
                continue;
            }
            else {
                return a - b;
            }
        }
        if (vB.length > vA.length) {
            return -1;
        }
        else {
            return 0;
        }

    }

    public static checkUpdate(checkNewVersionCallback?:Function) {

        if (this.isUpdating) {
            cc.log("正在更新中...");
            return;
        }

        this.checkNewVersionListener = checkNewVersionCallback;

        if (this.am.getState() === jsb.AssetsManager.State.UNINITED) {
            var url = this.manifestUrl;
            cc.log(url);
            if (cc.loader.md5Pipe) {
                url = cc.loader.md5Pipe.transformURL(url);
            }
            this.am.loadLocalManifest(url);
        }

        if (!this.am.getLocalManifest() || !this.am.getLocalManifest().isLoaded()) {
            cc.log('Failed to load local manifest ...');
            return;
        }
        this.checkUpdateListener = new jsb.EventListenerAssetsManager(this.am, this.checkCallback.bind(this));
        cc.eventManager.addListener(this.checkUpdateListener, 1);

        this.am.checkUpdate();
        this.isUpdating = true;
    }

    private static checkCallback(event) {
        cc.log('Code: ' + event.getEventCode());
        switch (event.getEventCode()) {
            case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
                cc.log("No local manifest file found, hot update skipped.");
                break;
            case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
            case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
                cc.log("Fail to download manifest file, hot update skipped.");
                break;
            case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
                cc.log("Already up to date with the latest remote version.");
                break;
            case jsb.EventAssetsManager.NEW_VERSION_FOUND:
                cc.log('New version found, please try to update.');
                if (this.checkNewVersionListener) {
                    this.checkNewVersionListener();
                }
                break;
            default:
                return;
        }
        cc.eventManager.removeListener(this.checkUpdateListener);
        this.checkUpdateListener = null;
        this.isUpdating = false;
    }

    public static update(updateProgressListener?:Function,updateErrorListener?:Function,updateFinishListener?:Function) {

        if (this.am && !this.isUpdating) {

            this.updateProgressListener = updateProgressListener;
            this.updateErrorListener = updateErrorListener;
            this.updateFinishListener = updateFinishListener;

            this.updateListener = new jsb.EventListenerAssetsManager(this.am, this.updateCallback.bind(this));
            cc.eventManager.addListener(this.updateListener, 1);

            if (this.am.getState() === jsb.AssetsManager.State.UNINITED) {
                // Resolve md5 url
                var url = this.manifestUrl;
                if (cc.loader.md5Pipe) {
                    url = cc.loader.md5Pipe.transformURL(url);
                }
                this.am.loadLocalManifest(url);
            }

            this.am.update();
            this.isUpdating = true;
        }
    }


    private static updateCallback(event) {
        var needRestart = false;
        var failed = false;
        switch (event.getEventCode()) {
            case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
                cc.log('No local manifest file found, hot update skipped.');
                failed = true;
                break;
            case jsb.EventAssetsManager.UPDATE_PROGRESSION:
              
                if(this.updateProgressListener){
                    this.updateProgressListener(event.getDownloadedBytes(),event.getTotalBytes());
                }
                break;
            case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
            case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
                cc.log('Fail to download manifest file, hot update skipped.');
                if(this.updateErrorListener){
                    this.updateFinishListener('Fail to download manifest file, hot update skipped.');
                }
                failed = true;
                break;
            case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
                cc.log("Already up to date with the latest remote version.");
                failed = true;
                break;
            case jsb.EventAssetsManager.UPDATE_FINISHED:
                cc.log('Update finished. ' + event.getMessage());
                needRestart = true;
                if(this.updateFinishListener){
                    this.updateFinishListener();
                }
                break;
            case jsb.EventAssetsManager.UPDATE_FAILED:
                cc.log('Update failed. ' + event.getMessage());
                if(this.updateErrorListener){
                    this.updateErrorListener('Update failed. ' + event.getMessage());
                }
                this.isUpdating = false;
                break;
            case jsb.EventAssetsManager.ERROR_UPDATING:
                cc.log('Asset update error: ' + event.getAssetId() + ', ' + event.getMessage());
                if(this.updateErrorListener){
                    this.updateErrorListener('Asset update error: ' + event.getAssetId() + ', ' + event.getMessage());
                }
                break;
            case jsb.EventAssetsManager.ERROR_DECOMPRESS:
                cc.log(event.getMessage());
                break;
            default:
                break;
        }

        if (failed) {
            cc.eventManager.removeListener(this.updateListener);
            this.updateListener = null;
            this.isUpdating = false;
        }

        if (needRestart) {
            cc.eventManager.removeListener(this.updateListener);
            this.updateListener = null;
            // Prepend the manifest's search path
            var searchPaths = jsb.fileUtils.getSearchPaths();
            var newPaths = this.am.getLocalManifest().getSearchPaths();
            cc.log(JSON.stringify(newPaths));
            Array.prototype.unshift.apply(searchPaths, newPaths);
            // This value will be retrieved and appended to the default search path during game startup,
            // please refer to samples/js-tests/main.js for detailed usage.
            // !!! Re-add the search paths in main.js is very important, otherwise, new scripts won't take effect.
            cc.sys.localStorage.setItem('HotUpdateSearchPaths', JSON.stringify(searchPaths));
            jsb.fileUtils.setSearchPaths(searchPaths);
     
        }
    }
}

使用脚本

import { HotUpdateUtil } from "./HotUpdateUtil";

const {ccclass, property} = cc._decorator;

@ccclass
export default class HotUpdate extends cc.Component {

   @property(cc.Asset)
   manifest:cc.Asset = null;
   @property(cc.Node)
   newVersionTip:cc.Node = null;
   @property(cc.Node)
   updateTip:cc.Node = null;
   @property(cc.Node)
   finishUpdateTip:cc.Node = null;

    onLoad(){
        HotUpdateUtil.init(this.manifest);
    }  

    start(){
        HotUpdateUtil.checkUpdate(()=>{
            this.newVersionTip.active = true;
        });
    }

    private updateVersion(){
        this.updateTip.active = true;
        HotUpdateUtil.update((progress)=>{
            let temp = ~~(progress * 100);
            this.updateTip.getComponentInChildren(cc.Label).string = "下载中" + temp + "%";
        },null,()=>{
            this.finishUpdateTip.active = true;
        });
    }

    private restart(){
        cc.game.restart();
    }

}

生成manifest文件

首先,一开始HotUpdate的manifest是空的:

image.png

然后,开始构建项目,不要勾选MD5 Cache,以Android为例,模板为default,构建完成后,在项目的根目录下放文件version_generator.js,里面的路径根据需要修改:

/**
 * 此模块用于热更新工程清单文件的生成
 */

var fs = require('fs');
var path = require('path');
var crypto = require('crypto');

var manifest = {
    //服务器上资源文件存放路径(src,res的路径)
    packageUrl: 'http://192.168.0.136:8000',
    //服务器上project.manifest路径
    remoteManifestUrl: 'http://192.168.0.136:8000/project.manifest',
    //服务器上version.manifest路径
    remoteVersionUrl: 'http://192.168.0.136:8000/version.manifest',
    version: '1.0.0',
    assets: {},
    searchPaths: []
};

//生成的manifest文件存放目录
var dest = 'assets/';
//项目构建后资源的目录
var src = 'build/jsb-default/';

/**
 * node version_generator.js -v 1.0.0 -u http://your-server-address/tutorial-hot-update/remote-assets/ -s native/package/ -d assets/
 */
// Parse arguments
var i = 2;
while ( i < process.argv.length) {
    var arg = process.argv[i];

    switch (arg) {
    case '--url' :
    case '-u' :
        var url = process.argv[i+1];
        manifest.packageUrl = url;
        manifest.remoteManifestUrl = url + 'project.manifest';
        manifest.remoteVersionUrl = url + 'version.manifest';
        i += 2;
        break;
    case '--version' :
    case '-v' :
        manifest.version = process.argv[i+1];
        i += 2;
        break;
    case '--src' :
    case '-s' :
        src = process.argv[i+1];
        i += 2;
        break;
    case '--dest' :
    case '-d' :
        dest = process.argv[i+1];
        i += 2;
        break;
    default :
        i++;
        break;
    }
}


function readDir (dir, obj) {
    var stat = fs.statSync(dir);
    if (!stat.isDirectory()) {
        return;
    }
    var subpaths = fs.readdirSync(dir), subpath, size, md5, compressed, relative;
    for (var i = 0; i < subpaths.length; ++i) {
        if (subpaths[i][0] === '.') {
            continue;
        }
        subpath = path.join(dir, subpaths[i]);
        stat = fs.statSync(subpath);
        if (stat.isDirectory()) {
            readDir(subpath, obj);
        }
        else if (stat.isFile()) {
            // Size in Bytes
            size = stat['size'];
            md5 = crypto.createHash('md5').update(fs.readFileSync(subpath, 'binary')).digest('hex');
            compressed = path.extname(subpath).toLowerCase() === '.zip';

            relative = path.relative(src, subpath);
            relative = relative.replace(/\\/g, '/');
            relative = encodeURI(relative);
            obj[relative] = {
                'size' : size,
                'md5' : md5
            };
            if (compressed) {
                obj[relative].compressed = true;
            }
        }
    }
}

var mkdirSync = function (path) {
    try {
        fs.mkdirSync(path);
    } catch(e) {
        if ( e.code != 'EEXIST' ) throw e;
    }
}

// Iterate res and src folder
readDir(path.join(src, 'src'), manifest.assets);
readDir(path.join(src, 'res'), manifest.assets);

var destManifest = path.join(dest, 'project.manifest');
var destVersion = path.join(dest, 'version.manifest');

mkdirSync(dest);

fs.writeFile(destManifest, JSON.stringify(manifest), (err) => {
  if (err) throw err;
  console.log('Manifest successfully generated');
});

delete manifest.assets;
delete manifest.searchPaths;
fs.writeFile(destVersion, JSON.stringify(manifest), (err) => {
  if (err) throw err;
  console.log('Version successfully generated');
});

接着cd到工程目录下,执行 node version_generator.js,在assets会自动生成了两个文件,project. manifest和version. manifest,把project. manifest拖给HotUpdate:

image.png

然后构建项目,构建成功后,修改mian.js:

(function () {
     //添加这段
     if ( cc.sys.isNative) {
        var hotUpdateSearchPaths = cc.sys.localStorage.getItem('HotUpdateSearchPaths');  //这个key对于HotUpdateUtil
        if (hotUpdateSearchPaths) {
            jsb.fileUtils.setSearchPaths(JSON.parse(hotUpdateSearchPaths));
        }
    }
    'use strict';
    //-------------
    function boot () {

        var settings = window._CCSettings;
        window._CCSettings = undefined;

        if ( !settings.debug ) {
            var uuids = settings.uuids;

            var rawAssets = settings.rawAssets;
            var assetTypes = settings.assetTypes;
            var realRawAssets = settings.rawAssets = {};
            for (var mount in rawAssets) {
                var entries = rawAssets[mount];
                var realEntries = realRawAssets[mount] = {};
                for (var id in entries) {
                    var entry = entries[id];
                    var type = entry[1];
                    // retrieve minified raw asset
                    if (typeof type === 'number') {
                        entry[1] = assetTypes[type];
                    }
                    // retrieve uuid
                    realEntries[uuids[id] || id] = entry;
                }
            }

            var scenes = settings.scenes;
            for (var i = 0; i < scenes.length; ++i) {
                var scene = scenes[i];
                if (typeof scene.uuid === 'number') {
                    scene.uuid = uuids[scene.uuid];
                }
            }

            var packedAssets = settings.packedAssets;
            for (var packId in packedAssets) {
                var packedIds = packedAssets[packId];
                for (var j = 0; j < packedIds.length; ++j) {
                    if (typeof packedIds[j] === 'number') {
                        packedIds[j] = uuids[packedIds[j]];
                    }
                }
            }
        }

        // init engine
        var canvas;

        if (cc.sys.isBrowser) {
            canvas = document.getElementById('GameCanvas');
        }

        if (false) {
            var ORIENTATIONS = {
                'portrait': 1,
                'landscape left': 2,
                'landscape right': 3
            };
            BK.Director.screenMode = ORIENTATIONS[settings.orientation];
            initAdapter();
        }

        function setLoadingDisplay () {
            // Loading splash scene
            var splash = document.getElementById('splash');
            var progressBar = splash.querySelector('.progress-bar span');
            cc.loader.onProgress = function (completedCount, totalCount, item) {
                var percent = 100 * completedCount / totalCount;
                if (progressBar) {
                    progressBar.style.width = percent.toFixed(2) + '%';
                }
            };
            splash.style.display = 'block';
            progressBar.style.width = '0%';

            cc.director.once(cc.Director.EVENT_AFTER_SCENE_LAUNCH, function () {
                splash.style.display = 'none';
            });
        }

        var onStart = function () {
            cc.loader.downloader._subpackages = settings.subpackages;

            if (false) {
                BK.Script.loadlib();
            }

            cc.view.resizeWithBrowserSize(true);

            if (!false && !false) {
                if (cc.sys.isBrowser) {
                    setLoadingDisplay();
                }

                if (cc.sys.isMobile) {
                    if (settings.orientation === 'landscape') {
                        cc.view.setOrientation(cc.macro.ORIENTATION_LANDSCAPE);
                    }
                    else if (settings.orientation === 'portrait') {
                        cc.view.setOrientation(cc.macro.ORIENTATION_PORTRAIT);
                    }
                    cc.view.enableAutoFullScreen([
                        cc.sys.BROWSER_TYPE_BAIDU,
                        cc.sys.BROWSER_TYPE_WECHAT,
                        cc.sys.BROWSER_TYPE_MOBILE_QQ,
                        cc.sys.BROWSER_TYPE_MIUI,
                    ].indexOf(cc.sys.browserType) < 0);
                }

                // Limit downloading max concurrent task to 2,
                // more tasks simultaneously may cause performance draw back on some android system / browsers.
                // You can adjust the number based on your own test result, you have to set it before any loading process to take effect.
                if (cc.sys.isBrowser && cc.sys.os === cc.sys.OS_ANDROID) {
                    cc.macro.DOWNLOAD_MAX_CONCURRENT = 2;
                }
            }

            // init assets
            cc.AssetLibrary.init({
                libraryPath: 'res/import',
                rawAssetsBase: 'res/raw-',
                rawAssets: settings.rawAssets,
                packedAssets: settings.packedAssets,
                md5AssetsMap: settings.md5AssetsMap
            });

            if (false) {
                cc.Pipeline.Downloader.PackDownloader._doPreload("WECHAT_SUBDOMAIN", settings.WECHAT_SUBDOMAIN_DATA);
            }

            var launchScene = settings.launchScene;

            // load scene
            cc.director.loadScene(launchScene, null,
                function () {
                    if (cc.sys.isBrowser) {
                        // show canvas
                        canvas.style.visibility = '';
                        var div = document.getElementById('GameDiv');
                        if (div) {
                            div.style.backgroundImage = '';
                        }
                    }
                    cc.loader.onProgress = null;
                    console.log('Success to load scene: ' + launchScene);
                }
            );
        };

        // jsList
        var jsList = settings.jsList;

        if (!false) {
            var bundledScript = settings.debug ? 'src/project.dev.js' : 'src/project.js';
            if (jsList) {
                jsList = jsList.map(function (x) {
                    return 'src/' + x;
                });
                jsList.push(bundledScript);
            }
            else {
                jsList = [bundledScript];
            }
        }

        // anysdk scripts
        if (cc.sys.isNative && cc.sys.isMobile) {
//            jsList = jsList.concat(['src/anysdk/jsb_anysdk.js', 'src/anysdk/jsb_anysdk_constants.js']);
        }

        var option = {
            //width: width,
            //height: height,
            id: 'GameCanvas',
            scenes: settings.scenes,
            debugMode: settings.debug ? cc.DebugMode.INFO : cc.DebugMode.ERROR,
            showFPS: (!false && !false) && settings.debug,
            frameRate: 60,
            jsList: jsList,
            groupList: settings.groupList,
            collisionMatrix: settings.collisionMatrix,
            renderMode: 0
        }

        cc.game.run(option, onStart);
    }

    if (false) {
        BK.Script.loadlib('GameRes://libs/qqplay-adapter.js');
        BK.Script.loadlib('GameRes://src/settings.js');
        BK.Script.loadlib();
        BK.Script.loadlib('GameRes://libs/qqplay-downloader.js');
        qqPlayDownloader.REMOTE_SERVER_ROOT = "";
        var prevPipe = cc.loader.md5Pipe || cc.loader.assetLoader;
        cc.loader.insertPipeAfter(prevPipe, qqPlayDownloader);
        // <plugin script code>
        boot();
        return;
    }

    if (false) {
        require(window._CCSettings.debug ? 'cocos2d-js.js' : 'cocos2d-js-min.js');
        require('./libs/weapp-adapter/engine/index.js');
        var prevPipe = cc.loader.md5Pipe || cc.loader.assetLoader;
        cc.loader.insertPipeAfter(prevPipe, wxDownloader);
        boot();
        return;
    }

    if (window.jsb) {
        require('src/settings.js');
        require('src/jsb_polyfill.js');
        boot();
        return;
    }

    if (window.document) {
        var splash = document.getElementById('splash');
        splash.style.display = 'block';

        var cocos2d = document.createElement('script');
        cocos2d.async = true;
        cocos2d.src = window._CCSettings.debug ? 'cocos2d-js.js' : 'cocos2d-js-min.js';

        var engineLoaded = function () {
            document.body.removeChild(cocos2d);
            cocos2d.removeEventListener('load', engineLoaded, false);
            if (typeof VConsole !== 'undefined') {
                window.vConsole = new VConsole();
            }
            boot();
        };
        cocos2d.addEventListener('load', engineLoaded, false);
        document.body.appendChild(cocos2d);
    }

})();

修改好main.js后,就可以编辑项目安装到手机上了,接着修改项目,换个图片,保存然后构建,不用选MD5 Cache,构建成功后,修改version_generator.js版本号改为1.0.1,然后执行 node version_generator.js,然后把构建后的src、res和生成的project.manifest、version.manifest放在服务端,比如:
通过mac模拟服务器 python -m SimpleHTTPServer 8000


image.png

启动好后,就可以打开App安装测试了。

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