1
0
Fork 0

Compare commits

...

10 Commits

Author SHA1 Message Date
ivan tkachenko d02e594457 WIP: Add spawn rate patch to make the event more likely 2025-08-03 14:29:15 +03:00
ivan tkachenko 05749ff122 Add Animator and Audio to MineshaftStartTile 2025-08-03 00:31:07 +03:00
ivan tkachenko f131ad7148 Fix NarrowHallwayTile2x2 mineshaft lights flickering 2025-08-03 00:31:07 +03:00
ivan tkachenko f50989b5ae Refactor: Optimize DiscoBallManager to create and cache at start of round 2025-08-03 00:31:06 +03:00
ivan tkachenko 72adb9e713 Refactor: Fix up visibility and static modifiers, and other minor things 2025-08-02 16:25:45 +03:00
ivan tkachenko 76e9ca3595 Refactor: Make State an internal class of JesterPatch class 2025-08-02 16:12:44 +03:00
ivan tkachenko b6f2ca355b Refactor: Factor out displaying lyrics as a tip in its own method 2025-08-02 15:54:07 +03:00
ivan tkachenko 78370da460 Fix LEDHangingLight (GarageTile & PoolTile) lights flickering 2025-08-02 15:50:59 +03:00
ivan tkachenko 4d84a2d001 Fix multiple Light components per animator
Add them all to the allPoweredLights list,
not just the whatever first one was found.
2025-08-02 15:50:59 +03:00
ivan tkachenko 0eb02698eb Fix KitchenTile lights flickering 2025-08-02 01:04:12 +03:00
6 changed files with 412 additions and 102 deletions

View File

@ -2,6 +2,9 @@
## MuzikaGromche 13.37.9001 ## MuzikaGromche 13.37.9001
- Fixed more missing flickering behaviours for some animators controllers.
- Fixed some powered lights not fully turning off or flickering when there are multiple Light components per container.
- Improved performance by pre-loading certain assets at the start of round instead of at a timing-critical frame update.
## MuzikaGromche 13.37.1337 - Photosensitivity Warning Edition ## MuzikaGromche 13.37.1337 - Photosensitivity Warning Edition

View File

