1
0
Fork 0

Compare commits

..

5 Commits

Author SHA1 Message Date
ivan tkachenko f77e41bd17 Add integration with QMK/VIA keyboard on Framework Laptop 16 2026-03-07 03:42:09 +02:00
ivan tkachenko 65784e726e Add sources of patches Unity assets 2026-03-07 01:33:39 +02:00
ivan tkachenko 0d44728ef7 README: Rewrite paragraph about HookahPlace 2026-03-07 01:33:39 +02:00
ivan tkachenko d8651ce7db README: Fix typo 2026-03-07 01:33:39 +02:00
ivan tkachenko f15e3a1e3d Bump version of dependency WaterGun-V70PoweredLights_Fix to 1.1.0
It doesn't quite work on its own: sound and animators are still somehow
missing, so I'm not removing the fixes on my end.
2026-03-07 01:33:38 +02:00
21 changed files with 1338 additions and 236 deletions

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

Binary file not shown.

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

Binary file not shown.

View File

@ -1,15 +1,9 @@
# Changelog
## MuzikaGromche 1337.9001.69 - Six Seven Edition
## MuzikaGromche 1337.9001.69
- Added support for v80 (also known as "v81").
- Show real Artist & Song info in the config.
- 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.
- Integrated visual effects with QMK/VIA RGB keyboard (specifically input modules for Framework Laptop 16).
## 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.2 &quot;$(TargetPath)&quot; @(ReferencePathWithRefAssemblies->'&quot;%(Identity)&quot;', ' ')"/>
<Exec Command="dotnet netcode-patch -uv 2022.3.62 -nv 1.12.0 &quot;$(TargetPath)&quot; @(ReferencePathWithRefAssemblies->'&quot;%(Identity)&quot;', ' ')"/>
</Target>
</Project>

View File

