手撸三个有关Bundle详细教程,大厅+子游戏模式从入门到进阶,版本Creator 2.4.x

概述

在去年CocosCreator 2.4发布的时候,做了一次版本更新的盘点,对其中Asset Manager和Bundle部分专门做了重点盘点,用了一个小例子,大概展示了其能力,以及吹了一波应用场景,后来很多小伙伴问我了很多实际使用的问题,我发现很多问题来自于使用方法不对造成的疑惑,今天正好有一些时间,专门撰写一篇文稿,详细讲解Bundle的使用方法,用三个实际应用展示它。


B站传送门:CocosCreator 2.4.0 更新盘点

目录

本文将会按照下面的结构:

  1. 例子展示
  2. 手撸工程
    2.1 同项目Bundle,动态加载
    2.2 跨项目Bundle,大厅+子游戏
    2.3 跨项目Bundle,代码互调
  3. 踩坑总结

本项目有视频版本,目前还未剪辑完成,等发布完成,再做更新。

GIT 地址在文章后面 ╮( ̄▽  ̄)╭

先看例子

我将完成结果先放在前面,帮助各位可以快速找到自己所需要的部分。

第一个例子,同项目Bundle,动态加载

1
直接加载内部的bundle包,它是项目本身的某个目录,只不过被设置为bundle,里面包含了动画、脚本、场景、资源等,我们可以看到,项目运行起来的时候,被设置为bundle的部分,不会被加载,只有主动加载的时候才会载入,通过API也可以读取内部的素材,就像我们现在做得这样,读取了一个Prefab创建成Node,还有就是读取一张图片显示出来。

第二个例子,跨项目Bundle,大厅+子游戏

2
加载另外一个项目的bundle包,它没有和主项目在一起,而是另外一个CocosCreator项目的一部分,我把生成的素材放在一个资源服务器上,让主项目去载入它,然后运行,通过远程载入创建出来,它是实现动态加载的核心,一会儿后面咱们详细讲解。

第三个例子,跨项目Bundle,代码互相调方案

3
不同bundle内资源或代码的互相调用测试,这个例子主项目为子游戏bundle提供调用接口,我们可以看到除了能调用主项目,也可以通过一些方法被调用,那么怎么做到呢?且听后面分解。

手撸详解

看完了例子,那么我们把上面的全部手撸一遍,注意本视频中的所有代码为Typescript,Cocos Creator 版本使用的是2.4.5版本。

一、同项目Bundle,动态加载

首先我们先要建立一个CocosCreator的项目,这个项目必须使用的2.4.0以上的版本,这样才能有Bundle的特性,起一个名字就叫BundleLobby。


打开项目,建立一些目录和场景,随便来一个目录就叫:aaa 吧,这个名字越随便越好,以后就会设置成为bundle,然后在它下面建立一些基本的项目目录,比如res、src,用来存放你的资源和代码,创建一个主场景叫做Main,搭建一下基本的UI功能,这里作了两个按钮:一个是读取,一个是跳转,还有一个进度条,用来表示Bundle的读取成功与否。

现在为aaa的目录里增加一些素材,创建一个aaa的场景,简单摆放一下,为了方便展示它的复杂性,所以我加入了一个spine动画,并且把spineboy制作成了一个Prefab。

先实现一下基本的场景跳转,也就是没有把它配置成为bundle的情况下,它只是整个项目的一部分,写一些代码让Main和aaa两个场景互相跳转,需要完成两个组件代码,我这里为aaa场景和Main场景各自加入了一个组件Script,并且为各自的场景添加了场景跳转代码:

onClickSceneTo(){
    cc.director.loadScene('aaa');
}


这种跳转是最普通的情况,当把aaa这个目录给配置成为bundle之后,就不一样了。
4
此时跳转到aaa的按钮已经不管用了,我们看调试信息里已经给出了报错信息,它找不到名为aaa的场景资源。

5
这是因为bundle资源不会在启动的时候加载,而是需要用asset manager的loadBundle之后,所以,我们为读取按钮添加一个click事件,并且实现如下代码

