1
0
Fork 0

Compare commits

..

18 Commits

Author SHA1 Message Date
ivan tkachenko c6b580af5a Release v1337.9001.69 2026-04-06 07:50:51 +03:00
ivan tkachenko 47f42a5d49 Rewrite links in README 2026-04-06 07:50:06 +03:00
ivan tkachenko 3cdd99f57e When player dies, check for nearby Jester music source to show Game Over text 2026-04-06 07:50:06 +03:00
ivan tkachenko 8561834e22 Attempt to fix missing track selection 2026-04-06 07:50:06 +03:00
ivan tkachenko df0cfc16ff Fix Game Over Text resetting at the end of round
When the last player dies and should have their custom Game Over text
overridden, the mod previously would Clear() the override and destroy
the coroutine together with custom behaviour object.
2026-04-06 07:50:05 +03:00
ivan tkachenko b99e4c1e2c Select new random track groups every time 2026-04-06 07:50:05 +03:00
ivan tkachenko dfefc06abf Add tracks count getter to ISelectableTrack 2026-04-06 07:50:05 +03:00
ivan tkachenko 702eb9fccd Port to modern [Rpc] attribute
Method ChooseTrackServerRpc should never have been an RPC. Server will
invoke it when it's the right time. And only server was calling it
anyway.
2026-04-06 07:50:04 +03:00
ivan tkachenko 28f63eff3d Update NGO version to 1.12.2 which Lethal Company v81 uses 2026-04-06 07:50:04 +03:00
ivan tkachenko 7794e44831 Add new track Yesterday 2026-04-06 07:50:04 +03:00
ivan tkachenko 24bae0e370 Add stylized lyrics for HighLow 2026-04-06 05:38:55 +03:00
ivan tkachenko 871c608a33 Guard against null during shutdown 2026-04-06 05:38:55 +03:00
ivan tkachenko a71c61b5c7 Add support for v80 2026-04-05 23:33:07 +03:00
ivan tkachenko f55e2e8436 [v80] Use vanilla RefreshLightsList
Vanilla v80 changed three things about this patch:
- allPoweredLightsAnimators list became nullable, breaking the patch.
- implemented looking up the List<> of children, rendering the patch
  obsolete.
- added new logic for objects tagged with "IndirectLightSource", making
  the patch incomplete.

All in all, the patch is not needed anymore.
2026-04-05 23:33:06 +03:00
ivan tkachenko 7fb63e4718 Bump version of dependency WaterGun-V70PoweredLights_Fix to 1.2.1
Version 1.2.1 correctly applies animators patches, so this mod doesn't
have to include a copy anymore.
2026-04-05 23:33:06 +03:00
ivan tkachenko d600a3170f Add sources of patches Unity assets 2026-04-04 23:24:46 +03:00
ivan tkachenko 70bdcc7ecf README: Rewrite paragraph about HookahPlace 2026-04-04 23:24:28 +03:00
ivan tkachenko 2eef12048d README: Fix typo 2026-04-04 23:24:11 +03:00
21 changed files with 236 additions and 1338 deletions

BIN
Assets/YesterdayIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/YesterdayLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -1,9 +1,15 @@
# Changelog
## MuzikaGromche 1337.9001.69
## MuzikaGromche 1337.9001.69 - Six Seven Edition
- Added support for v80 (also known as "v81").
- Show real Artist & Song info in the config.
- Integrated visual effects with QMK/VIA RGB keyboard (specifically input modules for Framework Laptop 16).
- Added a new track Yesterday.
- Added lyrics to the existing track HighLow.
- New random track will be selected every time instead of having the same track all day.
- Groupped tracks (verses of the same song) are still played together in a sequence.
- Override Death Screen / Game Over text in more cases.
- Attempted to fix issue when Jester wouldn't select any track after the first one.
## MuzikaGromche 1337.9001.68 - LocalHost hotfix

View File

@ -1,5 +1,5 @@
<Project>
<Target Name="NetcodePatch" AfterTargets="PostBuildEvent">
<Exec Command="dotnet netcode-patch -uv 2022.3.62 -nv 1.12.0 &quot;$(TargetPath)&quot; @(ReferencePathWithRefAssemblies->'&quot;%(Identity)&quot;', ' ')"/>
<Exec Command="dotnet netcode-patch -uv 2022.3.62 -nv 1.12.2 &quot;$(TargetPath)&quot; @(ReferencePathWithRefAssemblies->'&quot;%(Identity)&quot;', ' ')"/>
</Target>
</Project>

View File

@ -1,4 +1,5 @@
using System.Collections;
using GameNetcodeStuff;
using HarmonyLib;
using TMPro;
using UnityEngine;
@ -21,7 +22,12 @@ namespace MuzikaGromche
SetTextImpl(text ?? GameOverTextModdedDefault);
}
public static IEnumerator SetTextAndClear(string? text)
public static void SetTextAndClear(string? text)
{
HUDManager.Instance.StartCoroutine(SetTextAndClearImpl(text));
}
public static IEnumerator SetTextAndClearImpl(string? text)
{
SetText(text);
// Game Over animation duration is about 4.25 seconds
@ -60,7 +66,6 @@ namespace MuzikaGromche
[HarmonyPatch(typeof(RoundManager))]
static class DeathScreenGameOverTextResetPatch
{
[HarmonyPatch(nameof(RoundManager.DespawnPropsAtEndOfRound))]
[HarmonyPatch(nameof(RoundManager.OnDestroy))]
[HarmonyPrefix]
static void OnDestroy(RoundManager __instance)
@ -69,4 +74,29 @@ namespace MuzikaGromche
DeathScreenGameOverTextManager.Clear();
}
}
[HarmonyPatch(typeof(PlayerControllerB))]
static class PlayerControllerBKillPlayerPatch
{
// Use prefix to test distance from listener before the listener is reassigned
[HarmonyPatch(nameof(PlayerControllerB.KillPlayer))]
[HarmonyPrefix]
static void KillPlayerPrefix(PlayerControllerB __instance)
{
if (__instance.IsOwner && !__instance.isPlayerDead && __instance.AllowPlayerDeath())
{
// KILL LOCAL PLAYER
var list = Object.FindObjectsByType<EnemyAI>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
foreach (var jester in list)
{
if (jester.TryGetComponent<MuzikaGromcheJesterNetworkBehaviour>(out var behaviour) &&
behaviour.IsPlaying && Plugin.LocalPlayerCanHearMusic(jester))
{
behaviour.OverrideDeathScreenGameOverText();
break;
}
}
}
}
}
}

View File

@ -113,11 +113,17 @@ namespace MuzikaGromche
foreach (var discoBall in CachedDiscoBalls)
{
discoBall.SetActive(on);
if (discoBall != null)
{
discoBall.SetActive(on);
}
}
foreach (var animator in CachedDiscoBallAnimators)
{
animator?.SetBool("on", on);
if (animator != null)
{
animator.SetBool("on", on);
}
}
}

