【新手教程】【羊了个羊】

改造 UIManager.open 方法

上个步骤中,我们解决了界面的层级问题,并使用 UIManager.getInstance().open(Match3UI) 的方式打开了三消主玩法界面。

在 UIManager 的 open 函数中,我直接写死了打开的界面。

open(uiClass: any) {
    ResManager.getInstance().loadPrefab("Match3BN", "Match3UI", prefab => {
        let match3Node = instantiate(prefab);
        this.m_Layers[EViewLayer.UI].node.addChild(match3Node);
        match3Node.getComponent(UITransform).setContentSize(G_VIEW_SIZE.clone());
    });
}

现在还需要做一些改造工作,才能初步实现 open 函数。
首先,我们需要修改成能够根据 uiClass 自己获得预制体所在的 AssetBundle 和对应的预制体 Url。

根据 uiClass 获得预制体资源依赖

我们需要实现一个方法 getUIClassBUrl(uiClass: any) => IBundleUrl
一个更具像的是这样的实现,我把它补充在 UIManager 中 MyLayer 的定义上方:

function getUIClassBUrl(uiClass: any): IBundleUrl {
    if (uiClass === Match3UI) {
        return BL("Match3UI", "Match3BN")
    }
    console.log(`其它类型暂未处理`)
}

它展示了这个函数的一个特例,用来说明其核心逻辑是:如果这个类是 Match3UI 类,那么就应该返回 BL("Match3UI", "Match3BN") 这样的结果。

于是修改 open 为:

open(uiClass: any) {
    const bUrl = getUIClassBUrl(uiClass);
    ResManager.getInstance().loadPrefab(bUrl.l, bUrl.b, prefab => {
        let match3Node = instantiate(prefab);
        this.m_Layers[EViewLayer.UI].node.addChild(match3Node);
        match3Node.getComponent(UITransform).setContentSize(G_VIEW_SIZE.clone());
    });
}

注意到对资源的描述,基本上是通过 IBundleUrl 的对象传入的,因此,在 ResManager 中补充对应类型资源的加载接口:

loadPrefabByBUrl(bUrl: IBundleUrl, cb: (prefab: Prefab | null) => void) { this.loadPrefab(bUrl.b, bUrl.l, cb); }
loadAudioByBUrl(bUrl: IBundleUrl, cb: (audioClip: AudioClip | null) => void) { this.loadAudioClip(bUrl.b, bUrl.l, cb); }

open 方法最终改为

open(uiClass: any) {
    const bUrl = getUIClassBUrl(uiClass);
    ResManager.getInstance().loadPrefabByBUrl(bUrl, prefab => {
        let match3Node = instantiate(prefab);
        this.m_Layers[EViewLayer.UI].node.addChild(match3Node);
        match3Node.getComponent(UITransform).setContentSize(G_VIEW_SIZE.clone());
    });
}

这样我们就完成了 open 函数的改造工作。

1赞

getUIClassBUrl 的实现

现在我们需要专注于 getUIClassBUrl 方法的实现。

function getUIClassBUrl(uiClass: any): IBundleUrl {
    if (uiClass === Match3UI) {
        return BL("Match3UI", "Match3BN")
    }
    console.log(`其它类型暂未处理`)
}

这里我们发现,我们需要根据类 uiClass 去获得 IBundleUrl 对象。
而这里,我们不可能去写一堆的 if else 去特化这些情形,一个解决方案是,通过注册机制来实现这种依赖的反转。

const g_UICls2BUrl = new Map<any, IBundleUrl>();
/** 注册接口 */
export function setUIClassBUrl(uiClass: any, bUrl: IBundleUrl) {
    g_UICls2BUrl.set(uiClass, bUrl)
}
/** 获取接口 */
export function getUIClassBUrl(uiClass: any): IBundleUrl {
    return g_UICls2BUrl.get(uiClass);
}

g_UICls2BUrl.set(Match3UI, BL("Match3UI", "Match3BN"))

这样,我们完成了 getUIClassBUrl 的机制。我们只需要将 g_UICls2BUrl.set(Match3UI, BL("Match3UI", "Match3BN")) 移动到外部的某个地方,就倒转了框架层的依赖关系。

至于移动到什么位置,这个留到后面的帖子中解决。

1赞

引入脚本包 GScriptBN

