Files
necromants-tome-7d2d-3-2/HarmonySrc/CorpseLootPatch.cs
T
AlexCubeandClaude Opus 5.5 31aa99860c Одержимость питомцев: роли медведя, волка, собаки и крысы; ноша стервятника; череп при обыске
- Могильная крыса: своя модель без волчьей шерсти; стоит на земле (центр капсулы);
  E - осмотр контейнеров без права взять (окно игры, рюкзак скрыт, отказ «инвентарь
  заполнен», контейнер не разрушается); ПКМ - проход сквозь постройки, полупрозрачная.
- Стервятник: курица в когтях - слепок с обвисшими крыльями и шеей, без хитбокса.
- Зомбособака: нюх - все существа рядом на компасе и карте.
- Медведь: толстая шкура (вдвое меньше урона), рёв-провокация, удар лапой по блокам.
- Лютоволк: бросок на цель с опрокидыванием, след крови раненых, сумка на 3 ячейки
  (лут, выгрузка у тела, выпадает мешком при гибели и отзыве; отдельный файл сейва).
- Череп (останки): 2% при обычном обыске красного трупа вместо браслета.
- Прицел книги не рисуется в одержимости; подсказки вселения на 13 языках.
- Замер EntityLeakProbe: удалённые, но живые в памяти сущности - в лог.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:40 +03:00