View File

@ -899,6 +899,97 @@ public static class Library
FadeOutDuration = 1.5f,
FlickerLightsTimeSeries = [-33, 39],
Lyrics = [
(-64, "Load up on guns"),
(-59, """
Load up on guns,
bring your friends
"""),
(-55, """
Load up on guns, It's
bring your friends fun
to lose
"""),
(-51, """
and
to
pretend
"""),
(-47, """
She's over-bored
"""),
(-43, """
She's over-bored
and self-assured
"""),
(-39, """
Oh no,
I know
"""),
(-35, """
Oh no,
I know
a dirty word
"""),
// 1
(-32, "Hello"),
(-30, """
HE LL O
"""),
(-28, """
H L
E L
O
"""),
(-26, """
W L O W
How W W W
W W
"""),
// 2
(-24, "Hello"),
(-22, """
L
H E L O
"""),
(-20, """
H LO
H E H L
H L OO
"""),
(-18, """
low
How
"""),
// 3
(-16, " Hello"),
(-14, """
L O
He LL
"""),
(-12, """
H H EEE L L OO
H<>h Ee L L O O
h h EEE LLL LLL OO
"""),
(-10, """
W w W
HOW w w W
W loW
"""),
// 4
( -8, "Hello"),
( -6, """
HE LL O
"""),
( -4, """
H L
L O
E
"""),
],
DrunknessLoopOffsetTimeSeries = new(
[-2f, -1f, 6f],
@ -1118,5 +1209,34 @@ public static class Library
[ 0f, 0.5f, 0f]),
GameOverText = "[LIFE SUPPORT: HEXTECH]",
},
new SelectableAudioTrack
{
Name = "Yesterday",
Artist = "BTS",
Song = "Not Today",
AudioType = AudioType.OGGVORBIS,
Language = Language.KOREAN,
WindUpTimer = 43.63f,
Bars = 16,
BeatsOffset = 0f,
ColorTransitionIn = 0.2f,
ColorTransitionOut = 0.3f,
ColorTransitionEasing = Easing.InOutExpo,
Palette = Palette.Parse([
"#F0FBFF", "#0B95FF", "#A8F5E2", "#B79BF2", "#fed2e1",
]),
LoopOffset = 0,
FadeOutBeat = -5f,
FadeOutDuration = 4f,
FlickerLightsTimeSeries = [-48.1f, -44.1f, -16, 15.9f, 31.9f],
Lyrics = [],
DrunknessLoopOffsetTimeSeries = new(
[-64, -63.5f, -58f, -34f, -31.75f, -22f, 0f, 0.25f, 6f],
[ 0f, 0.4f, 0f, 0f, 0.4f, 0f, 0f, 0.4f, 0f]),
CondensationLoopOffsetTimeSeries = new(
[32f, 32.25f, 38f],
[ 0f, 1f, 0f]),
GameOverText = "[LIFE SUPPORT: NOT TODAY]",
},
];
}

View File

@ -47,13 +47,15 @@
<PackageReference Include="BepInEx.AssemblyPublicizer.MSBuild" Version="0.4.1" PrivateAssets="all" Private="false" />
<PackageReference Include="AinaVT-LethalConfig" Version="1.4.6" PrivateAssets="all" Private="false" />
<PackageReference Include="TeamBMX.LobbyCompatibility" Version="1.*" PrivateAssets="all" Private="false" />
<PackageReference Include="HidSharp" Version="2.6.4" PrivateAssets="all" Private="false" GeneratePathProperty="true" />
</ItemGroup>
<ItemGroup>
<Reference Include="Assembly-CSharp" Publicize="true" Private="false">
<HintPath>$(LethalCompanyDir)Lethal Company_Data\Managed\Assembly-CSharp.dll</HintPath>
</Reference>
<Reference Include="DunGen" Publicize="true" Private="false">
<HintPath>$(LethalCompanyDir)Lethal Company_Data\Managed\DunGen.dll</HintPath>
</Reference>
<Reference Include="Unity.Collections" Private="false">
<HintPath>$(LethalCompanyDir)Lethal Company_Data\Managed\Unity.Collections.dll</HintPath>
</Reference>
@ -90,9 +92,7 @@
<PackagedResources Include="$(SolutionDir)icon.png" />
<PackagedResources Include="$(SolutionDir)manifest.json" />
<PackagedResources Include="$(ProjectDir)UnityAssets\muzikagromche_discoball" />
<PackagedResources Include="$(ProjectDir)UnityAssets\muzikagromche_poweredlightsanimators" />
<PackagedResources Include="$(TargetDir)$(AssemblyName).dll" />
<PackagedResources Include="$(PkgHidSharp)\lib\netstandard2.0\*.dll" />
</ItemGroup>
<ItemGroup>

View File

