diff --git a/README.md b/README.md index 580da50..222c48f 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ 开启彩蛋功能后,系统可能会随机或者在满足特定条件后主动开启某些风景效果。 ### 声音效果 -开启独立的环境音效托管开关 `EnableAmbientSounds`(默认关闭)后,系统会按条件管理声音效果。 +开启 `Automation.EnableAmbientSounds`(默认关闭)后,系统会按条件托管做饭、空调和蝉鸣;关闭时沿用原有托管清理与用户接管规则,不是全局静音。 + +雨声由独立的 `Automation.EnablePrecipitationSounds` 控制(默认开启,旧配置缺少此项时保留原有雨声行为)。设为 `false` 后,天气同步仍切换小雨、大雨、雷雨画面,但会静音对应雨声,不影响角色语音、动作声、其他环境音或雪景。天气同步开启时,在设置面板切换会立即更新当前雨声,无需刷新天气;过场或鲸鱼覆盖期间仍遵循同步保护。修改配置文件后按 F7 重载并同步。两项开关可以独立组合,手动雨景操作仍由游戏控制。 ### TODO @@ -134,8 +136,10 @@ [Automation] ## 启用季节性景色彩蛋自动托管 EnableSeasonalEasterEggs = true -## 启用环境音效自动托管 +## 启用做饭、空调、蝉鸣环境音效自动托管(不控制雨声) EnableAmbientSounds = false +## 天气同步时开启降水声音;false 保留雨景并静音雨声,设置面板切换立即更新当前雨声 +EnablePrecipitationSounds = true ## 跟随游戏内实际雨景,阻止角色自动开窗;已开窗时,小雨 30–90 秒、雨 3–8 秒、雷雨 1–4 秒后请求关窗,等待现有动作完成 CloseWindowWhenRaining = true diff --git a/README_EN.md b/README_EN.md index 7b0b911..60b5e9b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -96,7 +96,9 @@ When seasonal easter eggs are enabled, some scenery effects may be activated ran ### Sound Effects -When the separate EnableAmbientSounds switch is enabled (off by default), ambient sound effects are managed according to their conditions. +`Automation.EnableAmbientSounds` (off by default) automates cooking, air conditioner and cicada sounds according to their conditions. Disabling it retains the existing ownership and cleanup rules; it is not a global mute. + +`Automation.EnablePrecipitationSounds` separately controls rain audio. It defaults to `true`, preserving rain audio for existing configs without this key. Set it to `false` to keep synchronized light, heavy and thunderstorm rain visuals while muting their sounds, without affecting character voices, action sounds, other ambient audio or snow. With weather sync enabled, the settings-panel toggle updates current rain audio immediately without refreshing weather; cutscene and whale overrides still take priority. After editing the config file, press F7 to reload and sync. Both switches are independent. Manual rain controls retain the game's behavior. ### TODO @@ -147,8 +149,10 @@ After first run, config is generated at `BepInEx/config/chillwithyou.envsync.cfg [Automation] ## Enable automatic seasonal scenery easter eggs EnableSeasonalEasterEggs = true -## Enable automatic ambient sound management +## Automate cooking, air conditioner and cicada sounds (does not control rain audio) EnableAmbientSounds = false +## Play precipitation sounds during weather sync; false keeps rain visuals and mutes rain audio; panel changes update current rain audio immediately +EnablePrecipitationSounds = true ## Follow active in-game rain; block opening and request closing after 30–90s in light rain, 3–8s in rain, or 1–4s in thunderstorms, allowing existing actions to finish CloseWindowWhenRaining = true diff --git a/RealTimeWeatherMod.Tests/Core/CombinedEnvironmentStatePolicyTests.cs b/RealTimeWeatherMod.Tests/Core/CombinedEnvironmentStatePolicyTests.cs index b1650d6..5447e46 100644 --- a/RealTimeWeatherMod.Tests/Core/CombinedEnvironmentStatePolicyTests.cs +++ b/RealTimeWeatherMod.Tests/Core/CombinedEnvironmentStatePolicyTests.cs @@ -5,6 +5,56 @@ namespace RealTimeWeatherMod.Tests.Core { public class CombinedEnvironmentStatePolicyTests { + [Theory] + [InlineData(false, false, false, true, false)] + [InlineData(false, true, true, true, true)] + [InlineData(true, true, true, false, true)] + [InlineData(true, false, true, false, true)] + [InlineData(true, true, false, false, true)] + [InlineData(true, false, false, false, false)] + public void SilentRain_OpensOrKeepsWindow_AndMutesStoredOrLiveSound( + bool windowActive, bool soundActive, bool liveSoundActive, + bool changeWindow, bool changeSound) + { + Assert.Equal(changeWindow, CombinedEnvironmentStatePolicy.NeedsWindowChange(windowActive, true)); + Assert.Equal(changeSound, CombinedEnvironmentStatePolicy.NeedsSoundChange( + true, soundActive, liveSoundActive, false)); + Assert.Equal(!changeWindow && !changeSound, CombinedEnvironmentStatePolicy.IsInTargetState( + windowActive, true, soundActive, liveSoundActive, true, targetSoundState: false)); + } + + [Fact] + public void SilentRain_Stopping_ClosesWindowWithoutMutingAgain() + { + Assert.True(CombinedEnvironmentStatePolicy.NeedsWindowChange(true, false)); + Assert.False(CombinedEnvironmentStatePolicy.NeedsSoundChange(true, false, false, false)); + Assert.False(CombinedEnvironmentStatePolicy.IsInTargetState( + true, true, false, false, false, targetSoundState: false)); + Assert.True(CombinedEnvironmentStatePolicy.IsInTargetState( + false, true, false, false, false, targetSoundState: false)); + } + + [Fact] + public void EnablingRainSounds_WhenWindowAlreadyOpen_ChangesOnlySound() + { + Assert.False(CombinedEnvironmentStatePolicy.NeedsWindowChange(true, true)); + Assert.True(CombinedEnvironmentStatePolicy.NeedsSoundChange(true, false, false, true)); + Assert.False(CombinedEnvironmentStatePolicy.IsInTargetState( + true, true, false, false, true, targetSoundState: true)); + Assert.True(CombinedEnvironmentStatePolicy.IsInTargetState( + true, true, true, true, true, targetSoundState: true)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ViewOnlyScenery_DoesNotRequireSound_WithEitherSoundTarget(bool soundTarget) + { + Assert.True(CombinedEnvironmentStatePolicy.IsInTargetState( + true, false, false, false, true, targetSoundState: soundTarget)); + Assert.False(CombinedEnvironmentStatePolicy.NeedsSoundChange(false, false, false, soundTarget)); + } + [Fact] public void Closing_WhenOnlySoundRemains_ChangesOnlySound() { diff --git a/RealTimeWeatherMod/ChillEnvPlugin.cs b/RealTimeWeatherMod/ChillEnvPlugin.cs index 54a56b7..efc2c1d 100644 --- a/RealTimeWeatherMod/ChillEnvPlugin.cs +++ b/RealTimeWeatherMod/ChillEnvPlugin.cs @@ -46,6 +46,7 @@ public class ChillEnvPlugin : BaseUnityPlugin internal static ConfigEntry Cfg_EnableEasterEggs; internal static ConfigEntry Cfg_EnableAmbientSounds; + internal static ConfigEntry Cfg_EnablePrecipitationSounds; internal static ConfigEntry Cfg_CloseWindowWhenRaining; // 调试配置 @@ -130,7 +131,8 @@ private void InitConfig() Cfg_DetailedTimeSegments = Config.Bind("UI", "DetailedTimeSegments", true, "开启12小时制时,显示详细时段(凌晨/清晨/上午等)"); Cfg_EnableEasterEggs = Config.Bind("Automation", "EnableSeasonalEasterEggs", true, "启用季节性景色彩蛋自动托管"); - Cfg_EnableAmbientSounds = Config.Bind("Automation", "EnableAmbientSounds", false, "启用环境音效自动托管"); + Cfg_EnableAmbientSounds = Config.Bind("Automation", "EnableAmbientSounds", false, "启用做饭、空调、蝉鸣环境音效自动托管(不控制雨声)"); + Cfg_EnablePrecipitationSounds = Config.Bind("Automation", "EnablePrecipitationSounds", true, "天气同步时开启小雨、大雨、雷雨声音;关闭时只保留雨景,不影响其他音效;天气同步开启时,设置面板切换立即更新当前雨声"); Cfg_CloseWindowWhenRaining = Config.Bind( "Automation", "CloseWindowWhenRaining", diff --git a/RealTimeWeatherMod/Core/GameEnvironmentRuntime.cs b/RealTimeWeatherMod/Core/GameEnvironmentRuntime.cs index 7f9c87f..cd61f98 100644 --- a/RealTimeWeatherMod/Core/GameEnvironmentRuntime.cs +++ b/RealTimeWeatherMod/Core/GameEnvironmentRuntime.cs @@ -153,27 +153,35 @@ private bool InvokeBase(EnvironmentType target, EnvironmentApplyMode mode) internal bool TryApplySceneryState( EnvironmentType environmentType, bool targetState, - EnvironmentApplyMode mode) + EnvironmentApplyMode mode, + bool applyPrecipitationSoundSetting = true) { + bool mutePrecipitation = applyPrecipitationSoundSetting && IsPrecipitation(environmentType) && + !(ChillEnvPlugin.Cfg_EnablePrecipitationSounds?.Value ?? true); + bool targetSoundState = targetState && !mutePrecipitation; if (TryGetController(environmentType, out var controller)) { - if (TryIsControllerInTargetState(controller, targetState, out var isInTargetState)) + if (TryIsControllerInTargetState(controller, targetState, targetSoundState, out var isInTargetState)) { - if (isInTargetState || TrySetControllerCombinedState(controller, targetState)) + if (isInTargetState || TrySetControllerCombinedState(controller, targetState, targetSoundState)) { return true; } } - if (mode == EnvironmentApplyMode.Interactive) + if (mode == EnvironmentApplyMode.Interactive && !mutePrecipitation) { return TrySimulateMainIconClick(environmentType); } } - if (mode == EnvironmentApplyMode.EarlyStartup) + // The main icon toggles BOTH view and sound, so it cannot represent silent rain. + // Use the direct window path and reconcile live sound if a controller exists. + if (mode == EnvironmentApplyMode.EarlyStartup || mutePrecipitation) { - return TryApplySceneryStateDirect(environmentType, targetState); + bool windowApplied = TryApplySceneryStateDirect(environmentType, targetState, targetSoundState); + bool soundApplied = !mutePrecipitation || TrySetAmbientSoundState(environmentType, false, out _); + return windowApplied && soundApplied; } if (TryIsWindowActive(environmentType, out var isActive) && isActive == targetState) @@ -184,6 +192,44 @@ internal bool TryApplySceneryState( return TrySimulateMainIconClick(environmentType); } + internal void RefreshPrecipitationSounds() + { + if (!ChillEnvPlugin.Cfg_EnableWeatherSync.Value || + ChillEnvPlugin.IsInCutscene() || SceneryAutomationSystem.IsWhaleSystemTriggered) + { + return; + } + + bool soundsEnabled = ChillEnvPlugin.Cfg_EnablePrecipitationSounds.Value; + foreach (var environmentType in ModEnvironmentMap.SceneryEnvironments) + { + if (!IsPrecipitation(environmentType)) continue; + + // Preserve the current view, including rain that is still fading out. + if (TryGetController(environmentType, out var controller) && + TryGetControllerCombinedState(controller, out var windowActive, out _, out _, out _) && + TrySetControllerCombinedState(controller, windowActive, windowActive && soundsEnabled)) + { + continue; + } + + if (TryIsWindowActive(environmentType, out var fallbackWindowActive) && + TrySetAmbientSoundState(environmentType, fallbackWindowActive && soundsEnabled, out _)) + { + continue; + } + + ChillEnvPlugin.Log?.LogWarning($"[环境运行时] 无法立即更新降水声音: {environmentType}"); + } + } + + private static bool IsPrecipitation(EnvironmentType environmentType) + { + return environmentType == EnvironmentType.LightRain || + environmentType == EnvironmentType.HeavyRain || + environmentType == EnvironmentType.ThunderRain; + } + internal ModEnvironment? GetActiveRain() { if (TryIsWindowActive(EnvironmentType.ThunderRain, out var thunder) && thunder) @@ -535,7 +581,8 @@ internal bool TryGetRuntimeServices( } } - private bool TryApplySceneryStateDirect(EnvironmentType environmentType, bool targetState) + private bool TryApplySceneryStateDirect( + EnvironmentType environmentType, bool targetState, bool targetSoundState) { if (!TryGetRuntimeServices(out _, out var windowViewService, out var environmentDataService) || !EnsureDirectSceneryMetadata(windowViewService, environmentDataService) || @@ -570,12 +617,12 @@ private bool TryApplySceneryStateDirect(EnvironmentType environmentType, bool ta environmentType.ToString(), out var ambientEnumValue) && TryGetDirectSoundActive(environmentDataService, ambientEnumValue, out var isSoundActive) && - isSoundActive != targetState) + isSoundActive != targetSoundState) { - TryInvoke( + return TryInvoke( _setMuteMethod, environmentDataService, - new object[] { ambientEnumValue, !targetState }, + new object[] { ambientEnumValue, !targetSoundState }, "[环境运行时] 启动期环境音状态写入失败"); } @@ -652,6 +699,7 @@ private bool TryGetDirectSoundActive( private bool TryIsControllerInTargetState( EnvironmentController controller, bool targetState, + bool targetSoundState, out bool isInTargetState) { isInTargetState = false; @@ -670,11 +718,13 @@ private bool TryIsControllerInTargetState( hasSound, soundActive, liveSoundActive, - targetState); + targetState, + targetSoundState); return true; } - private bool TrySetControllerCombinedState(EnvironmentController controller, bool targetState) + private bool TrySetControllerCombinedState( + EnvironmentController controller, bool targetState, bool targetSoundState) { if (!TryGetControllerCombinedState( controller, @@ -693,7 +743,7 @@ private bool TrySetControllerCombinedState(EnvironmentController controller, boo hasSound, soundActive, liveSoundActive, - targetState); + targetSoundState); if (!needsWindowChange && !needsSoundChange) { return true; @@ -705,7 +755,7 @@ private bool TrySetControllerCombinedState(EnvironmentController controller, boo MethodInfo changeMuteMethod = needsSoundChange ? AccessTools.Method( controller.GetType(), - targetState ? "MuteDeactivate" : "MuteActivate") + targetSoundState ? "MuteDeactivate" : "MuteActivate") : null; if ((needsWindowChange && changeWindowMethod == null) || (needsSoundChange && changeMuteMethod == null)) @@ -769,7 +819,11 @@ private bool TryGetControllerCombinedState( FieldInfo ambientBehaviorField = AccessTools.Field( controller.GetType(), "_ambientSoundBehavior"); - object ambientBehavior = ambientBehaviorField?.GetValue(controller); + if (ambientBehaviorField == null) + { + return false; + } + object ambientBehavior = ambientBehaviorField.GetValue(controller); hasSound = ambientBehavior != null; if (!hasSound) { diff --git a/RealTimeWeatherMod/Core/WeatherDebugControl.cs b/RealTimeWeatherMod/Core/WeatherDebugControl.cs index 630c4ae..8f7bc85 100644 --- a/RealTimeWeatherMod/Core/WeatherDebugControl.cs +++ b/RealTimeWeatherMod/Core/WeatherDebugControl.cs @@ -101,7 +101,8 @@ internal static void StopManualPreview() runtime.TryApplyBaseImmediately(_previewBase.Value, EnvironmentApplyMode.Interactive); if (!ChillEnvPlugin.Cfg_EnableWeatherSync.Value) foreach (var pair in _previewScenery) - runtime.TryApplySceneryState(pair.Key, pair.Value, EnvironmentApplyMode.Interactive); + runtime.TryApplySceneryState(pair.Key, pair.Value, EnvironmentApplyMode.Interactive, + applyPrecipitationSoundSetting: false); } _previewBase = null; _previewScenery.Clear(); diff --git a/RealTimeWeatherMod/Patches/ModSettingsIntegration.cs b/RealTimeWeatherMod/Patches/ModSettingsIntegration.cs index cb9f8e7..d397bd9 100644 --- a/RealTimeWeatherMod/Patches/ModSettingsIntegration.cs +++ b/RealTimeWeatherMod/Patches/ModSettingsIntegration.cs @@ -147,7 +147,8 @@ private bool TryRegisterSettings() regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_UI", "Show Weather on UI", "UIに天気を表示", "日期栏天气" }); regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_DETAIL", "Detailed Segments", "詳細セグメント", "详细时段" }); regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_EGG", "Seasonal Scenery", "季節の景色", "季节性景色" }); - regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_AMBIENT", "Ambient Sounds", "環境音", "环境音效" }); + regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_AMBIENT", "Ambient Sound Automation", "環境音の自動管理", "环境音效托管" }); + regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_PRECIPITATION_SOUND", "Precipitation Sounds (Weather Sync)", "降水音(天気同期)", "降水声音(天气同步)" }); regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_RAIN_WINDOW", "Close Window in Rain", "雨天に窓を閉める", "下雨自动关窗" }); regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_CITY", "City (Seniverse)", "都市(Seniverse)", "城市 (心知天气)" }); regTransMethod.Invoke(managerInstance, new object[] { "ENV_SYNC_PROVIDER", "Weather Provider", "天気提供元", "天气数据源" }); @@ -239,7 +240,7 @@ private bool TryRegisterSettings() ChillEnvPlugin.Instance.Config.Save(); }); - string labelAmbient = hasTranslation ? "ENV_SYNC_AMBIENT" : "环境音效"; + string labelAmbient = hasTranslation ? "ENV_SYNC_AMBIENT" : "环境音效托管"; AddToggleSafe(managerInstance, managerType, labelAmbient, ChillEnvPlugin.Cfg_EnableAmbientSounds.Value, @@ -249,6 +250,16 @@ private bool TryRegisterSettings() ChillEnvPlugin.Instance.Config.Save(); }); + AddToggleSafe(managerInstance, managerType, + hasTranslation ? "ENV_SYNC_PRECIPITATION_SOUND" : "降水声音(天气同步)", + ChillEnvPlugin.Cfg_EnablePrecipitationSounds.Value, + (value) => + { + ChillEnvPlugin.Cfg_EnablePrecipitationSounds.Value = value; + ChillEnvPlugin.Instance.Config.Save(); + Core.GameEnvironmentRuntime.Shared.RefreshPrecipitationSounds(); + }); + AddToggleSafe(managerInstance, managerType, hasTranslation ? "ENV_SYNC_RAIN_WINDOW" : "下雨自动关窗", ChillEnvPlugin.Cfg_CloseWindowWhenRaining.Value, diff --git a/RealTimeWeatherMod/Pure/Core/CombinedEnvironmentStatePolicy.cs b/RealTimeWeatherMod/Pure/Core/CombinedEnvironmentStatePolicy.cs index de3e873..07fe8ff 100644 --- a/RealTimeWeatherMod/Pure/Core/CombinedEnvironmentStatePolicy.cs +++ b/RealTimeWeatherMod/Pure/Core/CombinedEnvironmentStatePolicy.cs @@ -7,14 +7,12 @@ internal static bool IsInTargetState( bool hasSound, bool soundActive, bool liveSoundActive, - bool targetState) + bool targetState, + bool? targetSoundState = null) { - if (targetState) - { - return windowActive && (!hasSound || soundActive); - } - - return !windowActive && (!hasSound || (!soundActive && !liveSoundActive)); + // Omitted sound targets retain the original coupled view/sound behavior. + return !NeedsWindowChange(windowActive, targetState) && + !NeedsSoundChange(hasSound, soundActive, liveSoundActive, targetSoundState ?? targetState); } internal static bool NeedsWindowChange(bool windowActive, bool targetState)