我们知道,在微信小游戏平台,脚本是不能放在远程,所以脚本基本上是会以小游戏分包的形式存在。

所以我们在设计上,可以把脚本单独设计到一个脚本分包中。

有的朋友就会有疑问,这不就破坏独立性了?我更新的时候,变成整个脚本包都要更新,增加了更新的大小?这里只能说,还是要看情况,如果对用户更新大小有极致的要求,或者本身某个功能玩法是独立的。那可以根据情况单独分出去,也是完全支持的。

现在我们要引入脚本包: GScriptBN,并改造我们现有的一些流程:

新建 GScriptBN 脚本包

在 assets 目录下新建文件夹 GScript,勾选配置为Bundle,将 Bundle 名称更改为 GScriptBN,优先级设置为 9. (为什么是 9,这个从我个人长期的实践来看, 9 这个优先级是比较合适的,如果不明白,可以先用着)

编辑目标平台中的默认配置

点击平台设置,选中 Bundle 配置中的小游戏,将抖音小游戏、微信小游戏的压缩类型更改为小游戏分包(其它平台如果要上的,可自行同步更改)。

移动 fw 中的内容到 GScript 文件夹下的 core/modules 文件夹

请在 VSCode 中进行上述操作。注意各个文件夹都请重新新建,不要将文件夹拖拽到该目录,而应该新建完文件夹,再将脚本和对应的meta 文件进行拖拽。(举个例子:需要同时拖拽 ResConst.ts 和 ResConst.ts.meta 文件到 GScript/core/modules/res 文件夹下)VSCode 会自动修改引用的路径。

移动游戏逻辑到 GScript 文件夹下的 GamePlay/modules 文件夹


同样的,在 GScript 中新建 GamePlay/modules 文件夹,然后分别新建 Login 和 Match3 文件夹。
然后按之前的方式,在 VSCode 中,将各个脚本拖拽过来。然后删除 fw 及其相关的空文件夹和meta 文件

将 Boost.ts 和 Main.scene 移动到 assets 文件夹下

请注意

  1. 所有的脚本移动都要在 VSCode 中进行,需要同时移动脚本的 meta 文件。
  1. 所有的资源移动,需要在编辑器中进行。
    image

在 GScript 中新增 GCtr.ts 脚本

image
将其内容修改如下:

import { _decorator, Canvas, Component, js, ResolutionPolicy, screen, Size, view } from 'cc';
import { ResManager } from './core/modules/res/ResManager';
import { UIManager } from './core/modules/ui/UIManager';
const { ccclass } = _decorator;

/** 
 * 画布的标准化尺寸,就是之前说的
 * iPad 设备中的画布尺寸 = 1001 x 1334 (其中 1001 ≈ 1668/1.6672)
 * iPhone16设备中的画布尺寸 = 750 x1626(其中 1626 = 2556/1.572)
 */
export const G_VIEW_SIZE = new Size(0, 0);

@ccclass('GCtr')
class GCtr extends Component {
    async init(param: {
        canvas2d: Canvas,
    }) {
        this.adapterScreen()

        UIManager.getInstance().init(param.canvas2d);

        ResManager.getInstance().loadBundle("LoginBN", _ => {
            const loginEntryClass = js.getClassByName("LoginEntry") as typeof Component;
            this.node.addComponent(loginEntryClass)
        })
    }

    adapterScreen() {
        let resolutionPolicy: ResolutionPolicy = view.getResolutionPolicy();
        let designSize = view.getDesignResolutionSize();
        let frameSize = screen.windowSize;
        let frameW = frameSize.width;
        let frameH = frameSize.height;
        /** 是否是屏幕更宽 */
        const isScreenWidthLarger = (frameW / frameH) > (designSize.width / designSize.height);
        let targetResolutionPolicy = isScreenWidthLarger ? ResolutionPolicy.FIXED_HEIGHT : ResolutionPolicy.FIXED_WIDTH;
        if (targetResolutionPolicy !== resolutionPolicy.getContentStrategy().strategy) {
            /** 保证设计分辨率的内容都能显示出来 */
            view.setDesignResolutionSize(designSize.width, designSize.height, targetResolutionPolicy);
            view.emit("canvas-resize")
        }

        /** 实际的尺寸会和设计分辨率在一个维度,但是宽或高更大 */
        if (isScreenWidthLarger) {
            G_VIEW_SIZE.width = Math.ceil(designSize.height * frameSize.width / frameSize.height);
            G_VIEW_SIZE.height = designSize.height;
        } else {
            G_VIEW_SIZE.width = designSize.width;
            G_VIEW_SIZE.height = Math.ceil(designSize.width * frameSize.height / frameSize.width);
        }

        console.log(`屏幕${isScreenWidthLarger ? "更宽, 高度适配" : "更高, 宽度适配"} 设计分辨率比例下的屏幕尺寸: ${G_VIEW_SIZE.width}x${G_VIEW_SIZE.height}`);

        return isScreenWidthLarger;
    }
}

