diff --git a/CHANGELOG.md b/CHANGELOG.md index e946a18..69c8f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ [English](CHANGELOG_EN.md) | 中文 +## v1.8.1 + +- 新增音效标签 `trigger_on_hurt`,用于角色受伤时自动播放音效 +- 新增 `search_found_item_quality_xxx` 系列音效标签,可在搜索完成时根据物品品质触发不同音效(支持 none/white/green/blue/purple/orange/red/q7/q8) +- 改进了事件订阅的清理逻辑,防止内存泄漏 + ## v1.8.0-fix1 - 修复了因修改加载方式导致的多语言加载失败问题 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 452e907..5740998 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -2,6 +2,12 @@ English | [中文](CHANGELOG.md) +## v1.8.1 + +- Added sound tag `trigger_on_hurt` for automatically playing sounds when a character is hurt +- Added `search_found_item_quality_xxx` tag series so different sounds can play after finishing a search based on the revealed item quality (supports none/white/green/blue/purple/orange/red/q7/q8) +- Improved event subscription cleanup logic to prevent memory leaks + ## v1.8.0-fix1 - Fixed issue where multilingual loading failed due to changes in loading method diff --git a/DuckovCustomModel.Core/Data/SoundTags.cs b/DuckovCustomModel.Core/Data/SoundTags.cs index 55735e2..db3e5d6 100644 --- a/DuckovCustomModel.Core/Data/SoundTags.cs +++ b/DuckovCustomModel.Core/Data/SoundTags.cs @@ -8,7 +8,17 @@ public static class SoundTags 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"; + public const string SearchFoundItemQualityNone = "search_found_item_quality_none"; + public const string SearchFoundItemQualityWhite = "search_found_item_quality_white"; + public const string SearchFoundItemQualityGreen = "search_found_item_quality_green"; + public const string SearchFoundItemQualityBlue = "search_found_item_quality_blue"; + public const string SearchFoundItemQualityPurple = "search_found_item_quality_purple"; + public const string SearchFoundItemQualityOrange = "search_found_item_quality_orange"; + public const string SearchFoundItemQualityRed = "search_found_item_quality_red"; + public const string SearchFoundItemQualityQ7 = "search_found_item_quality_q7"; + public const string SearchFoundItemQualityQ8 = "search_found_item_quality_q8"; public static IReadOnlyCollection ValidTags => [ @@ -16,7 +26,17 @@ public static class SoundTags Surprise, Death, Idle, + TriggerOnHurt, TriggerOnDeath, + SearchFoundItemQualityNone, + SearchFoundItemQualityWhite, + SearchFoundItemQualityGreen, + SearchFoundItemQualityBlue, + SearchFoundItemQualityPurple, + SearchFoundItemQualityOrange, + SearchFoundItemQualityRed, + SearchFoundItemQualityQ7, + SearchFoundItemQualityQ8, ]; } } diff --git a/DuckovCustomModel/Constant.cs b/DuckovCustomModel/Constant.cs index f057c97..2bd7785 100644 --- a/DuckovCustomModel/Constant.cs +++ b/DuckovCustomModel/Constant.cs @@ -1,20 +1,10 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; - -namespace DuckovCustomModel +namespace DuckovCustomModel { public static class Constant { public const string ModID = "DuckovCustomModel"; public const string ModName = "Duckov Custom Model"; - public const string ModVersion = "1.8.0-fix1"; + public const string ModVersion = "1.8.1"; public const string HarmonyId = "com.ritsukage.DuckovCustomModel"; - - public static readonly JsonSerializerSettings JsonSettings = new() - { - TypeNameHandling = TypeNameHandling.Auto, - Formatting = Formatting.Indented, - Converters = [new StringEnumConverter()], - }; } } diff --git a/DuckovCustomModel/HarmonyPatches/ItemDisplayPatches.cs b/DuckovCustomModel/HarmonyPatches/ItemDisplayPatches.cs new file mode 100644 index 0000000..febda20 --- /dev/null +++ b/DuckovCustomModel/HarmonyPatches/ItemDisplayPatches.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using Duckov; +using Duckov.UI; +using DuckovCustomModel.Core.Data; +using DuckovCustomModel.MonoBehaviours; +using HarmonyLib; +using ItemStatsSystem; + +namespace DuckovCustomModel.HarmonyPatches +{ + [HarmonyPatch] + internal class ItemDisplayPatches + { + private static readonly IReadOnlyDictionary QualitySoundTags = + new Dictionary + { + { DisplayQuality.None, SoundTags.SearchFoundItemQualityNone }, + { DisplayQuality.White, SoundTags.SearchFoundItemQualityWhite }, + { DisplayQuality.Green, SoundTags.SearchFoundItemQualityGreen }, + { DisplayQuality.Blue, SoundTags.SearchFoundItemQualityBlue }, + { DisplayQuality.Purple, SoundTags.SearchFoundItemQualityPurple }, + { DisplayQuality.Orange, SoundTags.SearchFoundItemQualityOrange }, + { DisplayQuality.Red, SoundTags.SearchFoundItemQualityRed }, + { DisplayQuality.Q7, SoundTags.SearchFoundItemQualityQ7 }, + { DisplayQuality.Q8, SoundTags.SearchFoundItemQualityQ8 }, + }; + + private static readonly HashSet RecordedItems = []; + + [HarmonyPatch(typeof(ItemDisplay), nameof(ItemDisplay.Setup))] + [HarmonyPostfix] + // ReSharper disable once InconsistentNaming + private static void ItemDisplay_Setup_Postfix(ItemDisplay __instance, Item target) + { + if (target == null) return; + if (RecordedItems.Contains(target)) return; + if (!target.NeedInspection) return; + + RecordedItems.Add(target); + target.onDestroy += OnItemDestroyed; + } + + [HarmonyPatch(typeof(ItemDisplay), nameof(ItemDisplay.OnTargetInspectionStateChanged))] + [HarmonyPostfix] + private static void ItemDisplay_OnTargetInspectionStateChanged_Postfix(Item item) + { + if (item == null) return; + if (item.Inspecting || !item.Inspected) return; + if (!RecordedItems.Contains(item)) return; + + item.onDestroy -= OnItemDestroyed; + RecordedItems.Remove(item); + + var mainPlayer = CharacterMainControl.Main; + if (mainPlayer == null) return; + + var modelHandler = mainPlayer.GetComponent(); + if (modelHandler == null || !modelHandler.IsInitialized) return; + if (!modelHandler.IsModelAudioEnabled) return; + if (!modelHandler.HasAnySounds()) return; + + if (!QualitySoundTags.TryGetValue(item.DisplayQuality, out var soundTag)) return; + + var soundPath = modelHandler.GetRandomSoundByTag(soundTag); + if (string.IsNullOrEmpty(soundPath)) return; + + AudioManager.PostCustomSFX(soundPath); + } + + private static void OnItemDestroyed(Item item) + { + if (item == null) return; + if (!RecordedItems.Contains(item)) return; + + RecordedItems.Remove(item); + } + } +} diff --git a/DuckovCustomModel/MonoBehaviours/ModelHandler.cs b/DuckovCustomModel/MonoBehaviours/ModelHandler.cs index 1dea612..2160ad5 100644 --- a/DuckovCustomModel/MonoBehaviours/ModelHandler.cs +++ b/DuckovCustomModel/MonoBehaviours/ModelHandler.cs @@ -156,6 +156,14 @@ private void LateUpdate() } } + private void OnDestroy() + { + if (CharacterMainControl == null) return; + if (CharacterMainControl.Health == null) return; + CharacterMainControl.Health.OnHurtEvent.RemoveListener(OnHurt); + CharacterMainControl.Health.OnDeadEvent.RemoveListener(OnDeath); + } + public void Initialize(CharacterMainControl characterMainControl, ModelTarget target = ModelTarget.Character) { if (IsInitialized) return; @@ -199,7 +207,11 @@ public void Initialize(CharacterMainControl characterMainControl, ModelTarget ta RecordOriginalHeadCollider(); RecordOriginalSoundMaker(); - if (CharacterMainControl.Health != null) CharacterMainControl.Health.OnDeadEvent.AddListener(OnDeath); + if (CharacterMainControl.Health != null) + { + CharacterMainControl.Health.OnHurtEvent.AddListener(OnHurt); + CharacterMainControl.Health.OnDeadEvent.AddListener(OnDeath); + } ModLogger.Log("ModelHandler initialized successfully."); IsInitialized = true; @@ -761,6 +773,16 @@ private void ForceUpdateHealthBar() healthBar.RefreshOffset(); } + private void OnHurt(DamageInfo damageInfo) + { + if (!IsModelAudioEnabled) return; + + var soundPath = GetRandomSoundByTag(SoundTags.TriggerOnHurt); + if (string.IsNullOrEmpty(soundPath)) return; + + AudioManager.PostCustomSFX(soundPath); + } + private void OnDeath(DamageInfo damageInfo) { if (!IsModelAudioEnabled) return; diff --git a/README.md b/README.md index 02fea55..0b789ac 100644 --- a/README.md +++ b/README.md @@ -612,7 +612,9 @@ Animator Controller 可以使用以下参数: - `"surprise"`:惊讶音效,用于 AI 惊讶状态 - `"death"`:死亡音效,用于 AI 死亡状态 - `"idle"`:待机音效,用于角色自动播放(可通过配置控制哪些角色类型允许自动播放) + - `"trigger_on_hurt"`:受伤触发音效,用于角色受到伤害时自动播放 - `"trigger_on_death"`:死亡触发音效,用于角色死亡时自动播放 + - `"search_found_item_quality_xxx"`:搜索完成时发现指定品质物品会触发音效,`xxx` 可为 `none`、`white`、`green`、`blue`、`purple`、`orange`、`red`、`q7`、`q8` - 可以同时包含多个标签,表示该音效可用于多个场景 - 未指定标签时,默认为 `["normal"]` @@ -633,6 +635,7 @@ Animator Controller 可以使用以下参数: - `"normal"`:AI 普通状态时触发 - `"surprise"`:AI 惊讶状态时触发 - `"death"`:AI 死亡状态时触发 +- `"trigger_on_hurt"`:角色受到伤害时自动播放(适用于所有角色类型) - `"idle"`:启用了自动播放的角色会在随机间隔时间自动播放待机音效 - `"trigger_on_death"`:角色死亡时自动播放(适用于所有角色类型) - 播放间隔可在 `IdleAudioConfig.json` 中配置 @@ -642,6 +645,12 @@ Animator Controller 可以使用以下参数: - 默认情况下,AI 角色和宠物允许自动播放,玩家角色不允许(可通过配置启用) - 如果指定标签的音效不存在,将使用原版事件(不会回退到其他标签) +#### 搜索发现触发 + +- 玩家完成物品搜索或检查后(UI 展示品质信息的那一刻)会触发对应品质的音效 +- 使用 `search_found_item_quality_xxx` 标签,`xxx` 与 `Tags` 描述相同:`none`、`white`、`green`、`blue`、`purple`、`orange`、`red`、`q7`、`q8` +- 若模型未配置对应品质的音效,则不会播放,保持与原版一致 + ### 音效文件要求 - 音效文件应放置在模型包文件夹内 diff --git a/README_EN.md b/README_EN.md index 628e000..9ac84f9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -606,6 +606,9 @@ Sounds can be configured in `ModelInfo` within `bundleinfo.json`: - `"surprise"`: Surprise sound, used for AI surprise state - `"death"`: Death sound, used for AI death state - `"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 + - `"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` - Can contain multiple tags, indicating the sound can be used in multiple scenarios - Defaults to `["normal"]` when no tags are specified @@ -626,7 +629,9 @@ Sounds can be configured in `ModelInfo` within `bundleinfo.json`: - `"normal"`: Triggered during AI normal state - `"surprise"`: Triggered during AI surprise state - `"death"`: Triggered during AI death state +- `"trigger_on_hurt"`: Automatically plays when the character takes damage (applies to all character types) - `"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 @@ -634,6 +639,12 @@ Sounds can be configured in `ModelInfo` within `bundleinfo.json`: - By default, AI characters and pets are allowed to automatically play, while player characters are not (can be enabled through configuration) - If a sound with the specified tag doesn't exist, the original game event will be used (no fallback to other tags) +#### Search Discovery Trigger + +- When the player finishes searching or inspecting an item and its quality is revealed, the corresponding sound tag is triggered +- Use `search_found_item_quality_xxx`, where `xxx` matches the same quality suffix listed in `Tags`: `none`, `white`, `green`, `blue`, `purple`, `orange`, `red`, `q7`, `q8` +- If no sound is configured for that quality, nothing plays and the vanilla behavior remains unchanged + ### Sound File Requirements - Sound files should be placed inside the model bundle folder