using System;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
///
/// ТРУП ПОМЕЧЕННОГО ЗОМБИ САМ СТАНОВИТСЯ ДОБЫЧЕЙ — указание 2026-09-18: «зомби после упокоения
/// оставляет свой труп. Можем ли мы подменять этот труп в момент смерти точно таким же, но с
/// другой текстурой и с функцией мешка жертвы (не исчезает, можно забрать лут)?»
///
/// ПОДМЕНЯТЬ НЕ ПРИШЛОСЬ НИЧЕГО, и это главное, что стоит знать. Труп — не отдельная модель, а
/// та же скелетная модель зомби в позе смерти, поэтому «такой же, но с другой текстурой» — это
/// перекраска материалов уже лежащего трупа, тем же приёмом, что призрачные питомцы и глаза
/// собаки. Ни нового префаба, ни новой сущности.
///
/// ТРИ ЧАСТИ, И КАЖДАЯ ДЕРЖИТСЯ НА СВОЁМ ВАНИЛЬНОМ МЕХАНИЗМЕ:
///
/// 1. НЕ ИСЧЕЗАЕТ. `EntityAlive.timeStayAfterDeath` — обычное публичное поле экземпляра,
/// которое при спавне заполняется из entity_class (`TimeStayAfterDeath * 20`). У зомби
/// там 30 секунд, у ванильного мешка с лутом — 3600. Достаточно записать другое число.
///
/// 2. ЕГО МОЖНО ОБЫСКАТЬ. У сущностей есть своя система команд взаимодействия, отдельная от
/// блочной: `Entity.GetActivationCommands()` собирает список из виртуального
/// `InitLocalActivationCommands`, `AllowActivationCommand` решает, доступна ли команда
/// сейчас, `OnEntityActivated` её исполняет. Мешок с лутом объявляет свою так
/// (EntityItem, от которого он наследуется):
///
/// _addCallback(new EntityActivationCommand("search", "search"));
///
/// Мы объявляем ровно такую же — с тем же идентификатором и той же иконкой. Значит и
/// подпись на экране, и значок берутся готовые, локализованные на все языки игры, и
/// игроку это выглядит ровно как обыск мешка, потому что это и есть он.
///
/// 3. ВНУТРИ ЛУТ. А вот здесь сознательный обход. Само хранилище живёт внутри
/// `EntityLootContainer`: его `bag`, его `Read`/`Write` для сейва, его сетевая
/// синхронизация. Приделать всё это трупу зомби — работа масштаба Пространственного
/// хранилища, с выходом на сейвы, то есть на риск номер один этого проекта.
///
/// Поэтому обыск НЕ открывает труп, а ПРЕВРАЩАЕТ его в мешок «Жертва»: мешок спавнится
/// на месте трупа, труп убирается. Снаружи читается как «обыскал и нашёл», внутри —
/// обычный ванильный контейнер, который сам себя сохраняет, синхронизирует и открывает.
/// Ни одной новой сущности с состоянием.
///
/// ОБЫЧНЫЙ МЕШОК ЭТО НЕ ОТМЕНЯЕТ. Бросок вероятности в VictimPatch.cs остаётся как был: выпал
/// — обычный мешок лежит рядом, не выпал — только труп. «Жертва» переехала из «падает сразу» в
/// «лежит внутри трупа», и больше ничего не изменилось.
///
/// ПОЧЕМУ СПИСОК КОМАНД СБРАСЫВАЕТСЯ. `GetActivationCommands` кеширует результат в
/// `activationCommands` при первом обращении — а первое обращение случается, пока зомби ещё
/// жив и командой не обладает. Обнуление поля на смерти заставляет пересобрать список; тем же
/// приёмом пользуется сама игра в CopyPropertiesFromEntityClass.
///
public static class CorpseLoot
{
/// Сколько секунд лежит помеченный труп. В 1.4.0 было 3600, как у ванильного мешка с
/// лутом; 2026-09-24 уменьшено до 5 минут по указанию пользователя: «если игрок не будет
/// убирать, у него FPS начнёт падать» - каждый лежащий труп это отрисовываемая модель со
/// своими копиями материалов, а необобранных за час набирается много.
public const float CorpseSecondsAfterDeath = 300f;
/// Во что красится труп. Тёмно-багровый: издалека видно, что этот не как все, и
/// при этом не выглядит подсветкой из другой игры.
public static readonly Color CorpseTint = new Color(0.55f, 0.12f, 0.16f);
/// Идентификатор берётся у ванильного мешка (EntityItem), чтобы подпись досталась
/// готовой и локализованной на все языки игры. ИКОНКА - "hand", а не "search", по указанию
/// 2026-09-18 («при наведении на зомби пиктограмма руки»); имена иконок к идентификатору
/// команды не привязаны, у ванильного "take" стоит ровно эта.
public const string SearchCommand = "search";
public const string SearchIcon = "hand";
/// Секунды удержания. Круговой индикатор рисует сама игра - XUiC_Timer, тот же,
/// что крутится при снятии верстака.
public const float SearchSeconds = 3f;
/// Трупы, которые ещё не обысканы. Ключ — entityId. Чистится сама: запись
/// снимается при обыске, а остальные уходят вместе с миром.
public static readonly HashSet SearchableCorpses = new HashSet();
/// Трупы, которые ещё не удалось перекрасить: модель бывает не собрана на том
/// кадре, когда сущность умирает. Добивается односекундным тиком PetFollowPatch.
public static readonly HashSet PendingTint = new HashSet();
/// УТЕЧКА ПАМЯТИ, НАЙДЕНА 2026-09-24 (жалоба: «память течёт, лечится только
/// перезапуском»). Версия 1.4.0 перекрашивала труп и НИЧЕГО за собой не убирала:
///
/// - `renderer.materials` создаёт копии материалов, и Unity НЕ удаляет их вместе с
/// объектом - они живут до перезапуска;
/// - перекрашенная текстура создавалась несжатой (RGBA32, 16-64 МБ на штуку) и оставалась
/// читаемой, то есть лежала дважды - в видеопамяти и копией в оперативной;
/// - кеш текстур только рос.
///
/// Лог игры (`60sec delta: ... current: ..., desired: ...` - это Texture.currentTextureMemory)
/// показал рост видеопамяти текстур с 1,2 до 7,4 ГБ за два часа при нужных игре ~3,5 ГБ, и
/// FPS упал с 60 до 11. За сессию было перекрашено 53 трупа по 4-10 материалов.
///
/// Теперь каждый перекрашенный труп записан здесь вместе с копиями материалов и текстурами,
/// а тик раз в секунду (ReleaseGoneCorpses) убирает за теми, кого уже нет. Текстура
/// удаляется, когда её не использует ни один труп.
public class TintedCorpse
{
public readonly List Renderers = new List();
public readonly List Materials = new List();
public readonly List Textures = new List();
}
public static readonly Dictionary Tinted = new Dictionary();
/// Сколько трупов сейчас носит каждую перекрашенную текстуру.
public static readonly Dictionary TextureUsers = new Dictionary();
/// Предел стороны перекрашенной текстуры. Исходные 2048-4096; труп рассматривают
/// вблизи, но 1024 со сжатием это ~1 МБ вместо 16-64 МБ, и разница на глаз незаметна.
public const int MaxTintSize = 1024;
/// Повторная попытка перекраски. Зовётся из общего тика раз в секунду.
public static void RetryPendingTints(World _world)
{
ReleaseGoneCorpses(_world);
if (PendingTint.Count == 0 || _world == null)
{
return;
}
List done = new List();
foreach (int id in PendingTint)
{
Entity corpse = _world.GetEntity(id);
if (corpse == null || TintCorpse(corpse))
{
done.Add(id);
}
}
foreach (int id in done)
{
PendingTint.Remove(id);
}
}
/// Уборка за исчезнувшими трупами. Трупом считается исчезнувшим, когда его нет в
/// мире И уничтожены все его рендереры: удалять материал, который ещё рисуется, нельзя.
public static void ReleaseGoneCorpses(World _world)
{
if (Tinted.Count == 0 || _world == null)
{
return;
}
List gone = null;
foreach (KeyValuePair pair in Tinted)
{
if (_world.GetEntity(pair.Key) != null)
{
continue;
}
bool rendered = false;
foreach (Renderer renderer in pair.Value.Renderers)
{
if (renderer != null)
{
rendered = true;
break;
}
}
if (rendered)
{
continue;
}
if (gone == null)
{
gone = new List();
}
gone.Add(pair.Key);
}
if (gone == null)
{
return;
}
foreach (int id in gone)
{
TintedCorpse corpse = Tinted[id];
Tinted.Remove(id);
SearchableCorpses.Remove(id);
foreach (Material material in corpse.Materials)
{
if (material != null)
{
UnityEngine.Object.Destroy(material);
}
}
foreach (Texture2D texture in corpse.Textures)
{
ReleaseTexture(texture);
}
}
Debug.Log("[NecromancerTome] CorpseLoot: убрано за исчезнувшими трупами: " + gone.Count +
", осталось трупов " + Tinted.Count + ", текстур " + TextureUsers.Count);
}
public static void ReleaseTexture(Texture2D _texture)
{
if (_texture == null || !TextureUsers.TryGetValue(_texture, out int users))
{
return;
}
if (users > 1)
{
TextureUsers[_texture] = users - 1;
return;
}
TextureUsers.Remove(_texture);
List keys = new List();
foreach (KeyValuePair pair in TintedTextures)
{
if (pair.Value == _texture)
{
keys.Add(pair.Key);
}
}
foreach (Texture key in keys)
{
TintedTextures.Remove(key);
}
UnityEngine.Object.Destroy(_texture);
}
public static bool IsSearchable(Entity _entity)
{
return _entity != null && SearchableCorpses.Contains(_entity.entityId);
}
/// Зовётся из VictimPatch, когда помеченный зомби умер.
public static void MarkCorpse(EntityAlive _victim)
{
if (_victim == null)
{
return;
}
SearchableCorpses.Add(_victim.entityId);
// timeStayAfterDeath хранится в ТИКАХ: игра заполняет его как TimeStayAfterDeath * 20
// (EntityAlive.CopyPropertiesFromEntityClass), то есть двадцать тиков на секунду.
_victim.timeStayAfterDeath = (int)(CorpseSecondsAfterDeath * 20f);
// Список команд уже собран и закеширован, пока зомби был жив и обыскивать его было
// нельзя. Обнуляем, чтобы собрался заново - уже с "search".
_victim.activationCommands = null;
if (!TintCorpse(_victim))
{
// Модель на этом кадре может быть ещё не готова - ровно тем же болели призрачные
// торговцы и разведение коллайдеров у питомцев. Кладём в очередь, тик добьёт.
PendingTint.Add(_victim.entityId);
}
Debug.Log("[NecromancerTome] CorpseLoot: труп " + _victim.entityId +
" помечен как обыскиваемый, лежит " + CorpseSecondsAfterDeath + " с");
}
/// Перекраска трупа. ПЕРЕПИСАНА 2026-09-18, первая версия не работала вообще:
/// пользователь сообщил «труп не перекрасился», и причина нашлась в нашем же логе.
///
/// Первая версия ставила `_Color` на материалах - ровно как сделано у глаз собаки. Но у
/// собаки глаз сидит на шейдере `Standard`, а тело зомби и шкура зверей - на собственных
/// шейдерах игры, и проба, которую печатает GhostTraderPatch, по ним говорит прямо:
///
/// shader 'Game/Character' ... tint property: <none>
/// shader 'Game/Animal/Fur' ... tint property: <none>
///
/// Свойства цвета у них НЕТ ВООБЩЕ. `HasProperty("_Color")` возвращал false, цикл не делал
/// ни одной итерации, и метод честно печатал «материалов 0» - которое никто не прочитал.
///
/// Поэтому красится САМА ТЕКСТУРА, тем же приёмом, которым мод обесцвечивает торговцев:
/// прогон через RenderTexture (игровые текстуры сжаты и с isReadable=false, напрямую
/// GetPixels по ним падает), затем попиксельное умножение на цвет. Разница с
/// обесцвечиванием ровно в одной строке арифметики, всё остальное - тот же путь, уже
/// проверенный на шести торговцах.
///
/// Результат кешируется по исходной текстуре: они 2048x2048 и больше, а помеченных трупов
/// за игру будет много - пересчитывать на каждый незачем.
public static readonly Dictionary TintedTextures = new Dictionary();
public static bool TintCorpse(Entity _corpse)
{
Renderer[] renderers = _corpse.GetComponentsInChildren(true);
if (renderers == null || renderers.Length == 0)
{
return false;
}
if (!Tinted.TryGetValue(_corpse.entityId, out TintedCorpse record))
{
record = new TintedCorpse();
Tinted[_corpse.entityId] = record;
}
int tinted = 0;
foreach (Renderer renderer in renderers)
{
if (renderer == null || renderer is ParticleSystemRenderer)
{
continue;
}
Material[] shared = renderer.sharedMaterials;
if (shared == null || shared.Length == 0)
{
continue;
}
// Копии материалов создаются этой строкой, и все они теперь наши - записываем
// каждую, даже ту, что перекрасить не удалось: удалять её тоже нам.
Material[] instances = renderer.materials;
if (!record.Renderers.Contains(renderer))
{
record.Renderers.Add(renderer);
}
for (int i = 0; i < instances.Length && i < shared.Length; i++)
{
Material material = instances[i];
if (material == null)
{
continue;
}
if (!record.Materials.Contains(material))
{
record.Materials.Add(material);
}
string albedo = GhostTraderPatch.FindAlbedoProperty(material);
if (albedo == null)
{
continue;
}
Texture2D repainted = Tint(material.GetTexture(albedo));
if (repainted == null)
{
continue;
}
material.SetTexture(albedo, repainted);
record.Textures.Add(repainted);
TextureUsers.TryGetValue(repainted, out int users);
TextureUsers[repainted] = users + 1;
tinted++;
}
}
Debug.Log("[NecromancerTome] CorpseLoot: труп " + _corpse.entityId + " перекрашен, материалов " + tinted);
return tinted > 0;
}
/// Копия GhostTraderPatch.Desaturate с одной изменённой строкой: вместо сведения
/// каналов к яркости - умножение на CorpseTint. Прогон через RenderTexture обязателен и там
/// и здесь по одной причине: игровые текстуры сжаты и не читаются процессором напрямую.
public static Texture2D Tint(Texture _source)
{
if (_source == null)
{
return null;
}
if (TintedTextures.TryGetValue(_source, out Texture2D cached))
{
return cached;
}
Texture2D painted = null;
RenderTexture rt = null;
RenderTexture previous = RenderTexture.active;
try
{
// Уменьшение идёт бесплатно, самим Blit в RenderTexture меньшего размера. Стороны
// кратны четырём - иначе Compress ниже откажется сжимать.
float scale = Mathf.Min(1f, (float)MaxTintSize / Mathf.Max(_source.width, _source.height));
int width = Mathf.Max(4, (int)(_source.width * scale) / 4 * 4);
int height = Mathf.Max(4, (int)(_source.height * scale) / 4 * 4);
rt = RenderTexture.GetTemporary(width, height, 0,
RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
Graphics.Blit(_source, rt);
RenderTexture.active = rt;
painted = new Texture2D(width, height, TextureFormat.RGBA32, false);
painted.ReadPixels(new Rect(0f, 0f, width, height), 0, 0);
Color32[] pixels = painted.GetPixels32();
for (int i = 0; i < pixels.Length; i++)
{
Color32 p = pixels[i];
p.r = (byte)(p.r * CorpseTint.r);
p.g = (byte)(p.g * CorpseTint.g);
p.b = (byte)(p.b * CorpseTint.b);
pixels[i] = p;
}
painted.SetPixels32(pixels);
// Сжатие в DXT (вчетверо-восьмеро меньше RGBA32) и makeNoLongerReadable=true: без
// него копия пикселей оставалась в оперативной памяти рядом с видеопамятью.
painted.Compress(false);
painted.Apply(false, true);
}
catch (Exception e)
{
Debug.LogError("[NecromancerTome] CorpseLoot: не удалось перекрасить текстуру '" +
_source.name + "': " + e.Message);
if (painted != null)
{
UnityEngine.Object.Destroy(painted);
}
painted = null;
}
finally
{
RenderTexture.active = previous;
if (rt != null)
{
RenderTexture.ReleaseTemporary(rt);
}
}
TintedTextures[_source] = painted;
return painted;
}
/// Обыск: труп превращается в мешок «Жертва» на своём же месте.
public static void SearchCorpse(EntityAlive _corpse)
{
SearchableCorpses.Remove(_corpse.entityId);
Patch_EntityAlive_dropItemOnDeath_VictimBag.SpawnVictimBag(_corpse, false);
Debug.Log("[NecromancerTome] CorpseLoot: труп " + _corpse.entityId + " обыскан, заменён мешком");
if (_corpse.world != null)
{
_corpse.world.RemoveEntity(_corpse.entityId, EnumRemoveEntityReason.Despawned);
}
}
}
/// Добавляет команду «обыскать» в список. Постфикс, а не префикс: ванильные команды
/// сущности (у EntityAlive это «подобрать» для тех, у кого задан PickupItem) должны остаться.
[HarmonyPatch(typeof(EntityAlive), "InitLocalActivationCommands")]
public static class Patch_EntityAlive_InitLocalActivationCommands_Corpse
{
public static void Postfix(EntityAlive __instance, Action _addCallback)
{
if (!CorpseLoot.IsSearchable(__instance))
{
return;
}
_addCallback(new EntityActivationCommand(CorpseLoot.SearchCommand, CorpseLoot.SearchIcon));
}
}
/// Разрешает команду только помеченному трупу. Без этого «обыскать» досталось бы
/// любому существу, у которого список команд пересобрался бы по любой причине.
[HarmonyPatch(typeof(EntityAlive), "AllowActivationCommand")]
public static class Patch_EntityAlive_AllowActivationCommand_Corpse
{
public static void Postfix(EntityAlive __instance, ReadOnlySpan _commandName, ref bool __result)
{
if (!MemoryExtensions.Equals(_commandName, CorpseLoot.SearchCommand.AsSpan(), StringComparison.Ordinal))
{
return;
}
__result = CorpseLoot.IsSearchable(__instance);
}
}
/// ПОДСКАЗКА ПРИ НАВЕДЕНИИ. Баг-репорт 2026-09-18: «при наведении пиктограмма руки так
/// и не появилась. А без неё интуитивно непонятно что тут можно лутать».
///
/// Оказалось, я настроил не то место. Иконка у EntityActivationCommand рисуется в РАДИАЛЬНОМ
/// МЕНЮ (XUiC_Radial.SetCurrentEntityData -> CreateRadialEntry со спрайтом
/// $"ui_game_symbol_{icon}"), то есть только когда игрок ЗАЖМЁТ кнопку взаимодействия. А
/// подпись, которая появляется просто при наведении, берётся совсем из другого места - из
/// виртуального Entity.GetActivationText(), и у EntityAlive он для обычного существа
/// возвращает пустую строку.
///
/// Здесь возвращается ровно то же, что у ванильного лутаемого объекта (EntityItem):
///
/// string arg = playerInput.Activate.GetBindingXuiMarkupString()
/// + playerInput.PermanentActions.Activate.GetBindingXuiMarkupString();
/// return string.Format(Localization.Get("lootTooltipNew"), arg, имя);
///
/// `lootTooltipNew` - штатная строка лутаемых объектов, «{0} Search {1} (Untouched)» /
/// «{0}: обыскать {1} (нетронуто)», уже переведённая на все языки игры. Значок кнопки
/// подставляется разметкой [action:...] из первого аргумента, поэтому у игрока он будет ровно
/// такой же, как на любом мешке, и подпишется его собственной раскладкой.
///
/// Имя берётся у самого зомби (LocalizedEntityName), так что подсказка читается как
/// «обыскать Арлин», а не «обыскать мешок».
[HarmonyPatch(typeof(EntityAlive), "GetActivationText")]
public static class Patch_EntityAlive_GetActivationText_Corpse
{
public static void Postfix(EntityAlive __instance, ref string __result)
{
if (!CorpseLoot.IsSearchable(__instance))
{
return;
}
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player == null)
{
return;
}
PlayerActionsLocal input = player.playerInput;
string key = input.Activate.GetBindingXuiMarkupString() +
input.PermanentActions.Activate.GetBindingXuiMarkupString();
__result = string.Format(Localization.Get("lootTooltipNew"), key,
Localization.Get(__instance.LocalizedEntityName));
}
}
/// Запускает обыск - НЕ мгновенно, а с трёхсекундным круговым индикатором (указание
/// 2026-09-18). Индикатор рисует сама игра: XUiC_Timer.OpenTimer, тот же механизм, что крутится
/// при снятии верстака, и знакомый игроку без объяснений.
///
/// ПОЧЕМУ НЕ ЧЕРЕЗ activateTime У КОМАНДЫ. У EntityActivationCommand есть поле activateTime, и
/// Entity.ActivateEntityCommand действительно открывает по нему таймер - но по завершении он
/// дёргает ИГРОВОЕ СОБЫТИЕ из entity_class (`onActivateEvent`), а не наш код, и запускается
/// только если у команды заполнено eventName. Нам нужен свой обработчик в конце, поэтому таймер
/// открывается вручную, а не описанием команды.
///
/// CloseOnHit=true: если по игроку ударили посреди обыска, полоса закрывается и труп остаётся
/// нетронутым. Обыскивать труп посреди боя не должно быть бесплатно.
[HarmonyPatch(typeof(EntityAlive), "OnEntityActivated")]
public static class Patch_EntityAlive_OnEntityActivated_Corpse
{
public static void Postfix(EntityAlive __instance, EntityActivationCommand _command,
EntityPlayerLocal _playerFocusing)
{
if (_command.commandId != CorpseLoot.SearchCommand || !CorpseLoot.IsSearchable(__instance))
{
return;
}
if (_playerFocusing == null || _playerFocusing.PlayerUI == null)
{
CorpseLoot.SearchCorpse(__instance);
return;
}
EntityAlive corpse = __instance;
TimerEventData timer = new TimerEventData
{
Data = corpse,
CloseOnHit = true,
};
timer.FullTimeFinishEvent += delegate
{
// Повторная проверка на финише: за три секунды труп мог истлеть, а игрок - уйти.
if (CorpseLoot.IsSearchable(corpse))
{
CorpseLoot.SearchCorpse(corpse);
}
};
XUiC_Timer.OpenTimer(_playerFocusing.PlayerUI.xui, CorpseLoot.SearchSeconds, timer);
Debug.Log("[NecromancerTome] CorpseLoot: начат обыск трупа " + corpse.entityId +
", " + CorpseLoot.SearchSeconds + " с");
}
}
}