@ -35,12 +35,8 @@ namespace MuzikaGromche
private static int GetCurrentSeed()
{
var seed = 0;
var roundManager = RoundManager.Instance;
if (roundManager != null && roundManager.dungeonGenerator != null)
{
seed = roundManager.dungeonGenerator.Generator.ChosenSeed;
}
var rng = new System.Random(unchecked((int)DateTime.Now.Ticks));
var seed = rng.Next();
return seed;
}
@ -158,19 +154,17 @@ namespace MuzikaGromche
#endif
Config = new Config(base.Config);
DiscoBallManager.Load();
PoweredLightsAnimators.Load();
Harmony = new Harmony(MyPluginInfo.PLUGIN_NAME);
Harmony.PatchAll(typeof(GameNetworkManagerPatch));
Harmony.PatchAll(typeof(JesterPatch));
Harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch));
Harmony.PatchAll(typeof(AllPoweredLightsPatch));
Harmony.PatchAll(typeof(DiscoBallTilePatch));
Harmony.PatchAll(typeof(DiscoBallDespawnPatch));
Harmony.PatchAll(typeof(SpawnRatePatch));
Harmony.PatchAll(typeof(DeathScreenGameOverTextResetPatch));
Harmony.PatchAll(typeof(PlayerControllerBKillPlayerPatch));
Harmony.PatchAll(typeof(ScreenFiltersManager.HUDManagerScreenFiltersPatch));
Harmony.PatchAll(typeof(ClearAudioClipCachePatch));
Harmony.PatchAll(typeof(Via.ViaFlickerLightsPatch));
NetcodePatcher();
Compatibility.Register(this);
}
@ -348,6 +342,8 @@ namespace MuzikaGromche
internal IAudioTrack[] GetTracks();
internal int Count() => GetTracks().Length;
// Index is a non-negative monotonically increasing number of times
// this ISelectableTrack has been played for this Jester on this day.
// A group of tracks can use this index to rotate tracks sequentially.
@ -591,6 +587,8 @@ namespace MuzikaGromche
IAudioTrack[] ISelectableTrack.GetTracks() => [this];
int ISelectableTrack.Count() => 1;
IAudioTrack ISelectableTrack.SelectTrack(int index) => this;
void ISelectableTrack.Debug()
@ -615,6 +613,8 @@ namespace MuzikaGromche
IAudioTrack[] ISelectableTrack.GetTracks() => Tracks;
int ISelectableTrack.Count() => Tracks.Length;
IAudioTrack ISelectableTrack.SelectTrack(int index)
{
if (Tracks.Length == 0)
@ -1329,7 +1329,6 @@ namespace MuzikaGromche
{
// Calculate final color, substituting null with initialColor if needed.
public abstract Color GetColor(Color initialColor);
public abstract Color? GetNullableColor();
protected string NullableColorToString(Color? color)
{
@ -1346,11 +1345,6 @@ namespace MuzikaGromche
return Color ?? initialColor;
}
public override Color? GetNullableColor()
{
return Color;
}
public override string ToString()
{
return $"Color(#{NullableColorToString(Color)})";
@ -1372,7 +1366,7 @@ namespace MuzikaGromche
return Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f));
}
public override Color? GetNullableColor()
private Color? GetNullableColor()
{
return From is { } from && To is { } to ? Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)) : null;
}
@ -2052,11 +2046,6 @@ namespace MuzikaGromche
const string IntroAudioGameObjectName = "MuzikaGromcheAudio (Intro)";
const string LoopAudioGameObjectName = "MuzikaGromcheAudio (Loop)";
// Number of times a selected track has been played.
// Increases by 1 with each ChooseTrackServerRpc call.
// Resets on SettingChanged.
private int SelectedTrackIndex = 0;
internal IAudioTrack? CurrentTrack = null;
internal BeatTimeState? BeatTimeState = null;
internal AudioSource IntroAudioSource = null!;
@ -2101,8 +2090,6 @@ namespace MuzikaGromche
public override void OnDestroy()
{
Config.Volume.SettingChanged -= UpdateVolume;
DeathScreenGameOverTextManager.Clear();
Stop();
}
@ -2117,6 +2104,7 @@ namespace MuzikaGromche
public override void OnNetworkSpawn()
{
HostIsModded = IsServer;
ChooseTrackDeferred();
foreach (var track in Plugin.Tracks)
{
@ -2147,7 +2135,8 @@ namespace MuzikaGromche
private void ChooseTrackDeferredDelegate(object sender, EventArgs e)
{
SelectedTrackIndex = 0;
HostSelectableTrack = null;
HostSelectableTrackIndex = 0;
ChooseTrackDeferred();
}
@ -2168,7 +2157,8 @@ namespace MuzikaGromche
}
// Once host has set a track via RPC, it is considered modded, and expected to always set tracks, so never reset this flag back to false.
bool HostIsModded = false;
// Initialized to `IsServer` on network spawn. If I am the host, then I am modded, otherwise we'll find out later.
bool HostIsModded;
// Playing with modded host automatically disables vanilla compatability mode
public bool VanillaCompatMode => IsServer ? Config.VanillaCompatMode : !HostIsModded;
@ -2182,12 +2172,12 @@ namespace MuzikaGromche
if (Config.VanillaCompatMode)
{
// In vanilla compat mode no, matter whether you are a host or a client, you should skip networking anyway
// In vanilla compat mode, no matter whether you are a host or a client, you should skip networking anyway
ChooseTrackCompat();
}
else if (IsServer)
{
ChooseTrackServerRpc();
ChooseTrackOnServer();
}
else
{
@ -2204,7 +2194,7 @@ namespace MuzikaGromche
}
}
[ClientRpc]
[Rpc(SendTo.Everyone)]
void SetTrackClientRpc(string name)
{
Plugin.Log.LogDebug($"SetTrackClientRpc {name}");
@ -2227,14 +2217,25 @@ namespace MuzikaGromche
}
}
[ServerRpc]
void ChooseTrackServerRpc()
// Host selected this group of tracks, and
private ISelectableTrack? HostSelectableTrack = null;
// Number of times a selected track has been played.
// Increases by 1 with each ChooseTrackOnServer call.
// Resets on SettingChanged.
private int HostSelectableTrackIndex = 0;
void ChooseTrackOnServer()
{
var selectableTrack = Plugin.ChooseTrack();
var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex);
Plugin.Log.LogInfo($"ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}");
if (HostSelectableTrack == null || HostSelectableTrackIndex >= HostSelectableTrack.Count())
{
HostSelectableTrack = Plugin.ChooseTrack();
HostSelectableTrackIndex = 0;
}
var audioTrack = HostSelectableTrack.SelectTrack(HostSelectableTrackIndex);
Plugin.Log.LogInfo($"ChooseTrackOnServer {HostSelectableTrack.Name} #{HostSelectableTrackIndex} {audioTrack.Name}");
SetTrackClientRpc(audioTrack.Name);
SelectedTrackIndex += 1;
HostSelectableTrackIndex += 1;
}
void ChooseTrackCompat()
@ -2347,7 +2348,6 @@ namespace MuzikaGromche
internal void Stop()
{
PoweredLightsBehaviour.Instance.ResetLightColor();
Via.ViaBehaviour.Instance.Restore();
DiscoBallManager.Disable();
ScreenFiltersManager.Clear();
@ -2377,7 +2377,9 @@ namespace MuzikaGromche
// Just in case if players have spawned multiple Jesters,
// Don't reset Config.CurrentTrack to null,
// so that the latest chosen track remains set.
CurrentTrack = null;
// Don't reset MuzikaGromcheJesterNetworkBehaviour.CurrentTrack to null,
// because it may have already been set by host via RPC.
// CurrentTrack = null;
}
public void OverrideDeathScreenGameOverText()
@ -2387,7 +2389,7 @@ namespace MuzikaGromche
// Playing as a client with a host who doesn't have the mod
return;
}
StartCoroutine(DeathScreenGameOverTextManager.SetTextAndClear(CurrentTrack.GameOverText));
DeathScreenGameOverTextManager.SetTextAndClear(CurrentTrack.GameOverText);
}
}
@ -2557,18 +2559,9 @@ namespace MuzikaGromche
case SetLightsColorEvent e:
PoweredLightsBehaviour.Instance.SetLightColor(e);
if (localPlayerCanHearMusic && e.GetNullableColor() is { } color)
{
Via.ViaBehaviour.Instance.SetColor(color);
}
else
{
Via.ViaBehaviour.Instance.Restore();
}
break;
case FlickerLightsEvent:
// VIA is handled by a Harmony patch to integrate with all flickering events, not just from this mod.
RoundManager.Instance.FlickerLights(true);
break;

