模拟登录,后面会移除的,不用纠结。
2025年1月1日,本游戏同步集成到抖音小游戏《经典休闲移植单机合集》中,欢迎使用抖音、头条等字节系应用扫码体验:
音频管理器(初步)
现在我们来抽象出音频管理器 AudioManager.
AudioManager 单例
1、在 assets/fw/
文件夹下新建 audio
文件夹,audio 文件夹下新建 AudioManager.ts 文件
2、AudioManager.ts 实现单例模式
/** 单例模式的音频管理器 */
export class AudioManager {
private static _instance: AudioManager = null!;
/** 获取单例的接口 */
static getInstance() {
if (this._instance === null) {
this._instance = new AudioManager();
}
return this._instance;
}
private constructor() {
// 私有化的构造函数
}
}
3、音频管理器提供播放接口
3.1 音频管理器需要 AudioSource 控制播放,因此从正在运行的场景获得 Canvas 节点,并在其上添加 AudioSource 组件
export class AudioManager {
/** ======== 之前的单例模式实现代码 ======== */
/** AudioSource 挂载在此节点上 */
private m_AttachNode: Node = null;
/** AudioSource 组件 */
private m_AudioSource: AudioSource = null;
private constructor() {
// 私有化的构造函数
this.m_AttachNode = director.getScene().getChildByName("Canvas");
this.m_AudioSource = this.m_AttachNode.addComponent(AudioSource);
}
3.2 添加播放 API
目前我们先提供一个简单的 playMusic 接口,用于播放背景音乐
export class AudioManager {
/** ======== 其他代码 ======== */
/** 默认播放背景音乐接口 */
playMusic(bUrl: {
/** * 子包名 */ b: string,
/** * 资源路径 */ l: string,
}): void {
// 加载背景音乐后播放
ResManager.getInstance().loadAudioClip(bUrl.b, bUrl.l, audioClip => {
let audioSource = this.m_AudioSource;
audioSource.clip = audioClip;
audioSource.loop = true;
audioSource.play();
})
}
}
4、改造 LoginEntry 中的播放背景音乐实现
import { _decorator, Component, instantiate, UITransform } from 'cc';
import { G_VIEW_SIZE } from '../../Boost';
import { AudioManager } from '../../fw/audio/AudioManager';
import { ResManager } from '../../fw/res/ResManager';
const { ccclass, property } = _decorator;
@ccclass('LoginEntry')
export class LoginEntry extends Component {
start() {
this.scheduleOnce(() => {
ResManager.getInstance().loadPrefab("Match3BN", "Match3UI", prefab => {
let match3Node = instantiate(prefab);
this.node.addChild(match3Node);
match3Node.getComponent(UITransform).setContentSize(G_VIEW_SIZE.clone());
});
// 加载背景音乐后播放
AudioManager.getInstance().playMusic({
b: "Match3BN",
l: "Audio/bgm"
})
}, 1)
}
}
点击运行,完美!
当然,音频模块还不是最终版本,这里涉及到多个音频同时播放的问题,背景和音效的区别,音频的音量设置、静音功能、多平台适配、音频中断恢复逻辑等。当然如果对音频有更高的要求,比如做音游的游戏来说,可能需要引入更专业的音频库。
对新手来说,只能先入门,再深入。
添加主游戏场景入口
现在我们打开《羊了个羊》的逻辑是写在登录界面。这个是不正确的。正确的流程是:
graph TD
A[登录成功] --> B[移交控制权] --> C[打开三消主玩法界面]
因此我们添加一个 Match3Entry 用来接管登录成功后的管理权
1、创建 Match3Entry.ts,并提供 init 方法接口用于接管控制权限
1.1 在 Match3 目录下新建 Script 文件夹,在 Script 文件夹下新建 Match3Entry.ts 脚本:
1.2 Match3Entry.ts 实现如下:
import { _decorator, Component, instantiate, UITransform } from 'cc';
import { G_VIEW_SIZE } from '../../Boost';
import { ResManager } from '../../fw/res/ResManager';
const { ccclass } = _decorator;
@ccclass('Match3Entry')
export class Match3Entry extends Component {
async init() {
ResManager.getInstance().loadPrefab("Match3BN", "Match3UI", prefab => {
let match3Node = instantiate(prefab);
this.node.addChild(match3Node);
match3Node.getComponent(UITransform).setContentSize(G_VIEW_SIZE.clone());
});
}
}
其中 init 方法用于接管登录成功后的控制权。目前的控制逻辑主要是打开 Match3UI 界面。
1.3 修改 LoginEntry.ts 中的代码
import { _decorator, Component } from 'cc';
import { AudioManager } from '../../fw/audio/AudioManager';
import { ResManager } from '../../fw/res/ResManager';
const { ccclass } = _decorator;
@ccclass('LoginEntry')
export class LoginEntry extends Component {
start() {
// 加载背景音乐后播放
AudioManager.getInstance().playMusic({
b: "Match3BN",
l: "Audio/bgm"
})
this.scheduleOnce(() => {
ResManager.getInstance().loadBundle("Match3BN", () => {
let match3Entry = this.node.addComponent("Match3Entry");
(match3Entry as any).init();
})
}, 1)
}
}
2、注意事项
2.1 子包的脚本被 require 的时机
子包 Match3BN 在loadBundle 完成的时候,包中的脚本将被 require,Match3Entry 这样的组件就被注册到引擎的组件管理模块中。因此就可以通过 addComponent("Match3Entry")
的形式正确的添加组件。
2.2 注意 Match3Entry 组件的添加方式
在 LoginEntry 中,为什么要用 addComponent("Match3Entry")
的形式,而不是用下面的形式?
import { Match3Entry } from '../../Match3/Script/Match3Entry';
// ...省略
let match3Entry = this.node.addComponent(Match3Entry);
// ...省略
这是因为 LoginEntry.ts 脚本所在的Asset Bundle 包的优先级高于 Match3BN ,因此这样的引用是错误的。
会导致
1、加载依赖关系错乱;
2、微信小游戏、抖音小游戏中 Match3BN (是小游戏分包)的脚本将被错误的打包到 LoginBN 中。
重新运行,一切照旧!
背景音乐资源移动到登录模块
现在我们先做一个优化,因为在登录界面,我们就加载了背景音乐,但是这个背景音乐其实是 Match3BN 包中的。
这个做法严格意义上是错误的。因为 Match3BN 中的背景音乐,应该由 Match3Entry 负责播放。
换句话说:LoginEntry,你越线了。你把手伸到我的地盘上了。
但实际上,我们更想在登录界面就播放背景音乐,因此我们要将背景音乐调整到 LoginBN 模块中。
1、Login文件夹下新建 Res 文件夹(这里为什么要建这样的一个文件夹,后面会细说,这里简单说一下:每个Asset Bundle 包中,我们区分资源在 Res 文件夹下, 脚本在 Script 文件夹下,这样方便结构化我们的 Asset Bundle,也方便后续脚本自动生成资源配置文件)
2、将 Match3 文件夹中的 Audio 拖到 Login/Res 目录下。
3、修改 LoginEntry.ts 中的背景音乐播放逻辑
// 加载背景音乐后播放
AudioManager.getInstance().playMusic({
b: "LoginBN",
l: "Res/Audio/bgm"
})
···
重新运行,表现如初。
手写字符串是很容易出错的-为自动化做准备
在加载音频的时候,我们需要手动输入 Asset Bundle 名称,以及资源路径。
这…这…%#?@¥!…你受得了?
我肯定是受不了。
因此自动生成才是王道。
资源生成格式
首先我们要确定资源生成的格式,因此我们先手动创建我们希望的资源生成的最终格式。
1、在 fw/res/ 中新建 ResConst.ts 文件,用以定义和创建资源的通用格式
修改内容如下:
declare global {
interface IBundleUrl {
/** * 子包名 */ b: string,
/** * 资源路径 */ l: string,
/** 缓存关键字 */ id: string,
}
}
/**
* 创建 BundleUrl 对象
* @param url
* @param bundleName Asset Bundle 名称
* @returns
*/
export function BL(url: string, bundleName: string, k?: string): IBundleUrl {
let obj: IBundleUrl = Object.create(null);
obj.b = bundleName;
obj.l = url;
obj.id = `${bundleName}${url}`
return obj;
}
这里需要补充一点,为什么在 BL 方法中,要定义一个 id 字段,这里其实是为了后续做资源管理用的。用来标记一个资源的唯一ID
2、在 Login/Script/ 文件夹下,创建目标文件 LoginAudio.ts
2、修改 LoginAudio.ts 内容如下
import { BL } from "db://assets/fw/res/ResConst";
const B = (m: string) => BL(`Res/Audio/${m}`, "LoginBN");
export const LoginAudio = {
bgm: B("bgm"),
}
这样,我们就定义好了 LoginAudio.ts 这个输出文件格式了。只要后续我们在 LoginBN 包中的 Res/Audio/ 目录下新增音频文件,后续自动化脚本就会将这个变化同步到配置中,完美的设计。
3、修改 LoginEntry.ts 的 start 方法中的加载方式
import { LoginAudio } from './LoginAudio';
// ...... 省略
// 加载背景音乐后播放
AudioManager.getInstance().playMusic(LoginAudio.bgm)
// ...... 省略
现在我们播放的时候,只需要输入 LoginAudio.
,靠着代码提示,就能补全所缺的内容,真是太棒了!
重新运行,表现如初!
自动化脚本-生成音频配置文件
这个自动化脚本对应的需求已经在上一节中阐述清楚。
后续会以插件的形式完成实现的部分,然后发布到 Cocos 商店,当然,有时间和能力的开发可以自行实现。
// TODO
模拟登录态,预留登录接口
在 LoginEntry.ts 中,我使用了以下的方式:
this.scheduleOnce(() => {
ResManager.getInstance().loadBundle("Match3BN", () => {
let match3Entry = this.node.addComponent("Match3Entry");
(match3Entry as any).init();
})
}, 1)
这引起了一些朋友的疑惑,不知道这个 scheduleOnce 到底是什么意思。
其实这个方法,我的本意是【模拟登录成功,登录耗时 1 秒】 这样的流程。
现在来改造一下,我将 LoginEntry.ts 改为以下内容
import { _decorator, Component } from 'cc';
import { AudioManager } from '../../fw/audio/AudioManager';
import { ResManager } from '../../fw/res/ResManager';
import { LoginAudio } from './LoginAudio';
const { ccclass } = _decorator;
@ccclass('LoginEntry')
export class LoginEntry extends Component {
start() {
// 加载背景音乐后播放
AudioManager.getInstance().playMusic(LoginAudio.bgm)
this.autoLogin();
}
autoLogin() {
// 模拟登录实现(后续修改此实现即可)
this.scheduleOnce(() => {
this.onLoginSuccess();
}, 1)
}
onLoginSuccess() {
ResManager.getInstance().loadBundle("Match3BN", () => {
let match3Entry = this.node.addComponent("Match3Entry");
(match3Entry as any).init();
})
}
}
这样,完成了登录流程的模拟。(当然,如果要弹窗登录,也是可以的,本质上也是修改 autoLogin 方法的实现)
资源管理器提供加载 Bundle 的接口,其返回类型为 Promise<AssetManager.Bundle>
我们现在有一种加载 bundle 的接口,需要传入回调函数。
现在我们再提供另一种接口,其返回类型为 Promise<AssetManager.Bundle>
1、在 ResManager.ts 中,新增接口
/**
* 加载 Asset Bundle 接口
* @param bundleName
* @returns
*/
loadBundleAsync(bundleName: string): Promise<AssetManager.Bundle> {
return new Promise<AssetManager.Bundle>(rs => {
this.loadBundle(bundleName, rs);
})
}
2、优化登录成功后的加载代码
将 onLoginSuccess 方法修改为以下方式:
async onLoginSuccess() {
await ResManager.getInstance().loadBundleAsync("Match3BN")
let match3Entry = this.node.addComponent("Match3Entry");
(match3Entry as any).init();
}
显然,提供了 Promise 类型接口后,代码编写更加直观了。
PS:当然,一般的加载 API 会有加载失败的情况,需要返回错误信息。
CocosCreator 的加载是需要传入 (err: Error, bundle: AssetManager.Bundle>) => void 类型回调,也就是 2 参数的形式。
我通常的实现是定义了一个结果对象
type IResult<T> = {
// 错误码
e: number,
// 数据
d?: T
}
具体情况需要根据项目的具体情况,酌情考虑。这里不做深入探讨。
关注了,跟着大佬入门
这里要import Node. 我没import,Match3UI拖不进去, Match3Node 后面显示了 Null.
奇怪的是 我没import, 代码也没报红.是vscode里面.
嗯,记得要从 cc import Node
请问这个bg-2x2 图片资源在哪里下载,我从cocos的app store和git上没有发现这个文件
首场景背景图就是呀
感谢分享,我还以为我配置错了,找了半天
大佬,我这里会无限重复音乐播放背景音乐,看web的控制台日志,boost的start函数在疯狂的被执行,是哪里配置错了么?当然这个错误跟添加音频没关系,在很早之前我就发现了这个重复调用的问题了,音频只是让这个问题体现的更明显一些
不会吧,start 函数怎么会被重复执行,你是不是写 update 里了
大佬 催更 萌新学习
不是写到update里,就是start里,我打印了调用堆栈,但是看不懂, 我查了一下,说是我又其他的Node多处引用了ts脚本导致,但是没找到,我抽空把你的教程重新做一次看看到底是哪一步出问题了
Start function is called by Boost [Boost.ts:25:16](file:///Users/kangming/CCYang/assets/Boost.ts)
Call stack: start@http://localhost:7456/scripting/x/chunks/51/510049438992d1a34c87556d49a3f481cee5ec6a.js:68:38 execute/invokeStart<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:42390:16 createInvokeImpl/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:42205:17 invoke@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:42329:16 startPhase@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:42565:29 tick@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:16668:35 _updateCallback@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:17701:22 updateCallback@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:91108:22 execute/Pacer/this._handleRAF@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:91088:23 FrameRequestCallback*_stTime@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:91132:28 start@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:91112:33 resume@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:17236:103 @http://localhost:7456/preview-app/main.js:1:4370 runSceneImmediate@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:16384:13 @http://localhost:7456/preview-app/main.js:1:4332 execute/loadWithJson/task<.onComplete<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:185849:23 asyncify/</<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:193104:11 callInNextTick/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:145498:18 handleRAF@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:5935:18 FrameRequestCallback*setTimeoutRAF@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:5938:12 callInNextTick@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:145497:20 asyncify/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:193100:12 dispatch@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:192463:22 execute/_flow/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:191345:22 load/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:190289:7 cb@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:193003:21 onComplete@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:190276:13 dispatch@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:192463:22 execute/_flow/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:191345:22 onComplete@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:190374:14 dispatch@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:192463:22 execute/_flow/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:191345:22 load/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:190289:7 cb@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:193003:21 onComplete@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:190276:13 dispatch@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:192463:22 execute/_flow/<@http://localhost:7456/scripting/engine/bin/.cache/dev/preview/bundled/index.js:191345:2
感谢大佬无私分享