using System.Collections.Generic; using UnityEngine.Scripting; namespace NecromancerTome { /// /// `necroghost [percent|reset]` - turns the traders' transparency live, without a rebuild /// (user request 2026-09-14, right after the alpha went from 1% to 10%: "Сделай консольную /// команду на альфу, чтобы крутить в игре"). The number being hunted - "a ghost, not a broken /// model" - can only be judged by looking at him, and every step of that hunt otherwise costs /// an edit, a `dotnet build`, a restart and the four-minute walk back to a trader, because /// traders are streamed in on approach. This collapses the loop to one line in the console. /// /// IT TAKES PERCENT OF TRANSPARENCY, NOT ALPHA, and that is deliberate: percent is the unit /// the request has been made in twice ("буквально 1%", "пусть будет 10%"), while alpha is the /// unit the renderer wants, and they run in opposite directions - 10% transparent is alpha /// 0.9. Guessing which one a typed "10" meant would be a coin flip, so the command fixes the /// unit and prints both back every time. /// /// NOTHING IS PERSISTED. The value lives for the session; the one that turns out to be right /// gets written into GhostTraderPatch.DefaultGhostAlpha, which is the line a release ships. /// A settings file would just be a second place for the answer to hide. /// /// WHY THE GAME FINDS THIS CLASS WITHOUT ANY REGISTRATION. SdtdConsole.RegisterCommands goes /// through ReflectionHelpers.FindTypesImplementingBase(typeof(IConsoleCommand)), and that /// walks ModManager.GetLoadedAssemblies() alongside the game's own - so a ConsoleCmdAbstract /// in a mod DLL is picked up like any vanilla one. Ordering is not a gamble either: /// GameManager calls ModManager.LoadMods() three lines before RegisterCommands(). /// /// IsExecuteOnClient IS true BECAUSE THIS CHANGES PIXELS. On a server the command would /// otherwise run where there is nothing to look at; the flag makes the server bounce it back /// to the client that typed it (ConnectionManager.ServerConsoleCommand), which is where the /// materials and the eyes are. In single player it changes nothing. /// [Preserve] public class ConsoleCmdNecroGhost : ConsoleCmdAbstract { public override bool IsExecuteOnClient => true; public override bool AllowedInMainMenu => false; public override string[] getCommands() { return new string[] { "necroghost", "necrotrader" }; } public override string getDescription() { return "Necromancer's Tome: how transparent the ghost traders are, in percent."; } public override string getHelp() { return "necroghost - show the current value and mode\n" + "necroghost <0-100> - set transparency in percent (10 = the default, barely there;\n" + " 30 = clearly a ghost; past ~30 he stops reading as a body)\n" + "necroghost blend - fade the body by blending (re-shades it; smooth)\n" + "necroghost dither - fade the body by dithering (the game's own _Fade; grainy)\n" + "necroghost reset - back to the built-in default value and mode\n" + "\n" + "Applies to traders already in the world, immediately - walk up to one first and\n" + "watch him while you type. Not saved: tell the mod author what you settled on.\n" + "\n" + "THE MODES ARE NOT DEGREES OF ONE THING. The body's own shader cannot blend, so the\n" + "game fades it by throwing pixels away in a pattern - that is the fine grid. Blend\n" + "re-shades the body onto the hair's shader, which has a transparent pass, at the\n" + "cost of the character shader's own lighting. The hair fades the same way either\n" + "way, so it is the body you compare.\n" + "\n" + "If he comes apart instead of fading - teeth through the cheek, an arm through the\n" + "chest - that is not this number, that is depth writing, and no value here will fix it."; } public override void Execute(List _params, CommandSenderInfo _senderInfo) { if (_params.Count == 0) { Report("Ghost traders"); return; } string argument = _params[0].Trim(); if (argument.EqualsCaseInsensitive("reset")) { GhostTraderPatch.GhostAlpha = GhostTraderPatch.DefaultGhostAlpha; SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Reset"); return; } if (argument.EqualsCaseInsensitive("blend")) { SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Body mode"); return; } if (argument.EqualsCaseInsensitive("dither")) { SetMode(GhostTraderPatch.BodyOpacityMode.Dither, "Body mode"); return; } if (!TryParsePercent(argument, out float percent)) { SingletonMonoBehaviour.Instance.Output( "necroghost: '" + argument + "' is neither a percentage nor blend/dither/reset. " + "Try 'necroghost 10', or 'help necroghost'."); return; } if (percent < 0f || percent > 100f) { SingletonMonoBehaviour.Instance.Output( "necroghost: " + percent.ToString("0.#") + "% is outside 0-100. 0 = solid, 100 = invisible."); return; } GhostTraderPatch.GhostAlpha = 1f - percent / 100f; Report("Set"); // Said only when asked for, and only once the value is actually past the point where // the two failure modes stop looking different - see GhostTraderPatch.GhostAlpha. if (GhostTraderPatch.GhostAlpha < 0.7f) { SingletonMonoBehaviour.Instance.Output( " (past ~30% the silhouette stops reading as a solid body at all, which looks like " + "a broken model for a different reason than depth writing does)"); } } /// Switches how the body is faded and rebuilds the traders already standing, which /// is the expensive path - the materials have to be built again from the originals, since /// a re-shaded material cannot be un-re-shaded. Changing only the number never comes here. /// Saying so out loud matters: this is the one thing in the command that is not free, and /// flipping modes back and forth while hunting a value is the obvious way to use it. public static void SetMode(GhostTraderPatch.BodyOpacityMode _mode, string _prefix) { bool changed = GhostTraderPatch.BodyMode != _mode; GhostTraderPatch.BodyMode = _mode; int rebuilt = changed ? GhostTraderPatch.Reapply() : 0; Report(_prefix); if (changed && rebuilt > 0) { SingletonMonoBehaviour.Instance.Output( " (" + rebuilt + " renderer(s) rebuilt from their original materials)"); } } /// Current value plus what it actually reached, in both units, and which way the /// body is being faded. The count is the half that answers "did it do anything": 0 /// materials means no trader has been converted yet - they stream in on approach - not /// that the number was refused. public static void Report(string _prefix) { float alpha = GhostTraderPatch.GhostAlpha; int applied = GhostTraderPatch.Retint(); SingletonMonoBehaviour.Instance.Output( _prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " + alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode + ", applied to " + applied + " live material(s) across " + GhostTraderPatch.Ghosted.Count + " trader(s) converted this session."); } /// Percent out of what the user typed. StringParsers is the game's own parser and /// is culture-independent, which matters here - but it reads ',' as a THOUSANDS separator, /// so on a keyboard where the decimal key produces a comma "12,5" would silently parse as /// 125 and the trader would vanish. The comma is turned into a point before it gets there. /// A trailing '%' is accepted because it is the obvious thing to type. public static bool TryParsePercent(string _argument, out float _percent) { string text = _argument.Replace(',', '.').TrimEnd('%').Trim(); return StringParsers.TryParseFloat(text, out _percent); } } }