@ -1,4 +1,6 @@
using DunGen; using DunGen;
using HarmonyLib;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@ -7,54 +9,21 @@ using UnityEngine;
namespace MuzikaGromche namespace MuzikaGromche
{ {
public class DiscoBallManager : MonoBehaviour public static class DiscoBallManager
{ {
// A struct holding a disco ball container object and the name of a tile for which it was designed. // A struct holding a disco ball container object and the name of a tile for which it was designed.
public readonly record struct Data(string TileName, GameObject DiscoBallContainer) private readonly record struct TilePatch(string TileName, GameObject DiscoBallContainer)
{ {
// We are specifically looking for cloned tiles, not the original prototypes. // We are specifically looking for cloned tiles, not the original prototypes.
public readonly string TileCloneName = $"{TileName}(Clone)"; public readonly string TileCloneName = $"{TileName}(Clone)";
} }
public static readonly List<Data> Containers = []; private static TilePatch[] Patches = [];
private static readonly List<GameObject> InstantiatedContainers = [];
public static void Initialize() private static readonly List<GameObject> CachedDiscoBalls = [];
{ private static readonly List<Animator> CachedDiscoBallAnimators = [];
string bundlePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "muzikagromche_discoball");
var bundle = AssetBundle.LoadFromFile(bundlePath);
foreach ((string prefabPath, string tileName) in new[] { private static readonly string[] AnimatorContainersNames = [
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerManor.prefab", "ManorStartRoomSmall"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerManorOLD.prefab", "ManorStartRoom"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerFactory.prefab", "StartRoom"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerMineShaft.prefab", "MineshaftStartTile"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerLargeForkTileB.prefab", "LargeForkTileB"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerBirthdayRoomTile.prefab", "BirthdayRoomTile"),
})
{
var container = bundle.LoadAsset<GameObject>(prefabPath);
Containers.Add(new(tileName, container));
}
}
public static void Enable()
{
// Just in case
Disable();
var query = from tile in Resources.FindObjectsOfTypeAll<Tile>()
join container in Containers
on tile.gameObject.name equals container.TileCloneName
select (tile, container);
foreach (var (tile, container) in query)
{
Enable(tile, container);
}
}
private static readonly string[] animatorNames = [
"DiscoBallProp/AnimContainer", "DiscoBallProp/AnimContainer",
"DiscoBallProp1/AnimContainer", "DiscoBallProp1/AnimContainer",
"DiscoBallProp2/AnimContainer", "DiscoBallProp2/AnimContainer",
@ -63,29 +32,134 @@ namespace MuzikaGromche
"DiscoBallProp5/AnimContainer", "DiscoBallProp5/AnimContainer",
]; ];
private static void Enable(Tile tile, Data container) public static void Load()
{ {
Debug.Log($"{nameof(MuzikaGromche)} {nameof(DiscoBallManager)} Enabling at '{tile.gameObject.name}'"); const string BundleFileName = "muzikagromche_discoball";
var discoBall = Instantiate(container.DiscoBallContainer, tile.transform); string bundlePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), BundleFileName);
InstantiatedContainers.Add(discoBall); var assetBundle = AssetBundle.LoadFromFile(bundlePath)
?? throw new NullReferenceException("Failed to load bundle");
foreach (var animatorName in animatorNames) (string PrefabPath, string TileName)[] patchDescriptors =
[
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerManor.prefab", "ManorStartRoomSmall"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerManorOLD.prefab", "ManorStartRoom"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerFactory.prefab", "StartRoom"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerMineShaft.prefab", "MineshaftStartTile"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerLargeForkTileB.prefab", "LargeForkTileB"),
("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerBirthdayRoomTile.prefab", "BirthdayRoomTile"),
];
Patches = [.. patchDescriptors.Select(d =>
new TilePatch(d.TileName, assetBundle.LoadAsset<GameObject>(d.PrefabPath))
)];
}
internal static void Patch(Tile tile)
{ {
if (discoBall.transform.Find(animatorName)?.gameObject is GameObject animator) var query = from patch in Patches
where tile.gameObject.name == patch.TileCloneName
select patch;
// Should be just one, but FirstOrDefault() isn't usable with structs
foreach (var patch in query)
{ {
animator.GetComponent<Animator>().SetBool("on", true); Patch(tile, patch);
} }
} }
static void Patch(Tile tile, TilePatch patch)
{
var discoBall = UnityEngine.Object.Instantiate(patch.DiscoBallContainer, tile.transform);
if (discoBall == null)
{
return;
}
foreach (var animator in FindDiscoBallAnimators(discoBall))
{
CachedDiscoBallAnimators.Add(animator);
}
CachedDiscoBalls.Add(discoBall);
discoBall.SetActive(false);
Debug.Log($"{nameof(MuzikaGromche)} {nameof(DiscoBallManager)} Patched tile '{tile.gameObject.name}'");
}
static IEnumerable<Animator> FindDiscoBallAnimators(GameObject discoBall)
{
foreach (var animatorContainerName in AnimatorContainersNames)
{
var transform = discoBall.transform.Find(animatorContainerName);
if (transform == null)
{
// Not all prefabs have all possible animators, and it's OK
continue;
}
var animator = transform.gameObject?.GetComponent<Animator>();
if (animator == null)
{
// This would be weird
continue;
}
yield return animator;
}
}
public static void Toggle(bool on)
{
Debug.Log($"{nameof(MuzikaGromche)} {nameof(DiscoBallManager)} Toggle {(on ? "ON" : "OFF")} {CachedDiscoBallAnimators.Count} animators");
foreach (var discoBall in CachedDiscoBalls)
{
discoBall.SetActive(true);
}
foreach (var animator in CachedDiscoBallAnimators)
{
animator?.SetBool("on", on);
}
}
public static void Enable()
{
Toggle(true);
} }
public static void Disable() public static void Disable()
{ {
foreach (var discoBall in InstantiatedContainers) Toggle(false);
{
Debug.Log($"{nameof(MuzikaGromche)} {nameof(DiscoBallManager)}: Disabling {discoBall.name}");
Destroy(discoBall);
} }
InstantiatedContainers.Clear();
internal static void Clear()
{
Debug.Log($"{nameof(MuzikaGromche)} {nameof(DiscoBallManager)} Clearing {CachedDiscoBalls.Count} disco balls & {CachedDiscoBallAnimators.Count} animators");
CachedDiscoBallAnimators.Clear();
CachedDiscoBalls.Clear();
}
}
[HarmonyPatch(typeof(Tile))]
static class DiscoBallTilePatch
{
[HarmonyPatch(nameof(Tile.AddTriggerVolume))]
[HarmonyPostfix]
static void OnAddTriggerVolume(Tile __instance)
{
DiscoBallManager.Patch(__instance);
}
}
[HarmonyPatch(typeof(RoundManager))]
static class DiscoBallDespawnPatch
{
[HarmonyPatch(nameof(RoundManager.DespawnPropsAtEndOfRound))]
[HarmonyPatch(nameof(RoundManager.OnDestroy))]
[HarmonyPrefix]
static void OnDestroy(RoundManager __instance)
{
var _ = __instance;
DiscoBallManager.Clear();
} }
} }
} }

View File

