diff --git a/CHANGELOG.md b/CHANGELOG.md index a8545c4..d276a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ [English](CHANGELOG_EN.md) | 中文 +## v1.8.5 + +- 改进音效播放系统,统一使用 `ModelHandler.PlaySound` 方法管理所有音效播放 +- 新增音效播放模式支持(Normal、StopPrevious、SkipIfPlaying、UseTempObject),提供更精细的音效控制 +- 新增音效实例管理功能,支持停止指定音效或所有音效 +- 改进音效打断机制: + - 玩家按键触发(F1 嘎嘎)和 AI 自动触发(`normal`、`surprise` 标签)共享同一打断组,新播放的音效会打断同组内正在播放的音效 + - 脚步声拥有独立的打断组,新播放的脚步声会打断同组内正在播放的脚步声 + - 受伤音效(`trigger_on_hurt`)如果已有音效正在播放则跳过,避免重复播放 + - 死亡音效(`trigger_on_death`)会先停止所有正在播放的音效,然后播放死亡音效 +- 改进 `AudioUtils.PlayAudioWithTempObject` 方法,返回 `EventInstance` 以便更好地管理音效生命周期 +- 移除不再使用的 `SoundTags.Death` 常量,统一使用 `trigger_on_death` 标签 + ## v1.8.4 - 新增脚步声音效标签支持,支持为不同材质(有机、机械、危险、无声)和不同状态(轻/重步行、轻/重跑步)配置自定义脚步声 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 15ef033..86cb1c1 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -2,6 +2,19 @@ English | [中文](CHANGELOG.md) +## v1.8.5 + +- Improved audio playback system, unified use of `ModelHandler.PlaySound` method to manage all audio playback +- Added audio playback mode support (Normal, StopPrevious, SkipIfPlaying, UseTempObject), providing finer audio control +- Added audio instance management functionality, supporting stopping specific sounds or all sounds +- Improved sound interrupt mechanism: + - Player key press triggers (F1 quack) and AI automatic triggers (`normal`, `surprise` tags) share the same interrupt group, newly played sounds will interrupt sounds playing in the same group + - Footsteps have their own independent interrupt group, newly played footsteps will interrupt footsteps playing in the same group + - Hurt sounds (`trigger_on_hurt`) skip if a hurt sound is already playing to avoid duplicate playback + - Death sounds (`trigger_on_death`) stop all currently playing sounds before playing the death sound +- Improved `AudioUtils.PlayAudioWithTempObject` method to return `EventInstance` for better audio lifecycle management +- Removed unused `SoundTags.Death` constant, unified use of `trigger_on_death` tag + ## v1.8.4 - Added footstep sound tag support, allowing custom footstep sounds for different materials (organic, mech, danger, no sound) and different states (light/heavy walk, light/heavy run) diff --git a/DuckovCustomModel.Core/Data/SoundPlayMode.cs b/DuckovCustomModel.Core/Data/SoundPlayMode.cs new file mode 100644 index 0000000..0bee963 --- /dev/null +++ b/DuckovCustomModel.Core/Data/SoundPlayMode.cs @@ -0,0 +1,11 @@ +namespace DuckovCustomModel.Core.Data +{ + public enum SoundPlayMode + { + Normal, + StopPrevious, + SkipIfPlaying, + UseTempObject, + } +} + diff --git a/DuckovCustomModel.Core/Data/SoundTags.cs b/DuckovCustomModel.Core/Data/SoundTags.cs index ae9a735..de92ce3 100644 --- a/DuckovCustomModel.Core/Data/SoundTags.cs +++ b/DuckovCustomModel.Core/Data/SoundTags.cs @@ -6,7 +6,6 @@ public static class SoundTags { public const string Normal = "normal"; public const string Surprise = "surprise"; - public const string Death = "death"; public const string Idle = "idle"; public const string TriggerOnHurt = "trigger_on_hurt"; public const string TriggerOnDeath = "trigger_on_death"; @@ -41,7 +40,6 @@ public static class SoundTags [ Normal, Surprise, - Death, Idle, TriggerOnHurt, TriggerOnDeath, diff --git a/DuckovCustomModel/Constant.cs b/DuckovCustomModel/Constant.cs index 1c64b07..6c2b6f9 100644 --- a/DuckovCustomModel/Constant.cs +++ b/DuckovCustomModel/Constant.cs @@ -4,7 +4,7 @@ public static class Constant { public const string ModID = "DuckovCustomModel"; public const string ModName = "Duckov Custom Model"; - public const string ModVersion = "1.8.4"; + public const string ModVersion = "1.8.5"; public const string HarmonyId = "com.ritsukage.DuckovCustomModel"; } } diff --git a/DuckovCustomModel/HarmonyPatches/AudioManagerPatches.cs b/DuckovCustomModel/HarmonyPatches/AudioManagerPatches.cs index f60d730..1122ccd 100644 --- a/DuckovCustomModel/HarmonyPatches/AudioManagerPatches.cs +++ b/DuckovCustomModel/HarmonyPatches/AudioManagerPatches.cs @@ -44,7 +44,7 @@ private static bool Prefix( var soundPath = modelHandler.GetRandomSoundByTag(normalizedSoundKey); if (string.IsNullOrEmpty(soundPath)) return true; - AudioManager.PostCustomSFX(soundPath, gameObject); + modelHandler.PlaySound("quack", soundPath, playMode: SoundPlayMode.StopPrevious); __result = null; return false; } @@ -115,7 +115,7 @@ private static bool Prefix( var soundPath = modelHandler.GetRandomSoundByTag(soundTag); if (string.IsNullOrEmpty(soundPath)) return true; - AudioManager.PostCustomSFX(soundPath, character.gameObject); + modelHandler.PlaySound("footstep", soundPath, playMode: SoundPlayMode.StopPrevious); return false; } @@ -141,7 +141,7 @@ private static bool Prefix(CharacterMainControl __instance) var soundPath = modelHandler.GetRandomSoundByTag(SoundTags.Normal); if (string.IsNullOrEmpty(soundPath)) return true; - AudioManager.PostCustomSFX(soundPath, __instance.gameObject); + modelHandler.PlaySound("quack", soundPath, playMode: SoundPlayMode.StopPrevious); AIMainBrain.MakeSound(new() { fromCharacter = __instance, diff --git a/DuckovCustomModel/MonoBehaviours/ModelHandler.cs b/DuckovCustomModel/MonoBehaviours/ModelHandler.cs index 970b6fa..3044efe 100644 --- a/DuckovCustomModel/MonoBehaviours/ModelHandler.cs +++ b/DuckovCustomModel/MonoBehaviours/ModelHandler.cs @@ -8,6 +8,7 @@ using DuckovCustomModel.Core.Data; using DuckovCustomModel.Managers; using DuckovCustomModel.Utils; +using FMOD.Studio; using UnityEngine; using Random = UnityEngine.Random; @@ -24,6 +25,8 @@ public class ModelHandler : MonoBehaviour private readonly HashSet _modifiedDeathLootBoxes = []; private readonly Dictionary _originalModelLocators = []; private readonly Dictionary _originalModelSockets = []; + + private readonly Dictionary> _playingSoundInstances = []; private readonly Dictionary> _soundsByTag = []; private Renderer[]? _cachedCustomModelRenderers; @@ -124,6 +127,8 @@ private void Update() private void LateUpdate() { + RefreshPlayingSounds(); + if (OriginalCharacterModel == null) return; var equipmentSockets = new[] @@ -780,7 +785,7 @@ private void OnHurt(DamageInfo damageInfo) var soundPath = GetRandomSoundByTag(SoundTags.TriggerOnHurt); if (string.IsNullOrEmpty(soundPath)) return; - AudioManager.PostCustomSFX(soundPath, gameObject); + PlaySound("onHurt", soundPath, playMode: SoundPlayMode.SkipIfPlaying); } private void OnDeath(DamageInfo damageInfo) @@ -790,7 +795,8 @@ private void OnDeath(DamageInfo damageInfo) var soundPath = GetRandomSoundByTag(SoundTags.TriggerOnDeath); if (string.IsNullOrEmpty(soundPath)) return; - AudioUtils.PlayAudioWithTempObject(soundPath, gameObject.transform); + StopAllSounds(); + PlaySound("onDeath", soundPath, playMode: SoundPlayMode.UseTempObject); } private void InitializeCustomCharacterSubVisuals() @@ -966,6 +972,100 @@ public bool HasAnySounds() return sounds[index]; } + public EventInstance? PlaySound( + string eventName, + string path, + bool loop = false, + SoundPlayMode playMode = SoundPlayMode.Normal) + { + if (string.IsNullOrEmpty(path)) return null; + + if (!_playingSoundInstances.TryGetValue(eventName, out var existingInstances)) + { + existingInstances = []; + _playingSoundInstances[eventName] = existingInstances; + } + + EventInstance? eventInstance; + + switch (playMode) + { + case SoundPlayMode.StopPrevious: + StopSound(eventName); + goto default; + case SoundPlayMode.SkipIfPlaying: + if (existingInstances.Any(AudioUtils.CheckSoundIsPlaying)) + return null; + goto default; + case SoundPlayMode.UseTempObject: + if (loop) + ModLogger.LogWarning( + $"Sound '{eventName}' is set to loop, but 'useTempObject' is true. Loop will be ignored."); + eventInstance = AudioUtils.PlayAudioWithTempObject(path, gameObject.transform); + break; + default: + eventInstance = AudioManager.Instance.MPostCustomSFX(path, gameObject, loop); + break; + } + + if (eventInstance == null) + { + ModLogger.LogError($"Failed to play sound '{eventName}' from path: {path}"); + return null; + } + + existingInstances.Add(eventInstance.Value); + + return eventInstance; + } + + public bool IsSoundPlaying(string eventName) + { + return _playingSoundInstances.TryGetValue(eventName, out var existingInstances) && + existingInstances.Any(AudioUtils.CheckSoundIsPlaying); + } + + public void StopSound(string eventName) + { + if (!_playingSoundInstances.TryGetValue(eventName, out var existingInstances)) return; + + foreach (var existingInstance in existingInstances) + { + existingInstance.stop(STOP_MODE.IMMEDIATE); + existingInstance.release(); + } + + existingInstances.Clear(); + } + + public void StopAllSounds() + { + foreach (var existingInstances in _playingSoundInstances.Select(kvp => kvp.Value)) + { + foreach (var existingInstance in existingInstances) + { + existingInstance.stop(STOP_MODE.IMMEDIATE); + existingInstance.release(); + } + + existingInstances.Clear(); + } + + _playingSoundInstances.Clear(); + } + + private void RefreshPlayingSounds() + { + var keys = _playingSoundInstances.Keys.ToArray(); + foreach (var eventName in keys) + { + var existingInstances = _playingSoundInstances[eventName]; + existingInstances.RemoveAll(existingInstance => !AudioUtils.CheckSoundIsPlaying(existingInstance)); + if (existingInstances.Count == 0) + _playingSoundInstances.Remove(eventName); + } + } + private bool HasIdleSounds() { return _soundsByTag.ContainsKey(SoundTags.Idle) && @@ -979,7 +1079,7 @@ private void PlayIdleAudio() var soundPath = GetRandomSoundByTag(SoundTags.Idle); if (string.IsNullOrEmpty(soundPath)) return; - AudioManager.PostCustomSFX(soundPath, gameObject); + PlaySound("idle", soundPath); } private void ScheduleNextIdleAudio() diff --git a/DuckovCustomModel/Utils/AudioUtils.cs b/DuckovCustomModel/Utils/AudioUtils.cs index 6cfd529..f198c2f 100644 --- a/DuckovCustomModel/Utils/AudioUtils.cs +++ b/DuckovCustomModel/Utils/AudioUtils.cs @@ -1,6 +1,7 @@ using System; using Cysharp.Threading.Tasks; using Duckov; +using FMOD.Studio; using UnityEngine; using Object = UnityEngine.Object; @@ -8,10 +9,18 @@ namespace DuckovCustomModel.Utils { public static class AudioUtils { - public static void PlayAudioWithTempObject(string soundPath, Transform parentTransform) + public static bool CheckSoundIsPlaying(EventInstance eventInstance) { - if (string.IsNullOrEmpty(soundPath)) return; - if (parentTransform == null) return; + if (!eventInstance.isValid()) return false; + + eventInstance.getPlaybackState(out var playbackState); + return playbackState is PLAYBACK_STATE.STARTING or PLAYBACK_STATE.PLAYING; + } + + public static EventInstance? PlayAudioWithTempObject(string soundPath, Transform parentTransform) + { + if (string.IsNullOrEmpty(soundPath)) return null; + if (parentTransform == null) return null; var tempObject = new GameObject("DuckovCustomModel_TempAudioObject") { @@ -25,7 +34,7 @@ public static void PlayAudioWithTempObject(string soundPath, Transform parentTra if (eventInstance == null || !eventInstance.Value.isValid()) { Object.Destroy(tempObject); - return; + return null; } UniTask.Void(async () => @@ -49,6 +58,8 @@ public static void PlayAudioWithTempObject(string soundPath, Transform parentTra Object.Destroy(tempObject); } }); + + return eventInstance; } } } diff --git a/README.md b/README.md index c62b7b2..a550b39 100644 --- a/README.md +++ b/README.md @@ -598,7 +598,7 @@ Animator Controller 可以使用以下参数: }, { "Path": "sounds/death.wav", - "Tags": ["death"] + "Tags": ["trigger_on_death"] } ] } @@ -608,12 +608,11 @@ Animator Controller 可以使用以下参数: - `Path`(必需):音效文件路径,相对于模型包文件夹 - `Tags`(可选):音效标签数组,用于指定音效的使用场景 - - `"normal"`:普通音效,用于玩家按键触发和 AI 普通状态 - - `"surprise"`:惊讶音效,用于 AI 惊讶状态 - - `"death"`:死亡音效,用于 AI 死亡状态 + - `"normal"`:普通音效,用于玩家按键触发(F1 嘎嘎)和 AI 自动触发(普通状态和惊讶状态) + - `"surprise"`:惊讶音效,用于 AI 惊讶状态(与 `"normal"` 标签共享同一打断组) - `"idle"`:待机音效,用于角色自动播放(可通过配置控制哪些角色类型允许自动播放) - - `"trigger_on_hurt"`:受伤触发音效,用于角色受到伤害时自动播放 - - `"trigger_on_death"`:死亡触发音效,用于角色死亡时自动播放 + - `"trigger_on_hurt"`:受伤触发音效,用于角色受到伤害时自动播放(如果已有受伤音效正在播放则跳过) + - `"trigger_on_death"`:死亡触发音效,用于角色死亡时自动播放(会打断所有正在播放的音效) - `"search_found_item_quality_xxx"`:搜索完成时发现指定品质物品会触发音效,`xxx` 可为 `none`、`white`、`green`、`blue`、`purple`、`orange`、`red`、`q7`、`q8` - `"footstep_organic_walk_light"`、`"footstep_organic_walk_heavy"`、`"footstep_organic_run_light"`、`"footstep_organic_run_heavy"`:有机材质脚步声(轻/重步行、轻/重跑步) - `"footstep_mech_walk_light"`、`"footstep_mech_walk_heavy"`、`"footstep_mech_run_light"`、`"footstep_mech_run_heavy"`:机械材质脚步声(轻/重步行、轻/重跑步) @@ -627,26 +626,29 @@ Animator Controller 可以使用以下参数: #### 玩家按键触发 -- 当角色模型配置了音效时,玩家按下游戏中的 `Quack` 键会触发音效 +- 当角色模型配置了音效时,玩家按下游戏中的 `Quack` 键(F1)会触发音效 - 只会播放标签为 `"normal"` 的音效 - 从所有 `"normal"` 标签的音效中随机选择一个播放 - 只有玩家角色会响应按键,宠物不会触发 - 播放音效时会同时创建 AI 声音,使其他 AI 能够听到玩家发出的声音 +- **音效打断机制**:玩家按键触发的音效与 AI 自动触发的音效(`"normal"` 和 `"surprise"` 标签)共享同一打断组,新播放的音效会打断同组内正在播放的音效 #### AI 自动触发 - AI 会根据游戏状态自动触发相应标签的音效 - `"normal"`:AI 普通状态时触发 - `"surprise"`:AI 惊讶状态时触发 -- `"death"`:AI 死亡状态时触发 +- **音效打断机制**:AI 自动触发的音效(`"normal"` 和 `"surprise"` 标签)与玩家按键触发的音效共享同一打断组,新播放的音效会打断同组内正在播放的音效 - `"trigger_on_hurt"`:角色受到伤害时自动播放(适用于所有角色类型) + - **音效打断机制**:如果已有受伤音效正在播放,则跳过新的受伤音效播放,避免重复播放 - `"idle"`:启用了自动播放的角色会在随机间隔时间自动播放待机音效 -- `"trigger_on_death"`:角色死亡时自动播放(适用于所有角色类型) - 播放间隔可在 `IdleAudioConfig.json` 中配置 - 默认间隔为 30-45 秒(随机) - 角色死亡时不会播放 - 哪些角色类型允许自动播放可通过 `EnableIdleAudio` 和 `AICharacterEnableIdleAudio` 配置控制 - 默认情况下,AI 角色和宠物允许自动播放,玩家角色不允许(可通过配置启用) +- `"trigger_on_death"`:角色死亡时自动播放(适用于所有角色类型) + - **音效打断机制**:播放死亡音效前会先停止所有正在播放的音效,然后播放死亡音效 - 如果指定标签的音效不存在,将使用原版事件(不会回退到其他标签) #### 脚步声触发 @@ -655,6 +657,7 @@ Animator Controller 可以使用以下参数: - 支持四种地面材质:有机(organic)、机械(mech)、危险(danger)、无声(no sound) - 支持四种移动状态:轻步行(walkLight)、重步行(walkHeavy)、轻跑步(runLight)、重跑步(runHeavy) - 系统会根据角色的 `footStepMaterialType` 和 `FootStepTypes` 自动选择对应的音效标签 +- **音效打断机制**:脚步声拥有独立的打断组,新播放的脚步声会打断同组内正在播放的脚步声 - 如果模型未配置对应材质和状态的脚步声,将使用原版脚步声 #### 搜索发现触发 diff --git a/README_EN.md b/README_EN.md index ad6354f..2f5333e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -592,7 +592,7 @@ Sounds can be configured in `ModelInfo` within `bundleinfo.json`: }, { "Path": "sounds/death.wav", - "Tags": ["death"] + "Tags": ["trigger_on_death"] } ] } @@ -602,12 +602,11 @@ Sounds can be configured in `ModelInfo` within `bundleinfo.json`: - `Path` (required): Sound file path, relative to the model bundle folder - `Tags` (optional): Array of sound tags, used to specify sound usage scenarios - - `"normal"`: Normal sound, used for player key press triggers and AI normal state - - `"surprise"`: Surprise sound, used for AI surprise state - - `"death"`: Death sound, used for AI death state + - `"normal"`: Normal sound, used for player key press triggers (F1 quack) and AI automatic triggers (normal state and surprise state) + - `"surprise"`: Surprise sound, used for AI surprise state (shares the same interrupt group with `"normal"` tag) - `"idle"`: Idle sound, used for automatic playback by characters (can be controlled through configuration to determine which character types are allowed to automatically play) - - `"trigger_on_hurt"`: Hurt trigger sound, automatically plays when the character takes damage - - `"trigger_on_death"`: Death trigger sound, automatically plays when the character dies + - `"trigger_on_hurt"`: Hurt trigger sound, automatically plays when the character takes damage (skips if a hurt sound is already playing) + - `"trigger_on_death"`: Death trigger sound, automatically plays when the character dies (stops all currently playing sounds before playing) - `"search_found_item_quality_xxx"`: Plays when a searched item of the specified quality is revealed; `xxx` can be `none`, `white`, `green`, `blue`, `purple`, `orange`, `red`, `q7`, or `q8` - `"footstep_organic_walk_light"`, `"footstep_organic_walk_heavy"`, `"footstep_organic_run_light"`, `"footstep_organic_run_heavy"`: Organic material footstep sounds (light/heavy walk, light/heavy run) - `"footstep_mech_walk_light"`, `"footstep_mech_walk_heavy"`, `"footstep_mech_run_light"`, `"footstep_mech_run_heavy"`: Mechanical material footstep sounds (light/heavy walk, light/heavy run) @@ -621,26 +620,29 @@ Sounds can be configured in `ModelInfo` within `bundleinfo.json`: #### Player Key Press Trigger -- When a character model has sounds configured, pressing the `Quack` key in-game will trigger a sound +- When a character model has sounds configured, pressing the `Quack` key (F1) in-game will trigger a sound - Only sounds tagged with `"normal"` will be played - Randomly selects one sound from all sounds tagged with `"normal"` - Only player characters respond to key presses, pets do not trigger - When playing a sound, it also creates an AI sound, allowing other AIs to hear the player's sound +- **Sound Interrupt Mechanism**: Player key press triggered sounds share the same interrupt group with AI automatic triggered sounds (`"normal"` and `"surprise"` tags), newly played sounds will interrupt sounds playing in the same group #### AI Automatic Trigger - AI will automatically trigger sounds with corresponding tags based on game state - `"normal"`: Triggered during AI normal state - `"surprise"`: Triggered during AI surprise state -- `"death"`: Triggered during AI death state +- **Sound Interrupt Mechanism**: AI automatic triggered sounds (`"normal"` and `"surprise"` tags) share the same interrupt group with player key press triggered sounds, newly played sounds will interrupt sounds playing in the same group - `"trigger_on_hurt"`: Automatically plays when the character takes damage (applies to all character types) + - **Sound Interrupt Mechanism**: If a hurt sound is already playing, the new hurt sound will be skipped to avoid duplicate playback - `"idle"`: Characters with automatic playback enabled will automatically play idle sounds at random intervals -- `"trigger_on_death"`: Automatically plays when the character dies (applies to all character types) - Play interval can be configured in `IdleAudioConfig.json` - Default interval is 30-45 seconds (random) - Will not play when the character is dead - Which character types are allowed to automatically play can be controlled through `EnableIdleAudio` and `AICharacterEnableIdleAudio` configurations - By default, AI characters and pets are allowed to automatically play, while player characters are not (can be enabled through configuration) +- `"trigger_on_death"`: Automatically plays when the character dies (applies to all character types) + - **Sound Interrupt Mechanism**: Before playing the death sound, all currently playing sounds will be stopped, then the death sound will play - If a sound with the specified tag doesn't exist, the original game event will be used (no fallback to other tags) #### Footstep Trigger @@ -649,6 +651,7 @@ Sounds can be configured in `ModelInfo` within `bundleinfo.json`: - Supports four ground materials: organic, mech, danger, and no sound - Supports four movement states: light walk (walkLight), heavy walk (walkHeavy), light run (runLight), and heavy run (runHeavy) - The system automatically selects the corresponding sound tag based on the character's `footStepMaterialType` and `FootStepTypes` +- **Sound Interrupt Mechanism**: Footsteps have their own independent interrupt group, newly played footsteps will interrupt footsteps playing in the same group - If the model doesn't have footstep sounds configured for the corresponding material and state, vanilla footstep sounds will be used #### Search Discovery Trigger