推箱子玩法完成,欢迎扫码体验
欢迎试玩~
改造 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 函数的改造工作。
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"))
移动到外部的某个地方,就倒转了框架层的依赖关系。
至于移动到什么位置,这个留到后面的帖子中解决。
引入脚本包 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 文件夹下
请注意
- 所有的脚本移动都要在 VSCode 中进行,需要同时移动脚本的 meta 文件。
- 所有的资源移动,需要在编辑器中进行。
在 GScript 中新增 GCtr.ts 脚本
将其内容修改如下:
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 组件
将屏幕适配逻辑移动到 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 个问题留到后续。
追更!!!
大佬。强!!!
赶紧卖课
大佬,催更!!!!!
关于这个,有个疑问请教大佬:假设resources包里有1万个图片,我首屏只用到里面的一个图片作为背景图,其它9999个资源都没用到,这种情况下,resources包是如何影响首屏启动时间的?resources包里的资源不也是用到才下载吗?难道是因为resources对应的配置文件列表太大导致下载时间变长,影响启动时间?
你已经知道如何构造测试的用例了。自打包对比两个结果就知道了。
我测下来,看起来就是索引文件的大小区别,别的没看出啥区别。都是按需加载。
赞·!催更!
好好看下 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个文件夹中。如何取舍,还是由你自己最终决定。)
太卷了…放假倒计时了
引入全局变量 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 的使用,请参考官方文档。
预制体资源和类的映射关系
我将预制体所在的 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。应该改成提供注册接口,在打开界面前提前注册的模式,这个留给大家自行修改了。
现在运行,表现如初!
现在我们已经搭建了一个非常基础的框架了,后续只需要不断完善就可以了。
下个帖子,我们要开始进入正题:羊了个羊的逻辑开发。
好好好,不得不说是难得的精品,这玩意儿看懂,基本架构就有清晰的认识了,作者大义