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

455 lines
14 KiB

3 weeks ago
import { TeamComp, type EntityId } from 'db://assets/mx/module/battle';
import { GAudioPath } from '../../game/ConfigRes';
2 days ago
import { TableNames } from '../../game/ConfigTableData';
3 weeks ago
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 压低。
2 days ago
* 普攻/技能按五行 Hero.herotype、宝石 Weapon.type 解析(现表 id 101+)。
3 weeks ago
*/
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 {
2 days ago
for (const name of ['剑攻击', '枪攻击', '棍攻击', '箭攻击','法杖攻击', '孙悟空技能', '悟净技能', '李靖技能', '阎王技能', '杨戬技能音效', '金箍棒技能', '玲珑宝塔技能', '碎片掉落']) {
3 weeks ago
void gg.audio.preloadEffect({ name, path: GAudioPath.SfxWeapon });
}
2 days 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 {
}
2 days ago
/** 同级数字合成升级 */
3 weeks ago
static playUpgrade(): void {
this._play('合成', GAudioPath.SfxUi, Pri.System, 0);
}
2 days ago
/** @deprecated 多格神将组合已废弃;若残留路径触发则同「合成」 */
3 weeks ago
static playCompose(): void {
this._play('合成', GAudioPath.SfxUi, Pri.System, 0);
}
/**
2 days ago
* @deprecated 多格神将组合已废弃,无调用时机
* @param _weapon1Id 旧 Hero.id
3 weeks ago
*/
2 days ago
static playComposeVoice(_weapon1Id: number): void {
3 weeks ago
}
/** @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);
}
2 days ago
/** 击杀宝石掉落飞出 */
3 weeks ago
static playFragmentDrop(): void {
this._play('碎片掉落', GAudioPath.SfxWeapon, Pri.UI, 80);
}
2 days ago
/** 局内波次/击杀金币飞行 */
static playCoinFly(): void {
this._play('结算金币效果', GAudioPath.SfxUi, Pri.UI, 80);
}
3 weeks ago
/**
2 days ago
* 普攻(按 Hero.herotype → 现有刀/枪/棍/箭)。
* 弹道类(水弹连发等)应在真正出手时调,勿在 PresentationCue 提前播。
3 weeks ago
* @param caster 攻击实体
2 days ago
* @param debounceMs 去抖;连发出手用较短间隔
3 weeks ago
*/
2 days ago
static playWeaponAttack(caster: EntityId, debounceMs = 150): void {
3 weeks ago
if (!this._isLocalEntity(caster)) return;
2 days ago
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);
3 weeks ago
}
/**
2 days ago
* 英雄主动或宝石武器技能
3 weeks ago
* @param caster 施法实体
2 days ago
* @param isEquipSkill true=宝石/武器技能
3 weeks ago
*/
static playWeaponSkill(caster: EntityId, isEquipSkill = false): void {
if (!this._isLocalEntity(caster)) return;
2 days ago
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);
3 weeks ago
}
/**
* 命中受击
* @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);
}
}
2 days ago
/**
* Hero.herotype → 普攻音效。
* 1金剑 2木棍 3水法杖 4火箭 5土枪
*/
const ATTACK_BY_HEROTYPE: Record<number, string> = {
1: '剑攻击',
2: '棍攻击',
3: '法杖攻击',
3 weeks ago
4: '箭攻击',
2 days ago
5: '枪攻击',
3 weeks ago
};
2 days ago
/**
* Hero.herotype → 英雄主动技能音效(复用现有神将技能资源)。
* 1金剑阵→孙悟空 2木飞棍→悟净 3水暴风雪→阎王 4火箭雨→杨戬 5土冲锋→李靖
*/
const HERO_SKILL_BY_HEROTYPE: Record<number, string> = {
1: '孙悟空技能',
2: '悟净技能',
3: '阎王技能',
4: '杨戬技能音效',
5: '李靖技能',
3 weeks ago
};
2 days ago
/**
* 五行元素 → 宝石武器技能音效(Weapon.type 2金…6土 → element 1…5)。
* 金→金箍棒 木→悟净 水→阎王 火→杨戬 土→玲珑宝塔
*/
const GEM_SKILL_BY_ELEMENT: Record<number, string> = {
1: '金箍棒技能',
2: '悟净技能',
3: '阎王技能',
4: '杨戬技能音效',
5: '玲珑宝塔技能',
3 weeks ago
};
2 days ago
/**
* @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;
}