Работа по указаниям 2026-09-18. В релиз пока не выходит. НОВЫЙ ПИТОМЕЦ "Дух крысы" (necroRatSpirit), Некромантия 3 (60 убийств): модель зомбоволка в масштабе 0.28, урон 5, кроличьи звуки. Сам не нападает никогда, держится справа-сзади в двух блоках, упёршись в препятствие проходит СКВОЗЬ него и блоков не ломает. Укус замедляет, оставляет метку духа (видна на компасе, +25% получаемого урона) и рвёт жилу. Обгрызая труп, лечится. СВОЯ ЗАДАЧА ИИ. Задачи "иди за сущностью" в игре нет вовсе - проверены все 32 типа EAI*. Написана NecroFollowOwnerTask по образцу EAIApproachSpot: сектор "3-6 часов" от хозяина, FindPath с canBreak:false, проход сквозь препятствие через Entity.IsStuck. Вешается в рантайме, минуя Type.GetType. ПРИКАЗ АТАКОВАТЬ. Повторное применение свитка при живом питомце натравливает его на цель под прицелом: EntityPlayerLocal.HitInfo + ItemActionAttack. GetEntityFromHit. Торговцы и игроки отсеяны. Нет цели - "Нет цели для атаки". ЗОМБОЖИВОТНЫЕ ПРИВЕДЕНЫ К ТОМУ ЖЕ ОБРАЗЦУ. Сняты BreakBlock, Territorial, ApproachSpot, Wander, BlockingTargetTask и поедание трупов; цель они больше не выбирают сами. Лестница урона 20/35/45/60 плюс расчленение у Пса, Медведя и Волка. Кровотечение всем, метка и ослабление - только у крысы. УБИЙСТВА ПИТОМЦЕМ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ, включая добивание кровотечением. Префикс на AwardKillXPServer подменяет убийцу владельцем; для смерти от баффа заведена память укусов, потому что в DamageSource от баффа нет того, кто его наложил. Зомби под Камнем духов это не задело - решение от 17.09 в силе. ГРИФ откачен на летающую ветку EntityVulture и переименован в Могильного стервятника: модель наконец соответствует имени. Держится у игрока сам, через собственный механизм "дома" (setHomeArea), на время погони дом отвязывается. Попытка натянуть птичий префаб на наземный класс провалилась и записана - так делать нельзя. ПРОЧЕЕ: призрачный вид распространён с торговцев на питомцев (у Пса, Медведя и Волка выключен по указанию), у Пса светятся фиолетовые глаза, белая иконка книги снята со всех свитков призыва, "Жуки Властелина" переименованы в "Рой фараона" на всех 13 языках. ПОРОГИ: крыса 60, стервятник 500, пёс 1300, медведь 2000, волк 4000. ИСПРАВЛЕНО ПО ХОДУ ИГРОВЫХ ПРОВЕРОК: питомцы не призывались обычным кликом (AnimWait требовал удержания), отзыв срабатывал не с первого раза (автомат состояний ItemActionSpawnEntity), крыса проваливалась сквозь мир (IsStuck отключает и пол), питомец подбрасывал хозяина (коллайдеры разводились до появления модели). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1069 lines
49 KiB
C#
1069 lines
49 KiB
C#
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using UnityEngine.Rendering;
|
||
|
||
namespace NecromancerTome
|
||
{
|
||
/// <summary>
|
||
/// Renders every trader in black and white (user request 2026-09-13: "сделать модельки всех
|
||
/// торговцев полупрозрачными и чёрнобелыми", then after two in-game looks: "прозрачность у
|
||
/// торговцев убираем совсем"). Fits the mod - the necromancer deals with the dead, and the
|
||
/// only people still trading are not quite alive.
|
||
///
|
||
/// TRANSPARENCY WAS DROPPED, THEN ASKED BACK FOR AT A SLIVER, THEN RAISED TO 10%. The user
|
||
/// first said "убираем совсем", confirmed the result ("торговец стал непрозрачным и
|
||
/// полностью чёрно-белым, как и требовалось"), then asked for "лёгкую прозрачность,
|
||
/// буквально 1%", and on 2026-09-14 for 10% - "чтобы он выглядел как призрак, а не как
|
||
/// сломанная модель". Dropping it was still the release this effect needed, because it
|
||
/// is what allowed the shader to stay put - see below; the alpha is a separate, optional layer
|
||
/// on top (ApplyTransparency) that cannot break the greyscale if the shaders refuse it.
|
||
///
|
||
/// The two failed attempts are worth keeping written down, because neither could have been
|
||
/// predicted from the decompiler and each was settled by one log line:
|
||
///
|
||
/// 1. "Unlit/Transparent Greyscale" (what GameManager uses for greyed-out item icons) has
|
||
/// NO _Color - nowhere to put an alpha. Traders came out opaque, and because that shader
|
||
/// is built for NGUI atlases rather than skinned meshes, the user saw them "в негативе".
|
||
/// 2. "Unlit/Transparent Colored" has no _Color either: `has _Color: False` in the log.
|
||
/// NGUI tints through VERTEX colours, not a material property, and a character mesh has
|
||
/// none - so that whole family of shaders was always a dead end here.
|
||
///
|
||
/// WITHOUT THE ALPHA REQUIREMENT THE SHADER DOES NOT HAVE TO BE REPLACED AT ALL, and that is
|
||
/// strictly better than anything above: the material is cloned with its own shader intact and
|
||
/// only its albedo texture is swapped for a desaturated copy. Lighting, normal maps, specular,
|
||
/// skinning - all still the game's own. The trader looks exactly like himself, in black and
|
||
/// white. Nothing can go "negative", because nothing but the pixels changes.
|
||
///
|
||
/// THE ALBEDO IS FOUND, NOT ASSUMED - this is what the earlier runs bought us. The first
|
||
/// attempt reached for _MainTex and produced an untextured silhouette, because traders are
|
||
/// drawn by TWO different shaders and only one of them uses that name. The probe below walks
|
||
/// the shader's declared properties instead, and the log then said exactly what they are:
|
||
///
|
||
/// shader 'Game/Character' texture properties: _Albedo=set, _Normal=set, _RMOE=set,
|
||
/// _texcoord=empty; chosen albedo: HD_Rekt 4096x4096
|
||
/// shader 'Game/Autodesk' texture properties: _MainTex=set, _BumpMap=set, ...;
|
||
/// chosen albedo: HD_Rekt_Hair 2048x2048
|
||
///
|
||
/// So the body uses _Albedo and the hair uses _MainTex - which is precisely why the name is
|
||
/// discovered rather than hard-coded, and why the probe stays in: Jen is assembled by a
|
||
/// different character system than Rekt (AvatarSDCSController vs AvatarNpcController in
|
||
/// entityclasses.xml) and may well introduce a third shader.
|
||
///
|
||
/// GREYSCALE IS DONE TO THE TEXTURE, via a RenderTexture round trip. The round trip is the
|
||
/// point: game textures are compressed with isReadable=false, so GetPixels on the original
|
||
/// throws - blitting into an ARGB32 RenderTexture and reading THAT back is the standard way to
|
||
/// reach pixels the CPU was never handed. Luma weights 0.299/0.587/0.114 rather than a flat
|
||
/// average, so it reads like a black-and-white photograph instead of a muddy one. Cached per
|
||
/// source texture: these are 4096x4096, and a readback per renderer per sweep would be
|
||
/// indefensible.
|
||
///
|
||
/// THE ALPHA IS TURNED FROM THE CONSOLE, not from this file: `necroghost <percent>`, in
|
||
/// GhostTraderCommand.cs. A rendering balance can only be judged by looking at it, and a
|
||
/// rebuild-restart-walk-to-a-trader cycle per step is not a way to look at anything.
|
||
///
|
||
/// WHY A TICK AND NOT A SPAWN HOOK. Traders are streamed in on approach ("force spawning
|
||
/// pending entity npcTraderRekt" appeared ~4 minutes after the world loaded), and Jen is built
|
||
/// at runtime, so her renderers do not all exist when the entity is added to the world.
|
||
/// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here -
|
||
/// avoids guessing at the right moment inside someone else's character pipeline. A trader with
|
||
/// no renderers yet is simply not marked done and is picked up on the next sweep.
|
||
///
|
||
/// The same tick is what puts a trader BACK once the game has rebuilt him - see Ghosted, and
|
||
/// the bug of 2026-09-15 that taught this file the difference between an entity id and a
|
||
/// model. A spawn hook would not have helped there either: the entity was never re-created as
|
||
/// far as its id is concerned.
|
||
/// </summary>
|
||
public static class GhostTraderPatch
|
||
{
|
||
/// <summary>Seconds between sweeps.</summary>
|
||
public const float SweepInterval = 2f;
|
||
|
||
/// <summary>What a fresh game boots with. 1 = solid; 0.3 is what the hunt actually landed
|
||
/// on - the user set 70% transparency in game on 2026-09-14 once the body was blending
|
||
/// instead of dithering, and kept it. THE VALUE FOUND IN GAME BELONGS HERE: the console
|
||
/// command turns GhostAlpha for the session only and deliberately persists nothing, so a
|
||
/// number worth keeping has to be written into this line.
|
||
///
|
||
/// The road here was 1% -> 10% -> 70%, and the last jump was not a change of taste: at 10%
|
||
/// the body was still being dithered, and a dither is only bearable while it is faint. Once
|
||
/// it became real blending, far more of it turned out to look right.</summary>
|
||
public const float DefaultGhostAlpha = 0.3f;
|
||
|
||
/// <summary>The opacity actually in use. THIS IS THE ONE NUMBER TO TURN when hunting the
|
||
/// balance between "ghost" and "broken model" - everything else in this file is about
|
||
/// making the number mean what it says - and `necroghost <percent>` turns it live
|
||
/// (GhostTraderCommand.cs), because every step of that hunt otherwise costs a rebuild, a
|
||
/// restart and the four-minute walk to a trader.
|
||
///
|
||
/// Which way to turn it is decided by WHICH failure you are looking at, and the two look
|
||
/// nothing alike:
|
||
/// - reads as a solid person, no ghost at all -> lower it (0.85, 0.8).
|
||
/// - the world shows through him but he still reads as one body -> this is the target.
|
||
/// - you can see his teeth through his cheek, eyes through eyelids, an arm through the
|
||
/// chest -> that is the "broken model", and it is NOT this number's fault. It means
|
||
/// depth writing came off somewhere; see _ZWrite in ApplyTransparency. Dropping the
|
||
/// alpha further only makes it worse.
|
||
/// THE OLD "nothing below ~0.7" NOTE WAS WRONG, and it is worth saying why rather than
|
||
/// quietly deleting: it was written while the body was still dithering, where a low value
|
||
/// means a coarse pattern and the silhouette falls apart early. With the body blending,
|
||
/// 0.3 reads as a ghost and holds together - the limit belonged to the technique, not to
|
||
/// the eye. Depth writing is what keeps him one body, and it does not care how low the
|
||
/// number goes.</summary>
|
||
public static float GhostAlpha = DefaultGhostAlpha;
|
||
|
||
/// <summary>One opacity lever this patch installed on a trader, remembered so the alpha
|
||
/// can be turned again WITHOUT rebuilding anything. Keeping the base value is the whole
|
||
/// point: the live value is always base * GhostAlpha, never "multiply what is there now by
|
||
/// the new number" - that would compound (0.9 then 0.8 would land on 0.72) and the second
|
||
/// turn of the dial would lie about where it put you.
|
||
///
|
||
/// IsColour says which of the two kinds this is - the alpha channel of a colour, or a
|
||
/// plain float - because a trader needs BOTH and they are not interchangeable: the body
|
||
/// only has the float (_Fade) and the hair only has the colour (_Color). One material can
|
||
/// therefore contribute one entry, and one trader contributes several of both kinds.</summary>
|
||
public struct GhostMaterial
|
||
{
|
||
public Material Material;
|
||
public string Property;
|
||
public bool IsColour;
|
||
public float BaseValue;
|
||
}
|
||
|
||
/// <summary>A renderer this patch has taken over, with the materials it had before. The
|
||
/// originals are the reason a mode can be switched at all: after the first pass
|
||
/// renderer.sharedMaterials hands back OUR clones, so rebuilding from what is currently
|
||
/// on the renderer would re-shade an already re-shaded material and there would be no way
|
||
/// back to the body's real one. Rebuilding always starts here instead.</summary>
|
||
public struct GhostRenderer
|
||
{
|
||
public Renderer Renderer;
|
||
public Material[] Originals;
|
||
}
|
||
|
||
/// <summary>What one trader was actually given, kept so the sweep can ask "is he STILL a
|
||
/// ghost" instead of only "have I seen this id". The renderers are the answer: a trader
|
||
/// that streams out and back in is rebuilt from scratch - new GameObject, new renderers,
|
||
/// the game's own materials - while keeping the id he was saved under, so an id on its own
|
||
/// says nothing about the model standing there now. See the Ghosted comment.</summary>
|
||
public struct GhostBody
|
||
{
|
||
public Renderer[] Renderers;
|
||
}
|
||
|
||
/// <summary>Every renderer taken over, in the order it was found. Pruned of destroyed
|
||
/// renderers as they are walked; dropped wholesale when the world unloads.</summary>
|
||
public static readonly List<GhostRenderer> Converted = new List<GhostRenderer>();
|
||
|
||
/// <summary>Every material installed on a live trader that has somewhere to put an alpha.
|
||
/// Retint walks this instead of redoing the work: re-running the sweep would re-clone the
|
||
/// materials and, far worse, hand the 4096x4096 desaturation another readback per trader.
|
||
/// Entries go stale when the game destroys the material with its trader - Retint prunes
|
||
/// those, and WorldShuttingDown drops the lot.</summary>
|
||
public static readonly List<GhostMaterial> TintedMaterials = new List<GhostMaterial>();
|
||
|
||
/// <summary>Colour properties that might carry an alpha, best first. Confirmed in game:
|
||
/// the hair/beard shader "Game/Autodesk" has _Color; the BODY shader "Game/Character" has
|
||
/// no colour property at all - see FadeNameHints.</summary>
|
||
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
|
||
|
||
/// <summary>How the BODY is made see-through. The two are not two settings of one thing,
|
||
/// they are two different renderers' worth of behaviour, and only a look in game can pick
|
||
/// between them - which is why both stay reachable from the console.
|
||
///
|
||
/// Dither is the game's own _Fade, and on 2026-09-14 the user reported what it actually
|
||
/// looks like: "тело гаснет, но идёт мелкой сеточкой". That is screen-door transparency -
|
||
/// the shader is not blending anything, it is THROWING PIXELS AWAY in a fixed pattern.
|
||
/// It is what an opaque-queue character shader can do without a transparent pass, and no
|
||
/// value of GhostAlpha turns a dropped pixel into a translucent one.
|
||
///
|
||
/// Blend re-shades the body onto the shader the HAIR is already drawn with, which does
|
||
/// have a transparent pass. The existence proof is on the same model in the same frame:
|
||
/// the beard has been fading smoothly this whole time while the body was dithering.</summary>
|
||
public enum BodyOpacityMode
|
||
{
|
||
Dither,
|
||
Blend
|
||
}
|
||
|
||
/// <summary>Blend by default: dither has been looked at and rejected. `necroghost blend`
|
||
/// and `necroghost dither` switch it live - see GhostTraderCommand.cs.</summary>
|
||
public static BodyOpacityMode BodyMode = BodyOpacityMode.Blend;
|
||
|
||
/// <summary>The hair's shader, kept once it is found on a real trader. Taken off the model
|
||
/// rather than through Shader.Find so it is the exact shader already proven to work in
|
||
/// this scene - name lookups can miss a stripped or renamed shader and hand back null,
|
||
/// and the mod has been down that road twice already with the Unlit family.</summary>
|
||
public static Shader BlendShader;
|
||
|
||
/// <summary>Normal-map properties, best first. "Game/Character" calls it _Normal, the hair
|
||
/// shader wants _BumpMap - the same disagreement the albedo has.</summary>
|
||
public static readonly string[] NormalNameHints = { "_BumpMap", "_Normal", "_NormalMap", "_NormalTex" };
|
||
|
||
/// <summary>Float properties that fade the whole material out, best first. This is the
|
||
/// body's only lever and the game's own: EntityModel.SetFade writes exactly "_Fade", and
|
||
/// guards it with `material.HasProperty("_Fade") && shader.name.Contains("Game/Character")`
|
||
/// - the same shader our traders' bodies are drawn with. EntityAlive.Update drives it from
|
||
/// renderFade, and SetVisible(renderFade > 0.01f) right next to it settles the direction
|
||
/// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha.</summary>
|
||
public static readonly string[] FadeNameHints = { "_Fade" };
|
||
|
||
/// <summary>Traders already converted, by entity id, WITH the renderers each was given.
|
||
/// Cleared when the world unloads.
|
||
///
|
||
/// THE VALUE IS NOT DECORATION - it is the fix for "the trader stopped being a ghost the
|
||
/// next morning" (2026-09-15). This was a HashSet of ids, and an id is not enough:
|
||
/// EntityFactory restores `entity.entityId = ecd.id` from the save, so a trader who is
|
||
/// streamed out while the player is away (they are streamed IN on approach in the first
|
||
/// place - see the class comment) comes back as a BRAND NEW GameObject carrying the SAME
|
||
/// id, with the game's own materials on it. The set still held the id, the sweep skipped
|
||
/// him, and he stayed an ordinary living person for the rest of the session.
|
||
///
|
||
/// It is NOT the restock, which was the first guess and is worth writing down as ruled
|
||
/// out: TraderData's reset rewrites PrimaryInventory and lastInventoryUpdate and touches
|
||
/// no renderer, and TraderArea.SetClosed - the whole open/close cycle - only works doors,
|
||
/// lights and speakers. Nothing on the shop's clock ever reaches the model. What does is
|
||
/// the chunk the shop sits in, which is why the symptom looks like it follows the morning:
|
||
/// the player is away for the night, the trader unloads with his chunk, and he is rebuilt
|
||
/// when they walk back.
|
||
///
|
||
/// Holding the renderers makes the question answerable: Unity's destroyed objects compare
|
||
/// equal to null, so a trader whose model is gone is visible as such, and the same check
|
||
/// covers any other rebuild of the model for free.</summary>
|
||
public static readonly Dictionary<int, GhostBody> Ghosted = new Dictionary<int, GhostBody>();
|
||
|
||
/// <summary>Source shader names already described in the log, so the probe says each
|
||
/// distinct thing once rather than once per trader per part.</summary>
|
||
public static readonly HashSet<string> ProbedShaders = new HashSet<string>();
|
||
|
||
/// <summary>Desaturated copies, keyed by the texture they came from.</summary>
|
||
public static readonly Dictionary<Texture, Texture2D> GreyTextures = new Dictionary<Texture, Texture2D>();
|
||
|
||
/// <summary>Property names that look like an albedo, best first. Confirmed in game:
|
||
/// "Game/Character" uses _Albedo, "Game/Autodesk" uses _MainTex. Anything else falls
|
||
/// through to "the first texture property that has something in it".</summary>
|
||
public static readonly string[] AlbedoNameHints =
|
||
{
|
||
"_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap",
|
||
"_Diffuse", "_ColorMap", "_MainTexture", "_Texture"
|
||
};
|
||
|
||
public static float timer;
|
||
|
||
/// <summary>Called from ModEntry.InitMod.</summary>
|
||
public static void Init()
|
||
{
|
||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||
ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown);
|
||
}
|
||
|
||
public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data)
|
||
{
|
||
Ghosted.Clear();
|
||
GreyTextures.Clear();
|
||
TintedMaterials.Clear();
|
||
Converted.Clear();
|
||
BlendShader = null;
|
||
timer = 0f;
|
||
}
|
||
|
||
/// <summary>Кого этот проход обращает в призрака.
|
||
///
|
||
/// РАСШИРЕНО 2026-09-18 С ТОРГОВЦЕВ НА ПИТОМЦЕВ, по указанию: "всех призванных существ
|
||
/// (зомбопёс, зомбомедведь, зомбоволк, зомбогриф) тоже делаем полупрозрачными как сейчас
|
||
/// торговцев". Плюс Дух крысы, который призраком и задуман.
|
||
///
|
||
/// Обобщение вышло механическим, и это не совпадение: ApplyGreyscale ниже никогда не
|
||
/// пользовался ничем трейдерским - только GetComponentsInChildren<Renderer>(),
|
||
/// EntityClass.entityClassName и entityId, а всё это есть у любой EntityAlive.
|
||
///
|
||
/// СПИСОК БЕРЁТСЯ ИЗ SummonPatch.LimitedPets, а не дублируется здесь: новый питомец
|
||
/// становится призраком автоматически, в одном месте, и разойтись эти два списка не могут.
|
||
/// Рой жуков в нём тоже есть, но он безвреден - его рендереры это системы частиц, а
|
||
/// ApplyGreyscale их намеренно пропускает (см. Convert), так что для него проход вхолостую.
|
||
///
|
||
/// Проверка по имени класса, а не по типу: все наземные питомцы мода - это C#-класс
|
||
/// EntityZombieDog (см. entityclasses.xml), то есть по типу их не отличить ни друг от
|
||
/// друга, ни от настоящего зомбопса из мира.</summary>
|
||
/// <summary>Исключения из призрачного вида. Зомбопёс - по прямому указанию 2026-09-18:
|
||
/// "зомбособаку не делай полупрозрачной или серой, пусть будет обычной".
|
||
///
|
||
/// Проверка идёт по ИМЕНИ КЛАССА, а не по типу, и это здесь важно: Дух крысы наследуется
|
||
/// от necroZombieWolf, а тот от necroZombieDog, то есть по типу они все EntityZombieDog и
|
||
/// исключение задело бы заодно и крысу, которая призраком как раз и задумана.</summary>
|
||
public static readonly HashSet<string> NotGhosted = new HashSet<string>
|
||
{
|
||
"necroZombieDog",
|
||
// Медведь и Волк добавлены 2026-09-18: "прозрачность им не нужна". Призрачными
|
||
// остаются Дух крысы, Зомбогриф и торговцы.
|
||
"necroZombieBear",
|
||
"necroZombieWolf",
|
||
// Гриф выведен 18.09 вместе с откатом: "не делай его прозрачным". На птичьей модели
|
||
// обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и
|
||
// голова, что и выглядело жутко.
|
||
"necroZombieGriffin",
|
||
};
|
||
|
||
public static bool ShouldBeGhost(EntityAlive _entity)
|
||
{
|
||
if (_entity is EntityTrader)
|
||
{
|
||
return true;
|
||
}
|
||
EntityClass entityClass = EntityClass.list[_entity.entityClass];
|
||
if (entityClass == null || entityClass.entityClassName == null)
|
||
{
|
||
return false;
|
||
}
|
||
if (NotGhosted.Contains(entityClass.entityClassName))
|
||
{
|
||
return false;
|
||
}
|
||
return Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(entityClass.entityClassName);
|
||
}
|
||
|
||
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||
{
|
||
timer += Time.deltaTime;
|
||
if (timer < SweepInterval)
|
||
{
|
||
return;
|
||
}
|
||
timer = 0f;
|
||
|
||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||
if (world == null || world.EntityAlives == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < world.EntityAlives.Count; i++)
|
||
{
|
||
EntityAlive entity = world.EntityAlives[i];
|
||
if (entity == null || entity.IsDead() || !ShouldBeGhost(entity))
|
||
{
|
||
continue;
|
||
}
|
||
if (Ghosted.TryGetValue(entity.entityId, out GhostBody body))
|
||
{
|
||
if (IsIntact(body))
|
||
{
|
||
continue;
|
||
}
|
||
// His model was destroyed and rebuilt under him. Drop what is known about the
|
||
// old one before building the new, or Converted and TintedMaterials keep
|
||
// entries for renderers and materials that no longer exist.
|
||
Debug.Log("[NecromancerTome] GhostTraderPatch: entity " + entity.entityId +
|
||
" came back with a new model - ghosting him again");
|
||
Ghosted.Remove(entity.entityId);
|
||
Prune();
|
||
}
|
||
if (ApplyGreyscale(entity, out GhostBody fresh))
|
||
{
|
||
Ghosted[entity.entityId] = fresh;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Is this trader still wearing what we put on him? False the moment any part of
|
||
/// the model we converted has been destroyed - which is what a stream-out and back in
|
||
/// looks like from here, and equally what any other rebuild of the model would look like.
|
||
///
|
||
/// Deliberately NOT "does he have renderers we have not converted": a trader gains and
|
||
/// loses renderers in normal play (a held item, worn equipment), and treating that as a
|
||
/// rebuild would re-run the conversion on renderers already carrying our materials - whose
|
||
/// sharedMaterials hand back OUR clones, so the "originals" kept for the next mode switch
|
||
/// would be re-shaded ones with no way back. The known gap that leaves is a part of the
|
||
/// model built AFTER the first sweep reached him: it stays in colour until he next
|
||
/// reloads. Nothing like that has been seen on the six traders.</summary>
|
||
public static bool IsIntact(GhostBody _body)
|
||
{
|
||
if (_body.Renderers == null || _body.Renderers.Length == 0)
|
||
{
|
||
return false;
|
||
}
|
||
foreach (Renderer renderer in _body.Renderers)
|
||
{
|
||
if (renderer == null)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Drops every entry whose Unity object the game has destroyed. Both lists are
|
||
/// session-long and keyed by nothing - without this they grow by one trader's worth of
|
||
/// renderers and materials every time a trader is rebuilt, and Retint/Reapply would be
|
||
/// walking the wreckage. A material assigned through renderer.materials is owned by that
|
||
/// renderer and dies with it, so one pass settles both.</summary>
|
||
public static void Prune()
|
||
{
|
||
for (int i = Converted.Count - 1; i >= 0; i--)
|
||
{
|
||
if (Converted[i].Renderer == null)
|
||
{
|
||
Converted.RemoveAt(i);
|
||
}
|
||
}
|
||
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
|
||
{
|
||
if (TintedMaterials[i].Material == null)
|
||
{
|
||
TintedMaterials.RemoveAt(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>False when there is nothing to work on yet (model not built), so the caller
|
||
/// leaves this trader unmarked and tries again on the next sweep. On true, _body carries
|
||
/// the renderers taken over, which is how the next sweep tells this trader from a rebuilt
|
||
/// one standing under the same entity id.</summary>
|
||
public static bool ApplyGreyscale(EntityAlive _trader, out GhostBody _body)
|
||
{
|
||
_body = default(GhostBody);
|
||
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
|
||
if (renderers == null || renderers.Length == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Before anything is touched, because the body's replacement shader is found on the
|
||
// trader's OWN hair and the hair is not guaranteed to come first in this array.
|
||
FindBlendShader(renderers);
|
||
|
||
int converted = 0;
|
||
int leversBefore = TintedMaterials.Count;
|
||
List<Renderer> taken = new List<Renderer>(renderers.Length);
|
||
foreach (Renderer renderer in renderers)
|
||
{
|
||
if (renderer == null || renderer is ParticleSystemRenderer)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Material[] sources = renderer.sharedMaterials;
|
||
if (sources == null || sources.Length == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources });
|
||
taken.Add(renderer);
|
||
if (Convert(renderer, sources))
|
||
{
|
||
converted++;
|
||
}
|
||
}
|
||
_body.Renderers = taken.ToArray();
|
||
|
||
// The lever count is the half that answers "will the console command reach him":
|
||
// desaturation and opacity come from different properties, and the body had the first
|
||
// without the second until 2026-09-14. Fewer levers than converted renderers means
|
||
// some part of this trader can only ever be black-and-white, never transparent.
|
||
Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName +
|
||
" (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length +
|
||
" renderer(s) desaturated, " + (TintedMaterials.Count - leversBefore) + " opacity lever(s) installed");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Builds and installs this renderer's ghost materials from the ORIGINALS it was
|
||
/// handed. True when anything was actually changed - a renderer nothing could be done to
|
||
/// keeps the materials it has rather than being handed a half-built array.</summary>
|
||
public static bool Convert(Renderer _renderer, Material[] _sources)
|
||
{
|
||
Material[] ghosts = new Material[_sources.Length];
|
||
bool anyChanged = false;
|
||
for (int i = 0; i < _sources.Length; i++)
|
||
{
|
||
ghosts[i] = MakeGreyMaterial(_sources[i], ref anyChanged);
|
||
}
|
||
if (anyChanged)
|
||
{
|
||
_renderer.materials = ghosts;
|
||
}
|
||
return anyChanged;
|
||
}
|
||
|
||
/// <summary>Rebuilds every trader already converted, from their original materials, under
|
||
/// whatever BodyMode is set now. This is the expensive path and exists only for the mode
|
||
/// switch; changing the alpha alone goes through Retint, which touches no materials at
|
||
/// all. Even here the 4096x4096 desaturation is not redone - GreyTextures is keyed by the
|
||
/// source texture, and the source is the same original every time.</summary>
|
||
public static int Reapply()
|
||
{
|
||
TintedMaterials.Clear();
|
||
int rebuilt = 0;
|
||
for (int i = Converted.Count - 1; i >= 0; i--)
|
||
{
|
||
GhostRenderer entry = Converted[i];
|
||
if (entry.Renderer == null)
|
||
{
|
||
Converted.RemoveAt(i);
|
||
continue;
|
||
}
|
||
if (Convert(entry.Renderer, entry.Originals))
|
||
{
|
||
rebuilt++;
|
||
}
|
||
}
|
||
return rebuilt;
|
||
}
|
||
|
||
/// <summary>Remembers the first shader on this model that can actually blend - it needs
|
||
/// both a colour to put an alpha in and a blend mode to honour it. On a trader that is the
|
||
/// hair's shader. Falls back to looking the name up only if the model somehow has no such
|
||
/// material, and says so either way, because a null here silently disables the whole Blend
|
||
/// mode and leaves the body dithering with no explanation.</summary>
|
||
public static Shader FindBlendShader(Renderer[] _renderers)
|
||
{
|
||
if (BlendShader != null)
|
||
{
|
||
return BlendShader;
|
||
}
|
||
|
||
foreach (Renderer renderer in _renderers)
|
||
{
|
||
if (renderer == null || renderer is ParticleSystemRenderer)
|
||
{
|
||
continue;
|
||
}
|
||
Material[] materials = renderer.sharedMaterials;
|
||
if (materials == null)
|
||
{
|
||
continue;
|
||
}
|
||
foreach (Material material in materials)
|
||
{
|
||
if (material == null || material.shader == null || !CanBlendInPlace(material))
|
||
{
|
||
continue;
|
||
}
|
||
BlendShader = material.shader;
|
||
Debug.Log("[NecromancerTome] GhostTraderPatch: blend shader taken off the model: '" +
|
||
BlendShader.name + "'");
|
||
return BlendShader;
|
||
}
|
||
}
|
||
|
||
BlendShader = Shader.Find("Game/Autodesk");
|
||
Debug.Log("[NecromancerTome] GhostTraderPatch: no blend-capable material on this model; " +
|
||
"the shader was looked up by name instead and came back " +
|
||
(BlendShader != null ? BlendShader.name : "NULL - the body stays dithered"));
|
||
return BlendShader;
|
||
}
|
||
|
||
/// <summary>Whether this material can be made see-through where it stands: somewhere to
|
||
/// put an alpha, and a blend mode to make the alpha mean something. The body fails both
|
||
/// halves and the hair passes both - which is the whole difference between them, and the
|
||
/// reason this is a capability test rather than a check on the shader's name.</summary>
|
||
public static bool CanBlendInPlace(Material _material)
|
||
{
|
||
if (!_material.HasProperty("_SrcBlend") || !_material.HasProperty("_DstBlend"))
|
||
{
|
||
return false;
|
||
}
|
||
foreach (string hint in TintNameHints)
|
||
{
|
||
if (_material.HasProperty(hint))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The body, re-shaded onto the hair's shader so it can blend the way the hair does, with
|
||
/// the black-and-white albedo it would have had anyway.
|
||
///
|
||
/// ONLY WHAT IS UNDERSTOOD IS CARRIED OVER - albedo and normal map. The body's third map,
|
||
/// _RMOE, is a packed roughness/metallic/occlusion/emission texture, and the destination
|
||
/// shader's _MetallicGlossMap expects metallic in R and smoothness in A: the channels do
|
||
/// not line up, and wiring them by hope is exactly how a character comes out looking like
|
||
/// wet plastic or a negative. It is left empty and the two floats are set by hand instead
|
||
/// - no metal, barely any gloss, which is what a dead man in black and white should be.
|
||
///
|
||
/// This is a real trade and worth stating plainly: the body loses the character shader's
|
||
/// own lighting response and gets standard lighting instead. In exchange it can actually
|
||
/// be translucent rather than dithered. If it reads wrong in game, `necroghost dither` is
|
||
/// one line away and nothing has to be rebuilt.
|
||
/// </summary>
|
||
public static Material MakeBlendMaterial(Material _source, Texture2D _albedo)
|
||
{
|
||
Material blend = new Material(BlendShader);
|
||
blend.SetTexture("_MainTex", _albedo);
|
||
|
||
Texture normal = FindTexture(_source, NormalNameHints);
|
||
if (normal != null && blend.HasProperty("_BumpMap"))
|
||
{
|
||
blend.SetTexture("_BumpMap", normal);
|
||
}
|
||
MakeMatte(blend);
|
||
// Standard's own "Fade" setting. The shader branches on keywords rather than on this
|
||
// float, and ApplyTransparency sets those - but a material inspected later with its
|
||
// mode still reading "Opaque" is a trap for whoever looks next.
|
||
if (blend.HasProperty("_Mode"))
|
||
{
|
||
blend.SetFloat("_Mode", 2f);
|
||
}
|
||
|
||
ApplyTransparency(blend);
|
||
return blend;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Takes the shine off (user request 2026-09-14, once the body was blending properly:
|
||
/// "слишком оно бликует, я бы вообще сделал матовым"). Applied to EVERY ghost material, not just the
|
||
/// re-shaded body: the hair is drawn by the same shader and was keeping its own shine, and
|
||
/// a matte body under a glossy beard would have read worse than either.
|
||
///
|
||
/// THREE THINGS, BECAUSE THEY FAIL DIFFERENTLY:
|
||
///
|
||
/// - _Glossiness (smoothness) to 0. This one is plain shader maths and always works -
|
||
/// the surface becomes maximally rough, and a rough surface has no tight highlight.
|
||
/// It is the load-bearing half of this method.
|
||
/// - _SpecularHighlights / _GlossyReflections to 0 WITH their keywords. These are the
|
||
/// Standard shader's [ToggleOff] pair: the float is only what the inspector shows, the
|
||
/// shader branches on _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, so setting the
|
||
/// float alone does nothing at all. They kill the direct highlight and the reflection
|
||
/// probe contribution outright.
|
||
/// - The gloss MAPS, cleared if anything is in them. A map wins over the float: leave
|
||
/// one in place and _Glossiness is ignored, which is the silent way for all of this to
|
||
/// appear to do nothing. They are empty on these traders today - this is for the next
|
||
/// model that is not.
|
||
///
|
||
/// HONEST LIMIT: those two keywords are `shader_feature`, which Unity strips at build time
|
||
/// if no material in the game ships with them set. If they were stripped, EnableKeyword is
|
||
/// a no-op and only the roughness half lands. That is why the roughness half is not
|
||
/// treated as a fallback but as the main lever - and why this is not attempted at all on
|
||
/// the body's own "Game/Character" shader, which has none of these properties: its shine
|
||
/// lives inside the packed _RMOE texture and would have to be repainted, not switched off.
|
||
/// </summary>
|
||
public static void MakeMatte(Material _material)
|
||
{
|
||
if (_material.HasProperty("_Glossiness"))
|
||
{
|
||
_material.SetFloat("_Glossiness", 0f);
|
||
}
|
||
if (_material.HasProperty("_GlossMapScale"))
|
||
{
|
||
_material.SetFloat("_GlossMapScale", 0f);
|
||
}
|
||
if (_material.HasProperty("_Metallic"))
|
||
{
|
||
_material.SetFloat("_Metallic", 0f);
|
||
}
|
||
if (_material.HasProperty("_SpecularHighlights"))
|
||
{
|
||
_material.SetFloat("_SpecularHighlights", 0f);
|
||
_material.EnableKeyword("_SPECULARHIGHLIGHTS_OFF");
|
||
}
|
||
if (_material.HasProperty("_GlossyReflections"))
|
||
{
|
||
_material.SetFloat("_GlossyReflections", 0f);
|
||
_material.EnableKeyword("_GLOSSYREFLECTIONS_OFF");
|
||
}
|
||
ClearTexture(_material, "_SpecGlossMap");
|
||
ClearTexture(_material, "_MetallicGlossMap");
|
||
}
|
||
|
||
/// <summary>Empties a texture slot if this material has one and it is not already empty.
|
||
/// </summary>
|
||
public static void ClearTexture(Material _material, string _property)
|
||
{
|
||
if (_material.HasProperty(_property) && _material.GetTexture(_property) != null)
|
||
{
|
||
_material.SetTexture(_property, null);
|
||
}
|
||
}
|
||
|
||
/// <summary>First texture among these property names that this material actually has
|
||
/// something in.</summary>
|
||
public static Texture FindTexture(Material _source, string[] _hints)
|
||
{
|
||
foreach (string hint in _hints)
|
||
{
|
||
if (_source.HasProperty(hint))
|
||
{
|
||
Texture texture = _source.GetTexture(hint);
|
||
if (texture != null)
|
||
{
|
||
return texture;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// This material's ghost version. Two shapes, and which one it takes is decided by what
|
||
/// the material can do rather than by what it is called:
|
||
///
|
||
/// - CAN be made see-through where it stands (the hair) -> a clone with the SAME shader
|
||
/// and only its albedo swapped for a black-and-white copy. Lighting, normal maps,
|
||
/// specular, skinning - all still the game's own.
|
||
/// - CANNOT (the body: no colour, no blend mode) -> under BodyMode.Blend it is re-shaded
|
||
/// onto the shader the hair uses, which can. Under BodyMode.Dither it takes the clone
|
||
/// path anyway and gets the _Fade lever, which works but throws pixels away.
|
||
///
|
||
/// The albedo is desaturated first either way, because both shapes need it and the result
|
||
/// is cached per source texture - the re-shaded body is not paying for a second readback.
|
||
/// </summary>
|
||
public static Material MakeGreyMaterial(Material _source, ref bool _changed)
|
||
{
|
||
if (_source == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
ProbeShaderOnce(_source);
|
||
|
||
string albedoProperty = FindAlbedoProperty(_source);
|
||
Texture2D desaturated = albedoProperty != null
|
||
? Desaturate(_source.GetTexture(albedoProperty))
|
||
: null;
|
||
|
||
// Re-shading without an albedo to hand over would produce an untextured silhouette -
|
||
// the exact failure this patch already shipped once, on 2026-09-13. If the texture
|
||
// could not be read, the body keeps its own shader and stays dithered instead, and
|
||
// the lever count in the log is what says so.
|
||
if (BodyMode == BodyOpacityMode.Blend && BlendShader != null && desaturated != null &&
|
||
!CanBlendInPlace(_source))
|
||
{
|
||
_changed = true;
|
||
return MakeBlendMaterial(_source, desaturated);
|
||
}
|
||
|
||
Material grey = new Material(_source);
|
||
MakeMatte(grey);
|
||
if (ApplyTransparency(grey))
|
||
{
|
||
_changed = true;
|
||
}
|
||
if (desaturated == null)
|
||
{
|
||
// Nothing to desaturate on this material; hand back the clone unchanged rather
|
||
// than dropping the renderer's material entirely.
|
||
return grey;
|
||
}
|
||
|
||
grey.SetTexture(albedoProperty, desaturated);
|
||
_changed = true;
|
||
return grey;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dials this material's opacity down to GhostAlpha. THREE levers, each conditional,
|
||
/// because a trader is drawn by two different shaders that have almost nothing in common -
|
||
/// and the first version of this method only had the levers the hair happens to own, which
|
||
/// is why the user reported on 2026-09-14 that the console command "управляет только
|
||
/// прозрачностью бороды". The probe had already printed the reason, one line each:
|
||
///
|
||
/// shader 'Game/Character' - albedo: _Albedo -> HD_Rekt 4096x4096;
|
||
/// tint property: <none>; blend-mode properties present: False
|
||
/// shader 'Game/Autodesk' - albedo: _MainTex -> HD_Rekt_Hair 2048x2048;
|
||
/// tint property: _Color (alpha 1); blend-mode properties present: True
|
||
///
|
||
/// The BODY has no colour property and no blend mode at all. Two of the three levers below
|
||
/// simply do not exist on it, and no value of GhostAlpha was ever going to reach it.
|
||
///
|
||
/// - A COLOUR with an alpha channel (_Color and friends). The hair's lever; the body
|
||
/// does not have one.
|
||
/// - THE FADE FLOAT (_Fade). The body's lever, and the game's own: EntityModel.SetFade
|
||
/// writes this exact property and guards it with a check for the "Game/Character"
|
||
/// shader by name, so this is not a property we found and hoped about - it is the one
|
||
/// the engine itself fades these very models with. Nothing else in the body's property
|
||
/// list can carry an opacity: the rest is _Albedo/_Normal/_RMOE, an alpha-CUTOUT
|
||
/// cluster (_AlphaCut, _AlphaCutoff, _SoftAlphaCutoff, _AlphaSoftness - a dissolve,
|
||
/// which punches holes rather than making glass), _EmissiveColor, and flags.
|
||
/// - THE BLEND MODE (_SrcBlend/_DstBlend). An opaque shader ignores any alpha it is
|
||
/// handed, so without this the COLOUR lever does nothing visible - the same wall the
|
||
/// mod's first transparency attempt hit back on 2026-08-28 with the summoned pets.
|
||
/// The recipe is the game's own: MeshDescription.SetupMaterialWithBlendMode writes
|
||
/// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword. The _Fade
|
||
/// lever needs none of it - the shader does its own fading internally, which is
|
||
/// exactly why it exists.
|
||
///
|
||
/// _ZWrite IS LEFT ALONE ON PURPOSE, AND AT 10% THAT IS THE WHOLE BALANCE. The usual
|
||
/// transparency recipe switches depth writing off, which is right for glass and wrong for
|
||
/// a person: without it every surface of the model blends over every other one, and the
|
||
/// trader becomes teeth through cheeks, eyeballs through eyelids, the far arm through the
|
||
/// chest - exactly the "сломанная модель" the user does not want. With it on, the depth
|
||
/// test keeps only the nearest surface and that one surface blends with the WORLD behind
|
||
/// him. So he goes see-through without coming apart: a ghost, not a mess.
|
||
///
|
||
/// At 99% this cost nothing because there was nothing to see through; at 90% it is the
|
||
/// reason the effect is usable at all. Note that it is not set here either way - these
|
||
/// shaders write depth by default, and leaving the property untouched is what keeps it.
|
||
///
|
||
/// Whatever is missing is reported by the probe rather than silently skipped - if neither
|
||
/// lever exists on these shaders, the traders stay solid black-and-white and the log says
|
||
/// why.
|
||
/// </summary>
|
||
public static bool ApplyTransparency(Material _material)
|
||
{
|
||
bool touched = false;
|
||
|
||
foreach (string hint in TintNameHints)
|
||
{
|
||
if (!_material.HasProperty(hint))
|
||
{
|
||
continue;
|
||
}
|
||
Color tint = _material.GetColor(hint);
|
||
float baseAlpha = tint.a;
|
||
tint.a = baseAlpha * GhostAlpha;
|
||
_material.SetColor(hint, tint);
|
||
TintedMaterials.Add(new GhostMaterial
|
||
{
|
||
Material = _material,
|
||
Property = hint,
|
||
IsColour = true,
|
||
BaseValue = baseAlpha
|
||
});
|
||
touched = true;
|
||
break;
|
||
}
|
||
|
||
foreach (string hint in FadeNameHints)
|
||
{
|
||
if (!_material.HasProperty(hint))
|
||
{
|
||
continue;
|
||
}
|
||
// A base of 0 would mean the material is already fully faded out, which no
|
||
// standing trader is - it means the property is sitting at a default nobody set.
|
||
// Multiplying by it would make him vanish outright and no value of GhostAlpha
|
||
// could bring him back, so it is read as "solid" instead.
|
||
float baseFade = _material.GetFloat(hint);
|
||
if (baseFade <= 0f)
|
||
{
|
||
baseFade = 1f;
|
||
}
|
||
_material.SetFloat(hint, baseFade * GhostAlpha);
|
||
TintedMaterials.Add(new GhostMaterial
|
||
{
|
||
Material = _material,
|
||
Property = hint,
|
||
IsColour = false,
|
||
BaseValue = baseFade
|
||
});
|
||
touched = true;
|
||
break;
|
||
}
|
||
|
||
// НАЙДЕНО 2026-09-18, НО НЕ ИСПРАВЛЕНО - записано, чтобы не искать заново.
|
||
// Пользователь: "собака не полупрозрачная". По логу у неё для этого всё было -
|
||
// шейдер Standard с _Color, _SrcBlend, _DstBlend, "5 of 5 renderer(s) desaturated,
|
||
// 5 opacity lever(s) installed". То есть альфа проставлена, а прозрачности нет.
|
||
//
|
||
// Разница с трейдерами, у которых она работает: их тело идёт по ДРУГОЙ ветке -
|
||
// MakeBlendMaterial создаёт материал заново и ставит там _Mode = 2 (Fade). Здесь,
|
||
// на ветке "блендим на месте", _Mode и _ZWrite не трогаются вовсе, а материал,
|
||
// собранный как Opaque, приходит с _Mode = 0 и _ZWrite = 1. Юнити для перевода
|
||
// Standard в прозрачность требует всю шестёрку: _Mode, _SrcBlend, _DstBlend,
|
||
// _ZWrite, ключевые слова и очередь отрисовки - у нас выставлены четыре из шести.
|
||
//
|
||
// Собаку это чинить больше не нужно (она выведена из призраков совсем), но Медведь,
|
||
// Волк и Гриф идут по этой же ветке. Если и они окажутся непрозрачными - причина
|
||
// здесь, и лечится добавлением _Mode = 2 и _ZWrite = 0 рядом со строками ниже.
|
||
if (_material.HasProperty("_SrcBlend") && _material.HasProperty("_DstBlend"))
|
||
{
|
||
_material.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha);
|
||
_material.SetFloat("_DstBlend", (float)BlendMode.OneMinusSrcAlpha);
|
||
_material.EnableKeyword("_ALPHABLEND_ON");
|
||
_material.renderQueue = (int)RenderQueue.Transparent;
|
||
touched = true;
|
||
}
|
||
|
||
return touched;
|
||
}
|
||
|
||
/// <summary>Pushes the current GhostAlpha onto every trader already standing in the world,
|
||
/// and returns how many materials took it. Traders that spawn later need nothing from this
|
||
/// - ApplyTransparency reads the same field on the way past.
|
||
///
|
||
/// Unity's fake null is the reason for the rebuild-in-place rather than a simple loop: a
|
||
/// destroyed Material compares equal to null but is still a live list entry, and touching
|
||
/// it throws. Walking backwards and dropping those as we go keeps the list from growing
|
||
/// across a session of traders streaming in and out.</summary>
|
||
public static int Retint()
|
||
{
|
||
int applied = 0;
|
||
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
|
||
{
|
||
GhostMaterial entry = TintedMaterials[i];
|
||
if (entry.Material == null)
|
||
{
|
||
TintedMaterials.RemoveAt(i);
|
||
continue;
|
||
}
|
||
if (entry.IsColour)
|
||
{
|
||
Color tint = entry.Material.GetColor(entry.Property);
|
||
tint.a = entry.BaseValue * GhostAlpha;
|
||
entry.Material.SetColor(entry.Property, tint);
|
||
}
|
||
else
|
||
{
|
||
entry.Material.SetFloat(entry.Property, entry.BaseValue * GhostAlpha);
|
||
}
|
||
applied++;
|
||
}
|
||
return applied;
|
||
}
|
||
|
||
/// <summary>Name of the texture property holding this material's albedo, or null. Walks
|
||
/// the shader's declared properties rather than assuming a name - the body and the hair of
|
||
/// the same trader disagree about it.</summary>
|
||
public static string FindAlbedoProperty(Material _source)
|
||
{
|
||
Shader shader = _source.shader;
|
||
if (shader == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
foreach (string hint in AlbedoNameHints)
|
||
{
|
||
if (_source.HasProperty(hint) && _source.GetTexture(hint) != null)
|
||
{
|
||
return hint;
|
||
}
|
||
}
|
||
|
||
int count = shader.GetPropertyCount();
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
if (shader.GetPropertyType(i) != ShaderPropertyType.Texture)
|
||
{
|
||
continue;
|
||
}
|
||
string name = shader.GetPropertyName(i);
|
||
if (_source.GetTexture(name) != null)
|
||
{
|
||
return name;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>Black-and-white copy of a texture. See the class comment for why this goes
|
||
/// through a RenderTexture instead of reading the source directly.</summary>
|
||
public static Texture2D Desaturate(Texture _source)
|
||
{
|
||
if (_source == null)
|
||
{
|
||
return null;
|
||
}
|
||
if (GreyTextures.TryGetValue(_source, out Texture2D cached))
|
||
{
|
||
return cached;
|
||
}
|
||
|
||
Texture2D grey = null;
|
||
RenderTexture rt = null;
|
||
RenderTexture previous = RenderTexture.active;
|
||
try
|
||
{
|
||
rt = RenderTexture.GetTemporary(_source.width, _source.height, 0,
|
||
RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
|
||
Graphics.Blit(_source, rt);
|
||
RenderTexture.active = rt;
|
||
|
||
grey = new Texture2D(_source.width, _source.height, TextureFormat.RGBA32, false);
|
||
grey.ReadPixels(new Rect(0f, 0f, _source.width, _source.height), 0, 0);
|
||
|
||
Color32[] pixels = grey.GetPixels32();
|
||
for (int i = 0; i < pixels.Length; i++)
|
||
{
|
||
Color32 p = pixels[i];
|
||
byte luma = (byte)((p.r * 299 + p.g * 587 + p.b * 114) / 1000);
|
||
p.r = luma;
|
||
p.g = luma;
|
||
p.b = luma;
|
||
pixels[i] = p;
|
||
}
|
||
grey.SetPixels32(pixels);
|
||
grey.Apply(false, false);
|
||
}
|
||
catch (System.Exception e)
|
||
{
|
||
Debug.LogError("[NecromancerTome] GhostTraderPatch: could not desaturate '" + _source.name + "': " + e.Message);
|
||
grey = null;
|
||
}
|
||
finally
|
||
{
|
||
RenderTexture.active = previous;
|
||
if (rt != null)
|
||
{
|
||
RenderTexture.ReleaseTemporary(rt);
|
||
}
|
||
}
|
||
|
||
// Cached even on failure (as null) so an unreadable texture is not retried per trader.
|
||
GreyTextures[_source] = grey;
|
||
return grey;
|
||
}
|
||
|
||
/// <summary>Says, once per distinct source shader, which texture properties it has and
|
||
/// which carry anything. This is what told us the body uses _Albedo and the hair _MainTex;
|
||
/// it stays in because the next trader built by a different character system will announce
|
||
/// itself the same way.</summary>
|
||
public static void ProbeShaderOnce(Material _source)
|
||
{
|
||
Shader shader = _source.shader;
|
||
string shaderName = shader != null ? shader.name : "<null shader>";
|
||
if (!ProbedShaders.Add(shaderName) || shader == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// EVERY property, not just the textures. The texture-only version answered the
|
||
// "where is the albedo" question; this one has to answer "is there anything here that
|
||
// can make it transparent at all", and that lives among the floats and colours.
|
||
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
||
int count = shader.GetPropertyCount();
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
string name = shader.GetPropertyName(i);
|
||
ShaderPropertyType type = shader.GetPropertyType(i);
|
||
sb.Append(sb.Length > 0 ? ", " : "").Append(name).Append(':').Append(type);
|
||
if (type == ShaderPropertyType.Texture)
|
||
{
|
||
sb.Append(_source.GetTexture(name) != null ? "=set" : "=empty");
|
||
}
|
||
}
|
||
|
||
string chosen = FindAlbedoProperty(_source);
|
||
Texture chosenTexture = chosen != null ? _source.GetTexture(chosen) : null;
|
||
string tint = "<none>";
|
||
foreach (string hint in TintNameHints)
|
||
{
|
||
if (_source.HasProperty(hint))
|
||
{
|
||
tint = hint + " (alpha " + _source.GetColor(hint).a.ToString("0.###") + ")";
|
||
break;
|
||
}
|
||
}
|
||
string fade = "<none>";
|
||
foreach (string hint in FadeNameHints)
|
||
{
|
||
if (_source.HasProperty(hint))
|
||
{
|
||
fade = hint + " (" + _source.GetFloat(hint).ToString("0.###") + ")";
|
||
break;
|
||
}
|
||
}
|
||
bool canBlend = _source.HasProperty("_SrcBlend") && _source.HasProperty("_DstBlend");
|
||
|
||
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' properties: " +
|
||
(sb.Length > 0 ? sb.ToString() : "<none>"));
|
||
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "<none>") +
|
||
" -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "<none>") +
|
||
"; tint property: " + tint + "; fade property: " + fade +
|
||
"; blend-mode properties present: " + canBlend);
|
||
}
|
||
}
|
||
}
|