其逻辑基本是从 Boost.ts 中迁移过去的。这里注意 GCtr 类需要使用 @ccclass('GCtr') 进行装饰,这样 Boost.ts 中加载完脚本包,使用 addComponent(‘GCtr’) 的方式是,才能正确添加该组件。

改造 Boost.ts 脚本中的内容

将 Boost.ts 中的内容改造如下

import { _decorator, AssetManager, assetManager, Canvas, Component, Size } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('Boost')
export class Boost extends Component {
    @property(Canvas) private canvas2d: Canvas = null;

    private loadBundle(bundleName: string): Promise<AssetManager.Bundle> {
        return new Promise<AssetManager.Bundle>(rs => {
            assetManager.loadBundle(bundleName, (e, asset) => {
                if (e) {
                    console.error(e);
                    rs(null)
                    return;
                }
                rs(asset);
            });
        })
    }

    async start() {
        /** 加载全局脚本包 */
        await this.loadBundle("GScriptBN");
        const gCtr: any = this.node.addComponent("GCtr");
        await gCtr.init({
            canvas2d: this.canvas2d,
        })
    }
}

这样改造的目的是,为了解耦 Boost.ts 启动逻辑,这样 Boost.ts 中没有从 GScript 中的 import 任何脚本,这样 Cocos Creator 打包的时候,就不会将 GScriptBN 中的脚本打包到其自带的一个称之为 main 的 AssetBundle。

这样,我们完成初步的重构逻辑。启动后,控制流程从 Boost 组件转移到了 GCtr 组件

1赞

将屏幕适配逻辑移动到 UIManager 中

GCtr 中的适配逻辑,从责任划分来说,其实是 UIManager 的工作,现在我们将其移动到 UIManager 中。

首先在 MyLayer 类的定义上方增加以下内容(从 GCtr.ts 中剪切过来并改造):

/** 
 * 画布的标准化尺寸,就是之前说的
 * iPad 设备中的画布尺寸 = 1001 x 1334 (其中 1001 ≈ 1668/1.6672)
 * iPhone16设备中的画布尺寸 = 750 x1626(其中 1626 = 2556/1.572)
 */
export const G_VIEW_SIZE = new Size(0, 0);
function adapterScreen() {
    let resolutionPolicy: ResolutionPolicy = view.getResolutionPolicy();
    let designSize = view.getDesignResolutionSize();
    let frameSize = screen.windowSize;
    let frameW = frameSize.width;
    let frameH = frameSize.height;
    /** 是否是屏幕更宽 */
    const isScreenWidthLarger = (frameW / frameH) > (designSize.width / designSize.height);
    let targetResolutionPolicy = isScreenWidthLarger ? ResolutionPolicy.FIXED_HEIGHT : ResolutionPolicy.FIXED_WIDTH;
    if (targetResolutionPolicy !== resolutionPolicy.getContentStrategy().strategy) {
        /** 保证设计分辨率的内容都能显示出来 */
        view.setDesignResolutionSize(designSize.width, designSize.height, targetResolutionPolicy);
        view.emit("canvas-resize")
    }

    /** 实际的尺寸会和设计分辨率在一个维度,但是宽或高更大 */
    if (isScreenWidthLarger) {
        G_VIEW_SIZE.width = Math.ceil(designSize.height * frameSize.width / frameSize.height);
        G_VIEW_SIZE.height = designSize.height;
    } else {
        G_VIEW_SIZE.width = designSize.width;
        G_VIEW_SIZE.height = Math.ceil(designSize.width * frameSize.height / frameSize.width);
    }

    console.log(`屏幕${isScreenWidthLarger ? "更宽, 高度适配" : "更高, 宽度适配"} 设计分辨率比例下的屏幕尺寸: ${G_VIEW_SIZE.width}x${G_VIEW_SIZE.height}`);

    return isScreenWidthLarger;
}

