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

2025年1月1日,本游戏同步集成到抖音小游戏《经典休闲移植单机合集》中,欢迎使用抖音、头条等字节系应用扫码体验:
qrcode

音频管理器(初步)

现在我们来抽象出音频管理器 AudioManager.

AudioManager 单例

1、在 assets/fw/ 文件夹下新建 audio 文件夹,audio 文件夹下新建 AudioManager.ts 文件

image

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 中。

重新运行,一切照旧!

2赞

背景音乐资源移动到登录模块

现在我们先做一个优化,因为在登录界面,我们就加载了背景音乐,但是这个背景音乐其实是 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 文件,用以定义和创建资源的通用格式

image
修改内容如下:

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.,靠着代码提示,就能补全所缺的内容,真是太棒了!

重新运行,表现如初!

1赞

自动化脚本-生成音频配置文件

这个自动化脚本对应的需求已经在上一节中阐述清楚。
后续会以插件的形式完成实现的部分,然后发布到 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 方法的实现)

1赞

资源管理器提供加载 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
}

具体情况需要根据项目的具体情况,酌情考虑。这里不做深入探讨。

1赞

关注了,跟着大佬入门

1赞

这里要import Node. 我没import,Match3UI拖不进去, Match3Node 后面显示了 Null.
奇怪的是 我没import, 代码也没报红.是vscode里面.

1赞

嗯,记得要从 cc import Node

请问这个bg-2x2 图片资源在哪里下载,我从cocos的app store和git上没有发现这个文件

首场景背景图就是呀

感谢分享,我还以为我配置错了,找了半天

大佬,我这里会无限重复音乐播放背景音乐,看web的控制台日志,boost的start函数在疯狂的被执行,是哪里配置错了么?当然这个错误跟添加音频没关系,在很早之前我就发现了这个重复调用的问题了,音频只是让这个问题体现的更明显一些

不会吧,start 函数怎么会被重复执行,你是不是写 update 里了

大佬 催更 萌新学习

1赞

不是写到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

感谢大佬无私分享

1赞

感谢大佬,萌新催更 :+1:

1赞