View File

@ -1,245 +1,9 @@
using DunGen;
using HarmonyLib;
using MuzikaGromche.Via;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEngine;
namespace MuzikaGromche
{
static class PoweredLightsAnimators
{
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)
{
// We are specifically looking for cloned tiles, not the original prototypes.
public readonly string TileCloneName = $"{TileName}(Clone)";
}
private readonly record struct AnimatorPatchDescriptor(
string AnimatorContainerPath,
string AnimatorControllerAssetPath,
bool AddTagAndAnimator = false,
ManualPatch? ManualPatch = null)
{
public AnimatorPatch Load(AssetBundle assetBundle)
{
var animationController = assetBundle.LoadAsset<RuntimeAnimatorController>(AnimatorControllerAssetPath)
?? throw new FileNotFoundException($"RuntimeAnimatorController not found: {AnimatorControllerAssetPath}", AnimatorControllerAssetPath);
return new(AnimatorContainerPath, animationController, AddTagAndAnimator, ManualPatch);
}
}
private readonly record struct TilePatchDescriptor(string[] TileNames, AnimatorPatchDescriptor[] Descriptors)
{
public TilePatchDescriptor(string TileName, AnimatorPatchDescriptor[] Descriptors)
: this([TileName], Descriptors)
{
}
public TilePatch[] Load(AssetBundle assetBundle)
{
var patches = Descriptors.Select(d => d.Load(assetBundle)).ToArray();
return [.. TileNames.Select(tileName => new TilePatch(tileName, patches))];
}
}
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()
{
const string BundleFileName = "muzikagromche_poweredlightsanimators";
string bundlePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), BundleFileName);
var assetBundle = AssetBundle.LoadFromFile(bundlePath)
?? 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 PointLight4 = $"{BasePath}Point Light (4) (Patched).controller";
const string MineshaftSpotlight = $"{BasePath}MineshaftSpotlight (Patched).controller";
const string LightbulbsLine = $"{BasePath}lightbulbsLineMesh (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 =
[
// any version
new("KitchenTile", [
new("PoweredLightTypeB", PointLight4),
new("PoweredLightTypeB (1)", PointLight4),
]),
// < v70
new("ManorStartRoom", [
new("ManorStartRoom/Chandelier/PoweredLightTypeB (1)", PointLight4),
new("ManorStartRoom/Chandelier2/PoweredLightTypeB", PointLight4),
]),
// v70+
new("ManorStartRoomSmall", [
new("ManorStartRoomMesh/Chandelier/PoweredLightTypeB (1)", PointLight4),
new("ManorStartRoomMesh/Chandelier2/PoweredLightTypeB", PointLight4),
]),
new("NarrowHallwayTile2x2", [
new("MineshaftSpotlight (1)", MineshaftSpotlight),
new("MineshaftSpotlight (2)", MineshaftSpotlight),
]),
new("BirthdayRoomTile", [
new("Lights/MineshaftSpotlight", MineshaftSpotlight),
]),
new("BathroomTileContainer", [
new("MineshaftSpotlight", MineshaftSpotlight),
new("LightbulbLine/lightbulbsLineMesh", LightbulbsLine),
]),
new(["BedroomTile", "BedroomTileB"], [
new("CeilingFanAnimContainer", CeilingFan),
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
// renamed by WaterGun-V70PoweredLights_Fix-1.1.0
// 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
.SelectMany(d => d.Load(assetBundle))
.ToDictionary(d => d.TileCloneName, d => d);
}
public static void Patch(Tile tile)
{
if (tile == null)
{
throw new ArgumentNullException(nameof(tile));
}
if (Patches.TryGetValue(tile.gameObject.name, out var tilePatch))
{
foreach (var patch in tilePatch.Patches)
{
Transform animationContainerTransform = tile.gameObject.transform.Find(patch.AnimatorContainerPath);
if (animationContainerTransform == null)
{
#if DEBUG
throw new NullReferenceException($"{tilePatch.TileName}/{patch.AnimatorContainerPath} Animation Container not found");
#endif
#pragma warning disable CS0162 // Unreachable code detected
continue;
#pragma warning restore CS0162 // Unreachable code detected
}
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 DEBUG
throw new NullReferenceException($"{tilePatch.TileName}/{patch.AnimatorContainerPath} Animation Component not found");
#endif
#pragma warning disable CS0162 // Unreachable code detected
continue;
#pragma warning restore CS0162 // Unreachable code detected
}
patch.ManualPatch?.Invoke(animationContainer);
animator.runtimeAnimatorController = patch.AnimatorController;
Plugin.Log.LogDebug($"{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;
Plugin.Log.LogDebug($"{nameof(PoweredLightsAnimatorsPatch)} {animatorContainer.name}/{relativePath}: Renamed GameObject");
};
}
[HarmonyPatch(typeof(Tile))]
static class PoweredLightsAnimatorsPatch
{
[HarmonyPatch(nameof(Tile.AddTriggerVolume))]
[HarmonyPostfix]
static void OnAddTriggerVolume(Tile __instance)
{
PoweredLightsAnimators.Patch(__instance);
}
}
[HarmonyPatch(typeof(RoundManager))]
static class AllPoweredLightsPatch
{
@ -250,47 +14,11 @@ namespace MuzikaGromche
// - (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)
[HarmonyPostfix]
static void OnRefreshLightsList(RoundManager __instance)
{
RefreshLightsListPatched(__instance);
var behaviour = __instance.gameObject.GetComponent<PoweredLightsBehaviour>() ?? __instance.gameObject.AddComponent<PoweredLightsBehaviour>();
behaviour.Refresh();
// 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));
}
}
}
@ -340,7 +68,11 @@ namespace MuzikaGromche
{
foreach (var data in AllPoweredLights)
{
data.Light.color = data.InitialColor;
var light = data.Light;
if (light != null)
{
light.color = data.InitialColor;
}
}
}
}

View File

@ -120,7 +120,9 @@ namespace MuzikaGromche
{
drunkness = 0f;
// Only the stop animation if vanilla doesn't animate TZP right now.
if (GameNetworkManager.Instance.localPlayerController.drunkness == 0f)
// Objects may be null on shutdown
var network = GameNetworkManager.Instance;
if (network != null && network.localPlayerController != null && network.localPlayerController.drunkness == 0f)
{
HUDManager.Instance.gasHelmetAnimator.SetBool("gasEmitting", value: false);
}

View File

@ -51,7 +51,7 @@ namespace MuzikaGromche
{
if (EnemyIndex is int index)
{
if (__instance.EnemyCannotBeSpawned(index))
if (__instance.InsideEnemyCannotBeSpawned(index, __instance.currentEnemyPower))
{
return;
}

View File

@ -1,83 +0,0 @@
using System.Threading.Tasks;
namespace MuzikaGromche.Via;
public sealed class AsyncEventProcessor<T> where T : struct
{
private readonly object stateLock = new();
private T latestData;
private T lastProcessedData;
private bool isShutdownRequested;
private TaskCompletionSource<bool> signal = new(TaskCreationOptions.RunContinuationsAsynchronously);
public delegate ValueTask ProcessEventAsync(T oldData, T newData, bool shutdown);
private readonly ProcessEventAsync processEventAsync;
public AsyncEventProcessor(T initialData, ProcessEventAsync processEventAsync)
{
latestData = initialData;
lastProcessedData = initialData;
this.processEventAsync = processEventAsync;
_ = Task.Run(ProcessLoopAsync);
}
/// <summary>
/// Signals the processor that new data is available.
/// If <c>requestShutdown</c> is set, the processor will perform one last pass before exiting.
/// </summary>
public void Notify(T data, bool requestShutdown = false)
{
lock (stateLock)
{
latestData = data;
if (requestShutdown)
{
isShutdownRequested = true;
}
// Trigger the task to wake up
signal.TrySetResult(true);
}
}
private async Task ProcessLoopAsync()
{
bool running = true;
while (running)
{
Task<bool> nextSignal;
lock (stateLock)
{
nextSignal = signal.Task;
}
// Wait for a notification or shutdown signal
//
// VSTHRD003 fix: We are awaiting a task we didn't "start",
// but by using RunContinuationsAsynchronously in the TCS constructor,
// we guarantee the 'await' won't hijack the signaler's thread.
await nextSignal.ConfigureAwait(false);
T newData;
T oldData;
// Reset the signal for the next round
lock (stateLock)
{
signal = new(TaskCreationOptions.RunContinuationsAsynchronously);
if (isShutdownRequested)
{
running = false;
}
newData = latestData;
oldData = lastProcessedData;
lastProcessedData = newData;
}
await processEventAsync(oldData, newData, !running);
}
}
}

View File

@ -1,169 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HidSharp;
namespace MuzikaGromche.Via;
class DevicePool<T> : IDisposable
where T : IDevicePoolDelegate
{
private readonly IDevicePoolFactory<T> sidecarFactory;
// Async synchronization
private readonly SemaphoreSlim semaphore;
// Async access, use semaphore!
private readonly Dictionary<string, (HidDevice, T)> existing = [];
private bool updating = false;
public DevicePool(IDevicePoolFactory<T> sidecarFactory)
{
semaphore = new SemaphoreSlim(1, 1);
this.sidecarFactory = sidecarFactory;
DeviceList.Local.Changed += (_sender, _args) =>
{
Console.WriteLine($"Pool Changed");
_ = Task.Run(async () =>
{
await Task.Delay(300);
if (!updating)
{
updating = true;
UpdateConnections();
updating = false;
return;
}
});
};
UpdateConnections();
}
private void WithSemaphore(Action action)
{
semaphore.Wait();
try
{
action.Invoke();
}
finally
{
semaphore.Release();
}
}
public void Dispose()
{
Console.WriteLine($"Pool Dispose");
Clear();
}
private void Clear()
{
WithSemaphore(ClearUnsafe);
}
private void ClearUnsafe()
{
Console.WriteLine($"Pool Clear");
ForEachUnsafe((sidecar) => sidecar.Dispose());
existing.Clear();
}
private void ClearUnsafe(string path)
{
if (existing.Remove(path, out var data))
{
var (_, sidecar) = data;
sidecar.Dispose();
}
}
public void Restore()
{
Console.WriteLine($"Pool Restore");
ForEach((sidecar) => sidecar.Restore());
}
private void UpdateConnections()
{
WithSemaphore(() =>
{
// set of removed devices to be cleaned up
var removed = existing.Keys.ToHashSet();
foreach (var hidDevice in DeviceList.Local.GetHidDevices())
{
try
{
// surely path is enough to uniquely differentiate
var path = hidDevice.DevicePath;
if (existing.ContainsKey(path))
{
// not gone anywhere
removed.Remove(path);
continue;
}
var sidecar = sidecarFactory.Create(hidDevice);
if (sidecar != null)
{
Console.WriteLine($"Pool Added {path}");
existing[path] = (hidDevice, sidecar);
}
}
catch (Exception)
{
}
}
foreach (var path in removed)
{
try
{
ClearUnsafe(path);
}
catch (Exception)
{
}
}
});
}
public void ForEach(Action<T> action)
{
WithSemaphore(() => ForEachUnsafe(action));
}
private void ForEachUnsafe(Action<T> action)
{
foreach (var (_, sidecar) in existing.Values)
{
try
{
action(sidecar);
}
// ignore. the faulty device will be removed soon.
catch (IOException)
{
}
catch (TimeoutException)
{
}
}
}
}
/// Dispose should call Restore
interface IDevicePoolDelegate : IDisposable
{
void Restore();
}
interface IDevicePoolFactory<T>
{
T? Create(HidDevice hidDevice);
}

View File

@ -1,231 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HidSharp;
namespace MuzikaGromche.Via;
class FrameworkDeviceFactory : IDevicePoolFactory<ViaDeviceDelegate>
{
// Framework Vendor ID
private const int VID = 0x32AC;
internal static readonly int[] PIDs =
[
0x0012, // Framework Laptop 16 Keyboard Module - ANSI
0x0013, // Framework Laptop 16 RGB macropad module
];
// VIA/QMK Raw HID identifiers
private const ushort UsagePage = 0xFF60;
private const byte Usage = 0x61;
// In case of Framework Laptop 16 RGB Keyboard and Macropad,
// Usage Page & Usage are encoded at the start of the report descriptor.
private static readonly byte[] UsagePageAndUsageEncoded = [0x06, 0x60, 0xFF, 0x09, 0x61];
private const byte ExpectedVersion = 12;
private static bool DevicePredicate(HidDevice device)
{
if (VID != device.VendorID)
{
return false;
}
if (!PIDs.Contains(device.ProductID))
{
return false;
}
var report = device.GetRawReportDescriptor();
if (report == null)
{
return false;
}
if (!report.AsSpan().StartsWith(UsagePageAndUsageEncoded))
{
return false;
}
return true;
}
public ViaDeviceDelegate? Create(HidDevice hidDevice)
{
if (!DevicePredicate(hidDevice))
{
return null;
}
if (!hidDevice.TryOpen(out var stream))
{
return null;
}
var via = new ViaKeyboardApi(stream);
var version = via.GetProtocolVersion();
if (version != ExpectedVersion)
{
return null;
}
var brightness = via.GetRgbMatrixBrightness();
if (brightness == null)
{
return null;
}
var effect = via.GetRgbMatrixEffect();
if (effect == null)
{
return null;
}
var color = via.GetRgbMatrixColor();
if (color == null)
{
return null;
}
return new ViaDeviceDelegate(hidDevice, stream, via, brightness.Value, effect.Value, color.Value.Hue, color.Value.Saturation);
}
}
class ViaDeviceDelegate : IDevicePoolDelegate, ILightshow, IDisposable
{
// Objects
public readonly HidDevice device;
public readonly HidStream stream;
public readonly ViaKeyboardApi via;
private readonly AsyncEventProcessor<ViaDeviceState> asyncEventProcessor;
private readonly ViaDeviceState initialState;
private byte? brightnessOverride = null;
private const ViaEffectMode EFFECT = ViaEffectMode.Static;
private byte? effectOverride = null;
private ColorHSV? color = null;
public ViaDeviceDelegate(HidDevice device, HidStream stream, ViaKeyboardApi via, byte brightness, byte effect, byte hue, byte saturation)
{
// Objects
this.device = device;
this.stream = stream;
this.via = via;
// Initial values
initialState = new()
{
Brightness = brightness,
Effect = effect,
Hue = hue,
Saturation = saturation,
};
asyncEventProcessor = new AsyncEventProcessor<ViaDeviceState>(initialState, ProcessDataAsync);
Notify();
}
public void Restore()
{
brightnessOverride = null;
effectOverride = null;
color = null;
Notify();
}
public void Dispose()
{
// Simplified version of Restore()
brightnessOverride = null;
effectOverride = null;
color = null;
asyncEventProcessor.Notify(initialState, requestShutdown: true);
}
public void SetBrightnessOverride(byte? brightness)
{
brightnessOverride = brightness;
effectOverride = (byte)EFFECT;
Notify();
}
public void SetColor(byte hue, byte saturation, byte value)
{
color = new ColorHSV(hue, saturation, value);
effectOverride = (byte)EFFECT;
Notify();
}
private void Notify()
{
asyncEventProcessor.Notify(new()
{
Brightness = brightnessOverride ?? color?.Value ?? initialState.Brightness,
Effect = effectOverride ?? initialState.Effect,
Hue = color?.Hue ?? initialState.Hue,
Saturation = color?.Saturation ?? initialState.Saturation,
});
}
private async ValueTask ProcessDataAsync(ViaDeviceState oldData, ViaDeviceState newData, bool shutdown)
{
var cancellationToken = CancellationToken.None;
try
{
if (oldData.Brightness != newData.Brightness)
{
await via.SetRgbMatrixBrightnessAsync(newData.Brightness, cancellationToken);
}
if (oldData.Effect != newData.Effect)
{
await via.SetRgbMatrixEffectAsync(newData.Effect, cancellationToken);
}
if (oldData.Hue != newData.Hue || oldData.Saturation != newData.Saturation)
{
await via.SetRgbMatrixColorAsync(newData.Hue, newData.Saturation, cancellationToken);
}
}
catch (IOException)
{
}
catch (TimeoutException)
{
}
finally
{
if (shutdown)
{
stream.Close();
}
}
}
}
class DevicePoolLightshow<T> : ILightshow
where T: IDevicePoolDelegate, ILightshow
{
private readonly DevicePool<T> devicePool;
public DevicePoolLightshow(DevicePool<T> devicePool)
{
this.devicePool = devicePool;
}
public void SetBrightnessOverride(byte? brightness)
{
devicePool.ForEach(d =>
{
d.SetBrightnessOverride(brightness);
});
}
public void SetColor(byte hue, byte saturation, byte value)
{
devicePool.ForEach(d =>
{
d.SetColor(hue, saturation, value);
});
}
}
struct ViaDeviceState
{
public byte Brightness;
public byte Effect;
public byte Hue;
public byte Saturation;
}
record struct ColorHSV(byte Hue, byte Saturation, byte Value);

View File

@ -1,68 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MuzikaGromche.Via;
interface ILightshow
{
/// <summary>
/// Override or reset brightness (value) for flickering animation.
/// </summary>
void SetBrightnessOverride(byte? brightness);
void SetColor(byte hue, byte saturation, byte value);
}
class Animations
{
private static CancellationTokenSource? cts;
public static void Flicker(ILightshow lightshow)
{
if (cts != null)
{
cts.Cancel();
}
cts = new();
_ = Task.Run(async () => await FlickerAsync(lightshow, cts.Token), cts.Token);
}
static async ValueTask FlickerAsync(ILightshow lightshow, CancellationToken cancellationToken)
{
await foreach (var on in FlickerAnimationAsync())
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
byte brightness = on ? (byte)0xFF : (byte)0x00;
lightshow.SetBrightnessOverride(brightness);
}
lightshow.SetBrightnessOverride(null);
}
// Timestamps (in frames of 60 fps) of state switches, starting with 0 => ON.
private static readonly int[] MansionWallLampFlicker = [
0, 4, 8, 26, 28, 32, 37, 41, 42, 58, 60, 71,
];
public static async IAsyncEnumerable<bool> FlickerAnimationAsync()
{
bool lastState = false;
int lastFrame = 0;
const int initialMillisecondsDelay = 4 * 1000 / 60;
await Task.Delay(initialMillisecondsDelay);
foreach (int frame in MansionWallLampFlicker)
{
// convert difference in frames into milliseconds
int millisecondsDelay = (int)((frame - lastFrame) / 60f * 1000f);
lastState = !lastState;
lastFrame = frame;
await Task.Delay(millisecondsDelay);
yield return lastState;
}
}
}

View File

@ -1,75 +0,0 @@
using HarmonyLib;
using UnityEngine;
namespace MuzikaGromche.Via;
class ViaBehaviour : MonoBehaviour
{
static ViaBehaviour? instance = null;
public static ViaBehaviour Instance
{
get
{
if (instance == null)
{
var go = new GameObject("MuzikaGromche_ViaBehaviour", [
typeof(ViaBehaviour),
])
{
hideFlags = HideFlags.HideAndDontSave
};
DontDestroyOnLoad(go);
instance = go.GetComponent<ViaBehaviour>();
}
return instance;
}
}
DevicePool<ViaDeviceDelegate> devicePool = null!;
DevicePoolLightshow<ViaDeviceDelegate> lightshow = null!;
void Awake()
{
devicePool = new DevicePool<ViaDeviceDelegate>(new FrameworkDeviceFactory());
lightshow = new DevicePoolLightshow<ViaDeviceDelegate>(devicePool);
}
void OnDestroy()
{
devicePool.Dispose();
}
public void SetColor(Color color)
{
Color.RGBToHSV(color, out var hue, out var saturation, out var value);
var h = (byte)Mathf.RoundToInt(hue * 255);
var s = (byte)Mathf.RoundToInt(saturation * 255);
var v = (byte)Mathf.RoundToInt(value * 255);
lightshow.SetColor(h, s, v);
}
public void Restore()
{
devicePool.Restore();
}
public void Flicker()
{
Animations.Flicker(lightshow);
}
}
[HarmonyPatch(typeof(RoundManager))]
static class ViaFlickerLightsPatch
{
[HarmonyPatch(nameof(RoundManager.FlickerLights))]
[HarmonyPrefix]
static void OnFlickerLights(RoundManager __instance)
{
var _ = __instance;
ViaBehaviour.Instance.Flicker();
}
}

View File

@ -1,374 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using HidSharp;
namespace MuzikaGromche.Via;
enum ViaCommandId : byte
{
GetProtocolVersion = 0x01,
GetKeyboardValue = 0x02,
SetKeyboardValue = 0x03,
DynamicKeymapGetKeycode = 0x04,
DynamicKeymapSetKeycode = 0x05,
DynamicKeymapReset = 0x06,
CustomSetValue = 0x07,
CustomGetValue = 0x08,
CustomSave = 0x09,
EepromReset = 0x0A,
BootloaderJump = 0x0B,
DynamicKeymapMacroGetCount = 0x0C,
DynamicKeymapMacroGetBufferSize = 0x0D,
DynamicKeymapMacroGetBuffer = 0x0E,
DynamicKeymapMacroSetBuffer = 0x0F,
DynamicKeymapMacroReset = 0x10,
DynamicKeymapGetLayerCount = 0x11,
DynamicKeymapGetBuffer = 0x12,
DynamicKeymapSetBuffer = 0x13,
DynamicKeymapGetEncoder = 0x14,
DynamicKeymapSetEncoder = 0x15,
Unhandled = 0xFF,
}
enum ViaChannelId : byte
{
CustomChannel = 0,
QmkBacklightChannel = 1,
QmkRgblightChannel = 2,
QmkRgbMatrixChannel = 3,
QmkAudioChannel = 4,
QmkLedMatrixChannel = 5,
}
enum ViaQmkRgbMatrixValue : byte
{
Brightness = 1,
Effect = 2,
EffectSpeed = 3,
Color = 4,
};
enum ViaEffectMode : byte
{
Off = 0x00,
Static = 0x01,
Breathing = 0x02,
}
class ViaKeyboardApi
{
private const uint RAW_EPSIZE = 32;
private const byte COMMAND_START = 0x00;
private readonly HidStream stream;
public ViaKeyboardApi(HidStream stream)
{
this.stream = stream;
}
/// <summary>
/// Sends a raw HID command prefixed with the command byte and returns the response if successful.
/// </summary>
public byte[]? HidCommand(ViaCommandId command, byte[] input)
{
byte[] commandBytes = [(byte)command, .. input];
if (!HidSend(commandBytes))
{
// Console.WriteLine($"no send");
return null;
}
var buffer = HidRead();
if (buffer == null)
{
// Console.WriteLine($"no read");
return null;
}
// Console.WriteLine($"write command {commandBytes.BytesToHex()}");
// Console.WriteLine($"read buffer {buffer.BytesToHex()}");
if (!buffer.AsSpan(1).StartsWith(commandBytes))
{
return null;
}
return buffer.AsSpan(1).ToArray();
}
/// <summary>
/// Sends a raw HID command prefixed with the command byte and returns the response if successful.
/// </summary>
public async ValueTask<byte[]?> HidCommandAsync(ViaCommandId command, byte[] input, CancellationToken cancellationToken)
{
byte[] commandBytes = [(byte)command, .. input];
if (!await HidSendAsync(commandBytes, cancellationToken))
{
return null;
// Console.WriteLine($"no send");
}
var buffer = await HidReadAsync(cancellationToken);
if (buffer == null)
{
// Console.WriteLine($"no read");
return null;
}
// Console.WriteLine($"write command {commandBytes.BytesToHex()}");
// Console.WriteLine($"read buffer {buffer.BytesToHex()}");
if (!buffer.AsSpan(1).StartsWith(commandBytes))
{
return null;
}
return buffer.AsSpan(1).ToArray();
}
/// <summary>
/// Reads from the HID device. Returns null if the read fails.
/// </summary>
public byte[]? HidRead()
{
byte[] buffer = new byte[RAW_EPSIZE + 1];
// Console.WriteLine($"{buffer.BytesToHex()}");
int count = stream.Read(buffer);
if (count != RAW_EPSIZE + 1)
{
return null;
}
return buffer;
}
/// <summary>
/// Reads from the HID device. Returns null if the read fails.
/// </summary>
public async ValueTask<byte[]?> HidReadAsync(CancellationToken cancellationToken)
{
byte[] buffer = new byte[RAW_EPSIZE + 1];
// Console.WriteLine($"{buffer.BytesToHex()}");
int count = await stream.ReadAsync(buffer, cancellationToken);
if (count != RAW_EPSIZE + 1)
{
return null;
}
return buffer;
}
/// <summary>
/// Sends a raw HID command prefixed with the command byte. Returns false if the send fails.
/// </summary>
public bool HidSend(byte[] bytes)
{
if (bytes.Length > RAW_EPSIZE)
{
return false;
}
byte[] commandBytes = [COMMAND_START, .. bytes];
byte[] paddedArray = new byte[RAW_EPSIZE + 1];
commandBytes.AsSpan().CopyTo(paddedArray);
// Console.WriteLine($"Send {paddedArray.BytesToHex()}");
stream.Write(paddedArray);
return true;
}
/// <summary>
/// Sends a raw HID command prefixed with the command byte. Returns false if the send fails.
/// </summary>
public async ValueTask<bool> HidSendAsync(byte[] bytes, CancellationToken cancellationToken)
{
if (bytes.Length > RAW_EPSIZE)
{
return false;
}
byte[] commandBytes = [COMMAND_START, .. bytes];
byte[] paddedArray = new byte[RAW_EPSIZE + 1];
commandBytes.AsSpan().CopyTo(paddedArray);
// Console.WriteLine($"Send {paddedArray.BytesToHex()}");
await stream.WriteAsync(paddedArray, cancellationToken);
return true;
}
/// <summary>
/// Returns the protocol version of the keyboard.
/// </summary>
public ushort GetProtocolVersion()
{
var output = HidCommand(ViaCommandId.GetProtocolVersion, []);
if (output == null)
{
return 0;
}
return (ushort)((output[1] << 8) | output[2]);
}
/// <summary>
/// Returns the protocol version of the keyboard.
/// </summary>
public async ValueTask<ushort> GetProtocolVersionAsync(CancellationToken cancellationToken)
{
var output = await HidCommandAsync(ViaCommandId.GetProtocolVersion, [], cancellationToken);
if (output == null)
{
return 0;
}
return (ushort)((output[1] << 8) | output[2]);
}
public byte? GetRgbMatrixBrightness()
{
var output = HidCommand(ViaCommandId.CustomGetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Brightness,
]);
if (output == null)
{
return null;
}
return output[3];
}
public async ValueTask<byte?> GetRgbMatrixBrightnessAsync(CancellationToken cancellationToken)
{
var output = await HidCommandAsync(ViaCommandId.CustomGetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Brightness,
], cancellationToken);
if (output == null)
{
return null;
}
return output[3];
}
public bool SetRgbMatrixBrightness(byte brightness)
{
return HidCommand(ViaCommandId.CustomSetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Brightness,
brightness,
]) != null;
}
public async ValueTask<bool> SetRgbMatrixBrightnessAsync(byte brightness, CancellationToken cancellationToken)
{
return await HidCommandAsync(ViaCommandId.CustomSetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Brightness,
brightness,
], cancellationToken) != null;
}
public byte? GetRgbMatrixEffect()
{
var output = HidCommand(ViaCommandId.CustomGetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Effect,
]);
if (output == null)
{
return null;
}
return output[3];
}
public async ValueTask<byte?> GetRgbMatrixEffectAsync(CancellationToken cancellationToken)
{
var output = await HidCommandAsync(ViaCommandId.CustomGetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Effect,
], cancellationToken);
if (output == null)
{
return null;
}
return output[3];
}
public bool SetRgbMatrixEffect(ViaEffectMode effect)
{
return SetRgbMatrixEffect((byte)effect);
}
public bool SetRgbMatrixEffect(byte effect)
{
return HidCommand(ViaCommandId.CustomSetValue, [
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Effect,
effect,
]) != null;
}
public ValueTask<bool> SetRgbMatrixEffectAsync(ViaEffectMode effect, CancellationToken cancellationToken)
{
return SetRgbMatrixEffectAsync((byte)effect, cancellationToken);
}
public async ValueTask<bool> SetRgbMatrixEffectAsync(byte effect, CancellationToken cancellationToken)
{
return await HidCommandAsync(ViaCommandId.CustomSetValue, [
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Effect,
effect,
], cancellationToken) != null;
}
public (byte Hue, byte Saturation)? GetRgbMatrixColor()
{
var output = HidCommand(ViaCommandId.CustomGetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Color,
]);
if (output == null)
{
return null;
}
byte hue = output[3];
byte saturation = output[4];
return (hue, saturation);
}
public async ValueTask<(byte Hue, byte Saturation)?> GetRgbMatrixColorAsync(CancellationToken cancellationToken)
{
var output = await HidCommandAsync(ViaCommandId.CustomGetValue,
[
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Color,
], cancellationToken);
if (output == null)
{
return null;
}
byte hue = output[3];
byte saturation = output[4];
return (hue, saturation);
}
public bool SetRgbMatrixColor(byte hue, byte saturation)
{
return HidCommand(ViaCommandId.CustomSetValue, [
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Color,
hue, saturation,
]) != null;
}
public async ValueTask<bool> SetRgbMatrixColorAsync(byte hue, byte saturation, CancellationToken cancellationToken)
{
return await HidCommandAsync(ViaCommandId.CustomSetValue, [
(byte)ViaChannelId.QmkRgbMatrixChannel,
(byte)ViaQmkRgbMatrixValue.Color,
hue, saturation,
], cancellationToken) != null;
}
}
public record struct HueSaturationColor(byte Hue, byte Saturation);

