import { TeamComp, type EntityId } from 'db://assets/mx/module/battle'; import { GAudioPath } from '../../game/ConfigRes'; import { TableNames } from '../../game/ConfigTableData'; 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 压低。 * 普攻/技能按五行 Hero.herotype、宝石 Weapon.type 解析(现表 id 101+)。 */ 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); } /** @deprecated 多格神将组合已废弃;若残留路径触发则同「合成」 */ static playCompose(): void { this._play('合成', GAudioPath.SfxUi, Pri.System, 0); } /** * @deprecated 多格神将组合已废弃,无调用时机 * @param _weapon1Id 旧 Hero.id */ static playComposeVoice(_weapon1Id: number): void { } /** @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); } /** 局内波次/击杀金币飞行 */ static playCoinFly(): void { this._play('结算金币效果', GAudioPath.SfxUi, Pri.UI, 80); } /** * 普攻(按 Hero.herotype → 现有刀/枪/棍/箭)。 * 弹道类(水弹连发等)应在真正出手时调,勿在 PresentationCue 提前播。 * @param caster 攻击实体 * @param debounceMs 去抖;连发出手用较短间隔 */ static playWeaponAttack(caster: EntityId, debounceMs = 150): void { if (!this._isLocalEntity(caster)) return; const heroId = getTowerLoadoutService().getLoadout(caster)?.weapon1Id; if (heroId == null) return; const name = resolveAttackSfxByHeroId(heroId); if (!name) return; this._play(name, GAudioPath.SfxWeapon, Pri.Attack, debounceMs); } /** * 英雄主动或宝石武器技能 * @param caster 施法实体 * @param isEquipSkill true=宝石/武器技能 */ static playWeaponSkill(caster: EntityId, isEquipSkill = false): void { if (!this._isLocalEntity(caster)) return; const loadout = getTowerLoadoutService().getLoadout(caster); if (!loadout) return; const name = isEquipSkill ? resolveGemSkillSfx(loadout.weaponId) : resolveHeroSkillSfxByHeroId(loadout.weapon1Id); if (!name) return; 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.herotype → 普攻音效。 * 1金剑 2木棍 3水法杖 4火箭 5土枪 */ const ATTACK_BY_HEROTYPE: Record = { 1: '剑攻击', 2: '棍攻击', 3: '法杖攻击', 4: '箭攻击', 5: '枪攻击', }; /** * Hero.herotype → 英雄主动技能音效(复用现有神将技能资源)。 * 1金剑阵→孙悟空 2木飞棍→悟净 3水暴风雪→阎王 4火箭雨→杨戬 5土冲锋→李靖 */ const HERO_SKILL_BY_HEROTYPE: Record = { 1: '孙悟空技能', 2: '悟净技能', 3: '阎王技能', 4: '杨戬技能音效', 5: '李靖技能', }; /** * 五行元素 → 宝石武器技能音效(Weapon.type 2金…6土 → element 1…5)。 * 金→金箍棒 木→悟净 水→阎王 火→杨戬 土→玲珑宝塔 */ const GEM_SKILL_BY_ELEMENT: Record = { 1: '金箍棒技能', 2: '悟净技能', 3: '阎王技能', 4: '杨戬技能音效', 5: '玲珑宝塔技能', }; /** * @param heroId Hero.id(五行 101+) * @returns 普攻资源名;无法解析时 warn 并返回空 */ function resolveAttackSfxByHeroId(heroId: number): string { const hero = gg.data.table.getTableData(TableNames.Hero, heroId); if (!hero) { console.warn('[BattleAudio] 普攻音效缺少 Hero', heroId); return ''; } const herotype = Math.floor(Number(hero.herotype)); const name = ATTACK_BY_HEROTYPE[herotype]; if (!name) { console.warn('[BattleAudio] 普攻音效未映射 herotype', herotype, 'heroId', heroId); return ''; } return name; } /** * @param heroId Hero.id * @returns 英雄主动技能资源名 */ function resolveHeroSkillSfxByHeroId(heroId: number): string { const hero = gg.data.table.getTableData(TableNames.Hero, heroId); if (!hero) { console.warn('[BattleAudio] 英雄技能音效缺少 Hero', heroId); return ''; } const herotype = Math.floor(Number(hero.herotype)); const name = HERO_SKILL_BY_HEROTYPE[herotype]; if (!name) { console.warn('[BattleAudio] 英雄技能音效未映射 herotype', herotype, 'heroId', heroId); return ''; } return name; } /** * @param weaponId Weapon.id(宝石);0 表示未镶嵌 * @returns 宝石技能资源名 */ function resolveGemSkillSfx(weaponId: number): string { if (!(weaponId > 0)) { console.warn('[BattleAudio] 宝石技能音效缺少 weaponId'); return ''; } const weapon = gg.data.table.getTableData(TableNames.Weapon, weaponId); if (!weapon) { console.warn('[BattleAudio] 宝石技能音效缺少 Weapon', weaponId); return ''; } // Weapon.type:1通用 2金 3木 4水 5火 6土 → element 1–5 const element = Math.floor(Number(weapon.type)) - 1; const name = GEM_SKILL_BY_ELEMENT[element]; if (!name) { console.warn('[BattleAudio] 宝石技能音效未映射 Weapon.type', weapon.type, 'weaponId', weaponId); return ''; } return name; }