@ -1,5 +1,4 @@
using System.Collections;
using GameNetcodeStuff;
using HarmonyLib;
using TMPro;
using UnityEngine;
@ -22,12 +21,7 @@ namespace MuzikaGromche
SetTextImpl(text ?? GameOverTextModdedDefault);
}
public static void SetTextAndClear(string? text)
{
HUDManager.Instance.StartCoroutine(SetTextAndClearImpl(text));
}
public static IEnumerator SetTextAndClearImpl(string? text)
public static IEnumerator SetTextAndClear(string? text)
{
SetText(text);
// Game Over animation duration is about 4.25 seconds
@ -66,6 +60,7 @@ namespace MuzikaGromche
[HarmonyPatch(typeof(RoundManager))]
static class DeathScreenGameOverTextResetPatch
{
[HarmonyPatch(nameof(RoundManager.DespawnPropsAtEndOfRound))]
[HarmonyPatch(nameof(RoundManager.OnDestroy))]
[HarmonyPrefix]
static void OnDestroy(RoundManager __instance)
@ -74,29 +69,4 @@ 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

@ -112,18 +112,12 @@ namespace MuzikaGromche
Plugin.Log.LogDebug($"{nameof(DiscoBallManager)} Toggle {(on ? "ON" : "OFF")} {CachedDiscoBallAnimators.Count} animators");
foreach (var discoBall in CachedDiscoBalls)
{
if (discoBall != null)
{
discoBall.SetActive(on);
}
}
foreach (var animator in CachedDiscoBallAnimators)
{
if (animator != null)
{
animator.SetBool("on", on);
}
animator?.SetBool("on", on);
}
}

View File

@ -899,97 +899,6 @@ 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],
@ -1209,34 +1118,5 @@ 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,15 +47,13 @@
<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>
@ -92,7 +90,9 @@
<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,8 +35,12 @@ namespace MuzikaGromche
private static int GetCurrentSeed()
{
var rng = new System.Random(unchecked((int)DateTime.Now.Ticks));
var seed = rng.Next();
var seed = 0;
var roundManager = RoundManager.Instance;
if (roundManager != null && roundManager.dungeonGenerator != null)
{
seed = roundManager.dungeonGenerator.Generator.ChosenSeed;
}
return seed;
}
@ -154,17 +158,19 @@ 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);
}
@ -342,8 +348,6 @@ 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.
@ -587,8 +591,6 @@ namespace MuzikaGromche
IAudioTrack[] ISelectableTrack.GetTracks() => [this];
int ISelectableTrack.Count() => 1;
IAudioTrack ISelectableTrack.SelectTrack(int index) => this;
void ISelectableTrack.Debug()
@ -613,8 +615,6 @@ namespace MuzikaGromche
IAudioTrack[] ISelectableTrack.GetTracks() => Tracks;
int ISelectableTrack.Count() => Tracks.Length;
IAudioTrack ISelectableTrack.SelectTrack(int index)
{
if (Tracks.Length == 0)
@ -1329,6 +1329,7 @@ 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)
{
@ -1345,6 +1346,11 @@ namespace MuzikaGromche
return Color ?? initialColor;
}
public override Color? GetNullableColor()
{
return Color;
}
public override string ToString()
{
return $"Color(#{NullableColorToString(Color)})";
@ -1366,7 +1372,7 @@ namespace MuzikaGromche
return Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f));
}
private Color? GetNullableColor()
public override Color? GetNullableColor()
{
return From is { } from && To is { } to ? Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)) : null;
}
@ -2046,6 +2052,11 @@ 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!;
@ -2090,6 +2101,8 @@ namespace MuzikaGromche
public override void OnDestroy()
{
Config.Volume.SettingChanged -= UpdateVolume;
DeathScreenGameOverTextManager.Clear();
Stop();
}
@ -2104,7 +2117,6 @@ namespace MuzikaGromche
public override void OnNetworkSpawn()
{
HostIsModded = IsServer;
ChooseTrackDeferred();
foreach (var track in Plugin.Tracks)
{
@ -2135,8 +2147,7 @@ namespace MuzikaGromche
private void ChooseTrackDeferredDelegate(object sender, EventArgs e)
{
HostSelectableTrack = null;
HostSelectableTrackIndex = 0;
SelectedTrackIndex = 0;
ChooseTrackDeferred();
}
@ -2157,8 +2168,7 @@ 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.
// Initialized to `IsServer` on network spawn. If I am the host, then I am modded, otherwise we'll find out later.
bool HostIsModded;
bool HostIsModded = false;
// Playing with modded host automatically disables vanilla compatability mode
public bool VanillaCompatMode => IsServer ? Config.VanillaCompatMode : !HostIsModded;
@ -2172,12 +2182,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)
{
ChooseTrackOnServer();
ChooseTrackServerRpc();
}
else
{
@ -2194,7 +2204,7 @@ namespace MuzikaGromche
}
}
[Rpc(SendTo.Everyone)]
[ClientRpc]
void SetTrackClientRpc(string name)
{
Plugin.Log.LogDebug($"SetTrackClientRpc {name}");
@ -2217,25 +2227,14 @@ namespace MuzikaGromche
}
}
// 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()
[ServerRpc]
void ChooseTrackServerRpc()
{
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}");
var selectableTrack = Plugin.ChooseTrack();
var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex);
Plugin.Log.LogInfo($"ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}");
SetTrackClientRpc(audioTrack.Name);
HostSelectableTrackIndex += 1;
SelectedTrackIndex += 1;
}
void ChooseTrackCompat()
@ -2348,6 +2347,7 @@ namespace MuzikaGromche
internal void Stop()
{
PoweredLightsBehaviour.Instance.ResetLightColor();
Via.ViaBehaviour.Instance.Restore();
DiscoBallManager.Disable();
ScreenFiltersManager.Clear();
@ -2377,9 +2377,7 @@ 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.
// Don't reset MuzikaGromcheJesterNetworkBehaviour.CurrentTrack to null,
// because it may have already been set by host via RPC.
// CurrentTrack = null;
CurrentTrack = null;
}
public void OverrideDeathScreenGameOverText()
@ -2389,7 +2387,7 @@ namespace MuzikaGromche
// Playing as a client with a host who doesn't have the mod
return;
}
DeathScreenGameOverTextManager.SetTextAndClear(CurrentTrack.GameOverText);
StartCoroutine(DeathScreenGameOverTextManager.SetTextAndClear(CurrentTrack.GameOverText));
}
}
@ -2559,9 +2557,18 @@ 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,9 +1,245 @@
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
{
@ -14,11 +250,47 @@ namespace MuzikaGromche
// - (maybe more?)
// In order to fix that, replace singular GetComponentInChildren<Light> with plural GetComponentsInChildren<Light> version.
[HarmonyPatch(nameof(RoundManager.RefreshLightsList))]
[HarmonyPostfix]
static void OnRefreshLightsList(RoundManager __instance)
[HarmonyPrefix]
static bool 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));
}
}
}
@ -68,11 +340,7 @@ namespace MuzikaGromche
{
foreach (var data in AllPoweredLights)
{
var light = data.Light;
if (light != null)
{
light.color = data.InitialColor;
}
data.Light.color = data.InitialColor;
}
}
}

View File

@ -120,9 +120,7 @@ namespace MuzikaGromche
{
drunkness = 0f;
// Only the stop animation if vanilla doesn't animate TZP right now.
// Objects may be null on shutdown
var network = GameNetworkManager.Instance;
if (network != null && network.localPlayerController != null && network.localPlayerController.drunkness == 0f)
if (GameNetworkManager.Instance.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.InsideEnemyCannotBeSpawned(index, __instance.currentEnemyPower))
if (__instance.EnemyCannotBeSpawned(index))
{
return;
}

View File

@ -0,0 +1,83 @@
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

@ -0,0 +1,169 @@
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

@ -0,0 +1,231 @@
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

@ -0,0 +1,68 @@
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

@ -0,0 +1,75 @@
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();
}
}

374
MuzikaGromche/Via/Via.cs Normal file
View File

@ -0,0 +1,374 @@
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 time 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 day 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 tracks by **aespa** and **BTS**, and an anime opening from **One Punch Man.**
There are also a K-pop track by **aespa**, and an anime opening from **One Punch Man.**
Seasonal New Year's songs:
@ -61,12 +61,9 @@ 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 [MuzikaGromche mod's release thread](https://discord.com/channels/1168655651455639582/1433881654866477318) at [Lethal Company Modding](https://discord.gg/XeyYqRdRGC) Discord server.
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/)).
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/)
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)
---

View File

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