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(); private static _lastHitAt = new Map(); private static _active: ActiveSfx[] = []; private static _duckTimer: ReturnType | 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 }); } for (const name of ['怪物受击', '合成', '格子放置', '铲子铲地', '砚台出字', '开场号角', 'BOSS出场', '危险警报']) { 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); } /** 铲子挖地(系统级:不被普攻/受击并发挤掉) */ static playShovel(): void { this._play('铲子铲地', GAudioPath.SfxUi, Pri.System, 0); } /** * 拖动拾取成功短震:背包/地图字块、主动道具在「开始拖动」时调用。 * 与松手 `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); } /** 开局表演怪吹号角(双侧同时吹时去重) */ static playStartMonsterHorn(): void { this._play('开场号角', GAudioPath.SfxUi, Pri.System, 2000); } /** 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 = { 1: '刀攻击', 2: '枪攻击', 3: '棍攻击', 4: '箭攻击', 11: '棍攻击', 12: '刀攻击', 13: '棍攻击', 14: '枪攻击', 15: '枪攻击', 16: '刀攻击', 17: '枪攻击', 18: '箭攻击', 19: '刀攻击', 20: '刀攻击', 21: '箭攻击', 22: '刀攻击', }; /** Hero.id → 英雄主动技能音效 */ const HERO_SKILL_SFX: Record = { 11: '孙悟空技能', 13: '悟净技能', 17: '杨戬技能音效', 20: '阎王技能', 22: '李靖技能', }; /** Hero.id → 专属武器技能音效 */ const EQUIP_SKILL_SFX: Record = { 11: '金箍棒技能', 22: '玲珑宝塔技能', }; /** Hero.id → 合成语音 */ const HERO_COMPOSE_VOICE: Record = { 11: '孙悟空合成', 14: '哪吒合成', 17: '杨戬合成', 18: '王母合成', 19: '玉帝合成', 22: '李靖合成', };