在 UIManager 的 init 方法中,新增 adapterScreen() 调用

init(canvas: Canvas) {
    this.m_Canvas = canvas;
    adapterScreen()
    for (let layer = EViewLayer.Scene, maxLayer = EViewLayer.Toast; layer <= maxLayer; ++layer) {
        this.m_Layers.push(new MyLayer(layer, canvas, EViewLayer[layer]));
    }
}

重构 GCtr.ts 内容如下:

import { _decorator, Canvas, Component, js } from 'cc';
import { ResManager } from './core/modules/res/ResManager';
import { UIManager } from './core/modules/ui/UIManager';
const { ccclass } = _decorator;

@ccclass('GCtr')
class GCtr extends Component {
    async init(param: {
        canvas2d: Canvas,
    }) {
        UIManager.getInstance().init(param.canvas2d);

        ResManager.getInstance().loadBundle("LoginBN", _ => {
            const loginEntryClass = js.getClassByName("LoginEntry") as typeof Component;
            this.node.addComponent(loginEntryClass)
        })
    }
}

重新运行代码,表现如初。

现在我们回顾一下,发现还有 2 个问题:
1、如何注册界面类对应的预制体。
2、LoginEntry类目前创建的方式改造。

这 2 个问题留到后续。

1赞

追更!!!

大佬。强!!! :grin:

赶紧卖课 :grin:

1赞

大佬,催更!!!!!

1赞

关于这个,有个疑问请教大佬:假设resources包里有1万个图片,我首屏只用到里面的一个图片作为背景图,其它9999个资源都没用到,这种情况下,resources包是如何影响首屏启动时间的?resources包里的资源不也是用到才下载吗?难道是因为resources对应的配置文件列表太大导致下载时间变长,影响启动时间?

你已经知道如何构造测试的用例了。自打包对比两个结果就知道了。

我测下来,看起来就是索引文件的大小区别,别的没看出啥区别。都是按需加载。

赞·!催更!

1赞

好好看下 main 包和 resources 包中的资源

LoginEntry 改造

上个贴中,遗留了 2 个问题:
1、如何注册界面类对应的预制体。
2、LoginEntry类目前创建的方式改造。

这里先解决第二个问题。
首先说明一下为什么要改造 LoginEntry 创建方式。
之前我们是使用这样的方式获得 LoginEntry 类。

async init(param: {
    canvas2d: Canvas,
}) {
    UIManager.getInstance().init(param.canvas2d);

    ResManager.getInstance().loadBundle("LoginBN", _ => {
        const loginEntryClass = js.getClassByName("LoginEntry") as typeof Component;
        this.node.addComponent(loginEntryClass)
    })
}

当时的原因是: LoginEntry 是在 LoginBN 这个包中。调用者不能显示的 import 这个类,否则脚本就被打包到了调用者所在的包。所以当时采用了曲线救国的方式。

现在由于我们脚本统一在 GScriptBN 中,这样带来的好处是:
1、可以不用考虑脚本的引用关系
2、可以不用先加载脚本所在的 AssetBundle 包;

于是我们首先将 LoginEntry.ts 改成 LoginCtr.ts 并同时更改类名 LoginEntry 为 LoginCtr,并移除其组件属性。
同时移除 Match3Entry 相关内容。
LoginCtr.ts 调整后的结果如下:

import { AudioManager } from "../../../core/modules/audio/AudioManager";
import { LoginAudio } from "./LoginAudio";

export class LoginCtr {
    init() {
        // 加载背景音乐后播放
        AudioManager.getInstance().playMusic(LoginAudio.bgm);
    }

    private m_OnLoginSuccess: Function = null;
    showLogin(onLoginSuccess: Function) {
        this.m_OnLoginSuccess = onLoginSuccess;
        this.autoLogin();
    }

    autoLogin() {
        // 模拟登录耗时 1 秒回调实现(后续修改此为不同平台登录逻辑即可)
        setTimeout(() => {
            this.m_OnLoginSuccess();
        }, 1000)
    }
}