onClickLoad(){
    cc.assetManager.loadBundle('aaa',(err,bundle)=>{
        if(!err){
            this.progressBar.progress = 1;
        }
    });
}

运行项目后,先点击读取,读取成功之后再点击跳转aaa,就会跳转到对应的场景当中,不会报错。

既然基本的场景已经实现,那么能否更进一步,从Bundle包里面读取资源呢?我们需要对Main场景进行改造和调整一下,并且组件脚本中的对应处理也需要调整,在这里,我使用了两个空Node来显示读取出来的Prefab和图片,它们分别叫target1和target2,场景结构大概是这个样子:

MainScript1.ts的代码如下:

const {ccclass, property} = cc._decorator;
@ccclass
export default class MainScript1 extends cc.Component {
    @property(cc.ProgressBar)
    progressBar:cc.ProgressBar = null;
    @property(cc.Node)
    target1:cc.Node = null;
    @property(cc.Node)
    target2:cc.Node = null;
    private _bundle:cc.AssetManager.Bundle;
    start () {
        this.progressBar.progress = 0;
        this.target1.active = this.target2.active = false;
    }
    onClickLoad(){
        cc.assetManager.loadBundle('aaa',(err,bundle)=>{
            if(!err){
                this._bundle = bundle;
                this.progressBar.progress = 1;
                this.target1.active = this.target2.active = true;
            }
        });
    }
    onClickSceneTo(){
        cc.director.loadScene('aaa');
    }
    onClickLoadPrefab(s:cc.Event.EventTouch){
        this._bundle.load('res/spineboy',cc.Prefab,(err,asset:cc.Prefab)=>{
            if(!err){
                this.target1.addChild(cc.instantiate(asset));
                s.currentTarget.active = false;
            }
        });
    }
    onClickLoadSpriteFrame(s:cc.Event.EventTouch){
        this._bundle.load('res/button',cc.Texture2D,(err,tex:cc.Texture2D)=>{
            if(!err){
                s.currentTarget.active = false;
                const node = new cc.Node();
                node.addComponent(cc.Sprite).spriteFrame = new cc.SpriteFrame(tex);
                this.target2.addChild(node);
            }
        });        
    }
}

最终结果:


在第一个例子中,通过把一个目录设置成为bundle,实现动态加载资源内容的能力,这也是2.4版本最让开发者们兴奋的更新,它能大大的加强游戏的载入体验,而它的强大不止于此,官方文档中明确说明,它可以把其他项目的bundle给载入进来,下面第二个例子我们就来试试这个功能。

二、跨项目Bundle,大厅+子游戏

现在计划把这个项目当成大厅场景,通过loadBundle完成对子游戏的bundle加载,对BundleLobby的项目改造一下,为了方便区分和展示,先把Main给重命名为Main1,以及MainScript改名为MainScript1,新建一个场景叫Main2,然后重新建立一个组件脚本,名字叫MainScript2,把一些代码从1复制到2当中,这么是为了避免重写,先不用着急改代码,等子项目完成后回来再说,同样的,Main2场景当中的一些界面元素,就直接复用即可,只保留一个target的Node当容器,为两个按钮指定Click事件,作为大厅的项目已经准备好了。

构建子游戏项目

关掉主项目,下一步要完成子项目,新建一个Games的CocosCreator 2.4以上版本的项目,然后建立一个目录,名字就叫Game1吧,未来还得有Game2,我将会选择一个相对比较有趣的内容用于展示,内容尽量和大厅工程不一样,在后面的展示中,将不止是场景跳转,还有从内部创建,从而让它更像是一个动态载入进来的小游戏,这个部分我作了一些简单的开发,具体细节略过,它是一个一直在推进的连续场景,看起来不错,也很酷。
t8
我们把游戏内容的舞台部分制作成为Prefab,当然了你要注意把逻辑脚本挂载在Prefab这个节点上,不然的话,嘿嘿嘿。。。。


现在一个最简单的子游戏创建好了,复杂的内容,咱们放在第三个例子中详细说,现在直接build一下,通过菜单项“项目”=》“构建发布”构建,目标平台为了方便测试先选择Mobile,稍加等待之后,完成。

