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.
454 lines
14 KiB
454 lines
14 KiB
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<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);
|
|
}
|
|
|
|
/** @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<number, string> = {
|
|
1: '剑攻击',
|
|
2: '棍攻击',
|
|
3: '法杖攻击',
|
|
4: '箭攻击',
|
|
5: '枪攻击',
|
|
};
|
|
|
|
/**
|
|
* Hero.herotype → 英雄主动技能音效(复用现有神将技能资源)。
|
|
* 1金剑阵→孙悟空 2木飞棍→悟净 3水暴风雪→阎王 4火箭雨→杨戬 5土冲锋→李靖
|
|
*/
|
|
const HERO_SKILL_BY_HEROTYPE: Record<number, string> = {
|
|
1: '孙悟空技能',
|
|
2: '悟净技能',
|
|
3: '阎王技能',
|
|
4: '杨戬技能音效',
|
|
5: '李靖技能',
|
|
};
|
|
|
|
/**
|
|
* 五行元素 → 宝石武器技能音效(Weapon.type 2金…6土 → element 1…5)。
|
|
* 金→金箍棒 木→悟净 水→阎王 火→杨戬 土→玲珑宝塔
|
|
*/
|
|
const GEM_SKILL_BY_ELEMENT: Record<number, string> = {
|
|
1: '金箍棒技能',
|
|
2: '悟净技能',
|
|
3: '阎王技能',
|
|
4: '杨戬技能音效',
|
|
5: '玲珑宝塔技能',
|
|
};
|
|
|
|
/**
|
|
* @param heroId Hero.id(五行 101+)
|
|
* @returns 普攻资源名;无法解析时 warn 并返回空
|
|
*/
|
|
function resolveAttackSfxByHeroId(heroId: number): string {
|
|
const hero = gg.data.table.getTableData<ITableHero>(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<ITableHero>(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<ITableWeapon>(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;
|
|
}
|
|
|