GCtr.ts 调整后如下

import { _decorator, Canvas, Component } from 'cc';
import { ResManager } from './core/modules/res/ResManager';
import { UIManager } from './core/modules/ui/UIManager';
import { LoginCtr } from './GamePlay/modules/Login/LoginCtr';
import { Match3UI } from './GamePlay/modules/Match3/Match3UI';
const { ccclass } = _decorator;

@ccclass('GCtr')
class GCtr extends Component {
    readonly loginCtr = new LoginCtr();

    async init(param: {
        canvas2d: Canvas,
    }) {
        UIManager.getInstance().init(param.canvas2d);

        // 登录模块初始化
        this.loginCtr.init();
        
        // 显示登录界面(传入登录成功回调函数)
        this.loginCtr.showLogin(async () => {
            await ResManager.getInstance().loadBundleAsync("Match3BN");
            UIManager.getInstance().open(Match3UI)
        })
    }
}

可以看到,GCtr 实例化了一个 LoginCtr 对象,并负责初始化该管理器。
这里要注意的是 GCtr 被我改造成了一个管理所有控制器的对象,它负责所有模块控制器的实例化(new)和初始化(负责调用模块控制器的 init() 方法)。这个思路,就是二段构造法
在 Cocos2dx 的 create 方法中,就大量运用了这个模式。

这里还要注意的是, GCtr 的定位因人而已,我个人还会将整个游戏场景的流转逻辑放在 GCtr 中。
这样,我们就解决了因脚本在一些子包 Bundle 中引起的使用上的困难。(当然,我前面说过,脚本在一个包中,也会有其它的问题,比如说子游戏的资源和逻辑分到了2个文件夹中。如何取舍,还是由你自己最终决定。)

1赞

太卷了…放假倒计时了

引入全局变量 gtr,去除单例模式

为了方便游戏使用,我们引入一个 GCtr 类型的全局变量 gtr (当然,你可以改成自己喜欢的变量名)。并且将 UIManager 和 ResMananger 的单例模式去除:

import { _decorator, Canvas, Component } from 'cc';
import { ResManager } from './core/modules/res/ResManager';
import { UIManager } from './core/modules/ui/UIManager';
import { LoginCtr } from './GamePlay/modules/Login/LoginCtr';
import { Match3UI } from './GamePlay/modules/Match3/Match3UI';
const { ccclass } = _decorator;
// 新增全局变量声明
declare global {
    const gtr: GCtr;
}
@ccclass('GCtr')
class GCtr extends Component {
    readonly loginCtr = new LoginCtr();
    readonly res = new ResManager();
    readonly ui = new UIManager();

    async init(param: {
        canvas2d: Canvas,
    }) {
        // 全局变量设置
        (globalThis as any)["gtr"] = this;
        gtr.ui.init(param.canvas2d);

        // 登录模块初始化
        this.loginCtr.init();
        
        // 显示登录界面(传入登录成功回调函数)
        this.loginCtr.showLogin(async () => {
            await gtr.res.loadBundleAsync("Match3BN");
            gtr.ui.open(Match3UI)
        })
    }
}

UIManager 和 ResMananger 中的单例调用,都改成通过 gtr 的方式调用。

这样,我们引入了一个全局变量,消灭了所有的单例模式。
关于 globalThis 的使用,请参考官方文档。

1赞

预制体资源和类的映射关系

我将预制体所在的 Bundle 包名和其加载所需的 url 的整体信息,称之为 BUrl。后续说 BUrl 就等价于这 2 个信息。

那现在只剩下最后一个问题还没填上:如何通过类获得对应的预制体的 BUrl?

显然,如果没有约定,那么是不可能直接实现的。

因此,我做出如下约定:
1、一个预制体有且仅有一个控制脚本;
2、预制体的名称和类名相同;
3、所有的 Bundle 包,比如说 XX 包,其文件夹名称为 XX,其 Bundle 的名称必须为 XXBN,也就是添加了 BN 后缀;

有了上述约定以后,我们以 Match3UI 这个类为例,知道一定有一个预制体 Match3UI.prefab,在某个 Bundle 包中。

因此,我们只需要写一个自动化的脚本,将工程中的所有预制体的 BUrl 汇总起来,就能通过类名,获得到预制体的 BUrl。

