Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- 新增脚步声音效标签支持,支持为不同材质(有机、机械、危险、无声)和不同状态(轻/重步行、轻/重跑步)配置自定义脚步声
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions DuckovCustomModel.Core/Data/SoundPlayMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace DuckovCustomModel.Core.Data
{
public enum SoundPlayMode
{
Normal,
StopPrevious,
SkipIfPlaying,
UseTempObject,
}
}

2 changes: 0 additions & 2 deletions DuckovCustomModel.Core/Data/SoundTags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,7 +40,6 @@ public static class SoundTags
[
Normal,
Surprise,
Death,
Idle,
TriggerOnHurt,
TriggerOnDeath,
Expand Down
2 changes: 1 addition & 1 deletion DuckovCustomModel/Constant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
6 changes: 3 additions & 3 deletions DuckovCustomModel/HarmonyPatches/AudioManagerPatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
BAKAOLC marked this conversation as resolved.
__result = null;
return false;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
106 changes: 103 additions & 3 deletions DuckovCustomModel/MonoBehaviours/ModelHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using DuckovCustomModel.Core.Data;
using DuckovCustomModel.Managers;
using DuckovCustomModel.Utils;
using FMOD.Studio;
using UnityEngine;
using Random = UnityEngine.Random;

Expand All @@ -24,6 +25,8 @@ public class ModelHandler : MonoBehaviour
private readonly HashSet<GameObject> _modifiedDeathLootBoxes = [];
private readonly Dictionary<string, Transform> _originalModelLocators = [];
private readonly Dictionary<FieldInfo, Transform> _originalModelSockets = [];

private readonly Dictionary<string, List<EventInstance>> _playingSoundInstances = [];
private readonly Dictionary<string, List<string>> _soundsByTag = [];
private Renderer[]? _cachedCustomModelRenderers;

Expand Down Expand Up @@ -124,6 +127,8 @@ private void Update()

private void LateUpdate()
{
RefreshPlayingSounds();

if (OriginalCharacterModel == null) return;

var equipmentSockets = new[]
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Comment thread
BAKAOLC marked this conversation as resolved.
}

private void InitializeCustomCharacterSubVisuals()
Expand Down Expand Up @@ -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) &&
Expand All @@ -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()
Expand Down
19 changes: 15 additions & 4 deletions DuckovCustomModel/Utils/AudioUtils.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
using System;
using Cysharp.Threading.Tasks;
using Duckov;
using FMOD.Studio;
using UnityEngine;
using Object = UnityEngine.Object;

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")
{
Expand All @@ -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 () =>
Expand All @@ -49,6 +58,8 @@ public static void PlayAudioWithTempObject(string soundPath, Transform parentTra
Object.Destroy(tempObject);
}
});

return eventInstance;
}
}
}
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ Animator Controller 可以使用以下参数:
},
{
"Path": "sounds/death.wav",
"Tags": ["death"]
"Tags": ["trigger_on_death"]
}
]
}
Expand All @@ -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"`:死亡触发音效,用于角色死亡时自动播放(会打断所有正在播放的音效)
Comment thread
BAKAOLC marked this conversation as resolved.
- `"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"`:机械材质脚步声(轻/重步行、轻/重跑步)
Expand All @@ -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"`:角色死亡时自动播放(适用于所有角色类型)
- **音效打断机制**:播放死亡音效前会先停止所有正在播放的音效,然后播放死亡音效
- 如果指定标签的音效不存在,将使用原版事件(不会回退到其他标签)

#### 脚步声触发
Expand All @@ -655,6 +657,7 @@ Animator Controller 可以使用以下参数:
- 支持四种地面材质:有机(organic)、机械(mech)、危险(danger)、无声(no sound)
- 支持四种移动状态:轻步行(walkLight)、重步行(walkHeavy)、轻跑步(runLight)、重跑步(runHeavy)
- 系统会根据角色的 `footStepMaterialType` 和 `FootStepTypes` 自动选择对应的音效标签
- **音效打断机制**:脚步声拥有独立的打断组,新播放的脚步声会打断同组内正在播放的脚步声
- 如果模型未配置对应材质和状态的脚步声,将使用原版脚步声

#### 搜索发现触发
Expand Down
Loading