@ -41,7 +41,7 @@ namespace MuzikaGromche
.Select(a => $" Trying... {a}") .Select(a => $" Trying... {a}")
]; ];
public static Track[] Tracks = [ public static readonly Track[] Tracks = [
new Track new Track
{ {
Name = "MuzikaGromche", Name = "MuzikaGromche",
@ -482,8 +482,8 @@ namespace MuzikaGromche
return tracks[trackId]; return tracks[trackId];
} }
public static Track? CurrentTrack; internal static Track? CurrentTrack;
public static BeatTimeState? BeatTimeState; internal static BeatTimeState? BeatTimeState;
public static void SetLightColor(Color color) public static void SetLightColor(Color color)
{ {
@ -513,7 +513,14 @@ namespace MuzikaGromche
return distance <= AudioMaxDistance; return distance <= AudioMaxDistance;
} }
private void Awake() public static void DisplayLyrics(string text)
{
HUDManager.Instance.DisplayTip("[Lyrics]", text);
// Don't interrupt the music with constant HUD audio pings
HUDManager.Instance.UIAudio.Stop();
}
void Awake()
{ {
string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
UnityWebRequest[] requests = new UnityWebRequest[Tracks.Length * 2]; UnityWebRequest[] requests = new UnityWebRequest[Tracks.Length * 2];
@ -537,12 +544,16 @@ namespace MuzikaGromche
track.LoadedLoop = DownloadHandlerAudioClip.GetContent(requests[i * 2 + 1]); track.LoadedLoop = DownloadHandlerAudioClip.GetContent(requests[i * 2 + 1]);
} }
Config = new Config(base.Config); Config = new Config(base.Config);
DiscoBallManager.Initialize(); DiscoBallManager.Load();
PoweredLightsAnimators.Load(); PoweredLightsAnimators.Load();
var harmony = new Harmony(PluginInfo.PLUGIN_NAME); var harmony = new Harmony(PluginInfo.PLUGIN_NAME);
harmony.PatchAll(typeof(JesterPatch)); harmony.PatchAll(typeof(JesterPatch));
harmony.PatchAll(typeof(EnemyAIPatch)); harmony.PatchAll(typeof(EnemyAIPatch));
harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch)); harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch));
harmony.PatchAll(typeof(AllPoweredLightsPatch));
harmony.PatchAll(typeof(DiscoBallTilePatch));
harmony.PatchAll(typeof(DiscoBallDespawnPatch));
harmony.PatchAll(typeof(SpawnRatePatch));
} }
else else
{ {
@ -552,7 +563,7 @@ namespace MuzikaGromche
} }
}; };
public record Language(string Short, string Full) public readonly record struct Language(string Short, string Full)
{ {
public static readonly Language ENGLISH = new("EN", "English"); public static readonly Language ENGLISH = new("EN", "English");
public static readonly Language RUSSIAN = new("RU", "Russian"); public static readonly Language RUSSIAN = new("RU", "Russian");
@ -577,9 +588,9 @@ namespace MuzikaGromche
? Mathf.Pow(2f, 20f * x - 10f) / 2f ? Mathf.Pow(2f, 20f * x - 10f) / 2f
: (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f); : (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f);
public static Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo]; public static readonly Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo];
public static string[] AllNames => [.. All.Select(easing => easing.Name)]; public static readonly string[] AllNames = [.. All.Select(easing => easing.Name)];
public static Easing FindByName(string Name) public static Easing FindByName(string Name)
{ {
@ -592,9 +603,9 @@ namespace MuzikaGromche
} }
} }
public record Palette(Color[] Colors) public readonly record struct Palette(Color[] Colors)
{ {
public static Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]); public static readonly Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]);
public static Palette Parse(string[] hexColors) public static Palette Parse(string[] hexColors)
{ {
@ -776,7 +787,7 @@ namespace MuzikaGromche
} }
} }
public readonly record struct BeatTimestamp readonly record struct BeatTimestamp
{ {
// Number of beats in the loop audio segment. // Number of beats in the loop audio segment.
public readonly int LoopBeats; public readonly int LoopBeats;
@ -826,7 +837,7 @@ namespace MuzikaGromche
} }
} }
public readonly record struct BeatTimeSpan readonly record struct BeatTimeSpan
{ {
public readonly int LoopBeats; public readonly int LoopBeats;
public readonly float HalfLoopBeats => LoopBeats / 2f; public readonly float HalfLoopBeats => LoopBeats / 2f;
@ -976,7 +987,7 @@ namespace MuzikaGromche
} }
} }
public class BeatTimeState class BeatTimeState
{ {
// The object is newly created, the Start audio began to play but its time hasn't adjanced from 0.0f yet. // The object is newly created, the Start audio began to play but its time hasn't adjanced from 0.0f yet.
private bool hasStarted = false; private bool hasStarted = false;
@ -1243,9 +1254,9 @@ namespace MuzikaGromche
} }
} }
public abstract class BaseEvent; abstract class BaseEvent;
public class SetLightsColorEvent(Color color) : BaseEvent class SetLightsColorEvent(Color color) : BaseEvent
{ {
public readonly Color Color = color; public readonly Color Color = color;
public override string ToString() public override string ToString()
@ -1254,7 +1265,7 @@ namespace MuzikaGromche
} }
} }
public class SetLightsColorTransitionEvent(Color from, Color to, Easing easing, float t) class SetLightsColorTransitionEvent(Color from, Color to, Easing easing, float t)
: SetLightsColorEvent(Color.Lerp(from, to, Mathf.Clamp(easing.Eval(t), 0f, 1f))) : SetLightsColorEvent(Color.Lerp(from, to, Mathf.Clamp(easing.Eval(t), 0f, 1f)))
{ {
// Additional context for debugging // Additional context for debugging
@ -1268,12 +1279,12 @@ namespace MuzikaGromche
} }
} }
public class FlickerLightsEvent : BaseEvent class FlickerLightsEvent : BaseEvent
{ {
public override string ToString() => "Flicker"; public override string ToString() => "Flicker";
} }
public class LyricsEvent(string text) : BaseEvent class LyricsEvent(string text) : BaseEvent
{ {
public readonly string Text = text; public readonly string Text = text;
public override string ToString() public override string ToString()
@ -1282,14 +1293,14 @@ namespace MuzikaGromche
} }
} }
public class WindUpZeroBeatEvent : BaseEvent class WindUpZeroBeatEvent : BaseEvent
{ {
public override string ToString() => "WindUp"; public override string ToString() => "WindUp";
} }
// Default C#/.NET remainder operator % returns negative result for negative input // Default C#/.NET remainder operator % returns negative result for negative input
// which is unsuitable as an index for an array. // which is unsuitable as an index for an array.
public static class Mod static class Mod
{ {
public static int Positive(int x, int m) public static int Positive(int x, int m)
{ {
@ -1309,7 +1320,7 @@ namespace MuzikaGromche
} }
} }
public readonly struct RandomWeightedIndex readonly struct RandomWeightedIndex
{ {
public RandomWeightedIndex(int[] weights) public RandomWeightedIndex(int[] weights)
{ {
@ -1396,7 +1407,7 @@ namespace MuzikaGromche
readonly public int TotalWeights { get; } readonly public int TotalWeights { get; }
} }
public static class SyncedEntryExtensions static class SyncedEntryExtensions
{ {
// Update local values on clients. Even though the clients couldn't // Update local values on clients. Even though the clients couldn't
// edit them, they could at least see the new values. // edit them, they could at least see the new values.
@ -1409,7 +1420,7 @@ namespace MuzikaGromche
} }
} }
public class Config : SyncedConfig2<Config> class Config : SyncedConfig2<Config>
{ {
public static ConfigEntry<bool> DisplayLyrics { get; private set; } = null!; public static ConfigEntry<bool> DisplayLyrics { get; private set; } = null!;
@ -1417,6 +1428,8 @@ namespace MuzikaGromche
public static ConfigEntry<bool> SkipExplicitTracks { get; private set; } = null!; public static ConfigEntry<bool> SkipExplicitTracks { get; private set; } = null!;
public static SyncedEntry<bool> OverrideSpawnRates { get; private set; } = null!;
public static bool ShouldSkipWindingPhase { get; private set; } = false; public static bool ShouldSkipWindingPhase { get; private set; } = false;
public static Palette? PaletteOverride { get; private set; } = null; public static Palette? PaletteOverride { get; private set; } = null;
@ -1430,7 +1443,7 @@ namespace MuzikaGromche
public static float? ColorTransitionOutOverride { get; private set; } = null; public static float? ColorTransitionOutOverride { get; private set; } = null;
public static string? ColorTransitionEasingOverride { get; private set; } = null; public static string? ColorTransitionEasingOverride { get; private set; } = null;
public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID) internal Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID)
{ {
DisplayLyrics = configFile.Bind("General", "Display Lyrics", true, DisplayLyrics = configFile.Bind("General", "Display Lyrics", true,
new ConfigDescription("Display lyrics in the HUD tooltip when you hear the music.")); new ConfigDescription("Display lyrics in the HUD tooltip when you hear the music."));
@ -1445,6 +1458,11 @@ namespace MuzikaGromche
new ConfigDescription("When choosing tracks at random, skip the ones with Explicit Content/Lyrics.")); new ConfigDescription("When choosing tracks at random, skip the ones with Explicit Content/Lyrics."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(SkipExplicitTracks, requiresRestart: false)); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(SkipExplicitTracks, requiresRestart: false));
OverrideSpawnRates = configFile.BindSyncedEntry("General", "Override Spawn Rates", false,
new ConfigDescription("Deviate from vanilla spawn rates to experience content of this mod more often."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(OverrideSpawnRates.Entry, Default(new BoolCheckBoxOptions())));
CSyncHackAddSyncedEntry(OverrideSpawnRates);
#if DEBUG #if DEBUG
SetupEntriesToSkipWinding(configFile); SetupEntriesToSkipWinding(configFile);
SetupEntriesForPaletteOverride(configFile); SetupEntriesForPaletteOverride(configFile);
@ -1759,19 +1777,26 @@ namespace MuzikaGromche
// farAudio is during windup, Start overrides popGoesTheWeaselTheme // farAudio is during windup, Start overrides popGoesTheWeaselTheme
// creatureVoice is when popped, Loop overrides screamingSFX // creatureVoice is when popped, Loop overrides screamingSFX
[HarmonyPatch(typeof(JesterAI))] [HarmonyPatch(typeof(JesterAI))]
internal class JesterPatch static class JesterPatch
{ {
#if DEBUG #if DEBUG
[HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))]
[HarmonyPostfix] [HarmonyPostfix]
public static void AlmostInstantFollowTimerPostfix(JesterAI __instance) static void AlmostInstantFollowTimerPostfix(JesterAI __instance)
{ {
__instance.beginCrankingTimer = 1f; __instance.beginCrankingTimer = 1f;
} }
#endif #endif
class State
{
public required AudioSource farAudio;
public required int previousState;
}
[HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPatch(nameof(JesterAI.Update))]
[HarmonyPrefix] [HarmonyPrefix]
public static void DoNotStopTheMusicPrefix(JesterAI __instance, out State __state) static void JesterUpdatePrefix(JesterAI __instance, out State __state)
{ {
__state = new State __state = new State
{ {
@ -1793,7 +1818,7 @@ namespace MuzikaGromche
[HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPatch(nameof(JesterAI.Update))]
[HarmonyPostfix] [HarmonyPostfix]
public static void DoNotStopTheMusic(JesterAI __instance, State __state) static void JesterUpdatePostfix(JesterAI __instance, State __state)
{ {
if (__instance.previousState == 1 && __state.previousState != 1) if (__instance.previousState == 1 && __state.previousState != 1)
{ {
@ -1875,9 +1900,7 @@ namespace MuzikaGromche
case LyricsEvent e: case LyricsEvent e:
if (Plugin.LocalPlayerCanHearMusic(__instance)) if (Plugin.LocalPlayerCanHearMusic(__instance))
{ {
HUDManager.Instance.DisplayTip("[Lyrics]", e.Text); Plugin.DisplayLyrics(e.Text);
// Don't interrupt the music with constant HUD audio pings
HUDManager.Instance.UIAudio.Stop();
} }
break; break;
} }
@ -1887,13 +1910,13 @@ namespace MuzikaGromche
} }
[HarmonyPatch(typeof(EnemyAI))] [HarmonyPatch(typeof(EnemyAI))]
internal class EnemyAIPatch static class EnemyAIPatch
{ {
// JesterAI class does not override abstract method OnDestroy, // JesterAI class does not override abstract method OnDestroy,
// so we have to patch its superclass directly. // so we have to patch its superclass directly.
[HarmonyPatch(nameof(EnemyAI.OnDestroy))] [HarmonyPatch(nameof(EnemyAI.OnDestroy))]
[HarmonyPrefix] [HarmonyPrefix]
public static void CleanUpOnDestroy(EnemyAI __instance) static void CleanUpOnDestroy(EnemyAI __instance)
{ {
if (__instance is JesterAI) if (__instance is JesterAI)
{ {
@ -1905,10 +1928,4 @@ namespace MuzikaGromche
} }
} }
} }
internal class State
{
public required AudioSource farAudio;
public required int previousState;
}
} }

View File

@ -9,9 +9,17 @@ using UnityEngine;
namespace MuzikaGromche namespace MuzikaGromche
{ {
internal class PoweredLightsAnimators static class PoweredLightsAnimators
{ {
private readonly record struct AnimatorPatch(string AnimatorContainerPath, RuntimeAnimatorController AnimatorController); private const string PoweredLightTag = "PoweredLight";
private delegate void ManualPatch(GameObject animatorContainer);
private readonly record struct AnimatorPatch(
string AnimatorContainerPath,
RuntimeAnimatorController AnimatorController,
bool AddTagAndAnimator,
ManualPatch? ManualPatch);
private readonly record struct TilePatch(string TileName, AnimatorPatch[] Patches) private readonly record struct TilePatch(string TileName, AnimatorPatch[] Patches)
{ {
@ -19,13 +27,17 @@ namespace MuzikaGromche
public readonly string TileCloneName = $"{TileName}(Clone)"; public readonly string TileCloneName = $"{TileName}(Clone)";
} }
private readonly record struct AnimatorPatchDescriptor(string AnimatorContainerPath, string AnimatorControllerAssetPath) private readonly record struct AnimatorPatchDescriptor(
string AnimatorContainerPath,
string AnimatorControllerAssetPath,
bool AddTagAndAnimator = false,
ManualPatch? ManualPatch = null)
{ {
public AnimatorPatch Load(AssetBundle assetBundle) public AnimatorPatch Load(AssetBundle assetBundle)
{ {
var animationController = assetBundle.LoadAsset<RuntimeAnimatorController>(AnimatorControllerAssetPath) var animationController = assetBundle.LoadAsset<RuntimeAnimatorController>(AnimatorControllerAssetPath)
?? throw new FileNotFoundException($"RuntimeAnimatorController not found: {AnimatorControllerAssetPath}", AnimatorControllerAssetPath); ?? throw new FileNotFoundException($"RuntimeAnimatorController not found: {AnimatorControllerAssetPath}", AnimatorControllerAssetPath);
return new(AnimatorContainerPath, animationController); return new(AnimatorContainerPath, animationController, AddTagAndAnimator, ManualPatch);
} }
} }
@ -45,6 +57,10 @@ namespace MuzikaGromche
private static IDictionary<string, TilePatch> Patches = new Dictionary<string, TilePatch>(); private static IDictionary<string, TilePatch> Patches = new Dictionary<string, TilePatch>();
private static AudioClip AudioClipOn = null!;
private static AudioClip AudioClipOff = null!;
private static AudioClip AudioClipFlicker = null!;
public static void Load() public static void Load()
{ {
const string BundleFileName = "muzikagromche_poweredlightsanimators"; const string BundleFileName = "muzikagromche_poweredlightsanimators";
@ -52,16 +68,26 @@ namespace MuzikaGromche
var assetBundle = AssetBundle.LoadFromFile(bundlePath) var assetBundle = AssetBundle.LoadFromFile(bundlePath)
?? throw new NullReferenceException("Failed to load bundle"); ?? throw new NullReferenceException("Failed to load bundle");
AudioClipOn = assetBundle.LoadAsset<AudioClip>("Assets/LethalCompany/Mods/MuzikaGromche/AudioClips/LightOn.ogg");
AudioClipOff = assetBundle.LoadAsset<AudioClip>("Assets/LethalCompany/Mods/MuzikaGromche/AudioClips/LightOff.ogg");
AudioClipFlicker = assetBundle.LoadAsset<AudioClip>("Assets/LethalCompany/Mods/MuzikaGromche/AudioClips/LightFlicker.ogg");
const string BasePath = "Assets/LethalCompany/Mods/MuzikaGromche/AnimatorControllers/"; const string BasePath = "Assets/LethalCompany/Mods/MuzikaGromche/AnimatorControllers/";
const string PointLight4 = $"{BasePath}Point Light (4) (Patched).controller"; const string PointLight4 = $"{BasePath}Point Light (4) (Patched).controller";
const string MineshaftSpotlight = $"{BasePath}MineshaftSpotlight (Patched).controller"; const string MineshaftSpotlight = $"{BasePath}MineshaftSpotlight (Patched).controller";
const string LightbulbsLine = $"{BasePath}lightbulbsLineMesh (Patched).controller"; const string LightbulbsLine = $"{BasePath}lightbulbsLineMesh (Patched).controller";
const string CeilingFan = $"{BasePath}CeilingFan (originally GameObject) (Patched).controller"; const string CeilingFan = $"{BasePath}CeilingFan (originally GameObject) (Patched).controller";
const string LEDHangingLight = $"{BasePath}LEDHangingLight (Patched).controller";
const string MineshaftStartTileSpotlight = $"{BasePath}MineshaftStartTileSpotlight (New).controller";
TilePatchDescriptor[] descriptors = TilePatchDescriptor[] descriptors =
[ [
// any version
new("KitchenTile", [
new("PoweredLightTypeB", PointLight4),
new("PoweredLightTypeB (1)", PointLight4),
]),
// < v70 // < v70
new("ManorStartRoom", [ new("ManorStartRoom", [
new("ManorStartRoom/Chandelier/PoweredLightTypeB (1)", PointLight4), new("ManorStartRoom/Chandelier/PoweredLightTypeB (1)", PointLight4),
@ -72,6 +98,10 @@ namespace MuzikaGromche
new("ManorStartRoomMesh/Chandelier/PoweredLightTypeB (1)", PointLight4), new("ManorStartRoomMesh/Chandelier/PoweredLightTypeB (1)", PointLight4),
new("ManorStartRoomMesh/Chandelier2/PoweredLightTypeB", PointLight4), new("ManorStartRoomMesh/Chandelier2/PoweredLightTypeB", PointLight4),
]), ]),
new("NarrowHallwayTile2x2", [
new("MineshaftSpotlight (1)", MineshaftSpotlight),
new("MineshaftSpotlight (2)", MineshaftSpotlight),
]),
new("BirthdayRoomTile", [ new("BirthdayRoomTile", [
new("Lights/MineshaftSpotlight", MineshaftSpotlight), new("Lights/MineshaftSpotlight", MineshaftSpotlight),
]), ]),
@ -83,6 +113,21 @@ namespace MuzikaGromche
new("CeilingFanAnimContainer", CeilingFan), new("CeilingFanAnimContainer", CeilingFan),
new("MineshaftSpotlight (1)", MineshaftSpotlight), new("MineshaftSpotlight (1)", MineshaftSpotlight),
]), ]),
new("GarageTile", [
new("HangingLEDBarLight (3)", LEDHangingLight),
new("HangingLEDBarLight (4)", LEDHangingLight,
// This HangingLEDBarLight's IndirectLight is wrongly named, so animator couldn't find it
ManualPatch: RenameGameObjectPatch("IndirectLight (1)", "IndirectLight")),
]),
new("PoolTile", [
new("PoolLights/HangingLEDBarLight", LEDHangingLight),
new("PoolLights/HangingLEDBarLight (4)", LEDHangingLight),
new("PoolLights/HangingLEDBarLight (5)", LEDHangingLight),
]),
new("MineshaftStartTile", [
new("Cylinder.001 (1)", MineshaftStartTileSpotlight, AddTagAndAnimator: true),
new("Cylinder.001 (2)", MineshaftStartTileSpotlight, AddTagAndAnimator: true),
]),
]; ];
Patches = descriptors Patches = descriptors
@ -112,7 +157,41 @@ namespace MuzikaGromche
#pragma warning restore CS0162 // Unreachable code detected #pragma warning restore CS0162 // Unreachable code detected
} }
var animator = animationContainerTransform.gameObject.GetComponent<Animator>(); GameObject animationContainer = animationContainerTransform.gameObject;
Animator animator = animationContainer.GetComponent<Animator>();
if (patch.AddTagAndAnimator)
{
animationContainer.tag = PoweredLightTag;
if (animator == null)
{
animator = animationContainer.AddComponent<Animator>();
}
if (!animationContainer.TryGetComponent<PlayAudioAnimationEvent>(out var audioScript))
{
audioScript = animationContainer.AddComponent<PlayAudioAnimationEvent>();
audioScript.audioClip = AudioClipOn;
audioScript.audioClip2 = AudioClipOff;
audioScript.audioClip3 = AudioClipFlicker;
}
if (!animationContainer.TryGetComponent<AudioSource>(out var audioSource))
{
// Copy from an existing AudioSource of another light animator
var otherSource = tile.gameObject.GetComponentInChildren<AudioSource>();
if (otherSource != null)
{
audioSource = animationContainer.AddComponent<AudioSource>();
audioSource.spatialBlend = 1;
audioSource.playOnAwake = false;
audioSource.outputAudioMixerGroup = otherSource.outputAudioMixerGroup;
audioSource.spread = otherSource.spread;
audioSource.rolloffMode = otherSource.rolloffMode;
audioSource.maxDistance = otherSource.maxDistance;
audioSource.SetCustomCurve(AudioSourceCurveType.CustomRolloff, otherSource.GetCustomCurve(AudioSourceCurveType.CustomRolloff));
}
}
}
if (animator == null) if (animator == null)
{ {
#if DEBUG #if DEBUG
@ -123,21 +202,89 @@ namespace MuzikaGromche
#pragma warning restore CS0162 // Unreachable code detected #pragma warning restore CS0162 // Unreachable code detected
} }
patch.ManualPatch?.Invoke(animationContainer);
animator.runtimeAnimatorController = patch.AnimatorController; animator.runtimeAnimatorController = patch.AnimatorController;
Debug.Log($"{nameof(MuzikaGromche)} {nameof(PoweredLightsAnimatorsPatch)} {tilePatch.TileName}/{patch.AnimatorContainerPath}: Replaced animator controller"); Debug.Log($"{nameof(MuzikaGromche)} {nameof(PoweredLightsAnimatorsPatch)} {tilePatch.TileName}/{patch.AnimatorContainerPath}: Replaced animator controller");
} }
} }
} }
private static ManualPatch RenameGameObjectPatch(string relativePath, string newName) => animatorContainer =>
{
var targetObject = animatorContainer.transform.Find(relativePath)?.gameObject;
if (targetObject == null)
{
#if DEBUG
throw new NullReferenceException($"{animatorContainer.name}/{relativePath}: GameObject not found!");
#endif
#pragma warning disable CS0162 // Unreachable code detected
return;
#pragma warning restore CS0162 // Unreachable code detected
}
targetObject.name = newName;
Debug.Log($"{nameof(MuzikaGromche)} {nameof(PoweredLightsAnimatorsPatch)} {animatorContainer.name}/{relativePath}: Renamed GameObject");
};
} }
[HarmonyPatch(typeof(Tile))] [HarmonyPatch(typeof(Tile))]
internal class PoweredLightsAnimatorsPatch static class PoweredLightsAnimatorsPatch
{ {
[HarmonyPatch("AddTriggerVolume")] [HarmonyPatch(nameof(Tile.AddTriggerVolume))]
[HarmonyPostfix] [HarmonyPostfix]
public static void OnAddTriggerVolume(Tile __instance) static void OnAddTriggerVolume(Tile __instance)
{ {
PoweredLightsAnimators.Patch(__instance); PoweredLightsAnimators.Patch(__instance);
} }
} }
[HarmonyPatch(typeof(RoundManager))]
static class AllPoweredLightsPatch
{
// Vanilla method assumes that GameObjects with tag "PoweredLight" only contain a single Light component each.
// This is, however, not true for certains double-light setups, such as:
// - double PointLight (even though one of them is 'Point' another is 'Spot') inside CeilingFanAnimContainer in BedroomTile/BedroomTileB;
// - MineshaftSpotlight when it has not only `Point Light` but also `IndirectLight` in BirthdayRoomTile;
// - (maybe more?)
// In order to fix that, replace singular GetComponentInChildren<Light> with plural GetComponentsInChildren<Light> version.
[HarmonyPatch(nameof(RoundManager.RefreshLightsList))]
[HarmonyPrefix]
static bool OnRefreshLightsList(RoundManager __instance)
{
RefreshLightsListPatched(__instance);
// Skip the original method
return false;
}
static void RefreshLightsListPatched(RoundManager self)
{
// Reusable list to reduce allocations
List<Light> lights = [];
self.allPoweredLights.Clear();
self.allPoweredLightsAnimators.Clear();
GameObject[] gameObjects = GameObject.FindGameObjectsWithTag("PoweredLight");
if (gameObjects == null)
{
return;
}
foreach (var gameObject in gameObjects)
{
Animator animator = gameObject.GetComponentInChildren<Animator>();
if (!(animator == null))
{
self.allPoweredLightsAnimators.Add(animator);
// Patched section: Use list instead of singular GetComponentInChildren<Light>
gameObject.GetComponentsInChildren(includeInactive: true, lights);
self.allPoweredLights.AddRange(lights);
}
}
foreach (var animator in self.allPoweredLightsAnimators)
{
animator.SetFloat("flickerSpeed", UnityEngine.Random.Range(0.6f, 1.4f));
}
}
}
} }

