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. ВНУТРИ ЛУТ, И ОТКРЫВАЕТСЯ САМ ТРУП (1.5.0, 2026-09-26; пользователь: «труп сам /// становится обыскиваемым объектом», как машина или ящик). До 1.5.0 обыск превращал /// труп в мешок «Жертва» - оказалось, что это лишнее: сумка есть у ЛЮБОЙ сущности. /// `Entity.bag` + `Entity.lootList`, первое открытие наполняет сумку по списку /// (LootManager.LootBagOpened -> GetLootList()), окно - XUiC_BagStorageWindowGroup, тот /// же путь, что у мешков, машин и дронов. Сумка сущности пишется в сейв /// (EntityCreationData.bagData), а мёртвые зомби сохраняются (EntityEnemy.IsSavedToFile). /// Единственный запрет - EntityAlive.OnLockRequestServer/OnLockResponseLocal не пускают /// мёртвых (`if (!IsDead())`); для помеченных трупов его снимают два префикса ниже. /// Таймер обыска - игровой, окна добычи (runOpenTimer при первом открытии). /// /// ОБЫЧНЫЙ МЕШОК ЭТО НЕ ОТМЕНЯЕТ. Бросок вероятности в 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"; /// Тег, по которому после загрузки отличаем наш труп (RestoreLoadedCorpses). public static readonly FastTags ZombieTag = FastTags.Parse("zombie"); /// Список добычи трупа - тот же, что был у мешка «Жертва» (Config/loot.xml). public const string VictimLootList = "zPackVictim"; /// Сколько лежит труп, который уже открывали, пока в нём что-то осталось. Столько /// же лежал мешок «Жертва» (TimeStayAfterDeath 3600): добыча не должна пропадать через /// 5 минут только потому, что игрок отошёл. Таких трупов единицы, а опустошённый убирается /// сразу (RemoveEmptiedCorpses), так что 5-минутное правило 1.4.1 про FPS не нарушается. public const float OpenedCorpseSecondsAfterDeath = 3600f; /// Трупы, которые можно открыть. Ключ — entityId. Запись снимается, когда труп /// исчезает (ReleaseGoneCorpses, RemoveEmptiedCorpses); после загрузки сейва /// восстанавливается по метке «Жертвы» (RestoreLoadedCorpses). 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) { if (_world != null) { RestoreLoadedCorpses(_world); RemoveEmptiedCorpses(_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); GiveBag(_victim); // 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 GiveBag(EntityAlive _corpse) { _corpse.lootList = VictimLootList; if (_corpse.bag != null && _corpse.bag.SlotCount > 0) { return; } LootContainer container = LootContainer.GetLootContainer(VictimLootList, false); if (container == null) { Debug.LogWarning("[NecromancerTome] CorpseLoot: нет списка добычи '" + VictimLootList + "' - труп " + _corpse.entityId + " откроется пустым"); return; } if (_corpse.bag == null) { _corpse.bag = new Bag(container.size, XUiC_ItemStack.StackLocationTypes.LootContainer, _corpse); } else { _corpse.bag.Resize(container.size); } } /// Трупы из сейва. Наша пометка живёт только в памяти, а срок трупа при загрузке /// снова берётся из entity_class (30 с у зомби) - без этого труп с добычей исчез бы через /// полминуты после загрузки. Тик раз в секунду. /// /// УЗНАЁМ ПО СУМКЕ, А НЕ ПО МЕТКЕ «ЖЕРТВЫ» (проверено в игре 2026-09-26: по метке труп после /// загрузки не узнавался и становился обычным). Баффы зомби в сейв НЕ пишутся: /// EntityAlive.Write кладёт только deathHealth и StressAmount, EntityCreationData - stats и /// bagData. Сумка же сохраняется (bagData), а своей сумки у зомби не бывает: ни у одного из /// 154 классов с тегом zombie в Data/Config/entityclasses.xml нет LootList (сверено 3.3). /// Значит, мёртвый зомби с непустой по размеру сумкой - это наш труп. public static void RestoreLoadedCorpses(World _world) { List list = _world.Entities.list; for (int i = 0; i < list.Count; i++) { if (!(list[i] is EntityAlive alive) || !alive.IsDead() || SearchableCorpses.Contains(alive.entityId)) { continue; } if (alive.bag == null || alive.bag.SlotCount == 0 || !alive.HasAnyTags(ZombieTag)) { continue; } MarkCorpse(alive); bool opened = alive.bag != null && alive.bag.Touched; if (opened) { alive.timeStayAfterDeath = (int)(OpenedCorpseSecondsAfterDeath * 20f); } Debug.Log("[NecromancerTome] CorpseLoot: труп " + alive.entityId + " восстановлен после загрузки" + (opened ? " (уже открывали)" : "")); } } /// Опустошённый и закрытый труп убирается сразу - как игра убирает пустой мешок /// (EntityContainerAbs.OnUpdateEntity: `bag.Touched && bag.IsEmpty()`). Только сервер. public static void RemoveEmptiedCorpses(World _world) { if (SearchableCorpses.Count == 0 || !SingletonMonoBehaviour.Instance.IsServer) { return; } List emptied = null; foreach (int id in SearchableCorpses) { if (!(_world.GetEntity(id) is EntityAlive corpse) || corpse.bag == null) { continue; } if (!corpse.bag.Touched || !corpse.bag.IsEmpty() || LockManager.Instance.IsLockedServer(corpse, 0)) { continue; } if (emptied == null) { emptied = new List(); } emptied.Add(id); } if (emptied == null) { return; } foreach (int id in emptied) { SearchableCorpses.Remove(id); _world.RemoveEntity(id, EnumRemoveEntityReason.Despawned); Debug.Log("[NecromancerTome] CorpseLoot: труп " + id + " опустошён и убран"); } } } /// Добавляет команду «обыскать» в список. Постфикс, а не префикс: ванильные команды /// сущности (у 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(); // Как у мешка (EntityItem.GetActivationText): нетронуто / пусто / обыскано. Bag bag = __instance.bag; string tooltip = bag == null || !bag.Touched ? "lootTooltipNew" : bag.IsEmpty() ? "lootTooltipEmpty" : "lootTooltipTouched"; __result = string.Format(Localization.Get(tooltip), key, Localization.Get(__instance.LocalizedEntityName)); } } /// Открывает труп так же, как игра открывает мешок: EntityItem.OnEntityActivated по /// команде "search" делает ровно `LockManager.Instance.LockRequestLocal(this, 0)` (у нас - через /// LockRequestHelper в TEPersistenceSrc, иначе не компилируется). Дальше всё /// игровое: сервер наполняет сумку при первом открытии, окно добычи показывает свой таймер /// обыска. До 1.5.0 здесь был свой 3-секундный таймер и превращение трупа в мешок. [HarmonyPatch(typeof(EntityAlive), "OnEntityActivated")] public static class Patch_EntityAlive_OnEntityActivated_Corpse { public static void Postfix(EntityAlive __instance, EntityActivationCommand _command) { if (_command.commandId != CorpseLoot.SearchCommand || !CorpseLoot.IsSearchable(__instance)) { return; } LockRequestHelper.RequestLocal(__instance); } } /// Сервер: пускает к сумке помеченного трупа. У EntityAlive тут `if (!IsDead())`, и /// мёртвому отказано всегда; для нашего трупа выполняется тело базового Entity.OnLockRequestServer /// (без веток Twitch-ящиков - зомби не бывает «чужим» ящиком). Заодно продлевает жизнь трупа: /// открытый, но не опустошённый лежит как мешок. [HarmonyPatch(typeof(EntityAlive), "OnLockRequestServer")] public static class Patch_EntityAlive_OnLockRequestServer_Corpse { public static bool Prefix(EntityAlive __instance, int _lockingPlayerID, ref bool __result) { if (!__instance.IsDead() || !CorpseLoot.IsSearchable(__instance)) { return true; } if (__instance.bag == null) { __result = false; return false; } if (!__instance.bag.Touched) { GameManager.Instance.lootManager.LootBagOpened(__instance.bag, __instance, _lockingPlayerID); NecroSkull.Roll(__instance, __instance.bag); // 2% - череп (останки), с 09.10 вместо браслета } __instance.timeStayAfterDeath = (int)(CorpseLoot.OpenedCorpseSecondsAfterDeath * 20f); Debug.Log("[NecromancerTome] CorpseLoot: труп " + __instance.entityId + " открыт"); __result = true; return false; } } /// Клиент: показывает окно добычи трупа. Повтор тела Entity.OnLockResponseLocal /// (у EntityAlive снова `if (!IsDead())`). OnLockResponseServer у EntityAlive не переопределён - /// базовый сам пишет, открывали ли сумку раньше (это и включает игровой таймер обыска), и её /// содержимое. [HarmonyPatch(typeof(EntityAlive), "OnLockResponseLocal")] public static class Patch_EntityAlive_OnLockResponseLocal_Corpse { public static bool Prefix(EntityAlive __instance, bool _success, PooledBinaryReader _brServerContext, ref bool __result) { if (!__instance.IsDead() || !CorpseLoot.IsSearchable(__instance)) { return true; } __result = false; LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPrimaryPlayer(); Bag bag = __instance.bag; if (bag == null || !_success) { if (playerUI != null) { GameManager.ShowTooltip(playerUI.entityPlayer, Localization.Get("ttNoInteractItem"), string.Empty, "ui_denied"); } return false; } bool runOpenTimer = _brServerContext.ReadBoolean(); bag.ItemGrid.ReadInto(_brServerContext, StreamModeRead.FromServer); LootContainer lootContainer = LootContainer.GetLootContainer(__instance.GetLootList()); EntityAlive corpse = __instance; XUiC_BagStorageWindowGroup.Open(playerUI.xui, corpse, bag, lootContainer, Localization.Get(corpse.LocalizedEntityName), corpse.OnBagModified, () => LockManager.Instance.UnlockRequestLocal(), () => corpse.GetDistanceSq(playerUI.entityPlayer) <= Constants.cPlayerInteractDistance * Constants.cPlayerInteractDistance, runOpenTimer); if (lootContainer != null && playerUI.entityPlayer != null) { lootContainer.ExecuteBuffActions(corpse.entityId, playerUI.entityPlayer); } bag.ItemGrid.Touch(); __result = true; return false; } } }