701 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// ТРУП ПОМЕЧЕННОГО ЗОМБИ САМ СТАНОВИТСЯ ДОБЫЧЕЙ — указание 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.
/// </summary>
public static class CorpseLoot
{
/// <summary>Сколько секунд лежит помеченный труп. В 1.4.0 было 3600, как у ванильного мешка с
/// лутом; 2026-09-24 уменьшено до 5 минут по указанию пользователя: «если игрок не будет
/// убирать, у него FPS начнёт падать» - каждый лежащий труп это отрисовываемая модель со
/// своими копиями материалов, а необобранных за час набирается много.</summary>
public const float CorpseSecondsAfterDeath = 300f;
/// <summary>Во что красится труп. Тёмно-багровый: издалека видно, что этот не как все, и
/// при этом не выглядит подсветкой из другой игры.</summary>
public static readonly Color CorpseTint = new Color(0.55f, 0.12f, 0.16f);
/// <summary>Идентификатор берётся у ванильного мешка (EntityItem), чтобы подпись досталась
/// готовой и локализованной на все языки игры. ИКОНКА - "hand", а не "search", по указанию
/// 2026-09-18 («при наведении на зомби пиктограмма руки»); имена иконок к идентификатору
/// команды не привязаны, у ванильного "take" стоит ровно эта.</summary>
public const string SearchCommand = "search";
public const string SearchIcon = "hand";
/// <summary>Тег, по которому после загрузки отличаем наш труп (RestoreLoadedCorpses).</summary>
public static readonly FastTags<TagGroup.Global> ZombieTag = FastTags<TagGroup.Global>.Parse("zombie");
/// <summary>Список добычи трупа - тот же, что был у мешка «Жертва» (Config/loot.xml).</summary>
public const string VictimLootList = "zPackVictim";
/// <summary>Сколько лежит труп, который уже открывали, пока в нём что-то осталось. Столько
/// же лежал мешок «Жертва» (TimeStayAfterDeath 3600): добыча не должна пропадать через
/// 5 минут только потому, что игрок отошёл. Таких трупов единицы, а опустошённый убирается
/// сразу (RemoveEmptiedCorpses), так что 5-минутное правило 1.4.1 про FPS не нарушается.</summary>
public const float OpenedCorpseSecondsAfterDeath = 3600f;
/// <summary>Трупы, которые можно открыть. Ключ — entityId. Запись снимается, когда труп
/// исчезает (ReleaseGoneCorpses, RemoveEmptiedCorpses); после загрузки сейва
/// восстанавливается по метке «Жертвы» (RestoreLoadedCorpses).</summary>
public static readonly HashSet<int> SearchableCorpses = new HashSet<int>();
/// <summary>Трупы, которые ещё не удалось перекрасить: модель бывает не собрана на том
/// кадре, когда сущность умирает. Добивается односекундным тиком PetFollowPatch.</summary>
public static readonly HashSet<int> PendingTint = new HashSet<int>();
/// <summary>УТЕЧКА ПАМЯТИ, НАЙДЕНА 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) убирает за теми, кого уже нет. Текстура
/// удаляется, когда её не использует ни один труп.</summary>
public class TintedCorpse
{
public readonly List<Renderer> Renderers = new List<Renderer>();
public readonly List<Material> Materials = new List<Material>();
public readonly List<Texture2D> Textures = new List<Texture2D>();
}
public static readonly Dictionary<int, TintedCorpse> Tinted = new Dictionary<int, TintedCorpse>();
/// <summary>Сколько трупов сейчас носит каждую перекрашенную текстуру.</summary>
public static readonly Dictionary<Texture2D, int> TextureUsers = new Dictionary<Texture2D, int>();
/// <summary>Предел стороны перекрашенной текстуры. Исходные 2048-4096; труп рассматривают
/// вблизи, но 1024 со сжатием это ~1 МБ вместо 16-64 МБ, и разница на глаз незаметна.</summary>
public const int MaxTintSize = 1024;
/// <summary>Повторная попытка перекраски. Зовётся из общего тика раз в секунду.</summary>
public static void RetryPendingTints(World _world)
{
if (_world != null)
{
RestoreLoadedCorpses(_world);
RemoveEmptiedCorpses(_world);
}
ReleaseGoneCorpses(_world);
if (PendingTint.Count == 0 || _world == null)
{
return;
}
List<int> done = new List<int>();
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);
}
}
/// <summary>Уборка за исчезнувшими трупами. Трупом считается исчезнувшим, когда его нет в
/// мире И уничтожены все его рендереры: удалять материал, который ещё рисуется, нельзя.</summary>
public static void ReleaseGoneCorpses(World _world)
{
if (Tinted.Count == 0 || _world == null)
{
return;
}
List<int> gone = null;
foreach (KeyValuePair<int, TintedCorpse> 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<int>();
}
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<Texture> keys = new List<Texture>();
foreach (KeyValuePair<Texture, Texture2D> 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);
}
/// <summary>Зовётся из VictimPatch, когда помеченный зомби умер.</summary>
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 + " с");
}
/// <summary>Перекраска трупа. ПЕРЕПИСАНА 2026-09-18, первая версия не работала вообще:
/// пользователь сообщил «труп не перекрасился», и причина нашлась в нашем же логе.
///
/// Первая версия ставила `_Color` на материалах - ровно как сделано у глаз собаки. Но у
/// собаки глаз сидит на шейдере `Standard`, а тело зомби и шкура зверей - на собственных
/// шейдерах игры, и проба, которую печатает GhostTraderPatch, по ним говорит прямо:
///
/// shader 'Game/Character' ... tint property: &lt;none&gt;
/// shader 'Game/Animal/Fur' ... tint property: &lt;none&gt;
///
/// Свойства цвета у них НЕТ ВООБЩЕ. `HasProperty("_Color")` возвращал false, цикл не делал
/// ни одной итерации, и метод честно печатал «материалов 0» - которое никто не прочитал.
///
/// Поэтому красится САМА ТЕКСТУРА, тем же приёмом, которым мод обесцвечивает торговцев:
/// прогон через RenderTexture (игровые текстуры сжаты и с isReadable=false, напрямую
/// GetPixels по ним падает), затем попиксельное умножение на цвет. Разница с
/// обесцвечиванием ровно в одной строке арифметики, всё остальное - тот же путь, уже
/// проверенный на шести торговцах.
///
/// Результат кешируется по исходной текстуре: они 2048x2048 и больше, а помеченных трупов
/// за игру будет много - пересчитывать на каждый незачем.</summary>
public static readonly Dictionary<Texture, Texture2D> TintedTextures = new Dictionary<Texture, Texture2D>();
public static bool TintCorpse(Entity _corpse)
{
Renderer[] renderers = _corpse.GetComponentsInChildren<Renderer>(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;
}
/// <summary>Копия GhostTraderPatch.Desaturate с одной изменённой строкой: вместо сведения
/// каналов к яркости - умножение на CorpseTint. Прогон через RenderTexture обязателен и там
/// и здесь по одной причине: игровые текстуры сжаты и не читаются процессором напрямую.</summary>
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;
}
/// <summary>Сумка и список добычи трупа. Сумка пустая и нетронутая: наполнит её сама игра
/// при первом открытии. Уже существующую (труп из сейва) не трогаем - в ней то, что игрок
/// не забрал.</summary>
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);
}
}
/// <summary>Трупы из сейва. Наша пометка живёт только в памяти, а срок трупа при загрузке
/// снова берётся из entity_class (30 с у зомби) - без этого труп с добычей исчез бы через
/// полминуты после загрузки. Тик раз в секунду.
///
/// УЗНАЁМ ПО СУМКЕ, А НЕ ПО МЕТКЕ «ЖЕРТВЫ» (проверено в игре 2026-09-26: по метке труп после
/// загрузки не узнавался и становился обычным). Баффы зомби в сейв НЕ пишутся:
/// EntityAlive.Write кладёт только deathHealth и StressAmount, EntityCreationData - stats и
/// bagData. Сумка же сохраняется (bagData), а своей сумки у зомби не бывает: ни у одного из
/// 154 классов с тегом zombie в Data/Config/entityclasses.xml нет LootList (сверено 3.3).
/// Значит, мёртвый зомби с непустой по размеру сумкой - это наш труп.</summary>
public static void RestoreLoadedCorpses(World _world)
{
List<Entity> 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 ? " (уже открывали)" : ""));
}
}
/// <summary>Опустошённый и закрытый труп убирается сразу - как игра убирает пустой мешок
/// (EntityContainerAbs.OnUpdateEntity: `bag.Touched &amp;&amp; bag.IsEmpty()`). Только сервер.</summary>
public static void RemoveEmptiedCorpses(World _world)
{
if (SearchableCorpses.Count == 0 || !SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer)
{
return;
}
List<int> 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<int>();
}
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 + " опустошён и убран");
}
}
}
/// <summary>Добавляет команду «обыскать» в список. Постфикс, а не префикс: ванильные команды
/// сущности (у EntityAlive это «подобрать» для тех, у кого задан PickupItem) должны остаться.</summary>
[HarmonyPatch(typeof(EntityAlive), "InitLocalActivationCommands")]
public static class Patch_EntityAlive_InitLocalActivationCommands_Corpse
{
public static void Postfix(EntityAlive __instance, Action<EntityActivationCommand> _addCallback)
{
if (!CorpseLoot.IsSearchable(__instance))
{
return;
}
_addCallback(new EntityActivationCommand(CorpseLoot.SearchCommand, CorpseLoot.SearchIcon));
}
}
/// <summary>Разрешает команду только помеченному трупу. Без этого «обыскать» досталось бы
/// любому существу, у которого список команд пересобрался бы по любой причине.</summary>
[HarmonyPatch(typeof(EntityAlive), "AllowActivationCommand")]
public static class Patch_EntityAlive_AllowActivationCommand_Corpse
{
public static void Postfix(EntityAlive __instance, ReadOnlySpan<char> _commandName, ref bool __result)
{
if (!MemoryExtensions.Equals(_commandName, CorpseLoot.SearchCommand.AsSpan(), StringComparison.Ordinal))
{
return;
}
__result = CorpseLoot.IsSearchable(__instance);
}
}
/// <summary>ПОДСКАЗКА ПРИ НАВЕДЕНИИ. Баг-репорт 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), так что подсказка читается как
/// «обыскать Арлин», а не «обыскать мешок».</summary>
[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));
}
}
/// <summary>Открывает труп так же, как игра открывает мешок: EntityItem.OnEntityActivated по
/// команде "search" делает ровно `LockManager.Instance.LockRequestLocal(this, 0)` (у нас - через
/// LockRequestHelper в TEPersistenceSrc, иначе не компилируется). Дальше всё
/// игровое: сервер наполняет сумку при первом открытии, окно добычи показывает свой таймер
/// обыска. До 1.5.0 здесь был свой 3-секундный таймер и превращение трупа в мешок.</summary>
[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);
}
}
/// <summary>Сервер: пускает к сумке помеченного трупа. У EntityAlive тут `if (!IsDead())`, и
/// мёртвому отказано всегда; для нашего трупа выполняется тело базового Entity.OnLockRequestServer
/// (без веток Twitch-ящиков - зомби не бывает «чужим» ящиком). Заодно продлевает жизнь трупа:
/// открытый, но не опустошённый лежит как мешок.</summary>
[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;
}
}
/// <summary>Клиент: показывает окно добычи трупа. Повтор тела Entity.OnLockResponseLocal
/// (у EntityAlive снова `if (!IsDead())`). OnLockResponseServer у EntityAlive не переопределён -
/// базовый сам пишет, открывали ли сумку раньше (это и включает игровой таймер обыска), и её
/// содержимое.</summary>
[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;
}
}
}