Обыск больше не превращает труп в мешок. Труп получает сумку со списком добычи zPackVictim и открывается игровым путём (LockRequestLocal -> LootBagOpened -> окно добычи с игровым таймером обыска). Запрет открывать мёртвых у EntityAlive снят префиксами только для таких трупов. Открытый и не опустошённый труп лежит час, опустошённый убирается сразу. После загрузки труп узнаётся по сохранённой сумке (баффы зомби в сейв не пишутся). LockRequestLocal вызывается из TEPersistence (перегрузка с ReadOnlySpan не компилируется в основном проекте). Класс EntityLootContainerVictim оставлен ради старых сейвов. Описание ножа исправлено на всех языках, README - текущее описание. Проверено в игре на 3.3: открытие, таймер, уборка опустошённых, восстановление после загрузки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
700 lines
35 KiB
C#
700 lines
35 KiB
C#
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: <none>
|
||
/// shader 'Game/Animal/Fur' ... tint property: <none>
|
||
///
|
||
/// Свойства цвета у них НЕТ ВООБЩЕ. `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 && 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);
|
||
}
|
||
__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;
|
||
}
|
||
}
|
||
}
|