You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/battle/audio/BattleAudio.ts

391 lines
12 KiB

3 weeks ago
import { TeamComp, type EntityId } from 'db://assets/mx/module/battle';
import { GAudioPath } from '../../game/ConfigRes';
import { getTowerLoadoutService } from '../combat/TowerLoadoutService';
import { resetTangSengAudioState } from './TangSengAudioState';
const BGM_DUCK = 0.7;
const MAX_CONCURRENT = 8;
/** 1=语音 2=结算 3=系统 4=普攻 5=战斗反馈 6=UI */
const Pri = {
Voice: 1,
Result: 2,
System: 3,
Attack: 4,
Combat: 5,
UI: 6,
} as const;
type SfxPri = (typeof Pri)[keyof typeof Pri];
type ActiveSfx = { priority: SfxPri; startedAt: number };
/**
* 战斗音效:按现有 audio 资源名映射,去叠音 / 并发上限 / 优先级 / BGM 压低。
*/
export class BattleAudio {
private static _lastPlayAt = new Map<string, number>();
private static _lastHitAt = new Map<EntityId, number>();
private static _active: ActiveSfx[] = [];
private static _duckTimer: ReturnType<typeof setTimeout> | null = null;
static resetSession(): void {
this._lastPlayAt.clear();
this._lastHitAt.clear();
this._active.length = 0;
resetTangSengAudioState();
this._clearDuck();
}
/** 退战斗释放武器音效预载缓存 */
static releaseBattleWarmup(): void {
gg.audio.releaseClipsByPath(GAudioPath.SfxWeapon);
}
/** @deprecated 新产品无唐僧求救 */
static tryTangSengHelp(
_remainingPathPx: number,
_cellSizePx: number,
_thresholdCells = 3,
_hooks?: { onStart?: () => void; onEnd?: () => void },
): void {
}
/** 局内 BGM 并预载战斗音效 */
static playBgmBattle(): void {
this.resetSession();
gg.audio.playMusic({ name: '局内背景音', path: GAudioPath.Bgm });
this._warmUpBattleSfx();
}
/** 进战斗预载常用音效,避免首帧 async 加载导致与动画不同步 */
private static _warmUpBattleSfx(): void {
for (const name of ['刀攻击', '枪攻击', '棍攻击', '箭攻击', '孙悟空技能', '悟净技能', '李靖技能', '阎王技能', '杨戬技能音效', '金箍棒技能', '玲珑宝塔技能', '碎片掉落']) {
void gg.audio.preloadEffect({ name, path: GAudioPath.SfxWeapon });
}
2 weeks ago
for (const name of ['怪物受击', '合成', '格子放置', '砚台出字', '开场号角', 'BOSS出场', '危险警报']) {
3 weeks ago
void gg.audio.preloadEffect({ name, path: GAudioPath.SfxUi });
}
}
/** @deprecated 新产品无此音效 */
static playSpiderSpiritCast(): void {
}
/** @deprecated 新产品无此音效 */
static playBloodMoonCast(): void {
}
/** @deprecated 新产品无此音效 */
static playWindBlow(): void {
}
/** @deprecated 新产品无此音效 */
static playFoxSpiritCast(): void {
}
/** @deprecated 新产品无此音效 */
static playWitchCast(): void {
}
/** @deprecated 新产品无此音效 */
static playBajiaoFanCast(): void {
}
/** 同字升级 */
static playUpgrade(): void {
this._play('合成', GAudioPath.SfxUi, Pri.System, 0);
}
/** 神将组合 */
static playCompose(): void {
this._play('合成', GAudioPath.SfxUi, Pri.System, 0);
}
/**
* 神将合成语音(仅有资源的英雄)
* @param weapon1Id Hero.id
*/
static playComposeVoice(weapon1Id: number): void {
const name = HERO_COMPOSE_VOICE[weapon1Id];
if (name) this._play(name, GAudioPath.SfxVoice, Pri.Voice, 1000);
}
/** @deprecated 新产品无此音效 */
static playHeal(): void {
}
/** 极短震动(拿起字块 / 主动道具) */
static vibrateShort(): void {
gg.sdk.vibrateShort();
}
/**
* 放置成功短震:交换两格、放置后立刻合成只震一次。
* 自动飞不要走这里。
*/
static vibratePlace(): void {
const key = 'haptic:place';
const now = Date.now();
const last = this._lastPlayAt.get(key) ?? 0;
if (now - last < 80) return;
this._lastPlayAt.set(key, now);
this.vibrateShort();
}
/** 长震动(己方营地掉血) */
static vibrateLong(): void {
gg.sdk.vibrateLong();
}
/** 字落入格子(仅音效;震动由放置成功路径触发,避免自动飞误震) */
static playPlaceGrid(): void {
this._play('格子放置', GAudioPath.SfxUi, Pri.UI, 100);
}
/**
* 拖动拾取成功短震:背包/地图字块、主动道具在「开始拖动」时调用。
* 与松手 `vibratePlace` 区分;自动飞/击杀升级等勿调。
*/
static vibratePickup(): void {
const key = 'haptic:pickup';
const now = Date.now();
const last = this._lastPlayAt.get(key) ?? 0;
if (now - last < 80) return;
this._lastPlayAt.set(key, now);
this.vibrateShort();
}
/** 开始拖动字块(拾取成功:音效 + 短震) */
static playWeaponDrag(): void {
this._play('武器拖动', GAudioPath.SfxUi, Pri.UI, 80);
this.vibratePickup();
}
/** 砚台刷新出字(每冒出一个字播一次) */
static playInkstoneSpawn(): void {
this._play('砚台出字', GAudioPath.SfxUi, Pri.UI, 0);
}
/** 召唤按钮首次可刷新 */
static playRefreshReady(): void {
this._play('可刷新通知', GAudioPath.SfxUi, Pri.UI, 300);
}
/** Boss 出场弹窗(同帧多 Boss 去重) */
static playBossIntro(): void {
this._play('BOSS出场', GAudioPath.SfxUi, Pri.System, 1500);
}
/** 怪物危机提示:循环动画每播完一轮播一次 */
static playMonsterCrisisAlert(): void {
this._play('危险警报', GAudioPath.SfxUi, Pri.System, 0);
}
/** @deprecated 新产品无此音效 */
static playWallHit(): void {
}
/** @deprecated 新产品无此音效 */
static playChestHit(): void {
}
/** 胜利结算 */
static playVictory(): void {
this._play('游戏胜利', GAudioPath.SfxUi, Pri.Result, 0);
}
/** 失败结算 */
static playFail(): void {
this._play('游戏失败', GAudioPath.SfxUi, Pri.Result, 0);
}
/** 击杀武器碎片飞出 */
static playFragmentDrop(): void {
this._play('碎片掉落', GAudioPath.SfxWeapon, Pri.UI, 80);
}
/**
* 普攻
* @param caster 攻击实体
*/
static playWeaponAttack(caster: EntityId): void {
if (!this._isLocalEntity(caster)) return;
const id = getTowerLoadoutService().getLoadout(caster)?.weapon1Id;
if (id == null) return;
const name = ATTACK_SFX[id];
if (name) this._play(name, GAudioPath.SfxWeapon, Pri.Attack, 150);
}
/**
* 英雄主动或专属武器技能
* @param caster 施法实体
* @param isEquipSkill true=专属武器技能(金箍棒/玲珑宝塔)
*/
static playWeaponSkill(caster: EntityId, isEquipSkill = false): void {
if (!this._isLocalEntity(caster)) return;
const id = getTowerLoadoutService().getLoadout(caster)?.weapon1Id;
if (id == null) return;
const name = isEquipSkill ? EQUIP_SKILL_SFX[id] : HERO_SKILL_SFX[id];
if (name) this._play(name, GAudioPath.SfxWeapon, Pri.Combat, 0);
}
/**
* 命中受击
* @param caster 攻击实体
* @param target 受击实体
*/
static playWeaponHit(caster: EntityId, target: EntityId): void {
if (!this._isLocalEntity(caster)) return;
const now = Date.now();
const last = this._lastHitAt.get(target) ?? 0;
if (now - last < 100) return;
if (!this._play('怪物受击', GAudioPath.SfxUi, Pri.Result, 50)) return;
this._lastHitAt.set(target, now);
}
/**
* 实体是否本机侧。无战斗或无 team 时不静音,避免误吞本机音效。
* @param entityId 施法/攻击实体
*/
private static _isLocalEntity(entityId: EntityId): boolean {
const battle = gg.game?.CurentBattle;
if (!battle) return true;
const team = battle.session.world.getComponent(entityId, TeamComp);
if (!team) return true;
return team.ownerId === battle.localPlayerId;
}
/** @returns 是否已开始播放 */
private static _play(
name: string,
path: string,
priority: SfxPri,
debounceMs: number,
hooks?: { onStart?: () => void; onEnd?: () => void },
): boolean {
if (!name || !gg.audio?.playEffect) return false;
const key = `${path}${name}`;
const now = Date.now();
if (debounceMs > 0) {
const last = this._lastPlayAt.get(key) ?? 0;
if (now - last < debounceMs) return false;
}
if (!this._canPlay(priority)) return false;
this._lastPlayAt.set(key, now);
const entry: ActiveSfx = { priority, startedAt: now };
this._active.push(entry);
this._trimActive();
this._duckBrief(0.6);
hooks?.onStart?.();
void gg.audio.playEffect({
name,
path,
onEnd: () => {
const i = this._active.indexOf(entry);
if (i >= 0) this._active.splice(i, 1);
if (this._active.length === 0) this._clearDuck();
hooks?.onEnd?.();
},
});
return true;
}
private static _canPlay(priority: SfxPri): boolean {
const total = this._active.length;
if (priority === Pri.UI && total >= 4) return false;
if (priority === Pri.Combat && total >= 5) return false;
if (priority === Pri.Attack && total >= 6) return false;
if (total >= MAX_CONCURRENT) {
if (priority <= Pri.System) {
this._evictLowest();
return true;
}
return false;
}
return true;
}
private static _evictLowest(): void {
let worst = -1;
let worstPri = 0;
for (let i = 0; i < this._active.length; i++) {
const p = this._active[i].priority;
if (p > worstPri) {
worstPri = p;
worst = i;
}
}
if (worst >= 0) this._active.splice(worst, 1);
}
private static _trimActive(): void {
while (this._active.length > MAX_CONCURRENT) this._evictLowest();
}
private static _duckBrief(sec: number): void {
gg.audio.applyMusicDuck(BGM_DUCK);
if (this._duckTimer) clearTimeout(this._duckTimer);
this._duckTimer = setTimeout(() => {
this._duckTimer = null;
if (this._active.length === 0) this._clearDuck();
}, sec * 1000);
}
private static _clearDuck(): void {
if (this._duckTimer) {
clearTimeout(this._duckTimer);
this._duckTimer = null;
}
gg.audio.applyMusicDuck(1);
}
}
/** Hero.id → 普攻音效(按 Hero.weapon:1刀 2枪 3棍 4箭) */
const ATTACK_SFX: Record<number, string> = {
1: '刀攻击',
2: '枪攻击',
3: '棍攻击',
4: '箭攻击',
11: '棍攻击',
12: '刀攻击',
13: '棍攻击',
14: '枪攻击',
15: '枪攻击',
16: '刀攻击',
17: '枪攻击',
18: '箭攻击',
19: '刀攻击',
20: '刀攻击',
21: '箭攻击',
22: '刀攻击',
};
/** Hero.id → 英雄主动技能音效 */
const HERO_SKILL_SFX: Record<number, string> = {
11: '孙悟空技能',
13: '悟净技能',
17: '杨戬技能音效',
20: '阎王技能',
22: '李靖技能',
};
/** Hero.id → 专属武器技能音效 */
const EQUIP_SKILL_SFX: Record<number, string> = {
11: '金箍棒技能',
22: '玲珑宝塔技能',
};
/** Hero.id → 合成语音 */
const HERO_COMPOSE_VOICE: Record<number, string> = {
11: '孙悟空合成',
14: '哪吒合成',
17: '杨戬合成',
18: '王母合成',
19: '玉帝合成',
22: '李靖合成',
};