1
0
Fork 0
muzika-gromche/MuzikaGromche/Plugin.cs

3012 lines
130 KiB
C#

using BepInEx;
using BepInEx.Configuration;
using HarmonyLib;
using LethalConfig;
using LethalConfig.ConfigItems;
using LethalConfig.ConfigItems.Options;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.Networking;
namespace MuzikaGromche
{
[BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)]
[BepInDependency("ainavt.lc.lethalconfig", "1.4.6")]
[BepInDependency("watergun.v72lightfix", BepInDependency.DependencyFlags.SoftDependency)]
[BepInDependency("BMX.LobbyCompatibility", BepInDependency.DependencyFlags.SoftDependency)]
public class Plugin : BaseUnityPlugin
{
internal new static Config Config { get; private set; } = null!;
// Not all lights are white by default. For example, Mineshaft's neon light is green-ish.
// We don't have to care about Light objects lifetime, as Unity would internally destroy them on scene unload anyway.
internal static Dictionary<Light, Color> InitialLightsColors = [];
private static readonly string[] PwnLyricsVariants = [
"", "", "", // make sure the array has enough items to index it without checking
..NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(a => a.Address.ToString())
.Select(a => $" Trying... {a}")
];
public static readonly ISelectableTrack[] Tracks = [
new SelectableAudioTrack
{
Name = "MuzikaGromche",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 46.3f,
Bars = 16,
BeatsOffset = 0.0f,
FadeOutBeat = -3,
FadeOutDuration = 3,
ColorTransitionIn = 0.25f,
ColorTransitionOut = 0.25f,
ColorTransitionEasing = Easing.OutExpo,
FlickerLightsTimeSeries = [-5, 29, 61],
DrunknessLoopOffsetTimeSeries = new(
[-2f, 0.0f, 1.0f, 03f, 30f, 32f, 33f, 35f, 62f],
[ 0f, 0.4f, 0.6f, 0f, 0f, 0.5f, 0.7f, 0f, 0f]),
Palette = Palette.Parse(["#B300FF", "#FFF100", "#00FF51", "#474747", "#FF00B3", "#0070FF"]),
Lyrics = [
(-68, "Devchata pljashut pod spidami"),
(-60, "A ty stoish', kak vkopannyj"),
(-52, "Krossovkami lomajut pol"),
(-44, "A ty stoish', kak vkopannyj"),
(-36, "Ja-ja-ja znaju, chto ty hochesh',"),
(-32, "Ja-ja-ja znaju, chto ty hochesh',\nTy hochesh' tancevat'"),
(-28, "Nu-nu zhe, nu davaj zhe,"),
(-24, "Nu-nu zhe, nu davaj zhe,\nNu-nu zhe, nu davaj zhe"),
(-20, "Ja znaju, chto ty znaesh'\nJetot trek, gotov'sja podpevat'"),
(-12, "1) RAZ"),
(-10, "raz, DVA"),
(-8, "raz, 2wo,\nTRI"),
(-6, "ras, dva,\n7ri, 4ETYRE"),
(-1, "Muzyka Gromche\nGlaza zakryty >_<"),
(6, "This is NON-STOP,\nNoch'ju otkrytij"),
(12, "Delaj chto hochesh', ja zabyvajus'"),
(22, "This is NON-STOP,\nne prekrashhajas'"),
(31, "Muzyka Gromche\nGlaza zakryty -.-"),
(38, "This is NON-STOP,\nNoch'ju otkrytij"),
(46, "Budu s toboju,\nsamoj primernoju"),
(54, "Utro v okne\nyi my budem pervye"),
(63, "Muzyka Gromche\nGlaza zakryty >_<"),
],
},
new SelectableAudioTrack
{
Name = "VseVZale",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 38.28f,
Bars = 16,
LoopOffset = 0,
BeatsOffset = 0.25f,
FadeOutBeat = -3,
FadeOutDuration = 2.5f,
ColorTransitionIn = 0.25f,
ColorTransitionOut = 0.25f,
ColorTransitionEasing = Easing.OutExpo,
FlickerLightsTimeSeries = [-5, 29, 59],
Palette = Palette.Parse(["#FF7F00", "#FFB600", "#FFED00", "#00D1FF", "#6696FB", "#704DF8"]).Use(palette =>
palette * 5 + new Palette(palette.Colors[0..2])
// stretch the second (OOOO oooo OO ooo) part, keep the colors perfectly cycled
+ (new Palette(palette.Colors[2..]) + palette * 2).Stretch(2)
),
Lyrics = [
(-30, "VSE V ZALE\nDvigajtes' s nami"),
(-24, "Chtob sotrjasalis'\nSami my, steny i pol!"),
(-14, "Vse znaem - jeto examen na dom nam zadan"),
(-4, "HIP-HOP, HOUSE & ROCK-N-ROLL"),
(2, "VSE V ZALE\nDvigajtes' s nami"),
(8, "Chtob sotrjasalis'\nSami my, steny i pol!"),
(18, "Vse znaem - jeto examen na dom nam zadan"),
(28, "HIP-HOP, HOUSE & ROCK-N-ROLL"),
(32, "O-o-o-o! Zdes' startuet hip-hop party"),
(44, "Tolstyj paren', nam igraj!"),
(48, "O-o-o-o! Pesen i devchonok hvatit!"),
(60, "Everybody shake your body"),
],
},
new SelectableAudioTrack
{
Name = "DeployDestroy",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 40.68f,
Bars = 8,
LoopOffset = 32,
BeatsOffset = 0.2f,
FadeOutBeat = -38,
FadeOutDuration = 4,
ColorTransitionIn = 0.25f,
ColorTransitionOut = 0.25f,
ColorTransitionEasing = Easing.OutExpo,
FlickerLightsTimeSeries = [-101, -93, -77, -61, -37, -5, 27],
DrunknessLoopOffsetTimeSeries = new(
[-48f, -46f, -42f, 16f, 19f, 23f],
[ 0f, 0.7f, 0f, 0f, 0.3f, 0f]),
Palette = Palette.Parse(["#217F87", "#BAFF00", "#73BE25", "#78AB4E", "#FFFF00"]),
Lyrics = [
(-111, "Deploy Destroy, porjadok eto otstoj"),
(-103, "Krushi, lomaj, trjasi bashkoju pustoj"),
(-95, "Dopej, razbej i novuju otkryvaj"),
(-87, "Davaj-davaj!"),
(-79, "Chestnoe slovo ja nevinoven"),
(-75, "Ja ne pomnju, otkuda stol'ko krovi"),
(-71, "Na moih ladonjah\nyi moej odezhde"),
(-67, "Ja nikogda nikogo\nne bil prezhde"),
(-63, "Ja nikogda nichego\nne pil prezhde"),
(-59, "Byl tih, spokoen,\nso vsemi vezhliv"),
(-55, "Vsegda tol'ko v urnu\nbrosal musor"),
(-51, "Obhodil storonoj shumnye tusy"),
(-47, "Zapreshhjonnyh veshhestv nikakih ne juzal"),
(-43, "Byl polozhitel'nej samogo pljusa"),
(-39, "A potom kak-to raz\njetu pesnju uslyshal"),
(-35, "I vsjo proshhaj, moja krysha"),
(-31, "Deploy Destroy, porjadok eto otstoj"),
(-23, "Krushi, lomaj, trjasi bashkoju pustoj"),
(-15, "Dopej, razbej i novuju otkryvaj"),
(-7, "Davaj-davaj!"),
(1, "Deploy Destroy, porjadok eto otstoj"),
(9, "Krushi, lomaj, trjasi bashkoju pustoj"),
(17, "Dopej, razbej i novuju otkryvaj"),
(25, "Davaj-davaj!"),
],
},
new SelectableAudioTrack
{
Name = "MoyaZhittya",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
WindUpTimer = 34.53f,
Bars = 8,
LoopOffset = 32,
BeatsOffset = 0.0f,
FadeOutBeat = -35,
FadeOutDuration = 3.3f,
ColorTransitionIn = 0.25f,
ColorTransitionOut = 0.25f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse(["#A3A3A3", "#BE3D39", "#5CBC69", "#BE3D39", "#BABC5C", "#BE3D39", "#5C96BC", "#BE3D39"]),
FlickerLightsTimeSeries = [-100.5f, -99.5f, -92.5f, -91.5f, -76.5f, -75.5f, -60.5f, -59.5f, -37f, -36f, -4.5f, -3.5f, 27.5f, 28.5f],
Lyrics = [
(-84, "This ain't a song for the broken-hearted"),
(-68, "No silent prayer for the faith-departed"),
(-52, "I ain't gonna be"),
(-48, "I ain't gonna be\njust a face in the crowd"),
(-45, "YOU'RE"),
(-44, "you're GONNA"),
(-43.5f, "you're gonna HEAR"),
(-43, "you're gonna hear\nMY"),
(-42, "you're gonna hear\nmy VOICE"),
(-41, "WHEN I"),
(-40, "When I SHOUT IT"),
(-39, "When I shout it\nOUT LOUD"),
(-34, "IT'S MY"),
(-32, "IT'S MY\nLIIIIIFE"),
(-28, "And it's now or never"),
(-22, "I ain't gonna"),
(-20, "I ain't gonna\nlive forever"),
(-14, "I just want to live"),
(-10, "I just want to live\nwhile I'm alive"),
( -2, "IT'S MY"),
( 0, "IT'S MY\nLIIIIIFE"),
( 2, "My heart is like"),
( 4, "My heart is like\nan open highway"),
( 10, "Like Frankie said,"),
( 12, "Like Frankie said,\n\"I did it my way\""),
( 18, "I just want to live"),
( 22, "I just want to live\nwhile I'm alive"),
( 30, "IT'S MY"),
],
},
new SelectableAudioTrack
{
Name = "Gorgorod",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 43.2f,
Bars = 6,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.25f,
ColorTransitionOut = 0.25f,
ColorTransitionEasing = Easing.InExpo,
Palette = Palette.Parse(["#42367E", "#FF9400", "#932A04", "#FF9400", "#932A04", "#42367E", "#FF9400", "#932A04"]),
LoopOffset = 0,
FadeOutBeat = -2,
FadeOutDuration = 2,
FlickerLightsTimeSeries = [20],
Lyrics = [],
},
new SelectableAudioTrack
{
Name = "Durochka",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 37.0f,
Bars = 10,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.25f,
ColorTransitionOut = 0.3f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse(["#5986FE", "#FEFEDC", "#FF4FDF", "#FEFEDC", "#FFAA23", "#FEFEDC", "#F95A5A", "#FEFEDC"]),
LoopOffset = 0,
FadeOutBeat = -7,
FadeOutDuration = 7,
FlickerLightsTimeSeries = [-9],
Lyrics = [],
},
new SelectableAudioTrack
{
Name = "ZmeiGorynich",
AudioType = AudioType.OGGVORBIS,
Language = Language.KOREAN,
WindUpTimer = 46.13f,
Bars = 8,
BeatsOffset = 0.1f,
ColorTransitionIn = 0.4f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse(["#4C8AC5", "#AF326A", "#0B1666", "#AFD2FC", "#C55297", "#540070"]),
LoopOffset = 0,
FadeOutBeat = -4,
FadeOutDuration = 4,
FlickerLightsTimeSeries = [-5, 31],
Lyrics = [],
GameOverText = "[MUZIKA GROMCHE: K-POP]",
},
new SelectableAudioTrack
{
Name = "GodMode",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
WindUpTimer = 40.38f,
Bars = 16,
BeatsOffset = 0.1f,
ColorTransitionIn = 0.5f,
ColorTransitionOut = 0.5f,
ColorTransitionEasing = Easing.OutCubic,
Palette = Palette.Parse(["#FBDBDB", "#4B81FF", "#564242", "#C90AE2", "#FBDBDB", "#61CBE3", "#564242", "#ED3131"]),
LoopOffset = 0,
FadeOutBeat = -4,
FadeOutDuration = 4,
FlickerLightsTimeSeries = [-5],
Lyrics = [],
GameOverText = "[COULD'VE BEEN: IMMORTAL]",
},
new SelectableAudioTrack
{
Name = "RiseAndShine",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
WindUpTimer = 59.87f,
Bars = 16,
BeatsOffset = 0.1f,
ColorTransitionIn = 0.5f,
ColorTransitionOut = 0.5f,
ColorTransitionEasing = Easing.OutCubic,
Palette = Palette.Parse(["#FC6F3C", "#3CB0FC", "#FCD489", "#564242", "#FC6F3C", "#3CB0FC", "#63E98C", "#866868"]),
LoopOffset = 0,
FadeOutBeat = -4.5f,
FadeOutDuration = 4,
FlickerLightsTimeSeries = [-5.5f, 31, 63.9f],
Lyrics = [],
GameOverText = "[ HEY, YOUNG BLOOD ]",
},
new SelectableAudioTrack
{
Name = "Song2",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 38.63f,
Beats = 17 * 2,
BeatsOffset = 0.1f,
ColorTransitionIn = 0.3f,
ColorTransitionOut = 0.3f,
ColorTransitionEasing = Easing.InCubic,
Palette = Palette.Parse(["#FFD3E3", "#78A0FF", "#FFD3E3", "#74A392", "#FFD3E3", "#E4B082", "#FFD3E3", "#E277AA"]),
LoopOffset = 0,
FadeOutBeat = -2,
FadeOutDuration = 2,
FlickerLightsTimeSeries = [2.5f],
Lyrics = [],
},
new SelectableAudioTrack
{
Name = "Peretasovka",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
WindUpTimer = 39.68f,
Bars = 8,
BeatsOffset = 0.3f,
ColorTransitionIn = 0.4f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse(["#65C7FA", "#FCEB3C", "#89FC8F", "#FEE9E9", "#FC3C9D", "#FCEB3C", "#89FC8F", "#FC3C9D"]),
LoopOffset = 0,
FadeOutBeat = -6,
FadeOutDuration = 4,
FlickerLightsTimeSeries = [-8, 31],
Lyrics = [],
},
new SelectableAudioTrack
{
Name = "Yalgaar",
AudioType = AudioType.OGGVORBIS,
Language = Language.HINDI,
WindUpTimer = 52.17f,
Bars = 8,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.1f,
ColorTransitionOut = 0.35f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse(["#C0402D", "#906F0B", "#DC8044", "#70190A", "#929FAF", "#4248A2", "#AE2727", "#2D2D42"]),
LoopOffset = 0,
FadeOutBeat = -4,
FadeOutDuration = 4,
FlickerLightsTimeSeries = [-5],
Lyrics = [],
},
new SelectableAudioTrack
{
Name = "Chereshnya",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 45.48f,
Bars = 16,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.3f,
ColorTransitionOut = 0.35f,
ColorTransitionEasing = Easing.InOutCubic,
Palette = Palette.Parse([
"#A01471", "#CB2243", "#4CAF50", "#F01D7A", "#AF005A", "#EF355F", "#FFD85D", "#FF66B2",
"#A01471", "#4CAF50", "#CB2243", "#F01D7A", "#AF005A", "#FFD85D", "#EF355F", "#FF66B2",
]),
LoopOffset = 0,
FadeOutBeat = -4,
FadeOutDuration = 4,
FlickerLightsTimeSeries = [-5, 27, 29, 59, 61],
Lyrics = [],
},
new SelectableAudioTrack
{
Name = "PWNED",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
IsExplicit = true,
WindUpTimer = 39.73f,
Bars = 32,
BeatsOffset = -0.2f,
ColorTransitionIn = 0.5f,
ColorTransitionOut = 0.3f,
ColorTransitionEasing = Easing.InExpo,
Palette = Palette.Parse(["#9E9E9E", "#383838", "#5E5E5E", "#2E2E2E", "#666666", "#4B4B4B", "#8E8E8E", "#1D1D1D"]).Use(gray8 =>
{
var flag4 = Palette.Parse(["#FFFFFF", "#0032A0", "#DA291C", "#000000"]);
var gray6 = new Palette(gray8.Colors[0..6]);
var gray14 = gray8 + gray6;
var lyrics = flag4 + gray14.Stretch(2);
var instrumental = gray8.Stretch(4);
return lyrics * 2 + instrumental * 2;
}),
LoopOffset = 0,
FadeOutBeat = -8,
FadeOutDuration = 6,
FlickerLightsTimeSeries = [-136, -72, -12, 88],
Lyrics = [
(-190, "These Russian hackers have been"),
(-184, "in these US governments\nsince March"),
(-172, "and it is an extraordinary invasion of our cyberspace"),
(-152, "Russian hackers got access to sensitive"),
(-142, "parts of the White House email system..."),
(-134, "[They began to recognize...]"),
(-126, "<Russian hackers/>"),
(-118, "<Russian hackers/>\n X__X"),
(-110, "Gonna crack your"),
(-102, "Gonna crack your\nStrongest pa$$words%123"),
(-94, "You popped online"),
(-86, "You popped online\nTo look for sneakers"),
(-78, "My hand just popped"),
(-70, "My hand just popped\nRight in your knickers >_< "),
(-62, "Keystrokes like Uzi"),
(-54, "Keystrokes like Uzi\nWill make you go all goosey"),
(-46, "Kicking down your backdoor"),
(-38, "Kicking down your backdoor\nCount down before you lose it"),
(-30, "Keystrokes like Uzi"),
(-22, "Keystrokes like Uzi\nWill make you go all goosey"),
(-14, "Kicking down your backdoor"),
(-6, "Kicking down your backdoor\nCount down before you lose it"),
(0, "C:\\> $Ru55ian hack3rs"),
(4, "C:\\> $Ru55ian hack3rs\n O__o"),
(8, "Infamous White House attackers"),
(16, "Stealing your cookies\nto this beat"),
(24, "Counting crypto to\nembarrass Wall Street"),
(32, "Russi?n ^hackers\tЯushan h@ckers###"),
(34, "\tЯushan h@ckers###\n X_X"),
(36, "Russi?n ^hackers\n--.--\tЯushan h@ckers###\n X___X"),
(38, "\tЯushan h@ckers###\n X_____X"),
(40, "Infamous White House attackers"),
(48, "Stealing your cookies\nto this beat"),
(56, "Counting crypto to\nembarrass Wall Street"),
(80, $"Instling min3r.exe\t\t\tresolving ur private IP\n/"),
(82, $"Instling min3r.exe\n00% [8=D ]\tHenllo ${{username = \"{Environment.UserName}\"}}\t\tresolving ur private IP\n-{PwnLyricsVariants[^3]}"),
(84, $"Instling min3r.exe\n33% [8====D ]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^3]}"),
(86, $"Instling min3r.exe\n66% [8=========D ]\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^2]}"),
(88, $"Instling min3r.exe\n95% [8============D ]\t\tWhere did you download\nthis < mod / dll > from?\tresolving ur private IP\n{PwnLyricsVariants[^2]}/"),
(90, $"Instling min3r.exe\n99% [8=============D]\t\t\tresolving ur private IP\n-{PwnLyricsVariants[^2]}"),
(92, $"Encrpt1ng f!les.. \n99% [8=============D]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^1]}"),
(94, $"Encrpt1ng f!les...\n100% enj0y \\o/\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^1]}"),
(96, $"\t\t\tresolving ur private IP\n/{PwnLyricsVariants[^1]}"),
(98, $"\t\t\tresolving ur private IP\nP_WNED"),
],
GameOverText = "[HACK3D BY: RUSSI4NS]",
},
new SelectableAudioTrack
{
Name = "Kach",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
WindUpTimer = 47.30f,
Bars = 12,
// let them overlap, such that there is an actual hard cut to the next color
BeatsOffset = 0.4f,
ColorTransitionIn = 0.8f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
// pump it loudeeeeeeeeeer
"#7774DE", "#1EA59A", "#3BC457", "#3BC457",
"#CA6935", "#A82615", "#A7AA43", "#A7AA43",
"#4C2B81", "#2E802B", "#C952E7", "#C952E7",
]),
LoopOffset = 0,
FadeOutBeat = -6,
FadeOutDuration = 6,
FlickerLightsTimeSeries = [-120.5f, -105, -89, -8, 44, 45],
Lyrics = [],
GameOverText = "[DIDN'T PUMP IT: LOUDER]",
},
new SelectableTracksGroup
{
Name = "BeefLiver",
Language = Language.ENGLISH,
Tracks =
[
new CoreAudioTrack
{
Name = "BeefLiver1",
FileNameLoop = "BeefLiverLoop.ogg",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 39.35f,
Bars = 12,
BeatsOffset = 0.2f,
ColorTransitionIn = 0.4f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#FFEBEB", "#FFEBEB", "#445782", "#EBA602",
"#5EEBB9", "#8EE3DC", "#A23045", "#262222",
]),
LoopOffset = 0,
FadeOutBeat = -3,
FadeOutDuration = 3,
FlickerLightsTimeSeries = [-48, -40, -4.5f, 44],
Lyrics = [],
},
new CoreAudioTrack
{
Name = "BeefLiver3",
FileNameLoop = "BeefLiverLoop.ogg",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 39.35f,
Bars = 12,
BeatsOffset = 0.2f,
ColorTransitionIn = 0.4f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#FFEBEB", "#FFEBEB", "#445782", "#EBA602",
"#5EEBB9", "#8EE3DC", "#A23045", "#262222",
]),
LoopOffset = 0,
FadeOutBeat = -3,
FadeOutDuration = 3,
FlickerLightsTimeSeries = [-48, -40, -4.5f, 44],
Lyrics = [],
},
new CoreAudioTrack
{
Name = "BeefLiver4",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 31.68f,
Bars = 12,
BeatsOffset = 0.2f,
ColorTransitionIn = 0.4f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#FFEBEB", "#FFEBEB", "#445782", "#EBA602",
"#5EEBB9", "#8EE3DC", "#A23045", "#262222",
]),
LoopOffset = 0,
FadeOutBeat = -3,
FadeOutDuration = 3,
FlickerLightsTimeSeries = [-32, -24, -4.5f, 44],
Lyrics = [],
},
],
},
new SelectableTracksGroup
{
Name = "Beha",
Language = Language.RUSSIAN,
IsExplicit = true,
Tracks =
[
new CoreAudioTrack
{
Name = "Beha1",
FileNameLoop = "BehaLoop.ogg",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 35.23f,
Beats = 8 * 4 + 2,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.1f,
ColorTransitionOut = 0.6f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#9554F9", "#3769FD", "#E43B65", "#59CFEA", "#7F3FEE", "#C831FE",
]),
LoopOffset = 0,
FadeOutBeat = -4,
FadeOutDuration = 3.9f,
FlickerLightsTimeSeries = [-6, 16.5f],
Lyrics = [],
},
new CoreAudioTrack
{
Name = "Beha2",
FileNameLoop = "BehaLoop.ogg",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 38.16f,
Beats = 8 * 4 + 2,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.1f,
ColorTransitionOut = 0.6f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#9554F9", "#3769FD", "#E43B65", "#59CFEA", "#7F3FEE", "#C831FE",
]),
LoopOffset = 0,
FadeOutBeat = -4,
FadeOutDuration = 3.9f,
FlickerLightsTimeSeries = [-6, 16.5f],
Lyrics = [],
},
new CoreAudioTrack
{
Name = "Beha3",
FileNameLoop = "BehaLoop.ogg",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 35.21f,
Beats = 8 * 4 + 2,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.1f,
ColorTransitionOut = 0.6f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#9554F9", "#3769FD", "#E43B65", "#59CFEA", "#7F3FEE", "#C831FE",
]),
LoopOffset = 0,
FadeOutBeat = -4,
FadeOutDuration = 3.9f,
FlickerLightsTimeSeries = [-6, 16.5f],
Lyrics = [],
},
],
},
new SelectableAudioTrack
{
Name = "OnePartiyaUdar",
AudioType = AudioType.OGGVORBIS,
Language = Language.JAPANESE,
WindUpTimer = 41.27f,
Bars = 12,
BeatsOffset = 0.3f,
ColorTransitionIn = 0.6f,
ColorTransitionOut = 0.15f,
ColorTransitionEasing = Easing.InOutExpo,
Palette = Palette.Parse([
"#9C3C37", "#E9BF5C", "#B5E3EA", "#662422", "#EBC3A8", "#AA8238",
]),
LoopOffset = 0,
FadeOutBeat = -8,
FadeOutDuration = 6,
FlickerLightsTimeSeries = [-68.5f, -16.5f, 30.5f],
Lyrics = [],
},
new SelectableTracksGroup
{
Name = "AttentionPls",
Language = Language.RUSSIAN,
IsExplicit = true,
Tracks =
[
new CoreAudioTrack
{
Name = "AttentionPls1",
FileNameLoop = "AttentionPlsLoop.ogg",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 39.19f,
Bars = 8,
BeatsOffset = 0.3f,
ColorTransitionIn = 0.4f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse(["#FCEB3C", "#FC3C9D", "#65C7FA", "#89FC8F", "#FEE9E9", "#FCEB3C", "#89FC8F", "#FC3C9D"]),
LoopOffset = 0,
FadeOutBeat = -6,
FadeOutDuration = 5,
FlickerLightsTimeSeries = [-8, 31],
Lyrics = [],
DrunknessLoopOffsetTimeSeries = new([7f, 12f, 15f], [0f, 0.90f, 0f]),
CondensationLoopOffsetTimeSeries = new([23f, 28f, 31f], [0f, 0.4f, 0f]),
},
new CoreAudioTrack
{
Name = "AttentionPls2",
FileNameLoop = "AttentionPlsLoop.ogg",
AudioType = AudioType.OGGVORBIS,
WindUpTimer = 39.19f,
Bars = 8,
BeatsOffset = 0.3f,
ColorTransitionIn = 0.4f,
ColorTransitionOut = 0.4f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse(["#FCEB3C", "#FC3C9D", "#65C7FA", "#89FC8F", "#FEE9E9", "#FCEB3C", "#89FC8F", "#FC3C9D"]),
LoopOffset = 0,
FadeOutBeat = -6,
FadeOutDuration = 5,
FlickerLightsTimeSeries = [-8, 31],
Lyrics = [],
DrunknessLoopOffsetTimeSeries = new([7f, 12f, 15f], [0f, 0.90f, 0f]),
CondensationLoopOffsetTimeSeries = new([23f, 28f, 31f], [0f, 0.4f, 0f]),
},
],
},
new SelectableAudioTrack
{
Name = "BbIXODaHET",
AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 40.85f,
Bars = 8,
BeatsOffset = 0.3f,
ColorTransitionIn = 0.7f,
ColorTransitionOut = 0.3f,
ColorTransitionEasing = Easing.InOutCubic,
Palette = Palette.Parse([
"#E6D58F", "#612F7E", "#D9783F", "#C3411C", "#D3B742", "#549BDE",
]),
LoopOffset = 0,
FadeOutBeat = -6,
FadeOutDuration = 6,
FlickerLightsTimeSeries = [-32.5f, -16.5f, 30.5f],
Lyrics = [],
DrunknessLoopOffsetTimeSeries = new(
[-1, 2, 7, 31],
[0, 0.4f, 0, 0]),
},
new SelectableAudioTrack
{
Name = "Whistle",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
WindUpTimer = 41.27f,
Bars = 12,
BeatsOffset = 0.0f,
ColorTransitionIn = 0.5f,
ColorTransitionOut = 0.2f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#8DDEDD", "#98DE28", "#E8DB4B", "#F060A8", "#EEC263", "#725DEB",
]),
LoopOffset = 16,
FadeOutBeat = -22,
FadeOutDuration = 6,
FlickerLightsTimeSeries = [-20, 12],
Lyrics = [
(-40, "Can you blow my whistle, baby, whistle, baby?"),
(-36, "Can you blow my whistle, baby, whistle, baby? Let me know"),
(-33.5f, "Girl, I'm gonna show you how to\ndo it"),
(-30.5f, "Girl, I'm gonna show you how to\ndo it and we start real slow"),
(-27, "You just put your lips together"),
(-24, "You just put your lips together and you come real close"),
(-21, "Can you blow my whistle, baby, whistle, baby?"),
(-17, "HERE WE GO"),
(10, "Yeah, baby, make that whistle"),
(12, "Yeah, baby, make that whistle\nblow oh oh oh"),
(15, "Can you blow my whistle, baby, whistle, baby?"),
(20, "Can you blow my whistle, baby, whistle, baby? Let me know"),
(23, "Girl, I'm gonna show you how to\ndo it"),
(28, "Girl, I'm gonna show you how to\ndo it and we start real slow"),
(32, "You just put your lips together"),
(36, "You just put your lips together and you come real close"),
(39, "Can you blow my whistle, baby, whistle, baby?"),
(46, "HERE"),
(47, "Here WE"),
(48, "Here we GO"),
],
DrunknessLoopOffsetTimeSeries = new(
[-16f, -15.25f, -12f, 9f, 15f, 16f, 18f, 21f],
[0f, 0.7f, 0f, 0f, 0.4f, 0.7f, 0.4f, 0f]),
},
new SelectableAudioTrack
{
Name = "ReelGoon",
AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
WindUpTimer = 45.15f,
Bars = 16,
BeatsOffset = -0.35f,
ColorTransitionIn = 0.1f,
ColorTransitionOut = 0.35f,
ColorTransitionEasing = Easing.OutExpo,
Palette = Palette.Parse([
"#DE1C31", "#F7E26B", "#3D3D3D", "#FBB040",
"#ED4E4A", "#F0BD37", "#E41E2E", "#2E2D2B",
]),
LoopOffset = 0,
FadeOutBeat = -2,
FadeOutDuration = 2,
FlickerLightsTimeSeries = [-41, 61],
Lyrics = [],
DrunknessLoopOffsetTimeSeries = new(
[-0.5f, -0.05f, 6f, 60f, 61f],
[0f, 0.5f, 0f, 0f, 0.5f]),
},
];
public static ISelectableTrack ChooseTrack()
{
var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed;
var tracks = Config.SkipExplicitTracks.Value ? [.. Tracks.Where(track => !track.IsExplicit)] : Tracks;
int[] weights = [.. tracks.Select(track => track.Weight.Value)];
var rwi = new RandomWeightedIndex(weights);
var trackId = rwi.GetRandomWeightedIndex(seed);
var track = tracks[trackId];
Debug.Log($"{nameof(MuzikaGromche)} Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}");
return tracks[trackId];
}
public static IAudioTrack? FindTrackNamed(string name)
{
return Tracks.SelectMany(track => track.GetTracks()).FirstOrDefault(track => track.Name == name);
}
// Max audible distance for AudioSource and LyricsEvent
public const float AudioMaxDistance = 150;
public static bool LocalPlayerCanHearMusic(EnemyAI jester)
{
var player = GameNetworkManager.Instance.localPlayerController;
var listener = StartOfRound.Instance.audioListener;
if (player == null || listener == null || !player.isInsideFactory)
{
return false;
}
var distance = Vector3.Distance(listener.transform.position, jester.transform.position);
return distance <= AudioMaxDistance;
}
public static void DisplayLyrics(string text)
{
HUDManager.Instance.DisplayTip("[Lyrics]", text);
// Don't interrupt the music with constant HUD audio pings
HUDManager.Instance.UIAudio.Stop();
}
void Awake()
{
// Sort in place by name
Array.Sort(Tracks.Select(track => track.Name).ToArray(), Tracks);
string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
Dictionary<string, (UnityWebRequest Request, List<Action<AudioClip>> Setters)> requests = [];
requests.EnsureCapacity(Tracks.Length * 2);
foreach (var track in Tracks.SelectMany(track => track.GetTracks()))
{
foreach (var (fileName, setter) in new (string, Action<AudioClip>)[]
{
(track.FileNameIntro, clip => track.LoadedIntro = clip),
(track.FileNameLoop, clip => track.LoadedLoop = clip),
})
{
if (requests.TryGetValue(fileName, out var tuple))
{
tuple.Setters.Add(setter);
}
else
{
var request = UnityWebRequestMultimedia.GetAudioClip($"file://{dir}/{fileName}", track.AudioType);
request.SendWebRequest();
requests[fileName] = (request, [setter]);
}
}
}
while (!requests.Values.All(tuple => tuple.Request.isDone)) { }
if (requests.Values.All(tuple => tuple.Request.result == UnityWebRequest.Result.Success))
{
foreach (var (fileName, tuple) in requests)
{
var clip = DownloadHandlerAudioClip.GetContent(tuple.Request);
foreach (var setter in tuple.Setters)
{
setter(clip);
}
}
#if DEBUG
foreach (var track in Tracks)
{
track.Debug();
}
#endif
Config = new Config(base.Config);
DiscoBallManager.Load();
PoweredLightsAnimators.Load();
var harmony = new Harmony(PluginInfo.PLUGIN_NAME);
harmony.PatchAll(typeof(GameNetworkManagerPatch));
harmony.PatchAll(typeof(JesterPatch));
harmony.PatchAll(typeof(EnemyAIPatch));
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(ScreenFiltersManager.HUDManagerScreenFiltersPatch));
NetcodePatcher();
Compatibility.Register(this);
}
else
{
var failed = requests.Values.Where(tuple => tuple.Request.result != UnityWebRequest.Result.Success).Select(tuple => tuple.Request.GetUrl());
Logger.LogError("Could not load audio file " + string.Join(", ", failed));
}
}
private static void NetcodePatcher()
{
var types = Assembly.GetExecutingAssembly().GetTypes();
foreach (var type in types)
{
var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static);
foreach (var method in methods)
{
var attributes = method.GetCustomAttributes(typeof(RuntimeInitializeOnLoadMethodAttribute), false);
if (attributes.Length > 0)
{
method.Invoke(null, null);
}
}
}
}
};
public readonly record struct Language(string Short, string Full)
{
public static readonly Language ENGLISH = new("EN", "English");
public static readonly Language RUSSIAN = new("RU", "Russian");
public static readonly Language KOREAN = new("KO", "Korean");
public static readonly Language JAPANESE = new("JP", "Japanese");
public static readonly Language HINDI = new("HI", "Hindi");
}
public readonly record struct Easing(string Name, Func<float, float> Eval)
{
public static Easing Linear = new("Linear", static x => x);
public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f));
public static Easing InCubic = new("InCubic", static x => x * x * x);
public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f);
public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f));
public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x));
public static Easing InOutExpo = new("InOutExpo", static x =>
x == 0f
? 0f
: x == 1f
? 1f
: x < 0.5f
? Mathf.Pow(2f, 20f * x - 10f) / 2f
: (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f);
public static readonly Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo];
public static readonly string[] AllNames = [.. All.Select(easing => easing.Name)];
public static Easing FindByName(string Name)
{
return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First();
}
public override string ToString()
{
return Name;
}
}
public readonly record struct Palette(Color[] Colors)
{
public static readonly Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]);
public static Palette Parse(string[] hexColors)
{
Color[] colors = new Color[hexColors.Length];
for (int i = 0; i < hexColors.Length; i++)
{
if (!ColorUtility.TryParseHtmlString(hexColors[i], out colors[i]))
{
throw new ArgumentException($"Unable to parse color #{i}: {hexColors}");
}
}
return new Palette(colors);
}
public static Palette operator +(Palette before, Palette after)
{
return new Palette([.. before.Colors, .. after.Colors]);
}
public static Palette operator *(Palette palette, int repeat)
{
var colors = Enumerable.Repeat(palette.Colors, repeat).SelectMany(x => x).ToArray();
return new Palette(colors);
}
public Palette Stretch(int times)
{
var colors = Colors.SelectMany(color => Enumerable.Repeat(color, times)).ToArray();
return new Palette(colors);
}
public Palette Use(Func<Palette, Palette> op)
{
return op.Invoke(this);
}
}
public readonly struct TimeSeries<T>
{
public TimeSeries() : this([], []) { }
public TimeSeries(float[] beats, T[] values)
{
if (beats.Length != values.Length)
{
throw new ArgumentOutOfRangeException($"Time series length mismatch: {beats.Length} != {values.Length}");
}
var dict = new SortedDictionary<float, T>();
for (int i = 0; i < values.Length; i++)
{
dict.Add(beats[i], values[i]);
}
Beats = [.. dict.Keys];
Values = [.. dict.Values];
}
public readonly int Length => Beats.Length;
public readonly float[] Beats { get; } = [];
public readonly T[] Values { get; } = [];
public override string ToString()
{
return $"{nameof(TimeSeries<T>)}([{string.Join(", ", Beats)}], [{string.Join(", ", Values)}])";
}
}
// An instance of a track which appears as a configuration entry and
// can be selected using weighted random from a list of selectable tracks.
public interface ISelectableTrack
{
// Name of the track, as shown in config entry UI; also used for default file names.
public string Name { get; init; }
// Language of the track's lyrics.
public Language Language { get; init; }
// Whether this track has NSFW/explicit lyrics.
public bool IsExplicit { get; init; }
// How often this track should be chosen, relative to the sum of weights of all tracks.
internal ConfigEntry<int> Weight { get; set; }
internal IAudioTrack[] GetTracks();
// 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.
internal IAudioTrack SelectTrack(int index);
internal void Debug();
}
// An instance of a track which has file names, timings data, palette; can be loaded and played.
public interface IAudioTrack
{
// Name of the track used for default file names.
public string Name { get; }
// Wind-up time can and should be shorter than the Intro audio track,
// so that the "pop" effect can be baked into the Intro audio and kept away
// from the looped part. This also means that the light show starts before
// the looped track does, so we need to sync them up as soon as we enter the Loop.
public float WindUpTimer { get; }
// Estimated number of beats per minute. Not used for light show, but might come in handy.
public float Bpm => 60f / (LoadedLoop.length / Beats);
// How many beats the loop segment has. The default strategy is to switch color of lights on each beat.
public int Beats { get; }
// Number of beats between WindUpTimer and where looped segment starts (not the loop audio).
public int LoopOffset { get; }
public float LoopOffsetInSeconds => (float)LoopOffset / (float)Beats * LoadedLoop.length;
// MPEG is basically mp3, and it can produce gaps at the start.
// WAV is OK, but takes a lot of space. Try OGGVORBIS instead.
public AudioType AudioType { get; }
public AudioClip LoadedIntro { get; internal set; }
public AudioClip LoadedLoop { get; internal set; }
public string FileNameIntro { get; }
public string FileNameLoop { get; }
public string Ext => AudioType switch
{
AudioType.MPEG => "mp3",
AudioType.WAV => "wav",
AudioType.OGGVORBIS => "ogg",
_ => "",
};
// Offset of beats. Bigger offset => colors will change later.
public float BeatsOffset { get; }
// Offset of beats, in seconds. Bigger offset => colors will change later.
public float BeatsOffsetInSeconds => BeatsOffset / Beats * LoadedLoop.length;
public float FadeOutBeat { get; }
public float FadeOutDuration { get; }
// Duration of color transition, measured in beats.
public float ColorTransitionIn { get; }
public float ColorTransitionOut { get; }
// Easing function for color transitions.
public Easing ColorTransitionEasing { get; }
public float[] FlickerLightsTimeSeries { get; }
public float[] LyricsTimeSeries { get; }
// Lyrics line may contain multiple tab-separated alternatives.
// In such case, a random number chosen and updated once per loop
// is used to select an alternative.
// If the chosen alternative is an empty string, lyrics event shall be skipped.
public string[] LyricsLines { get; }
public TimeSeries<float> DrunknessLoopOffsetTimeSeries { get; }
public TimeSeries<float> CondensationLoopOffsetTimeSeries { get; }
public Palette Palette { get; }
public string? GameOverText { get => null; }
}
// A proxy audio track with default implementation for every IAudioTrack method that simply forwards requests to the inner IAudioTrack.
public abstract class ProxyAudioTrack(IAudioTrack track) : IAudioTrack
{
internal IAudioTrack Track = track;
string IAudioTrack.Name => Track.Name;
float IAudioTrack.WindUpTimer => Track.WindUpTimer;
int IAudioTrack.Beats => Track.Beats;
int IAudioTrack.LoopOffset => Track.LoopOffset;
AudioType IAudioTrack.AudioType => Track.AudioType;
AudioClip IAudioTrack.LoadedIntro { get => Track.LoadedIntro; set => Track.LoadedIntro = value; }
AudioClip IAudioTrack.LoadedLoop { get => Track.LoadedLoop; set => Track.LoadedLoop = value; }
string IAudioTrack.FileNameIntro => Track.FileNameIntro;
string IAudioTrack.FileNameLoop => Track.FileNameLoop;
float IAudioTrack.BeatsOffset => Track.BeatsOffset;
float IAudioTrack.FadeOutBeat => Track.FadeOutBeat;
float IAudioTrack.FadeOutDuration => Track.FadeOutDuration;
float IAudioTrack.ColorTransitionIn => Track.ColorTransitionIn;
float IAudioTrack.ColorTransitionOut => Track.ColorTransitionOut;
Easing IAudioTrack.ColorTransitionEasing => Track.ColorTransitionEasing;
float[] IAudioTrack.FlickerLightsTimeSeries => Track.FlickerLightsTimeSeries;
float[] IAudioTrack.LyricsTimeSeries => Track.LyricsTimeSeries;
string[] IAudioTrack.LyricsLines => Track.LyricsLines;
TimeSeries<float> IAudioTrack.DrunknessLoopOffsetTimeSeries => Track.DrunknessLoopOffsetTimeSeries;
TimeSeries<float> IAudioTrack.CondensationLoopOffsetTimeSeries => Track.CondensationLoopOffsetTimeSeries;
Palette IAudioTrack.Palette => Track.Palette;
string? IAudioTrack.GameOverText => Track.GameOverText;
}
// Core audio track implementation with some defaults and config overrides.
// Suitable to declare elemnents of SelectableTracksGroup and as a base for standalone selectable tracks.
public class CoreAudioTrack : IAudioTrack
{
public /* required */ string Name { get; init; } = "";
public /* required */ float WindUpTimer { get; init; } = 0f;
public int Beats { get; init; }
// Shorthand for four beats
public int Bars
{
init => Beats = value * 4;
}
public int LoopOffset { get; init; } = 0;
public AudioType AudioType { get; init; } = AudioType.MPEG;
public AudioClip LoadedIntro { get; set; } = null!;
public AudioClip LoadedLoop { get; set; } = null!;
private string? FileNameIntroOverride = null;
public string FileNameIntro
{
get => FileNameIntroOverride ?? $"{Name}Intro.{((IAudioTrack)this).Ext}";
init => FileNameIntroOverride = value;
}
private string? FileNameLoopOverride = null;
public string FileNameLoop
{
get => FileNameLoopOverride ?? $"{Name}Loop.{((IAudioTrack)this).Ext}";
init => FileNameLoopOverride = value;
}
public float BeatsOffset { get; init; } = 0f;
public float FadeOutBeat { get; init; } = float.NaN;
public float FadeOutDuration { get; init; } = 2f;
public float ColorTransitionIn { get; init; } = 0.25f;
public float ColorTransitionOut { get; init; } = 0.25f;
public Easing ColorTransitionEasing { get; init; } = Easing.OutExpo;
public float[] _FlickerLightsTimeSeries = [];
public float[] FlickerLightsTimeSeries
{
get => _FlickerLightsTimeSeries;
init
{
Array.Sort(value);
_FlickerLightsTimeSeries = value;
}
}
public float[] LyricsTimeSeries { get; private set; } = [];
// Lyrics line may contain multiple tab-separated alternatives.
// In such case, a random number chosen and updated once per loop
// is used to select an alternative.
// If the chosen alternative is an empty string, lyrics event shall be skipped.
public string[] LyricsLines { get; private set; } = [];
public (float, string)[] Lyrics
{
set
{
var dict = new SortedDictionary<float, string>();
foreach (var (beat, text) in value)
{
dict.Add(beat, text);
}
LyricsTimeSeries = [.. dict.Keys];
LyricsLines = [.. dict.Values];
}
}
public TimeSeries<float> DrunknessLoopOffsetTimeSeries { get; init; } = new();
public TimeSeries<float> CondensationLoopOffsetTimeSeries { get; init; } = new();
public Palette Palette { get; set; } = Palette.DEFAULT;
public string? GameOverText { get; init; } = null;
}
// Standalone, top-level, selectable audio track
public class SelectableAudioTrack : CoreAudioTrack, ISelectableTrack
{
public /* required */ Language Language { get; init; }
public bool IsExplicit { get; init; } = false;
ConfigEntry<int> ISelectableTrack.Weight { get; set; } = null!;
IAudioTrack[] ISelectableTrack.GetTracks() => [this];
IAudioTrack ISelectableTrack.SelectTrack(int index) => this;
void ISelectableTrack.Debug()
{
Debug.Log($"{nameof(MuzikaGromche)} Track \"{Name}\", Intro={LoadedIntro.length:N4}, Loop={LoadedLoop.length:N4}");
}
}
public class SelectableTracksGroup : ISelectableTrack
{
public /* required */ string Name { get; init; } = "";
public /* required */ Language Language { get; init; }
public bool IsExplicit { get; init; } = false;
ConfigEntry<int> ISelectableTrack.Weight { get; set; } = null!;
public /* required */ IAudioTrack[] Tracks = [];
IAudioTrack[] ISelectableTrack.GetTracks() => Tracks;
IAudioTrack ISelectableTrack.SelectTrack(int index)
{
if (Tracks.Length == 0)
{
throw new IndexOutOfRangeException("Tracks list is empty");
}
return Mod.Index(Tracks, index);
}
void ISelectableTrack.Debug()
{
Debug.Log($"{nameof(MuzikaGromche)} Track Group \"{Name}\", Count={Tracks.Length}");
foreach (var (track, index) in Tracks.Select((x, i) => (x, i)))
{
Debug.Log($"{nameof(MuzikaGromche)} Track {index} \"{track.Name}\", Intro={track.LoadedIntro.length:N4}, Loop={track.LoadedLoop.length:N4}");
}
}
}
readonly record struct BeatTimestamp
{
// Number of beats in the loop audio segment.
public readonly int LoopBeats;
public readonly float HalfLoopBeats => LoopBeats / 2f;
// Whether negative time should wrap around. Positive time past LoopBeats always wraps around.
public readonly bool IsLooping;
// Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative.
public readonly float Beat;
// Additional metadata describing whether this timestamp is based on extrapolated source data.
public readonly bool IsExtrapolated;
public BeatTimestamp(int loopBeats, bool isLooping, float beat, bool isExtrapolated)
{
LoopBeats = loopBeats;
IsLooping = isLooping || beat >= HalfLoopBeats;
Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat;
IsExtrapolated = isExtrapolated;
}
public static BeatTimestamp operator +(BeatTimestamp self, float delta)
{
if (delta < -self.HalfLoopBeats && self.Beat > self.HalfLoopBeats /* implied: */ && self.IsLooping)
{
// Warning: you can't meaningfully subtract more than half of the loop
// from a looping timestamp whose Beat is past half of the loop,
// because the resulting IsLooping is unknown.
// Shouldn't be needed though, as deltas are usually short enough.
// But don't try to chain many short negative deltas!
}
return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta, self.IsExtrapolated);
}
public static BeatTimestamp operator -(BeatTimestamp self, float delta)
{
return self + -delta;
}
public readonly BeatTimestamp Floor()
{
// There is no way it wraps or affects IsLooping state
var beat = Mathf.Floor(Beat);
return new BeatTimestamp(LoopBeats, IsLooping, beat, IsExtrapolated);
}
public readonly override string ToString()
{
return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')} {Beat:N4}/{LoopBeats})";
}
}
readonly record struct BeatTimeSpan
{
public readonly int LoopBeats;
public readonly float HalfLoopBeats => LoopBeats / 2f;
public readonly bool IsLooping;
// Open lower bound
public readonly float BeatFromExclusive;
// Closed upper bound
public readonly float BeatToInclusive;
// Additional metadata describing whether this timestamp is based on extrapolated source data.
public readonly bool IsExtrapolated;
public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive, bool isExtrapolated)
{
LoopBeats = loopBeats;
IsLooping = isLooping || beatToInclusive >= HalfLoopBeats;
BeatFromExclusive = wrap(beatFromExclusive);
BeatToInclusive = wrap(beatToInclusive);
IsExtrapolated = isExtrapolated;
float wrap(float beat)
{
return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat;
}
}
public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive)
{
var isExtrapolated = timestampFromExclusive.IsExtrapolated || timestampToInclusive.IsExtrapolated;
return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat, isExtrapolated);
}
public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive)
{
return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat, timestampToInclusive.IsExtrapolated);
}
public static BeatTimeSpan Empty = new();
public readonly BeatTimestamp ToTimestamp()
{
return new(LoopBeats, IsLooping, BeatToInclusive, IsExtrapolated);
}
// The beat will not be wrapped.
public readonly bool ContainsExact(float beat)
{
return BeatFromExclusive < beat && beat <= BeatToInclusive;
}
public readonly int? GetLastIndex(float[] timeSeries)
{
if (IsEmpty() || timeSeries == null || timeSeries.Length == 0)
{
return null;
}
if (IsWrapped())
{
// Split the search in two non-wrapping searches:
// before wrapping (happens earlier) and after wrapping (happens later).
// Check the "happens later" part first.
var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive, IsExtrapolated);
var laterIndex = laterSpan.GetLastIndex(timeSeries);
if (laterIndex != null)
{
return laterIndex;
}
// The "happens earlier" part is easy: it's just the last value in the series.
var lastIndex = timeSeries.Length - 1;
if (timeSeries[lastIndex] > BeatFromExclusive)
{
return lastIndex;
}
}
else
{
// BeatFromExclusive might as well be -Infinity
var index = Array.BinarySearch(timeSeries, BeatToInclusive);
if (index > 0 && index < timeSeries.Length && timeSeries[index] > BeatFromExclusive)
{
return index;
}
else
{
// Restore from bitwise complement
index = ~index;
// index points to the next larger object, i.e. the next event in the series after the BeatToInclusive.
// Make it point to one event before that.
index -= 1;
if (index >= 0 && timeSeries[index] > BeatFromExclusive && timeSeries[index] <= BeatToInclusive)
{
return index;
}
}
}
return null;
}
public readonly float Duration(bool longest = false)
{
if (longest)
{
var to = BeatToInclusive;
if (BeatFromExclusive >= 0f && BeatToInclusive >= 0f && to < BeatFromExclusive)
{
// wrapped
to += LoopBeats;
}
return Mathf.Max(0f, to - BeatFromExclusive);
}
else if (IsEmpty())
{
return 0f;
}
else if (IsWrapped())
{
var beforeWrapping = LoopBeats - BeatFromExclusive;
var afterWrapping = BeatToInclusive - 0f;
return beforeWrapping + afterWrapping;
}
else
{
return BeatToInclusive - BeatFromExclusive;
}
}
public readonly bool IsEmpty()
{
if (IsLooping)
{
var to = BeatToInclusive;
// unwrap if needed
if (BeatToInclusive < BeatFromExclusive)
{
to = BeatToInclusive + LoopBeats;
}
// Due to audio offset changes, `to` may shift before `from`, so unwrapping it would result in a very large span
return to - BeatFromExclusive > HalfLoopBeats;
}
else
{
// straightforward requirement that time is monotonic
return BeatFromExclusive > BeatToInclusive;
}
}
public readonly bool IsWrapped()
{
return IsLooping && !IsEmpty() && BeatToInclusive < BeatFromExclusive;
}
public readonly override string ToString()
{
return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})";
}
}
class ExtrapolatedAudioSourceState
{
// AudioSource.isPlaying
public bool IsPlaying { get; private set; }
// AudioSource.time, possibly extrapolated
public float Time => ExtrapolatedTime;
// The object is newly created, the AudioSource began to play (possibly delayed) but its time hasn't advanced from 0.0f yet.
// Time can not be extrapolated when HasStarted is false.
public bool HasStarted { get; private set; } = false;
public bool IsExtrapolated => LastKnownNonExtrapolatedTime != ExtrapolatedTime;
private float ExtrapolatedTime = 0f;
private float LastKnownNonExtrapolatedTime = 0f;
// Any wall clock based measurements of when this state was recorded
private float LastKnownRealtime = 0f;
private const float MaxExtrapolationInterval = 0.5f;
public void Update(AudioSource audioSource, float realtime)
{
IsPlaying = audioSource.isPlaying;
HasStarted |= audioSource.time != 0f;
if (LastKnownNonExtrapolatedTime != audioSource.time)
{
LastKnownRealtime = realtime;
LastKnownNonExtrapolatedTime = ExtrapolatedTime = audioSource.time;
}
// Frames are rendering faster than AudioSource updates its playback time state
else if (IsPlaying && HasStarted && Config.ExtrapolateTime)
{
#if DEBUG
Debug.Assert(LastKnownNonExtrapolatedTime == audioSource.time); // implied
#endif
var deltaTime = realtime - LastKnownRealtime;
if (0 < deltaTime && deltaTime < MaxExtrapolationInterval)
{
ExtrapolatedTime = LastKnownNonExtrapolatedTime + deltaTime;
}
}
}
public override string ToString()
{
return $"{nameof(ExtrapolatedAudioSourceState)}({(IsPlaying ? 'P' : '_')}{(HasStarted ? 'S' : '0')} "
+ (IsExtrapolated
? $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4} => {ExtrapolatedTime:N4}"
: $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4}"
) + ")";
}
}
class JesterAudioSourcesState
{
private readonly float IntroClipLength;
// Neither intro.isPlaying or loop.isPlaying are reliable indicators of which track is actually playing right now:
// intro.isPlaying would be true during the loop when Jester chases a player,
// loop.isPlaying would be true when it is played delyaed but hasn't actually started playing yet.
private readonly ExtrapolatedAudioSourceState Intro = new();
private readonly ExtrapolatedAudioSourceState Loop = new();
// If true, use Start state as a reference, otherwise use Loop.
private bool ReferenceIsIntro = true;
public bool HasStarted => Intro.HasStarted;
public bool IsExtrapolated => ReferenceIsIntro ? Intro.IsExtrapolated : Loop.IsExtrapolated;
// Time from the start of the start clip. It wraps when the loop AudioSource loops:
// [...start...][...loop...]
// ^ |
// `----------'
public float Time => ReferenceIsIntro
? Intro.Time
: IntroClipLength + Loop.Time;
public JesterAudioSourcesState(float introClipLength)
{
IntroClipLength = introClipLength;
}
public void Update(AudioSource intro, AudioSource loop, float realtime)
{
// It doesn't make sense to update start state after loop has started (because start.isPlaying occasionally becomes true).
// But always makes sense to update loop, so we can check if it has actually started.
Loop.Update(loop, realtime);
if (!Loop.HasStarted)
{
#if DEBUG
Debug.Assert(ReferenceIsIntro);
#endif
Intro.Update(intro, realtime);
}
else
{
ReferenceIsIntro = false;
}
}
}
// This class tracks looping state of the playback, so that the timestamps can be correctly wrapped only when needed.
// [... ...time... ...]
// ^ |
// `---|---' loop
// ^ IsLooping becomes true and stays true forever.
class AudioLoopingState
{
public bool IsLooping { get; private set; } = false;
private readonly float StartOfLoop;
private readonly float LoopLength;
private readonly int Beats;
public AudioLoopingState(float startOfLoop, float loopLength, int beats)
{
StartOfLoop = startOfLoop;
LoopLength = loopLength;
Beats = beats;
}
public BeatTimestamp Update(float time, bool isExtrapolated, float additionalOffset)
{
// If popped, calculate which beat the music is currently at.
// In order to do that we should choose one of two strategies:
//
// 1. If start source is still playing, use its position since WindUpTimer.
// 2. Otherwise use loop source, adding the delay after WindUpTimer,
// which is the remaining of the start, i.e. (LoadedStart.length - WindUpTimer).
//
// NOTE 1: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true and as such it's useful.
// NOTE 2: There is a weird state when Jester has popped and chases a player:
// Intro/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that.
var offset = StartOfLoop + additionalOffset;
float timeSinceStartOfLoop = time - offset;
var adjustedTimeNormalized = timeSinceStartOfLoop / LoopLength;
var beat = adjustedTimeNormalized * Beats;
// Let it infer the isLooping flag from the beat
var timestamp = new BeatTimestamp(Beats, IsLooping, beat, isExtrapolated);
IsLooping |= timestamp.IsLooping;
#if DEBUG && false
Debug.LogFormat("{0} t={1,10:N4} d={2,7:N4} {3} Time={4:N4} norm={5,6:N4} beat={6,7:N4}",
nameof(MuzikaGromche),
Time.realtimeSinceStartup, Time.deltaTime,
isExtrapolated ? 'E' : '_', time,
adjustedTimeNormalized, beat);
#endif
return timestamp;
}
}
class BeatTimeState
{
private readonly IAudioTrack track;
private readonly JesterAudioSourcesState AudioState;
// Colors wrap from WindUpTimer
private readonly AudioLoopingState WindUpLoopingState;
// Events other than colors wrap from WindUpTimer+LoopOffset.
private readonly AudioLoopingState LoopLoopingState;
private float LastKnownLoopOffsetBeat = float.NegativeInfinity;
private static System.Random LyricsRandom = null!;
private int LyricsRandomPerLoop;
private bool WindUpZeroBeatEventTriggered = false;
public BeatTimeState(IAudioTrack track)
{
if (LyricsRandom == null)
{
LyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337);
LyricsRandomPerLoop = LyricsRandom.Next();
}
this.track = track;
AudioState = new(track.LoadedIntro.length);
WindUpLoopingState = new(track.WindUpTimer, track.LoadedLoop.length, track.Beats);
LoopLoopingState = new(track.WindUpTimer + track.LoopOffsetInSeconds, track.LoadedLoop.length, track.Beats);
}
public List<BaseEvent> Update(AudioSource intro, AudioSource loop)
{
var time = Time.realtimeSinceStartup;
AudioState.Update(intro, loop, time);
if (AudioState.HasStarted)
{
var loopOffsetTimestamp = Update(LoopLoopingState);
var loopOffsetSpan = BeatTimeSpan.Between(LastKnownLoopOffsetBeat, loopOffsetTimestamp);
// Do not go back in time
if (!loopOffsetSpan.IsEmpty())
{
if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive)
{
LyricsRandomPerLoop = LyricsRandom.Next();
}
var windUpOffsetTimestamp = Update(WindUpLoopingState);
LastKnownLoopOffsetBeat = loopOffsetTimestamp.Beat;
var events = GetEvents(loopOffsetTimestamp, loopOffsetSpan, windUpOffsetTimestamp);
#if DEBUG
Debug.Log($"{nameof(MuzikaGromche)} looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(",", events)}");
#endif
return events;
}
}
return [];
}
private BeatTimestamp Update(AudioLoopingState loopingState)
{
return loopingState.Update(AudioState.Time, AudioState.IsExtrapolated, AdditionalOffset());
}
// Timings that may be changed through config
private float AdditionalOffset()
{
return Config.AudioOffset.Value + track.BeatsOffsetInSeconds;
}
private List<BaseEvent> GetEvents(BeatTimestamp loopOffsetTimestamp, BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
{
List<BaseEvent> events = [];
if (windUpOffsetTimestamp.Beat >= 0f && !WindUpZeroBeatEventTriggered)
{
events.Add(new WindUpZeroBeatEvent());
WindUpZeroBeatEventTriggered = true;
}
if (GetColorEvent(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent)
{
events.Add(colorEvent);
}
if (loopOffsetSpan.GetLastIndex(track.FlickerLightsTimeSeries) != null)
{
events.Add(new FlickerLightsEvent());
}
// TODO: quick editor
if (Config.DisplayLyrics.Value)
{
var index = loopOffsetSpan.GetLastIndex(track.LyricsTimeSeries);
if (index is int i && i < track.LyricsLines.Length)
{
var line = track.LyricsLines[i];
var alternatives = line.Split('\t');
var randomIndex = LyricsRandomPerLoop % alternatives.Length;
var alternative = alternatives[randomIndex];
if (alternative != "")
{
events.Add(new LyricsEvent(alternative));
}
}
}
if (GetInterpolation(loopOffsetTimestamp, track.DrunknessLoopOffsetTimeSeries, Easing.Linear) is { } drunkness)
{
events.Add(new DrunkEvent(drunkness));
}
if (GetInterpolation(loopOffsetTimestamp, track.CondensationLoopOffsetTimeSeries, Easing.Linear) is { } condensation)
{
events.Add(new CondensationEvent(condensation));
}
return events;
}
private SetLightsColorEvent? GetColorEvent(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
{
if (FadeOut(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent1)
{
return colorEvent1;
}
if (ColorFromPaletteAtTimestamp(windUpOffsetTimestamp) is { } colorEvent2)
{
return colorEvent2;
}
return null;
}
private SetLightsColorTransitionEvent? FadeOut(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
{
var fadeOutStart = track.FadeOutBeat;
var fadeOutEnd = fadeOutStart + track.FadeOutDuration;
if (windUpOffsetTimestamp.Beat < 0f && track.FadeOutBeat < loopOffsetSpan.BeatToInclusive && loopOffsetSpan.BeatFromExclusive <= fadeOutEnd)
{
var t = (loopOffsetSpan.BeatToInclusive - track.FadeOutBeat) / track.FadeOutDuration;
return new SetLightsColorTransitionEvent(/* use initial light color */null, Color.black, Easing.Linear, t);
}
else
{
return null;
}
}
public SetLightsColorEvent? ColorFromPaletteAtTimestamp(BeatTimestamp timestamp)
{
if (timestamp.Beat <= -track.ColorTransitionIn)
{
return null;
}
// Imagine the timeline as a sequence of clips without gaps where each clip is a whole beat long.
// Transition is when two adjacent clips need to be combined with some blend function(t)
// where t is a factor in range 0..1 expressed as (time - Transition.Start) / Transition.Length;
//
// How to find a transition at a given time?
// First, we need to find the current clip's start and length.
// - Length is always 1 beat, and
// - start is just time rounded down.
//
// If time interval from the start of the clip is less than Transition.Out
// then blend between previous and current clips.
//
// Else if time interval to the end of the clip is less than Transition.In
// then blend between current and next clips.
//
// Otherwise there is no transition running at this time.
const float currentClipLength = 1f;
var currentClipStart = timestamp.Floor();
var currentClipEnd = currentClipStart + currentClipLength;
float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut;
if (transitionLength > /* epsilon */ 0.01)
{
if (BeatTimeSpan.Between(currentClipStart, timestamp).Duration() < track.ColorTransitionOut)
{
return ColorTransition(currentClipStart);
}
else if (BeatTimeSpan.Between(timestamp, currentClipEnd).Duration() < track.ColorTransitionIn)
{
return ColorTransition(currentClipEnd);
}
}
// default
return new SetLightsColorStaticEvent(ColorAtWholeBeat(timestamp));
SetLightsColorTransitionEvent ColorTransition(BeatTimestamp clipsBoundary)
{
var transitionStart = clipsBoundary - track.ColorTransitionIn;
var transitionEnd = clipsBoundary + track.ColorTransitionOut;
var t = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength;
if (track.ColorTransitionIn == 0.0f)
{
// Subtract an epsilon, so we don't use the same beat twice
transitionStart -= 0.01f;
}
return new SetLightsColorTransitionEvent(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), track.ColorTransitionEasing, t);
}
Color? ColorAtWholeBeat(BeatTimestamp timestamp)
{
if (timestamp.Beat >= 0f)
{
int wholeBeat = Mathf.FloorToInt(timestamp.Beat);
return Mod.Index(track.Palette.Colors, wholeBeat);
}
else
{
return float.IsNaN(track.FadeOutBeat) ? /* use initial light color */ null : Color.black;
}
}
}
private float? GetInterpolation(BeatTimestamp timestamp, TimeSeries<float> timeSeries, Easing easing)
{
if (timeSeries.Length == 0)
{
return null;
}
else if (timeSeries.Length == 1)
{
return timeSeries.Values[0];
}
else
{
int? indexOfPrevious = null;
// Find index of the previous time. If looped, wrap backwards. In either case it is possibly missing.
for (int i = timeSeries.Length - 1; i >= 0; i--)
{
if (timeSeries.Beats[i] <= timestamp.Beat)
{
indexOfPrevious = i;
break;
}
}
if (indexOfPrevious == null && timestamp.IsLooping)
{
indexOfPrevious = timeSeries.Length - 1;
}
// Find index of the next time. If looped, wrap forward.
int? indexOfNext = null;
for (int i = 0; i < timeSeries.Length; i++)
{
if (timeSeries.Beats[i] >= timestamp.Beat)
{
indexOfNext = i;
break;
}
}
if (indexOfNext == null && timestamp.IsLooping)
{
for (int i = 0; i < timeSeries.Length; i++)
{
if (timeSeries.Beats[i] >= 0f)
{
indexOfNext = i;
break;
}
}
}
switch (indexOfPrevious, indexOfNext)
{
case (null, null):
return null;
case (null, { } index):
return timeSeries.Values[index];
case ({ } index, null):
return timeSeries.Values[index];
case ({ } prev, { } next) when prev == next || timeSeries.Beats[prev] == timeSeries.Beats[next]:
return timeSeries.Values[prev];
case ({ } prev, { } next):
var prevBeat = timeSeries.Beats[prev];
var nextBeat = timeSeries.Beats[next];
var prevTimestamp = new BeatTimestamp(timestamp.LoopBeats, isLooping: false, prevBeat, false);
var nextTimestamp = new BeatTimestamp(timestamp.LoopBeats, isLooping: false, nextBeat, false);
var t = BeatTimeSpan.Between(prevTimestamp, timestamp).Duration(longest: true)
/ BeatTimeSpan.Between(prevTimestamp, nextTimestamp).Duration(longest: true);
var prevVal = timeSeries.Values[prev];
var nextVal = timeSeries.Values[next];
var val = Mathf.Lerp(prevVal, nextVal, easing.Eval(t));
return val;
}
}
}
}
abstract class BaseEvent;
abstract class SetLightsColorEvent : BaseEvent
{
// Calculate final color, substituting null with initialColor if needed.
public abstract Color GetColor(Color initialColor);
protected string NullableColorToString(Color? color)
{
return color is { } c ? ColorUtility.ToHtmlStringRGB(c) : "??????";
}
}
class SetLightsColorStaticEvent(Color? color) : SetLightsColorEvent
{
public readonly Color? Color = color;
public override Color GetColor(Color initialColor)
{
return Color ?? initialColor;
}
public override string ToString()
{
return $"Color(#{NullableColorToString(Color)})";
}
}
class SetLightsColorTransitionEvent(Color? from, Color? to, Easing easing, float t) : SetLightsColorEvent
{
// Additional context for debugging
public readonly Color? From = from;
public readonly Color? To = to;
public readonly Easing Easing = easing;
public readonly float T = t;
public override Color GetColor(Color initialColor)
{
var from = From ?? initialColor;
var to = To ?? initialColor;
return Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f));
}
private Color? GetNullableColor()
{
return From is { } from && To is { } to ? Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)) : null;
}
public override string ToString()
{
return $"Color(#{NullableColorToString(GetNullableColor())} = #{NullableColorToString(From)}..#{NullableColorToString(To)} {Easing} {T:N4})";
}
}
class FlickerLightsEvent : BaseEvent
{
public override string ToString() => "Flicker";
}
class LyricsEvent(string text) : BaseEvent
{
public readonly string Text = text;
public override string ToString()
{
return $"Lyrics({Text.Replace("\n", "\\n")})";
}
}
class WindUpZeroBeatEvent : BaseEvent
{
public override string ToString() => "WindUp";
}
abstract class HUDEvent : BaseEvent;
class DrunkEvent(float drunkness) : HUDEvent
{
public readonly float Drunkness = drunkness;
public override string ToString() => $"Drunk({Drunkness:N2})";
}
class CondensationEvent(float condensation) : HUDEvent
{
public readonly float Condensation = condensation;
public override string ToString() => $"Condensation({Condensation:N2})";
}
// Default C#/.NET remainder operator % returns negative result for negative input
// which is unsuitable as an index for an array.
static class Mod
{
public static int Positive(int x, int m)
{
int r = x % m;
return r < 0 ? r + m : r;
}
public static float Positive(float x, float m)
{
float r = x % m;
return r < 0f ? r + m : r;
}
public static T Index<T>(IList<T> array, int index)
{
return array[Mod.Positive(index, array.Count)];
}
}
readonly struct RandomWeightedIndex
{
public RandomWeightedIndex(int[] weights)
{
Weights = weights;
TotalWeights = Weights.Sum();
if (TotalWeights == 0)
{
// If everything is set to zero, everything is equally possible
Weights = [.. Weights.Select(_ => 1)];
TotalWeights = Weights.Length;
}
}
private byte[] GetHash(int seed)
{
var buffer = new byte[4 * (1 + Weights.Length)];
var offset = 0;
Buffer.BlockCopy(BitConverter.GetBytes(seed), 0, buffer, offset, sizeof(int));
// Make sure that tweaking weights even a little drastically changes the outcome
foreach (var weight in Weights)
{
offset += 4;
Buffer.BlockCopy(BitConverter.GetBytes(weight), 0, buffer, offset, sizeof(int));
}
var sha = SHA256.Create();
var hash = sha.ComputeHash(buffer);
return hash;
}
private int GetRawIndex(byte[] hash)
{
if (TotalWeights == 0)
{
// Should not happen, but what if Weights array is empty?
return -1;
}
var index = 0;
foreach (var t in hash)
{
// modulus division on byte array
index *= 256 % TotalWeights;
index %= TotalWeights;
index += t % TotalWeights;
index %= TotalWeights;
}
return index;
}
private int GetWeightedIndex(int rawIndex)
{
if (rawIndex < 0 || rawIndex >= TotalWeights)
{
return -1;
}
int sum = 0;
foreach (var (weight, index) in Weights.Select((x, i) => (x, i)))
{
sum += weight;
if (rawIndex < sum)
{
// Found
return index;
}
}
return -1;
}
public int GetRandomWeightedIndex(int seed)
{
var hash = GetHash(seed);
var index = GetRawIndex(hash);
return GetWeightedIndex(index);
}
public override string ToString()
{
return $"Weighted(Total={TotalWeights}, Weights=[{string.Join(',', Weights)}])";
}
readonly private int[] Weights;
readonly public int TotalWeights { get; }
}
class Config
{
public static ConfigEntry<bool> DisplayLyrics { get; private set; } = null!;
public static ConfigEntry<float> AudioOffset { get; private set; } = null!;
public static ConfigEntry<bool> SkipExplicitTracks { get; private set; } = null!;
public static ConfigEntry<bool> OverrideSpawnRates { get; private set; } = null!;
public static bool ExtrapolateTime { get; private set; } = true;
public static bool ShouldSkipWindingPhase { get; private set; } = false;
// Audio files are normalized to target -14 LUFS, which is too loud to communicate. Reduce by another -12 dB down to about -26 LUFS.
public static float DefaultVolume = 0.25f;
#if DEBUG
public static ConfigEntry<float> Volume { get; private set; } = null!;
// Latest set track, used for loading palette and timings.
private static IAudioTrack? CurrentTrack = null;
// All per-track values that can be overridden
private static float? BeatsOffsetOverride = null;
private static float? FadeOutBeatOverride = null;
private static float? FadeOutDurationOverride = null;
private static float? ColorTransitionInOverride = null;
private static float? ColorTransitionOutOverride = null;
private static string? ColorTransitionEasingOverride = null;
private static float[]? FlickerLightsTimeSeriesOverride = null;
private static float[]? LyricsTimeSeriesOverride = null;
private static TimeSeries<float>? DrunknessLoopOffsetTimeSeriesOverride = null;
private static TimeSeries<float>? CondensationLoopOffsetTimeSeriesOverride = null;
private static Palette? PaletteOverride = null;
private class AudioTrackWithConfigOverride(IAudioTrack track) : ProxyAudioTrack(track), IAudioTrack
{
float IAudioTrack.BeatsOffset => BeatsOffsetOverride ?? Track.BeatsOffset;
float IAudioTrack.FadeOutBeat => FadeOutBeatOverride ?? Track.FadeOutBeat;
float IAudioTrack.FadeOutDuration => FadeOutDurationOverride ?? Track.FadeOutDuration;
float IAudioTrack.ColorTransitionIn => ColorTransitionInOverride ?? Track.ColorTransitionIn;
float IAudioTrack.ColorTransitionOut => ColorTransitionOutOverride ?? Track.ColorTransitionOut;
Easing IAudioTrack.ColorTransitionEasing =>
ColorTransitionEasingOverride != null
? Easing.FindByName(ColorTransitionEasingOverride)
: Track.ColorTransitionEasing;
float[] IAudioTrack.FlickerLightsTimeSeries =>
FlickerLightsTimeSeriesOverride ?? Track.FlickerLightsTimeSeries;
float[] IAudioTrack.LyricsTimeSeries => LyricsTimeSeriesOverride ?? Track.LyricsTimeSeries;
TimeSeries<float> IAudioTrack.DrunknessLoopOffsetTimeSeries => DrunknessLoopOffsetTimeSeriesOverride ?? Track.DrunknessLoopOffsetTimeSeries;
TimeSeries<float> IAudioTrack.CondensationLoopOffsetTimeSeries => CondensationLoopOffsetTimeSeriesOverride ?? Track.CondensationLoopOffsetTimeSeries;
Palette IAudioTrack.Palette => PaletteOverride ?? Track.Palette;
}
#endif
internal Config(ConfigFile configFile)
{
DisplayLyrics = configFile.Bind("General", "Display Lyrics", true,
new ConfigDescription("Display lyrics in the HUD tooltip when you hear the music."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(DisplayLyrics, requiresRestart: false));
AudioOffset = configFile.Bind("General", "Audio Offset", 0f, new ConfigDescription(
"Adjust audio offset (in seconds).\n\nIf you are playing with Bluetooth headphones and experiencing a visual desync, try setting this to about negative 0.2.\n\nIf your video output has high latency (like a long HDMI cable etc.), try positive values instead.",
new AcceptableValueRange<float>(-0.5f, 0.5f)));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(AudioOffset, requiresRestart: false));
SkipExplicitTracks = configFile.Bind("General", "Skip Explicit Tracks", false,
new ConfigDescription("When choosing tracks at random, skip the ones with Explicit Content/Lyrics."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(SkipExplicitTracks, requiresRestart: false));
OverrideSpawnRates = configFile.Bind("General", "Override Spawn Rates", true,
new ConfigDescription("Deviate from vanilla spawn rates to experience content of this mod more often."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(OverrideSpawnRates, requiresRestart: false));
#if DEBUG
SetupEntriesForGameOverText(configFile);
SetupEntriesForScreenFilters(configFile);
SetupEntriesForExtrapolation(configFile);
SetupEntriesToSkipWinding(configFile);
SetupEntriesForVolume(configFile);
SetupEntriesForPaletteOverride(configFile);
SetupEntriesForTimingsOverride(configFile);
#endif
var chanceRange = new AcceptableValueRange<int>(0, 100);
var languageSectionButtonExists = new HashSet<Language>();
foreach (var track in Plugin.Tracks)
{
var language = track.Language;
string section = $"Tracks.{language.Short}";
// Create section toggle
if (!languageSectionButtonExists.Contains(language))
{
languageSectionButtonExists.Add(language);
string buttonOptionName = $"Toggle all {language.Full} tracks";
string buttonDescription = "Toggle all tracks in this section ON or OFF. Effective immediately.";
string buttonText = "Toggle";
var button = new GenericButtonConfigItem(section, buttonOptionName, buttonDescription, buttonText, () =>
{
var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList();
var isOff = tracks.All(t => t.Weight.Value == 0);
var newWeight = isOff ? 50 : 0;
foreach (var t in tracks)
{
t.Weight.Value = newWeight;
}
});
LethalConfigManager.AddConfigItem(button);
}
// Create slider entry for track
string warning = track.IsExplicit ? "Explicit Content/Lyrics!\n\n" : "";
string description = $"Language: {language.Full}\n\n{warning}Random (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track.";
track.Weight = configFile.Bind(
new ConfigDefinition(section, track.Name),
50,
new ConfigDescription(description, chanceRange, track));
LethalConfigManager.AddConfigItem(new IntSliderConfigItem(track.Weight, requiresRestart: false));
}
}
internal static IAudioTrack OverrideCurrentTrack(IAudioTrack track)
{
#if DEBUG
CurrentTrack = track;
return new AudioTrackWithConfigOverride(track);
#else
return track;
#endif
}
#if DEBUG
private void SetupEntriesForExtrapolation(ConfigFile configFile)
{
var entry = configFile.Bind("General", "Extrapolate Audio Playback Time", true,
new ConfigDescription("AudioSource only updates its playback position about 20 times per second.\n\nUse extrapolation technique to predict playback time between updates for smoother color animations."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, requiresRestart: false));
entry.SettingChanged += (sender, args) => apply();
apply();
void apply()
{
ExtrapolateTime = entry.Value;
}
}
private void SetupEntriesToSkipWinding(ConfigFile configFile)
{
var entry = configFile.Bind("General", "Skip Winding Phase", false,
new ConfigDescription("Skip most of the wind-up/intro music.\n\nUse this option to test your Loop audio segment."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, requiresRestart: false));
entry.SettingChanged += (sender, args) => apply();
apply();
void apply()
{
ShouldSkipWindingPhase = entry.Value;
}
}
private void SetupEntriesForVolume(ConfigFile configFile)
{
Volume = configFile.Bind("General", "Volume", DefaultVolume,
new ConfigDescription("Volume of the music played by this mod.", new AcceptableValueRange<float>(0f, 1f)));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(Volume, requiresRestart: false));
}
private void SetupEntriesForPaletteOverride(ConfigFile configFile)
{
const string section = "Palette";
const int maxCustomPaletteSize = 8;
// Declare and initialize early to avoid "Use of unassigned local variable"
ConfigEntry<int> customPaletteSizeEntry = null!;
var customPaletteEntries = new ConfigEntry<string>[maxCustomPaletteSize];
var loadButton = new GenericButtonConfigItem(section, "Load Palette from the Current Track",
"Override custom palette with the built-in palette of the current track.", "Load", load);
LethalConfigManager.AddConfigItem(loadButton);
customPaletteSizeEntry = configFile.Bind(section, "Palette Size", 0, new ConfigDescription(
"Number of colors in the custom palette.\n\nIf set to non-zero, custom palette overrides track's own built-in palette.",
new AcceptableValueRange<int>(0, maxCustomPaletteSize)));
LethalConfigManager.AddConfigItem(new IntSliderConfigItem(customPaletteSizeEntry, requiresRestart: false));
customPaletteSizeEntry.SettingChanged += (sender, args) => apply();
for (int i = 0; i < maxCustomPaletteSize; i++)
{
string entryName = $"Custom Color {i + 1}";
var customColorEntry = configFile.Bind(section, entryName, "#FFFFFF", "Choose color for the custom palette");
customPaletteEntries[i] = customColorEntry;
LethalConfigManager.AddConfigItem(new HexColorInputFieldConfigItem(customColorEntry, requiresRestart: false));
customColorEntry.SettingChanged += (sender, args) => apply();
}
apply();
void load()
{
var palette = CurrentTrack?.Palette ?? Palette.DEFAULT;
var colors = palette.Colors;
var count = Math.Min(colors.Count(), maxCustomPaletteSize);
customPaletteSizeEntry.Value = colors.Count();
for (int i = 0; i < maxCustomPaletteSize; i++)
{
var color = i < count ? colors[i] : Color.white;
string colorHex = $"#{ColorUtility.ToHtmlStringRGB(color)}";
customPaletteEntries[i].Value = colorHex;
}
}
void apply()
{
int size = customPaletteSizeEntry.Value;
if (size == 0 || size > maxCustomPaletteSize)
{
PaletteOverride = null;
}
else
{
var colors = customPaletteEntries.Select(entry => entry.Value).Take(size).ToArray();
PaletteOverride = Palette.Parse(colors);
}
}
}
private void SetupEntriesForTimingsOverride(ConfigFile configFile)
{
const string section = "Timings";
var colorTransitionRange = new AcceptableValueRange<float>(0f, 1f);
// Declare and initialize early to avoid "Use of unassigned local variable"
List<(Action<IAudioTrack?> Load, Action Apply)> entries = [];
ConfigEntry<bool> overrideTimingsEntry = null!;
ConfigEntry<float> fadeOutBeatEntry = null!;
ConfigEntry<float> fadeOutDurationEntry = null!;
ConfigEntry<string> flickerLightsTimeSeriesEntry = null!;
ConfigEntry<string> lyricsTimeSeriesEntry = null!;
ConfigEntry<string> drunknessTimeSeriesEntry = null!;
ConfigEntry<string> condensationTimeSeriesEntry = null!;
ConfigEntry<float> beatsOffsetEntry = null!;
ConfigEntry<float> colorTransitionInEntry = null!;
ConfigEntry<float> colorTransitionOutEntry = null!;
ConfigEntry<string> colorTransitionEasingEntry = null!;
var loadButton = new GenericButtonConfigItem(section, "Load Timings from the Current Track",
"Override custom timings with the built-in timings of the current track.", "Load", load);
LethalConfigManager.AddConfigItem(loadButton);
overrideTimingsEntry = configFile.Bind(section, "Override Timings", false,
new ConfigDescription("If checked, custom timings override track's own built-in timings."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(overrideTimingsEntry, requiresRestart: false));
overrideTimingsEntry.SettingChanged += (sender, args) => apply();
fadeOutBeatEntry = configFile.Bind(section, "Fade Out Beat", 0f,
new ConfigDescription("The beat at which to start fading out", new AcceptableValueRange<float>(-1000f, 0)));
fadeOutDurationEntry = configFile.Bind(section, "Fade Out Duration", 0f,
new ConfigDescription("Duration of fading out", new AcceptableValueRange<float>(0, 10)));
flickerLightsTimeSeriesEntry = configFile.Bind(section, "Flicker Lights Time Series", "",
new ConfigDescription("Time series of loop offset beats when to flicker the lights."));
lyricsTimeSeriesEntry = configFile.Bind(section, "Lyrics Time Series", "",
new ConfigDescription("Time series of loop offset beats when to show lyrics lines."));
drunknessTimeSeriesEntry = configFile.Bind(section, "Drunkness", "",
new ConfigDescription("Time series of loop offset beats which are keyframes for the drunkness effect. Format: 'time1: value1, time2: value2"));
condensationTimeSeriesEntry = configFile.Bind(section, "Helmet Condensation Drops", "",
new ConfigDescription("Time series of loop offset beats which are keyframes for the Helmet Condensation Drops effect. Format: 'time1: value1, time2: value2"));
beatsOffsetEntry = configFile.Bind(section, "Beats Offset", 0f,
new ConfigDescription("How much to offset the whole beat. More is later", new AcceptableValueRange<float>(-0.5f, 0.5f)));
colorTransitionInEntry = configFile.Bind(section, "Color Transition In", 0.25f,
new ConfigDescription("Fraction of a beat *before* the whole beat when the color transition should start.", colorTransitionRange));
colorTransitionOutEntry = configFile.Bind(section, "Color Transition Out", 0.25f,
new ConfigDescription("Fraction of a beat *after* the whole beat when the color transition should end.", colorTransitionRange));
colorTransitionEasingEntry = configFile.Bind(section, "Color Transition Easing", Easing.Linear.Name,
new ConfigDescription("Interpolation/easing method to use for color transitions", new AcceptableValueList<string>(Easing.AllNames)));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutBeatEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutDurationEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(flickerLightsTimeSeriesEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(lyricsTimeSeriesEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(drunknessTimeSeriesEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(condensationTimeSeriesEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutEntry, requiresRestart: false));
LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingEntry, requiresRestart: false));
registerStruct(fadeOutBeatEntry, t => t.FadeOutBeat, x => FadeOutBeatOverride = x);
registerStruct(fadeOutDurationEntry, t => t.FadeOutDuration, x => FadeOutDurationOverride = x);
registerArray(flickerLightsTimeSeriesEntry, t => t.FlickerLightsTimeSeries, xs => FlickerLightsTimeSeriesOverride = xs, float.Parse, sort: true);
registerArray(lyricsTimeSeriesEntry, t => t.LyricsTimeSeries, xs => LyricsTimeSeriesOverride = xs, float.Parse, sort: true);
registerTimeSeries(drunknessTimeSeriesEntry, t => t.DrunknessLoopOffsetTimeSeries, xs => DrunknessLoopOffsetTimeSeriesOverride = xs, float.Parse, f => f.ToString());
registerTimeSeries(condensationTimeSeriesEntry, t => t.CondensationLoopOffsetTimeSeries, xs => CondensationLoopOffsetTimeSeriesOverride = xs, float.Parse, f => f.ToString());
registerStruct(beatsOffsetEntry, t => t.BeatsOffset, x => BeatsOffsetOverride = x);
registerStruct(colorTransitionInEntry, t => t.ColorTransitionIn, x => ColorTransitionInOverride = x);
registerStruct(colorTransitionOutEntry, t => t.ColorTransitionOut, x => ColorTransitionOutOverride = x);
registerClass(colorTransitionEasingEntry, t => t.ColorTransitionEasing.Name, x => ColorTransitionEasingOverride = x);
void register<T>(ConfigEntry<T> entry, Func<IAudioTrack, T> getter, Action applier)
{
entry.SettingChanged += (sender, args) => applier();
void loader(IAudioTrack? track)
{
// if track is null, set everything to defaults
entry.Value = track == null ? (T)entry.DefaultValue : getter(track);
}
entries.Add((loader, applier));
}
void registerStruct<T>(ConfigEntry<T> entry, Func<IAudioTrack, T> getter, Action<T?> setter) where T : struct =>
register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null));
void registerClass<T>(ConfigEntry<T> entry, Func<IAudioTrack, T> getter, Action<T?> setter) where T : class =>
register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null));
void registerArray<T>(ConfigEntry<string> entry, Func<IAudioTrack, T[]> getter, Action<T[]?> setter, Func<string, T> parser, bool sort = false) where T : struct =>
register(entry,
(track) => string.Join(", ", getter(track)),
() =>
{
var values = parseStringArray(entry.Value, parser, sort);
if (values != null)
{
// ensure the entry is sorted and formatted
entry.Value = string.Join(", ", values);
}
setter.Invoke(overrideTimingsEntry.Value ? values : null);
});
void registerTimeSeries<T>(ConfigEntry<string> entry, Func<IAudioTrack, TimeSeries<T>> getter, Action<TimeSeries<T>?> setter, Func<string, T> parser, Func<T, string> formatter) =>
register(entry,
(track) =>
{
var ts = getter(track);
return formatTimeSeries(ts, formatter);
},
() =>
{
var ts = parseTimeSeries(entry.Value, parser);
if (ts is { } timeSeries)
{
entry.Value = formatTimeSeries(timeSeries, formatter);
}
setter.Invoke(overrideTimingsEntry.Value ? ts : null);
});
// current restriction is that formatted value can not contain commas or semicolons.
TimeSeries<T>? parseTimeSeries<T>(string str, Func<string, T> parser)
{
try
{
if (string.IsNullOrWhiteSpace(str))
{
return null;
}
List<float> beats = [];
List<T> values = [];
foreach (var pair in str.Split(","))
{
if (string.IsNullOrWhiteSpace(pair))
{
continue;
}
var keyvalue = pair.Split(":");
if (keyvalue.Length != 2)
{
throw new FormatException($"Pair must be separated by exactly one semicolon: '{pair}'");
}
var beat = float.Parse(keyvalue[0].Trim());
var val = parser(keyvalue[1].Trim());
beats.Add(beat);
values.Add(val);
}
var ts = new TimeSeries<T>(beats.ToArray(), values.ToArray());
return ts;
}
catch (Exception e)
{
Debug.Log($"{nameof(MuzikaGromche)} Unable to parse time series: {e}");
return null;
}
}
string formatTimeSeries<T>(TimeSeries<T> ts, Func<T, string> formatter)
{
StringBuilder strings = new();
for (int i = 0; i < ts.Length; i++)
{
var beat = ts.Beats[i];
var value = formatter(ts.Values[i]);
strings.Append($"{beat}: {value}");
if (i != ts.Length - 1)
{
strings.Append(", ");
}
}
Debug.Log($"{nameof(MuzikaGromche)} format time series {ts} {strings}");
return strings.ToString();
}
T[]? parseStringArray<T>(string str, Func<string, T> parser, bool sort = false) where T : struct
{
try
{
T[] xs = str.Replace(" ", "").Split(",").Select(parser).ToArray();
Array.Sort(xs);
return xs;
}
catch (Exception e)
{
Debug.Log($"{nameof(MuzikaGromche)} Unable to parse array: {e}");
return null;
}
}
void load()
{
foreach (var entry in entries)
{
entry.Load(CurrentTrack);
}
}
void apply()
{
foreach (var entry in entries)
{
entry.Apply();
}
}
}
private void SetupEntriesForGameOverText(ConfigFile configFile)
{
const string section = "Game Over";
var gameOverTextConfigEntry = configFile.Bind(section, "Game Over Text", DeathScreenGameOverTextManager.GameOverTextModdedDefault,
new ConfigDescription("Custom Game Over text to show."));
LethalConfigManager.AddConfigItem(new GenericButtonConfigItem(section, "Game Over Animation",
"Run Death Screen / Game Over animation 3 times.", "Trigger", () =>
{
HUDManager.Instance.StartCoroutine(AnimateGameOverText(gameOverTextConfigEntry.Value));
}));
LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(gameOverTextConfigEntry, requiresRestart: false));
}
static IEnumerator AnimateGameOverText(string text)
{
yield return new WaitForSeconds(1f);
for (int i = 0; i < 3; i++)
{
DeathScreenGameOverTextManager.SetText(text);
HUDManager.Instance.gameOverAnimator.SetTrigger("gameOver");
yield return new WaitForSeconds(5f);
HUDManager.Instance.gameOverAnimator.SetTrigger("revive");
yield return new WaitForSeconds(1f);
}
DeathScreenGameOverTextManager.Clear();
}
private void SetupEntriesForScreenFilters(ConfigFile configFile)
{
const string section = "Screen Filters";
var drunkConfigEntry = configFile.Bind(section, "Drunkness Level", 0f,
new ConfigDescription("Override drunkness level in Screen Filters Manager."));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(drunkConfigEntry, requiresRestart: false));
drunkConfigEntry.SettingChanged += (sender, args) =>
{
ScreenFiltersManager.Drunkness = drunkConfigEntry.Value;
};
var condensationConfigEntry = configFile.Bind(section, "Condensation Level", 0f,
new ConfigDescription("Override drunkness level in Screen Filters Manager."));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(condensationConfigEntry, new FloatSliderOptions()
{
Min = 0f,
Max = 0.27f,
RequiresRestart = false,
}));
condensationConfigEntry.SettingChanged += (sender, args) =>
{
ScreenFiltersManager.HelmetCondensationDrops = condensationConfigEntry.Value;
};
}
#endif
}
[HarmonyPatch(typeof(GameNetworkManager))]
static class GameNetworkManagerPatch
{
const string JesterEnemyPrefabName = "JesterEnemy";
[HarmonyPatch(nameof(GameNetworkManager.Start))]
[HarmonyPrefix]
static void StartPrefix(GameNetworkManager __instance)
{
var networkPrefab = NetworkManager.Singleton.NetworkConfig.Prefabs.Prefabs
.FirstOrDefault(prefab => prefab.Prefab.name == JesterEnemyPrefabName);
if (networkPrefab == null)
{
Debug.LogError($"{nameof(MuzikaGromche)} JesterEnemy prefab not found!");
}
else
{
networkPrefab.Prefab.AddComponent<MuzikaGromcheJesterNetworkBehaviour>();
Debug.Log($"{nameof(MuzikaGromche)} Patched JesterEnemy");
}
}
}
class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour
{
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!;
internal AudioSource LoopAudioSource = null!;
void Awake()
{
var farAudioTransform = gameObject.transform.Find("FarAudio");
if (farAudioTransform == null)
{
Debug.LogError($"{nameof(MuzikaGromche)} JesterEnemy->FarAudio prefab not found!");
}
else
{
// Instead of hijacking farAudio and creatureVoice sources,
// create our own copies to ensure uniform playback experience.
// For reasons unknown adding them directly to the prefab didn't work.
var introAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform);
introAudioGameObject.name = IntroAudioGameObjectName;
var loopAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform);
loopAudioGameObject.name = LoopAudioGameObjectName;
IntroAudioSource = introAudioGameObject.GetComponent<AudioSource>();
IntroAudioSource.maxDistance = Plugin.AudioMaxDistance;
IntroAudioSource.dopplerLevel = 0;
IntroAudioSource.loop = false;
IntroAudioSource.volume = Config.DefaultVolume;
LoopAudioSource = loopAudioGameObject.GetComponent<AudioSource>();
LoopAudioSource.maxDistance = Plugin.AudioMaxDistance;
LoopAudioSource.dopplerLevel = 0;
LoopAudioSource.loop = true;
LoopAudioSource.volume = Config.DefaultVolume;
Debug.Log($"{nameof(MuzikaGromche)} {nameof(MuzikaGromcheJesterNetworkBehaviour)} Patched JesterEnemy");
}
}
#if DEBUG
void Update()
{
IntroAudioSource.volume = Config.Volume.Value;
LoopAudioSource.volume = Config.Volume.Value;
}
#endif
public override void OnNetworkSpawn()
{
ChooseTrackDeferred();
foreach (var track in Plugin.Tracks)
{
track.Weight.SettingChanged += ChooseTrackDeferredDelegate;
}
Config.SkipExplicitTracks.SettingChanged += ChooseTrackDeferredDelegate;
base.OnNetworkSpawn();
}
public override void OnNetworkDespawn()
{
foreach (var track in Plugin.Tracks)
{
track.Weight.SettingChanged -= ChooseTrackDeferredDelegate;
}
Config.SkipExplicitTracks.SettingChanged -= ChooseTrackDeferredDelegate;
base.OnNetworkDespawn();
}
// Batch multiple weights changes in a single network RPC
private Coroutine? DeferredCoroutine = null;
private void ChooseTrackDeferredDelegate(object sender, EventArgs e)
{
SelectedTrackIndex = 0;
ChooseTrackDeferred();
}
private void ChooseTrackDeferred()
{
if (DeferredCoroutine != null)
{
StopCoroutine(DeferredCoroutine);
DeferredCoroutine = null;
}
DeferredCoroutine = StartCoroutine(ChooseTrackDeferredCoroutine());
}
private IEnumerator ChooseTrackDeferredCoroutine()
{
yield return new WaitForEndOfFrame();
DeferredCoroutine = null;
ChooseTrackServerRpc();
}
[ClientRpc]
public void SetTrackClientRpc(string name)
{
Debug.Log($"{nameof(MuzikaGromche)} SetTrackClientRpc {name}");
if (Plugin.FindTrackNamed(name) is { } track)
{
CurrentTrack = Config.OverrideCurrentTrack(track);
}
}
[ServerRpc]
public void ChooseTrackServerRpc()
{
var selectableTrack = Plugin.ChooseTrack();
var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex);
Debug.Log($"{nameof(MuzikaGromche)} ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}");
SetTrackClientRpc(audioTrack.Name);
SelectedTrackIndex += 1;
}
internal void PlayScheduledLoop()
{
double loopStartDspTime = AudioSettings.dspTime + IntroAudioSource.clip.length - IntroAudioSource.time;
LoopAudioSource.PlayScheduled(loopStartDspTime);
Debug.Log($"{nameof(MuzikaGromche)} Play Intro: dspTime={AudioSettings.dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime}");
}
public void OverrideDeathScreenGameOverText()
{
if (CurrentTrack == null)
{
// Playing as a client with a host who doesn't have the mod
return;
}
StartCoroutine(DeathScreenGameOverTextManager.SetTextAndClear(CurrentTrack.GameOverText));
}
}
[HarmonyPatch(typeof(JesterAI))]
static class JesterPatch
{
[HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))]
[HarmonyPostfix]
static void SetJesterInitialValuesPostfix(JesterAI __instance)
{
var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
behaviour.IntroAudioSource.Stop();
behaviour.LoopAudioSource.Stop();
#if DEBUG
// Almost instant follow timer
__instance.beginCrankingTimer = 1f;
#endif
}
class State
{
public int currentBehaviourStateIndex;
public int previousState;
public float stunNormalizedTimer;
}
[HarmonyPatch(nameof(JesterAI.Update))]
[HarmonyPrefix]
static void JesterUpdatePrefix(JesterAI __instance, out State __state)
{
__state = new State
{
currentBehaviourStateIndex = __instance.currentBehaviourStateIndex,
previousState = __instance.previousState,
stunNormalizedTimer = __instance.stunNormalizedTimer,
};
}
[HarmonyPatch(nameof(JesterAI.Update))]
[HarmonyPostfix]
static void JesterUpdatePostfix(JesterAI __instance, State __state)
{
var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
var introAudioSource = behaviour.IntroAudioSource;
var loopAudioSource = behaviour.LoopAudioSource;
if (behaviour.CurrentTrack == null)
{
#if DEBUG
Debug.LogError($"{nameof(MuzikaGromche)} CurrentTrack is not set!");
#endif
return;
}
// This switch statement resembles the one from JesterAI.Update
switch (__state.currentBehaviourStateIndex)
{
case 1:
if (__state.previousState != 1)
{
// if just started winding up
// then stop the default music... (already done above)
// ...and set up both modded audio clips in advance
introAudioSource.clip = behaviour.CurrentTrack.LoadedIntro;
loopAudioSource.clip = behaviour.CurrentTrack.LoadedLoop;
behaviour.BeatTimeState = new BeatTimeState(behaviour.CurrentTrack);
// Set up custom popup timer, which is shorter than Intro audio
__instance.popUpTimer = behaviour.CurrentTrack.WindUpTimer;
if (Config.ShouldSkipWindingPhase)
{
var rewind = 5f;
__instance.popUpTimer = rewind;
introAudioSource.time = behaviour.CurrentTrack.WindUpTimer - rewind;
}
else
{
// reset if previously skipped winding by assigning different starting time.
introAudioSource.time = 0f;
}
__instance.farAudio.Stop();
introAudioSource.Play();
behaviour.PlayScheduledLoop();
}
if (__instance.stunNormalizedTimer > 0f)
{
introAudioSource.Pause();
loopAudioSource.Stop();
}
else
{
if (!introAudioSource.isPlaying)
{
__instance.farAudio.Stop();
introAudioSource.UnPause();
behaviour.PlayScheduledLoop();
}
}
break;
case 2:
if (__state.previousState != 2)
{
__instance.creatureVoice.Stop();
}
break;
}
// transition away from state 2 ("poppedOut"), normally to state 0
if (__state.previousState == 2 && __instance.previousState != 2)
{
PoweredLightsBehaviour.Instance.ResetLightColor();
DiscoBallManager.Disable();
ScreenFiltersManager.Clear();
// Rotate track groups
behaviour.ChooseTrackServerRpc();
behaviour.BeatTimeState = null;
}
// Manage the timeline: switch color of the lights according to the current playback/beat position.
else if ((__instance.previousState == 1 || __instance.previousState == 2) && behaviour.BeatTimeState is { } beatTimeState)
{
var events = beatTimeState.Update(introAudioSource, loopAudioSource);
var localPlayerCanHearMusic = Plugin.LocalPlayerCanHearMusic(__instance);
foreach (var ev in events)
{
switch (ev)
{
case WindUpZeroBeatEvent:
DiscoBallManager.Enable();
break;
case SetLightsColorEvent e:
PoweredLightsBehaviour.Instance.SetLightColor(e);
break;
case FlickerLightsEvent:
RoundManager.Instance.FlickerLights(true);
break;
case LyricsEvent e when localPlayerCanHearMusic:
Plugin.DisplayLyrics(e.Text);
break;
case DrunkEvent e when localPlayerCanHearMusic:
ScreenFiltersManager.Drunkness = e.Drunkness;
break;
case CondensationEvent e when localPlayerCanHearMusic:
ScreenFiltersManager.HelmetCondensationDrops = e.Condensation;
break;
}
}
}
}
[HarmonyPatch(nameof(JesterAI.killPlayerAnimation))]
[HarmonyPrefix]
static void JesterKillPlayerAnimationPrefix(JesterAI __instance, int playerId)
{
// Note on cast to int: base game already downcasts ulong to int anyway
if (playerId == (int)GameNetworkManager.Instance.localPlayerController.playerClientId)
{
var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
behaviour.OverrideDeathScreenGameOverText();
}
}
}
[HarmonyPatch(typeof(EnemyAI))]
static class EnemyAIPatch
{
// JesterAI class does not override abstract method OnDestroy,
// so we have to patch its superclass directly.
[HarmonyPatch(nameof(EnemyAI.OnDestroy))]
[HarmonyPrefix]
static void CleanUpOnDestroy(EnemyAI __instance)
{
if (__instance is JesterAI)
{
PoweredLightsBehaviour.Instance.ResetLightColor();
DiscoBallManager.Disable();
DeathScreenGameOverTextManager.Clear();
ScreenFiltersManager.Clear();
// Just in case if players have spawned multiple Jesters,
// Don't reset Config.CurrentTrack to null,
// so that the latest chosen track remains set.
}
}
}
}