构建完成后,进入到项目目录,找到\build\web-mobile\assets下面
9
这里有个Game1,它就是bundle包了,其他的都不需要,咱们只需要这个部分。

资源服务器

但是,跨项目读取必须通过远程方式,所以,你需要用一个小服务器来当资源服务器,在我的项目中提供了一个小网站Node项目,来实现对它的远程读取,方便主项目远程加载,这个工程和大厅、子游戏放在了同一个地方,名字叫RemoteHttpServer的目录,这个你也可以用别的方式,取决于您的喜好,把build下面的asset下的Game1移动或复制到这个资源server下,确保能够通过网络能够访问到它。


切换CocosCreator的项目到BundleLobby下,要对远程的Bundle进行加载了,我们打开代码MainScript2.ts,使用第一个例子中的同样方法loadBundle,但是需要改成远程资源URL,在我的例子中,远程bundle在 127.0.0.1:8080/Game1 当中,所以代码读取需要修改,同时,由于子游戏在Build的时候为文件加了md5标记,所以,直接打开是不行的,需要借助可选参数的version字段来解决这个问题,因此最终的代码如下:

const {ccclass, property} = cc._decorator;

@ccclass
export default class MainScript2 extends cc.Component {
    @property(cc.ProgressBar)
    progressBar:cc.ProgressBar = null;
    @property(cc.Node)
    target1:cc.Node = null;

    private _bundle:cc.AssetManager.Bundle;
    start () {
        this.progressBar.progress = 0;
        this.target1.active =  false;
    }
    onClickLoad(){
        const options = {
            version:"08f26",
            onFileProgress:(n,t)=>{
                this.progressBar.progress = n / t;
            }
        }
        cc.assetManager.loadBundle('http://127.0.0.1:8080/Game1',
            options,
            (err,bundle)=>{
                if(!err){
                    this._bundle = bundle;
                    this.target1.active =  true;
                }
            });
    }
    onClickSceneTo(e:cc.Event.EventTouch){
        e.currentTarget.active = false;
        this._bundle.load("prefab/Game1Stage",cc.Prefab,(err,asset:cc.Prefab)=>{
            if(!err){
                this.target1.addChild(cc.instantiate(asset));
            }
        });
    }
}

10

最终结果如下:
t9
到目前为之,第二个例子已经结束了,虽然已经完成了远程包体的载入流程,但是真正的实现一个大厅加子游戏,或者动态功能模块的话,还似乎差了一些什么,这种的项目需求是要求子包和大厅之间的代码调用,或者互相通讯,下面我们开始尝试用第三个例子来解决这个问题

三、跨项目Bundle,代码互调

在这之前,我们可能需要了解和梳理Bundle的机制,在官方文档中描述Asset Bundle 的构造提到,内容分为代码和资源两个部分,资源的入口是config.json,代码入口为index.js,按照我的测试结果来看,Bundle在下载成功后,会立即将index.js中的代码加入到主包中,打开这个文件看看就能猜到个大概,因此,我们只需要设计大厅接口,在子游戏中实现同样的接口,最后不把它们build到bundle即可,设计思路大致为主包大厅和bundle子游戏内创建的控制组件,并开发通用接口,互相之间通过这种方法调用,为了开发的便捷性,可以为子游戏中创建虚拟的接口类,实现独立开发的能力。

在大厅项目中,我们新建一个场景Main3和MainScript3组件脚本,并且按照之前Main2样子搭建,有一些部分还得需要结合子游戏修改,先放在这里,现在用VS Code在src目录中,实现一个接口文件,就叫IMainController吧,我这里就简单实现一个输出文本接口

export interface IMainController {
    outString(str: string): void;
}

在实际项目中,接口可能要比这个复杂的多,主要看你的项目需求,现在我们再建立一个MainController的组件脚本,为了区分,我加上了Script为后缀,实现基础的组件类代码,并且实现IMainController的接口,回到Main3的场景中,为Canvas挂上刚刚的组件,在场景中创建并且指定一个cc.Label作为输出组件,现在主场景已经准备好了。

import { IMainController } from "./IMainController";

const {ccclass, property} = cc._decorator;