View File

@ -2,7 +2,7 @@
_Add some content to your Inverse teleporter experience on Titan!<sup>1</sup>_
Muzika Gromche literally means _"crank music louder"_. This mod replaces Jester's winding up and chasing sounds with **a whole library** of timed to the beat and **seamlessly looped** popular energetic songs, combined with various **visual effects**. Song choice is random each day but **synchronized** with clients: everyone in the lobby will vibe to the same tunes, however **vanilla-compatible** client-side playback is also supported.
Muzika Gromche literally means _"crank music louder"_. This mod replaces Jester's winding up and chasing sounds with **a whole library** of timed to the beat and **seamlessly looped** popular energetic songs, combined with various **visual effects**. Song choice is random each time but **synchronized** with clients: everyone in the lobby will vibe to the same tunes, however **vanilla-compatible** client-side playback is also supported.
A demo video is worth a thousand words. Check out what Muzika Gromche does:
@ -32,7 +32,7 @@ English playlist features artists such as **Imagine Dragons, Fall Out Boy, Bon J
Russian playlist includes **Би-2, Витас, ГлюкoZa** (Глюкоза) & **Ленинград, Дискотека Авария, Noize MC, Oxxxymiron, Сплин, Пошлая Молли.**
There are also a K-pop track by **aespa**, and an anime opening from **One Punch Man.**
There are also a K-pop tracks by **aespa** and **BTS**, and an anime opening from **One Punch Man.**
Seasonal New Year's songs:
@ -61,9 +61,12 @@ Any player can change the following personal preferences locally.
- [Just Nothing](https://t.me/REALJUSTNOTHING): Visual artist; contributed palettes, timings and animation curves.
- [WaterGun](https://www.youtube.com/channel/UCCxCFfmrnqkFZ8i9FsXBJVA): Created [`V70PoweredLights_Fix`] mod, patched certain tiles with amazing lightshow.
See also [mod's release thread](https://discord.com/channels/1168655651455639582/1433881654866477318) at [Lethal Company Modding](https://discord.gg/XeyYqRdRGC) Discord server (in case the invite link expires, there should be a fresh one at [lethal.wiki](https://lethal.wiki/)).
See also [MuzikaGromche mod's release thread](https://discord.com/channels/1168655651455639582/1433881654866477318) at [Lethal Company Modding](https://discord.gg/XeyYqRdRGC) Discord server.
Also check out my other mod, 💭 [Hookah Place](https://thunderstore.io/c/lethal-company/p/Ratijas/HookahPlace/) ship decor/furniture! (thread: https://discord.com/channels/1169792572382773318/1465128348434038980)
Also check out my other mods!
- 💭 [Hookah Place — Ship decor/furniture](https://thunderstore.io/c/lethal-company/p/Ratijas/HookahPlace/)
- 6⃣7⃣ [Scrap Value Detector 67 — Scan and notify when a scrap worth 67 spawns on a map](https://thunderstore.io/c/lethal-company/p/Ratijas/ScrapValueDetector67/)
---

View File

@ -7,6 +7,6 @@
"dependencies": [
"BepInEx-BepInExPack-5.4.2100",
"AinaVT-LethalConfig-1.4.6",
"WaterGun-V70PoweredLights_Fix-1.1.0"
"WaterGun-V70PoweredLights_Fix-1.2.1"
]
}