View File

@ -0,0 +1,69 @@
using HarmonyLib;
using System;
using UnityEngine;
namespace MuzikaGromche
{
[HarmonyPatch(typeof(RoundManager))]
static class SpawnRatePatch
{
const string JesterEnemyName = "Jester";
// If set to null, do not override spawn chances. Otherwise, it is an index of Jester in RoundManager.currentLevel.Enemies
static int? JesterEnemyIndex = null;
static float? SpawnTime = null;
[HarmonyPatch(nameof(RoundManager.AssignRandomEnemyToVent))]
[HarmonyPrefix]
static void AssignRandomEnemyToVentPrefix(RoundManager __instance, EnemyVent vent, float spawnTime)
{
if (!Config.OverrideSpawnRates.Value)
{
return;
}
var index = __instance.currentLevel.Enemies.FindIndex(enemy => enemy.enemyType.enemyName == JesterEnemyName);
if (index == -1)
{
return;
}
JesterEnemyIndex = index;
SpawnTime = spawnTime;
}
[HarmonyPatch(nameof(RoundManager.AssignRandomEnemyToVent))]
[HarmonyPostfix]
static void AssignRandomEnemyToVentPostfix(RoundManager __instance, EnemyVent vent, float spawnTime)
{
JesterEnemyIndex = null;
SpawnTime = null;
}
[HarmonyPatch(nameof(RoundManager.GetRandomWeightedIndex))]
[HarmonyPostfix]
static void GetRandomWeightedIndexPostfix(RoundManager __instance, ref int[] weights, ref System.Random randomSeed)
{
if (JesterEnemyIndex is int index && SpawnTime is float spawnTime)
{
if (__instance.EnemyCannotBeSpawned(index))
{
return;
}
var minMultiplierTime = 3 * __instance.timeScript.lengthOfHours;
var maxMultiplierTime = 7 * __instance.timeScript.lengthOfHours;
var normalizedMultiplierTime = Math.Clamp((spawnTime - minMultiplierTime) / (maxMultiplierTime - minMultiplierTime), 0f, 1f);
// TODO: Try Expo function instead of Lerp?
var minMultiplier = Mathf.Max(1, __instance.minEnemiesToSpawn);
var maxMultiplier = 9 + __instance.minEnemiesToSpawn;
var multiplier = Mathf.Lerp(minMultiplier, maxMultiplier, normalizedMultiplierTime);
var newWeight = (int)(weights[index] * multiplier);
Debug.Log($"{nameof(MuzikaGromche)} Overriding Jester spawn weight {weights[index]} * {multiplier} => {newWeight}");
weights[index] = newWeight;
}
}
}
}