@ccclass
export default class MainControllerScript extends cc.Component implements IMainController {
    @property(cc.Label)
    outLabel:cc.Label = null;
    outString(str: string): void {
        this.outLabel.string = str;
    }    
}

下一步开发子游戏,关闭大厅项目,打开子游戏项目,为了避免和之前的重复,新建一个Game2文件夹,放进去了一个龙骨制作的小熊,现在我将实现点击一下舞台区域,就变化一次动作,并且将动作名字输出给大厅。

布局好了基本的场景元素,创建Game2Logic的组件脚本,先实现点击舞台变化动作的功能,这些代码并不复杂,所以就暂时略过,参看后面的完整代码。

下一步就是实现前面的MainController接口,由于子游戏会运行在大厅环境中,并且可能会有很多游戏使用,所以它可以作为公共代码存在,完全没有必要将它也输出,现在我们建立一下相关的代码文件。
新建common目录:


新建IMainController.d.ts文件,对照大厅实现接口代码,借助一下Window的公共接口声明来达到调试类应用的目的,在这里我又弄了一个调试用的DebugMainController。

IMainController.d.ts

declare interface IMainController {
    outString(str: string): void;
}
declare interface Window{
    debugMainCtrl:IMainController;
}

DebugMainController.ts

class DebugMainController implements IMainController{
    outString(str: string): void {
        console.warn('Method is debug,str is ' + str);
    }
}
if(!window.debugMainCtrl){
    window.debugMainCtrl = new DebugMainController();
}

这个做法是为了当不在大厅的时候,本地调试的功能可以来测试真实的反馈,现在我们到Game2Logic的组件脚本中,先把名字给提取出来作为变量,然后我们通过获取当前场景的根节点进行组件遍历查找,getComponentInChildren获得大厅的组件脚本,还记得大厅组件已经接口实现了吧,如果找到就用它,如果没有找到就使用调试类,然后作输出。

MainScript3.ts

const {ccclass, property} = cc._decorator;

@ccclass
export default class Game2Logic extends cc.Component {

    @property(dragonBones.ArmatureDisplay)
    actor:dragonBones.ArmatureDisplay = null;


    start () {
        this.node.on(cc.Node.EventType.TOUCH_END,this.onTouchEnd,this);
        this.node.on("ActorAnimationPlay",this.onActorAnimationPlay,this);
    }
    onDestroy(){
        this.node.off(cc.Node.EventType.TOUCH_END,this.onTouchEnd,this);
        this.node.off("ActorAnimationPlay",this.onActorAnimationPlay,this);
    }
    private onActorAnimationPlay(aniname:string){
        this.actor.playAnimation(aniname,-1);
    }
    private index = 0;
    private onTouchEnd(){
        const arr = this.actor.getAnimationNames("ubbie");
        const aniName = arr[this.index % arr.length];
        this.node.emit("ActorAnimationPlay",aniName);
        this.index += 1;
        let mainCtrl:IMainController = cc.director.getScene().getComponentInChildren('MainControllerScript');
        if(!mainCtrl){
            mainCtrl = window.debugMainCtrl;
        }
        mainCtrl.outString(aniName);
    }
}

现在调试一下看看效果,可以看到它确实调用的是本地调试类的方法


在上面的代码中,加入了一个动画播放的事件监听,事件名为ActorAnimationPlay,这个监听主要是用来从大厅项目向子游戏通讯用的,具体细节后面详述。

下一步设置子游戏包,详细步骤参考例子二,并且将舞台做成一个Prefab,然后在build一下,在build目录下asset中复制或者移动Game2到HttpServer项目中,刷新一下页面看到有了Game2即可,记录一下名字中间的版本编号,更新到对应的代码中。

运行一下基本上可以能够得到如例子二一样的结果,只不过目前只是单向的,即子游戏向大厅调用,反过来也是一样,主场景也能调用子游戏代码,用事件是一个很好的办法,因此我上面加入了 ActorAnimationPlay 这个事件名的监听,用这个事件来实现控制子游戏的小熊动画,具体代码请参看后续代码,不明白的,可以看代码以及官方文档当中有关事件的部分,子游戏也可以用事件的方式来处理向大厅通讯,但是按照我的经验来看,写接口调用的方式会更加严谨,也比较容易排查错误,有时候甚至还得用上Promise异步,如果真的是需要用上事件,也最好封装一下。

