思考:鄙人从业cocos开发5、6年,之前一直用cocos2d-x c++版开发儿童教育类游戏。随着业务的增加,子游戏达到80多个了。也伴随一些问题出现,比如这些问题:1、包体太大。我们app包最大的时候达到200M,这对于用户来说实在是比较大呀!特别是安卓用户。2、如果某个学习的游戏有bug,需要再次发版修复。安卓平台审核比较快,可是苹果可慢了,一周的时间。之前我和小伙伴们也有想着解决这些问题。比如,让子游戏的资源可以从服务器下载,代码生成的.a、 .so动态库也下载。虽然解决了上述的问题,但是,太多的子游戏编译比较长,上传服务器测试等待时间长等问题。我们的业务已经不适合用cocos2d-x来做了。我也承认c++更加流畅、功能丰富、库多、调试方便,可是能驾驭c++的程序员有几个。一直关注CocosCreator的发展,发现这个新产物完全可以满足我们的业务。
接下来我们带着几个问题来探讨如何实现大厅和子游戏的^ - ^
一、CocosCreator能热更新吗?就是可以将代码和资源一起下载下来的那种可以吗?子游戏可以下载吗?
答:答案是可以,cocos官网就有详细的文档介绍热更新了。热更新文档在这里就不在介绍了。
大概的步骤:
- 1、用version_generator.js生成新版本的资源配置文件;
- 2、将资源放到配置文件对应的服务器中,最好是zip包;
- 3、每次进入游戏时,用
jsb.AssetsManager
检查版本checkUpdate()
检查版本和更新版本update()
问题来了,我们子游戏如何热更新呢?经过测试,子游戏只要服务器保存的地址不一样,版本号不一样就可以热更新。一般大厅都是一个游戏列表,携带子游戏的下载地址、是否更新、游戏图片、游戏名称等。点击大厅某个子游戏图标然后,热更新对应子游戏的地址。如果版本和本地不一致,assetsManager就会更新下载。客户端保存的子游戏的地方一般是jsb.fileUtils.getWritablePath()+'/'+md5(子游戏版服务器地址+子游戏版本)
下面是提供新版本2.0.6的代码
@ccclass
export default class HomeScene extends cc.Component {
start() {
initAssetManage("game1");
checkUpdate("game1");
}
//** 热更新代码 ***/
initAssetManage(gameName) {
if (cc.sys.isBrowser) {
return;
}
this.storagePath = ((jsb.fileUtils ? jsb.fileUtils.getWritablePath() : '') + gameName);
let versionComareHandle = function (versionA, versionB) {
let vA = versionA.split('.');
let vB = versionB.split('.');
for (let i = 0; i < vA.length; ++i) {
let a = parseInt(vA[i]);
let b = parseInt(vB[i] || 0);
if (a !== b) {
return a - b;
}
}
return vB.length > vA.length ? -1 : 0;
}
this.assetsManager = new jsb.AssetsManager('', this.storagePath, versionComareHandle);
// this.assetsManager.setVerifyCallback((filePath, asset) => {
//获取下载下来的文件的MD5跟manifest文件的md5进行比较,是否文件有问题
// if (filePath.endsWith('project.manifest')) return true;
// let data = jsb.fileUtils.getDataFromFile(filePath);
// let fileMD5 = MD5(data);
// let assetsMD5 = asset.md5;
// return (fileMD5 === assetsMD5 || assetsMD5 === '111111');
// });
if (cc.sys.os === cc.sys.OS_ANDROID) {
this.assetsManager.setMaxConcurrentTask(2);
}
}
checkCb(event) {
switch (event.getEventCode()) {
case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
cc.log('本地没有配置文件: ' + event.getMessage());
break;
case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
cc.log('下载配置文件错误: ' + event.getMessage());
break;
case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
cc.log('解析文件错误: ' + event.getMessage());
break;
case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
cc.log('已经是最新的: ' + event.getMessage());
setTimeout(() => this.getCourseConfig(), 2000);
break;
case jsb.EventAssetsManager.NEW_VERSION_FOUND:
cc.log('找到新版本');
setTimeout(() => this.hotUpdate(), 2000);
return;
default:
return;
}
}
checkUpdate(gameName) {
if (cc.sys.isBrowser) return;
let UIRLFILE = "http://192.168.0.1:8080/server/" + gameName;
let remoteManifestUrl = this.storagePath + "/project.manifest";
this.manifestUrl = remoteManifestUrl;
let customManifestStr = JSON.stringify({
"packageUrl": UIRLFILE,
"remoteManifestUrl": UIRLFILE + "/project.manifest",
"remoteVersionUrl": UIRLFILE + "/version.manifest",
"version": "1.0.0.0",
"assets": {},
"searchPaths": []
});
this.assetsManager.setEventCallback(this.checkCb.bind(this));
if (this.assetsManager.getState() === jsb.AssetsManager.State.UNINITED) {
if (jsb.fileUtils.isFileExist(remoteManifestUrl)) {
cc.log('加载本地Manifest');
this.assetsManager.loadLocalManifest(this.manifestUrl);
} else {
cc.log('加载网络Manifest');
let manifest = new jsb.Manifest(customManifestStr, this.storagePath);
this.assetsManager.loadLocalManifest(manifest, this.storagePath);
}
}
cc.log("检查文件更新:" + remoteManifestUrl);
this.assetsManager.checkUpdate();
}
updateCb(event) {
switch (event.getEventCode()) {
case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
/*0 本地没有配置文件*/
cc.log('本地没有配置文件: ' + event.getMessage());
break;
case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
/*1下载配置文件错误*/
cc.log('下载配置文件错误: ' + event.getMessage());
break;
case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
/*2 解析文件错误*/
cc.log('解析文件错误: ' + event.getMessage());
break;
case jsb.EventAssetsManager.NEW_VERSION_FOUND:
/*3发现新的更新*/
cc.log('发现新的更新: ' + event.getMessage());
break;
case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
/*4 已经是最新的*/
cc.log('已经是最新的: ' + event.getMessage());
break;
case jsb.EventAssetsManager.UPDATE_PROGRESSION:
/*5 最新进展 做 进度的*/
var msg = Math.round(event.getPercent() * 100) + "%";
cc.log('进度: ' + msg);
// this.progress.string = msg;
break;
case jsb.EventAssetsManager.ASSET_UPDATED:
/*6需要更新*/
// cc.log('需要更新: ' + event.getMessage());
break;
case jsb.EventAssetsManager.ERROR_UPDATING:
/*7更新错误*/
cc.log('更新错误: ' + event.getMessage());
break;
case jsb.EventAssetsManager.UPDATE_FINISHED:
/*8更新完成*/
cc.log("更新完成");
setTimeout(() => this.getCourseConfig(), 2000);
break;
case jsb.EventAssetsManager.UPDATE_FAILED:
/*9更新失败*/
cc.log('更新失败: ' + event.getMessage());
// this.getfiles("subgame", 1);
break;
case jsb.EventAssetsManager.ERROR_DECOMPRESS:
/*10解压失败*/
cc.log('解压失败: ' + event.getMessage());
break;
}
}
hotUpdate() {
if (this.assetsManager) {
if (this.assetsManager.getState() === jsb.AssetsManager.State.UNINITED) {
this.assetsManager.loadLocalManifest(this.manifestUrl);
}
this.assetsManager.setEventCallback(this.updateCb.bind(this));
this.assetsManager.update();
}
}
//** 热更新代码 ***/
}
二、子游戏如何进入退出?
答:这个问题是一个不错的问题,经过2周对CocosCreator的研究,特别游戏启动的过程退出的过程。
1、让我们先讨论一下【进入】;
进入流程如下图:
从图上来看到,main.js
是需要好好研究就的。添加一些注释大家可以更好的理解mian.js
是如何走。
// QQPlay window need to be inited first
if (false) {
BK.Script.loadlib('GameRes://libs/qqplay-adapter.js');
}
window.boot = function () {
//*step2 读取配置 把window._CCStettings
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]];
}
}
}
}
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';
});
}
//*step5 初始化大环境后进入具体的场景
var onStart = function () {
cc.loader.downloader._subpackages = settings.subpackages;
cc.view.enableRetina(true);
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 配置项目中资源的uuid,方便后面加载对应资源转换为路径。
//这样进入场景后才能找到资源。
cc.AssetLibrary.init({
libraryPath: 'res/import',
rawAssetsBase: 'res/raw-',
rawAssets: settings.rawAssets,
packedAssets: settings.packedAssets,
md5AssetsMap: settings.md5AssetsMap
});
var launchScene = settings.launchScene;
// load scene 进入第一个场景
cc.director.loadScene(launchScene, null,
function () {
if (cc.sys.isBrowser) {
// show canvas
var canvas = document.getElementById('GameCanvas');
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) {
BK.Script.loadlib();
}
else {
//*step3 加载项目源码
//游戏所有源码都被编译成一个文件了,那就src/project.js 没有什么可看
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];
}
}
//*step4 根据配置开启游戏
var option = {
id: 'GameCanvas',
scenes: settings.scenes,
debugMode: settings.debug ? cc.debug.DebugMode.INFO : cc.debug.DebugMode.ERROR,
showFPS: !false && settings.debug,
frameRate: 60,
jsList: jsList,
groupList: settings.groupList,
collisionMatrix: settings.collisionMatrix,
}
cc.game.run(option, onStart);
};
// main.js is qqplay and jsb platform entry file, so we must leave platform init code here
if (false) {
BK.Script.loadlib('GameRes://src/settings.js');
BK.Script.loadlib();
BK.Script.loadlib('GameRes://libs/qqplay-downloader.js');
var ORIENTATIONS = {
'portrait': 1,
'landscape left': 2,
'landscape right': 3
};
BK.Director.screenMode = ORIENTATIONS[window._CCSettings.orientation];
initAdapter();
cc.game.once(cc.game.EVENT_ENGINE_INITED, function () {
initRendererAdapter();
});
qqPlayDownloader.REMOTE_SERVER_ROOT = "";
var prevPipe = cc.loader.md5Pipe || cc.loader.assetLoader;
cc.loader.insertPipeAfter(prevPipe, qqPlayDownloader);
window.boot();
}
else if (window.jsb) {
//*step1 加载项目的配置到 window._CCSettings
//查看src/settings.js 发现window._CCSettings={....} 里面都是项目配置
//几个常用:
//groupList 分组
//collisionMatrix 刚体碰撞配置表
//rawAssets 资源列表
//launchScene 开始场景
//packedAssets资源打包列表uuid
require('src/settings.js');
require('src/cocos2d-jsb.js');
require('jsb-adapter/engine/index.js');
window.boot();
}
大概了解进入流程后,我们假设一个命题:【 在大厅运行的情况下,我们更新配置是否可以进入子游戏。】。这个命题也是有基础支撑的。
一个是require('/xxx/x/x/xxxx.js');
,这个函数可以加载绝对路径的脚本到jsVM,哪怕是sd卡里的。我们可以把子游戏的配置文件(subGamePath/src/settings.js)和代码(subGamePath/src/project.js)加载进来了。
第二个是jsb.fileUtils.addSearchPath(subGamePath, true);
,为什么是他?用过cocos2d-x的小伙伴都知道,它添加搜索路径后,不管游戏资源在哪里都能加载。哪怕还是在sd卡里。这样,子游戏的资源路径加载到搜索路径后,子游戏资源我们就可以找到了。
有了这两个支撑点,我们可以加载一个完整的子游戏了。之后,就是代码替换的工作了。替换的过程和main.js
的工作比较相似。让我们看看具体是如何进入子游戏的,直接上代码。
//支撑cocoscreator版本2.0.6或更高
cc.Hall = cc.Hall || {};
cc.Hall.changeConfig = function (subGamePath) {
subGamePath = subGamePath ? subGamePath : '';
if (window.jsb) {
/// 1.初始化资源Lib路径Root.
// var subGamePath = '/storage/emulated/0/bubbleGame/';
cc.log("Hall ----->subGamePath:" + subGamePath);
/// 2.subgame资源未映射,则初始化资源映射表,否则略过映射.
// cc.sys.garbageCollect();
// cc.sys.restartVM();
/// 加载settings.js
// var hallSettings = window._CCSettings;
window.require(subGamePath + 'src/settings.js');
cc.log("Hall ----->加载settings.js");
var settings = window._CCSettings;
// settings.rawAssets.push(hallSettings.rawAssets);
// settings.packedAssets.push(hallSettings.packedAssets);
// settings.md5AssetsMap.push(hallSettings.md5AssetsMap);
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]];
}
}
}
}
/// 加载project.js
var projectDir = 'src/project.js';
if (settings.debug) {
projectDir = 'src/project.dev.js';
}
cc.log("Hall ----->加载project.js");
window.require(subGamePath + projectDir);
/// 如果当前搜索路径没有subgame,则添加进去搜索路径。
var currentSearchPaths = jsb.fileUtils.getSearchPaths();
cc.log("Hall ----->currentSearchPaths:" + currentSearchPaths);
if (currentSearchPaths && currentSearchPaths.indexOf(subGamePath) === -1) {
jsb.fileUtils.addSearchPath(subGamePath, true);
cc.log('Hall -----> 之前未添加,添加下subGamePath' + currentSearchPaths);
}
cc.AssetLibrary.init({
libraryPath: 'res/import',
rawAssetsBase: 'res/raw-',
rawAssets: settings.rawAssets,
packedAssets: settings.packedAssets,
md5AssetsMap: settings.md5AssetsMap
});
cc.game.collisionMatrix = settings.collisionMatrix;
cc.game.groupList = settings.groupList;
cc.log('Hall ----->AssetLibrary init');
var launchScene = settings.launchScene;
/// 将subgame的场景添加到cc.game中,使得cc.director.loadScene可以从cc.game._sceneInfos查找到相关场景
for (var i = 0; i < settings.scenes.length; ++i) {
cc.game._sceneInfos.push(settings.scenes[i]);
}
/// 3.加载初始场景
cc.log('Hall ----->加载初始场景');
cc.director.loadScene(launchScene, null,
function () {
cc.log('Hall ----->成功加载初始场景' + launchScene);
}
);
}
}
//进入子游戏
cc.Hall.enterSubGame = function (subGamePath) {
this.changeConfig(subGamePath);
}
//回到大厅
cc.Hall.backHall = function () {
this.changeConfig();
}
看了代码后大家可能觉得就这么简单,是的,就这么简单。如何使用:
//进入子游戏
window.require('src/Hall.js');
//gamePath子游戏的下载保存路径也许是sd卡也许是jsb.fileUtils.getWritablePath()+'/'+md5(子游戏版服务器地址+子游戏版本)
cc.Hall.enterSubGame(gamePath);
//回到大厅
window.require('src/Hall.js');
cc.Hall.backHall();
2、让我们讨论一下【退出】;
为什么退出只是不填路径就可以了?因为大厅的main.js中也没有填路径呀。
你看上面main.js
代码里的*step1。没有路径?不是,jsVM在初始化的时候pwd就是指到工程的路径下面了。
三、大厅和子游戏如何数据交互?
答:答案在这里-》通过常驻节点进行场景资源管理和参数传递
具体实现:添加一个Gloal节点和Global.js保存为常驻节点。子游戏在不重启游戏的情况下,可以访问常驻节点数据。
@ccclass
export default class Global extends cc.Component {
//课程资源
public gameData;
public gameScriptName: string
public currentGameId = 0;
public currentCourseId = 0;
public engineIsReady = false;
}
@ccclass
export default class HomeScene extends cc.Component {
onLoad() {
cc.log("The loading scene did load")
let globalNode = cc.find('Global');
if (!cc.game.isPersistRootNode(globalNode)) {
cc.game.addPersistRootNode(globalNode);
}
}
@ccclass
export default class SubGameScene extends cc.Component {
onLoad() {
cc.log("The loading scene did load")
let global = cc.find("Global").getComponent("Global") as Global
let data = global.gameData
}
}
四、子游戏共享大厅的资源和代码
我:这个下次再说;
看客:不是不会吧❓;
我:不是不写,?需要插件去做这个事情,更加方便些。人工去做,太苦。根本不合适。
看客:你不会写插件吧。
我:?呵呵。
我:能给的自有思路,这个插件你们拿去也没有用不骗人。大概是这样的,打包子游戏的时候,把大厅的资源源码和子游戏一起打包,然后剔除大厅的资源。这样子游戏的settings.js里会有大厅资源的uuid,然而子游戏中代码会有大厅的代码。剔除后子游戏会根据settings.js里的uuid去找大厅的资源。代码在启动大厅的时候已经加载到jsVM,子游戏不会再次加载大厅的代码。
结语:好好工作,好好学习