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.
395 lines
12 KiB
395 lines
12 KiB
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 });
|
|
}
|
|
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);
|
|
}
|
|
|
|
/** 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: '李靖合成',
|
|
};
|
|
|