因此,最终 MainScript3.ts 的代码如下:

const {ccclass, property} = cc._decorator;

@ccclass
export default class MainScript3 extends cc.Component {
    @property(cc.ProgressBar)
    progressBar:cc.ProgressBar = null;
    @property(cc.Node)
    target1:cc.Node = null;

    private _bundle:cc.AssetManager.Bundle;
    start () {
        this.progressBar.progress = 0;
        this.target1.active =  false;
    }
    onClickLoad(){
        const options = {
            version:"78969",
            onFileProgress:(n,t)=>{
                this.progressBar.progress = n / t;
            }
        }
        cc.assetManager.loadBundle('http://127.0.0.1:8080/Game2',
            options,
            (err,bundle)=>{
                if(!err){
                    this._bundle = bundle;
                    this.target1.active =  true;
                }
            });
    }
    onClickSceneTo(e:cc.Event.EventTouch){
        // cc.director.loadScene('Game1');
        e.currentTarget.active = false;
        this._bundle.load("prefab/Game2Stage",cc.Prefab,(err,asset:cc.Prefab)=>{
            if(!err){
                this.target1.addChild(this._gameStage = cc.instantiate(asset));
            }
        });
    }
    private _gameStage:cc.Node;
    onClickActonWalk(){
        this._gameStage.emit("ActorAnimationPlay","walk");
    }
    
    onClickActonStand(){
        this._gameStage.emit("ActorAnimationPlay","stand");
    }
}

Main3的场景结果大致为这样的

可能有一些细节需要再作修正,不过我感觉已经很详细了,项目源码和视频已经准备好了,可以详细了解。
t12

注意事项

  • 第一是各个bundle中的代码中不要有一样的类名,或者全局变量名,这样的代码会在读取bundle后直接报重名错误;
  • 第二是bundle包代码尽量不要互相引用,如果你的业务需求必须这样做,应该用设置载入优先级解决,但只能解决在同一个项目中的bundle 读取,跨项目使用还是得自己控制先后顺序,建议可以把通用代码整合成一个包,在开始的时候读下来;
  • 第三是跨bundle的资源尽量互相保持独立,对象管理只是一方面,关键是有一些不可预期的奇怪错误,往往会从缓存和释放的地方出问题。

总结

bundle的方式是一个好东西,游戏行业总是再想办法尽可能缩短用户体验到游戏内容的时长,从而降低因等待造成的流失成本,CocosCreator的bundle包,不止可以应用到大厅和子游戏模式,它还比较适用于推进式关卡,人物角色形象包,教育用的图书绘本等等,相信有了上面的例子,您对bundle的使用一定能够更进一步理解。

文稿内容就到这里了,这个手撸Bundle当然还有视频版本,配有语音讲解,工程已经放在Git当中

GIT 地址

https://github.com/Nowpaper/CreatorBundleTest

视频地址

爆肝剪辑中~~~

如果您喜欢我的文章或视频,请给个点赞+关注,帖子回复个“支持”,那将是对我莫大的鼓励,我是Nowpaper,一爸作游戏,越来越有戏~~ 我们下次再见!

104赞

很棒的教程~~

支持,学习下

膜拜大佬~

nice, mark

mark 大佬牛批

战略性MARK!

不错~~ 很棒的教程~~

学习了,笔芯

大佬牛皮.

必须mark

收藏一波mark

收藏一波mark

不如您这个详细:https://mp.weixin.qq.com/s/9sLadl4Sjb1vl_dsrK1X3w

很棒的教程

loadBundle 并没有 把bundle里的东西都下载下来吧?这样在原生的环境下会不会体验不好,加载对应预制体时候再去下载不会很慢吗?

撸撸更健康

mark mark

嗯,loadBundle不会把资源都下载下来,只会加载脚本和配置文件,但里边的东西,也可以自己去提前下载

提前下载的意思是在loadBundle成功后进行预加载吗