Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration. Co-authored-by: Cursor <cursoragent@cursor.com>
124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
import { AudioClip, AudioSource, director, Node, resources } from 'cc';
|
||
|
||
/** Unity Assets/Art/Audio → resources/audio/ */
|
||
const CLIPS = {
|
||
background: 'audio/Backgroud',
|
||
move: 'audio/Move',
|
||
jump: 'audio/Jump',
|
||
vehicleMove: 'audio/FlyingCarpetMove',
|
||
fail: 'audio/Fail',
|
||
success: 'audio/Success',
|
||
coins: 'audio/GetCoins',
|
||
} as const;
|
||
|
||
type SfxKey = Exclude<keyof typeof CLIPS, 'background'>;
|
||
|
||
/**
|
||
* 对齐 Unity:关卡加载时 curLevel 上循环播放 Backgroud.mp3;
|
||
* 音效路径与 Player.prefab 一致。
|
||
*/
|
||
export class GameAudio {
|
||
private static readonly cache = new Map<string, AudioClip>();
|
||
private static readonly loading = new Map<string, Promise<AudioClip | null>>();
|
||
private static sfxHost: Node | null = null;
|
||
|
||
static async preload(): Promise<void> {
|
||
await Promise.all(Object.values(CLIPS).map((p) => GameAudio.loadClip(p)));
|
||
}
|
||
|
||
static loadClip(path: string): Promise<AudioClip | null> {
|
||
const hit = GameAudio.cache.get(path);
|
||
if (hit) return Promise.resolve(hit);
|
||
|
||
const pending = GameAudio.loading.get(path);
|
||
if (pending) return pending;
|
||
|
||
const task = new Promise<AudioClip | null>((resolve) => {
|
||
resources.load(path, AudioClip, (err, clip) => {
|
||
GameAudio.loading.delete(path);
|
||
if (err || !clip) {
|
||
console.warn(`[GameAudio] 加载失败: ${path}`, err);
|
||
resolve(null);
|
||
return;
|
||
}
|
||
GameAudio.cache.set(path, clip);
|
||
resolve(clip);
|
||
});
|
||
});
|
||
GameAudio.loading.set(path, task);
|
||
return task;
|
||
}
|
||
|
||
/** 在关卡根节点播放循环背景音乐(对齐 Unity createNewLevel) */
|
||
static async playBackground(levelRoot: Node): Promise<void> {
|
||
if (!levelRoot?.isValid) return;
|
||
const clip = await GameAudio.loadClip(CLIPS.background);
|
||
if (!clip || !levelRoot.isValid) return;
|
||
|
||
let host = levelRoot.getChildByName('_BGM');
|
||
if (!host) {
|
||
host = new Node('_BGM');
|
||
host.parent = levelRoot;
|
||
}
|
||
const src = host.getComponent(AudioSource) ?? host.addComponent(AudioSource);
|
||
src.clip = clip;
|
||
src.loop = true;
|
||
src.playOnAwake = false;
|
||
src.volume = 1;
|
||
if (!src.playing) {
|
||
src.play();
|
||
}
|
||
}
|
||
|
||
static playSfx(key: SfxKey, host?: Node) {
|
||
void GameAudio.playSfxAsync(key, host);
|
||
}
|
||
|
||
/** 对齐 Unity:同一 AudioSource 播放中则不重复触发移动/跳跃音效 */
|
||
static async playSfxOnSource(src: AudioSource, key: SfxKey): Promise<boolean> {
|
||
if (!src?.node?.isValid || src.playing) return false;
|
||
const clip = await GameAudio.loadClip(CLIPS[key]);
|
||
if (!clip) return false;
|
||
src.clip = clip;
|
||
src.loop = false;
|
||
src.volume = 1;
|
||
src.play();
|
||
return true;
|
||
}
|
||
|
||
private static async playSfxAsync(key: SfxKey, host?: Node) {
|
||
const clip = await GameAudio.loadClip(CLIPS[key]);
|
||
if (!clip) return;
|
||
|
||
const root = host?.isValid ? host : GameAudio.ensureSfxHost();
|
||
if (!root?.isValid) return;
|
||
|
||
const src = root.getComponent(AudioSource) ?? root.addComponent(AudioSource);
|
||
src.playOneShot(clip, 1);
|
||
}
|
||
|
||
private static ensureSfxHost(): Node | null {
|
||
if (GameAudio.sfxHost?.isValid) return GameAudio.sfxHost;
|
||
|
||
const scene = director.getScene();
|
||
if (!scene) return null;
|
||
|
||
let host = scene.getChildByName('_GameSFX');
|
||
if (!host) {
|
||
host = new Node('_GameSFX');
|
||
host.parent = scene;
|
||
}
|
||
GameAudio.sfxHost = host;
|
||
return host;
|
||
}
|
||
|
||
/** 浏览器需用户交互后才能播放音频,首次点击 HUD 时恢复 */
|
||
static resumeAll() {
|
||
const scene = director.getScene();
|
||
if (!scene) return;
|
||
for (const src of scene.getComponentsInChildren(AudioSource)) {
|
||
if (src.clip && !src.playing) src.play();
|
||
}
|
||
}
|
||
}
|