这边我忽略了自动化脚本的编写。只举例自动化脚本的输入和输出。
1、自动化脚本的输入,就是 Cocos Creator 的游戏工程 assets 目录所在的路径。
2、自动化脚本的输出,是一个名为 PrefabCfg.ts 的配置文件,其路径为:assets/GScript/auto/PrefabCfg.ts,我先手动输出其内容为:

/** 
 * !!! 本文件由下方脚本自动生成,请勿手动修改.
 * !!! /Users/z/Desktop/CCYang/_tool/nodejs/src/app/gen_res_cfg/gen.ts
 */
import { BL } from "db://assets/GScript/core/modules/res/ResConst";
export const PrefabCfg = {
    // -------- Match3BN --------
    Match3UI: BL("Match3UI", "Match3BN"),
}

这样,我们就可以用类的名称 “Match3UI" 作为 key,从 PrefabCfg 中拿到预制体的 BUrl 了。

因此 UIManager.ts 中 getUIClassBUrl 方法的一个简单的实现是这样的:

/** 获取接口 */
export function getUIClassBUrl(uiClass: any): IBundleUrl {
    // 如果有类 -> BUrl 的注册信息,直接拿
    if (g_UICls2BUrl.has(uiClass)) {
        return g_UICls2BUrl.get(uiClass);
    }
    // 如果没有的话,从配置表拿
    let uiClassName = js.getClassName(uiClass);
    let bUrl = PrefabCfg[uiClassName];
    if (!bUrl) {
        /** 没有找到类名对应的预制体 */
        debugger;
        console.error("没有找到类名对应的预制体 uiClassName: ", uiClassName);
        return null;
    }
    // 缓存住
    g_UICls2BUrl.set(uiClass, bUrl);
    return bUrl;
}

当然,这里其实 getUIClassBUrl 方法中,不应该直接使用 PrefabCfg。应该改成提供注册接口,在打开界面前提前注册的模式,这个留给大家自行修改了。

现在运行,表现如初!

现在我们已经搭建了一个非常基础的框架了,后续只需要不断完善就可以了。

下个帖子,我们要开始进入正题:羊了个羊的逻辑开发。

1赞

好好好,不得不说是难得的精品,这玩意儿看懂,基本架构就有清晰的认识了,作者大义

1赞

预制体信息注册

上个帖子中,使用类名获得预制体 BUrl 是通过查询配置表的形式。
而配置表只是信息关联的一种方式,本来是留给大家自己修改的,这里我们还是对其进行一番改造,让底层逻辑不依赖业务层的实现:

在 UIManager.ts 中,新增以下内容:

const g_Key2BUrl = new Map<string, IBundleUrl>();
export function registerBUrlByCfg(cfg: {
    [uiClassName: string]: IBundleUrl
}) {
    for (let uiClassName in cfg) {
        g_Key2BUrl.set(uiClassName, cfg[uiClassName])
    }
}

将其中的

let bUrl = PrefabCfg[uiClassName];

更改为

let bUrl = g_Key2BUrl.get(uiClassName);

最后,我们在 GCtr.ts 的 init 方法中进行预制体信息的注册(同步修改了下 this.loginCtr 为 gtr.loginCtr):

async init(param: {
    canvas2d: Canvas,
}) {
    // 全局变量设置
    (globalThis as any)["gtr"] = this;
    // 提前注册预制体信息
    registerBUrlByCfg(PrefabCfg);
    // 界面管理器二段构造
    gtr.ui.init(param.canvas2d);
    // 登录模块二段构造
    gtr.loginCtr.init();
    
    // 显示登录界面(传入登录成功回调函数)
    gtr.loginCtr.showLogin(async () => {
        await gtr.res.loadBundleAsync("Match3BN");
        gtr.ui.open(Match3UI)
    })
}

这样业务层的实现就不会耦合在底层框架中了。

到这里,我们彻底告别通过界面 ID 字符串打开界面这种难用的方式了。我们来对比一下这种方式的好处。
这种方式最大的好处在于,我们查看打开界面的代码时,是可以直接点击类,然后跳转到实现的。
而很多框架是通过字符串的方式打开 UI,这导致了要看类的实现的时候,得复制字符串,然后查询,效率极其低下。

4赞

大佬加油,mark