diff --git a/HarmonySrc/CorpseLootPatch.cs b/HarmonySrc/CorpseLootPatch.cs
new file mode 100644
index 0000000..9780567
--- /dev/null
+++ b/HarmonySrc/CorpseLootPatch.cs
@@ -0,0 +1,399 @@
+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
+ {
+ /// Сколько секунд лежит помеченный труп. У ванильного мешка с лутом 3600, и
+ /// причин быть скромнее нет: труп и есть мешок.
+ public const float CorpseSecondsAfterDeath = 3600f;
+
+ /// Во что красится труп. Тёмно-багровый: издалека видно, что этот не как все, и
+ /// при этом не выглядит подсветкой из другой игры.
+ 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();
+
+ /// Повторная попытка перекраски. Зовётся из общего тика раз в секунду.
+ public static void RetryPendingTints(World _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 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;
+ }
+ 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;
+ for (int i = 0; i < instances.Length && i < shared.Length; i++)
+ {
+ Material material = instances[i];
+ if (material == null)
+ {
+ continue;
+ }
+ string albedo = GhostTraderPatch.FindAlbedoProperty(material);
+ if (albedo == null)
+ {
+ continue;
+ }
+ Texture2D repainted = Tint(material.GetTexture(albedo));
+ if (repainted == null)
+ {
+ continue;
+ }
+ material.SetTexture(albedo, repainted);
+ 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
+ {
+ rt = RenderTexture.GetTemporary(_source.width, _source.height, 0,
+ RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
+ Graphics.Blit(_source, rt);
+ RenderTexture.active = rt;
+
+ painted = new Texture2D(_source.width, _source.height, TextureFormat.RGBA32, false);
+ painted.ReadPixels(new Rect(0f, 0f, _source.width, _source.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);
+ painted.Apply(false, false);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError("[NecromancerTome] CorpseLoot: не удалось перекрасить текстуру '" +
+ _source.name + "': " + e.Message);
+ 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 + " с");
+ }
+ }
+}
diff --git a/HarmonySrc/ModEntry.cs b/HarmonySrc/ModEntry.cs
index 088b7dc..357881f 100644
--- a/HarmonySrc/ModEntry.cs
+++ b/HarmonySrc/ModEntry.cs
@@ -41,7 +41,9 @@ namespace NecromancerTome
// that method at all - rules "patch never applied" in or out without waiting on
// another in-game death.
VerifyPrefixAttached(typeof(EntityAlive), "dropItemOnDeath");
- VerifyPrefixAttached(typeof(Entity), "DropBagServer");
+ // Патч на Entity.DropBagServer снят 2026-09-18 вместе с переделкой мешка «Жертвы»:
+ // он подменял собой ОБЫЧНЫЙ мешок, а тот теперь должен доставаться игроку как есть.
+ // Проверять нечего - см. VictimPatch.cs.
// Добавлено 2026-09-17 вместе с правкой "подчинённые не дерутся между собой"
// (CharmPatch.cs). Метод EAITarget.check в исходнике protected и назван со строчной
diff --git a/HarmonySrc/ParticlePatch.cs b/HarmonySrc/ParticlePatch.cs
index c043739..1becff9 100644
--- a/HarmonySrc/ParticlePatch.cs
+++ b/HarmonySrc/ParticlePatch.cs
@@ -90,7 +90,7 @@ namespace NecromancerTome
return;
}
bool isPortalChannel = entity.Buffs.HasBuff(Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelBuffName);
- bool isVictim = entity.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName);
+ bool isVictim = entity.Buffs.HasBuff(Patch_EntityAlive_dropItemOnDeath_VictimBag.VictimBuffName);
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
if (!isPortalChannel && !isVictim && !isCharm)
{
diff --git a/HarmonySrc/PetFollowPatch.cs b/HarmonySrc/PetFollowPatch.cs
index 68a65a3..c52116b 100644
--- a/HarmonySrc/PetFollowPatch.cs
+++ b/HarmonySrc/PetFollowPatch.cs
@@ -322,13 +322,19 @@ namespace NecromancerTome
return;
}
timer = 0f;
- if (TrackedPets.Count == 0)
+ World world = GameManager.Instance != null ? GameManager.Instance.World : null;
+ if (world == null)
{
return;
}
- World world = GameManager.Instance != null ? GameManager.Instance.World : null;
- if (world == null)
+ // Трупы, которые не удалось перекрасить в момент смерти (модель бывает ещё не
+ // собрана) - добиваем здесь: тик всё равно уже идёт. См. CorpseLootPatch.cs.
+ // ВЫШЕ проверки на пустой список питомцев намеренно: трупы помечает Нож некроманта,
+ // и к тому, есть ли у игрока сейчас призванный питомец, они отношения не имеют.
+ CorpseLoot.RetryPendingTints(world);
+
+ if (TrackedPets.Count == 0)
{
return;
}
diff --git a/HarmonySrc/VictimPatch.cs b/HarmonySrc/VictimPatch.cs
index 1e0b3d1..540d14a 100644
--- a/HarmonySrc/VictimPatch.cs
+++ b/HarmonySrc/VictimPatch.cs
@@ -4,97 +4,161 @@ using UnityEngine;
namespace NecromancerTome
{
///
- /// Necromancer's Knife (BACKLOG.md item 5, user request 2026-08-28): a zombie hit by the
- /// knife carries buffNecroVictim (buffs.xml) until it dies. On death, it's guaranteed to drop
- /// a green "Жертва" loot bag (EntityLootContainerVictim in entityclasses.xml) instead of
- /// whatever the normal random loot roll would have given it.
+ /// Нож некроманта (BACKLOG.md, п.5): зомби, задетый ножом, носит buffNecroVictim (buffs.xml) до
+ /// самой смерти. На смерти он роняет мешок «Жертва» (EntityLootContainerVictim в
+ /// entityclasses.xml).
///
- /// Two patch points, found the hard way (2026-08-28, "лута всё ещё нет" after confirming via
- /// the CharmPatch.cs-style AddBuff log that the debuff itself WAS being applied fine):
+ /// ПОВЕДЕНИЕ ИСПРАВЛЕНО 2026-09-18, И ЭТО ВОЗВРАТ К ТОМУ, О ЧЁМ ДОГОВАРИВАЛИСЬ. Баг-репорт
+ /// пользователя: «мы договаривались, что он выпадает только если не должен был выпасть мешок с
+ /// лутом. Но похоже падает только мешок жертвы».
///
- /// 1. Patch_EntityAlive_dropItemOnDeath_VictimBag - the actual gate. Confirmed by
- /// decompiling EntityAlive.dropItemOnDeath(): it only calls DropBagServer() at all if
- /// `lootDropProb > rand.RandomFloat` passes first - vanilla zombies have LootDropProb
- /// around .04 (4%), so DropBagServer() simply never runs for ~96% of deaths. The first
- /// version of this file only patched DropBagServer() itself, which was correct once
- /// inside it but never got a chance to run for most kills - confirmed by the "AddBuff
- /// Added" log firing repeatedly while the "zombie died" log from the DropBagServer patch
- /// never fired even once for a real, logged kill. This Prefix on dropItemOnDeath() calls
- /// DropBagServer() directly for a Victim-tagged zombie, bypassing the probability roll
- /// entirely, then skips the rest of the original method (the only other thing it does -
- /// dropping a non-AI entity's own inventory - never applies to a zombie anyway, hasAI is
- /// always true for those).
- /// 2. Patch_Entity_DropBagServer_VictimBag - decides WHICH bag. Confirmed by decompiling
- /// Entity.DropBagServer(): it reads entityClass.lootDrops/LootDropPick(rand) - a STATIC
- /// per-species value from the zombie's own entity_class (XML's LootDropEntityClass
- /// property), not anything a live buff or CVar can influence declaratively (unlike the
- /// knife's damage, which only needed a CVar the passive_effect system already reads
- /// live) - this needed an actual Harmony patch, not an XML trick.
+ /// Так и было. Прежняя версия состояла из двух префиксов, и оба возвращали false:
///
- /// Both are Prefixes returning false: they fully replace what they intercept rather than
- /// running alongside it.
+ /// 1. на dropItemOnDeath - при дебафе звала DropBagServer() НАПРЯМУЮ, минуя ванильный
+ /// бросок вероятности, и обрывала оригинал;
+ /// 2. на DropBagServer - при дебафе спавнила «Жертву» ВМЕСТО обычного мешка.
+ ///
+ /// Вместе это означало: помеченный зомби роняет «Жертву» ВСЕГДА и обычный мешок НИКОГДА. То
+ /// есть нож не добавлял добычу, а подменял её - и в редких 4% случаев, когда игроку и так
+ /// причитался нормальный мешок, он его терял.
+ ///
+ /// СТАЛО (уточнено пользователем в тот же день - «а можно сделать так, чтобы в случае когда
+ /// положен лут, падало два мешка»): «Жертва» не заменяет обычный мешок и не ждёт его отсутствия,
+ /// а ДОБАВЛЯЕТСЯ. Ванильный бросок делается как есть (формула скопирована из
+ /// EntityAlive.dropItemOnDeath дословно, включая модификатор от оружия убийцы и множитель
+ /// LootContainer.LootBagChance), и дальше:
+ ///
+ /// бросок прошёл -> обычный мешок рядом И добыча в трупе;
+ /// бросок не прошёл -> только добыча в трупе.
+ ///
+ /// То есть помеченный ножом зомби никогда не оставляет игрока ни с чем, а в удачном случае
+ /// даёт вдвое больше обычного. Это осознанно щедро: метку ещё надо поставить ножом, которым
+ /// по зомби надо попасть.
+ ///
+ /// УТОЧНЕНО В ТОТ ЖЕ ДЕНЬ: «Жертва» больше не падает мешком в момент смерти - она остаётся
+ /// ВНУТРИ ТРУПА, который перекрашивается, перестаёт исчезать и получает команду «обыскать»
+ /// (CorpseLootPatch.cs). Мешок появляется только по обыску. Сам бросок и вся арифметика ниже
+ /// от этого не изменились: они по-прежнему решают судьбу ОБЫЧНОГО мешка.
+ ///
+ /// Второй префикс (на DropBagServer) УДАЛЁН ЦЕЛИКОМ - он теперь вреден: подменял бы и тот
+ /// обычный мешок, который мы как раз хотим оставить. Спавн «Жертвы» переехал сюда, в
+ /// SpawnVictimBag.
+ ///
+ /// ПОЧЕМУ ВООБЩЕ НУЖЕН ПРЕФИКС, А НЕ ПОСТФИКС. Узнать «выпал ли мешок» после оригинала нечем:
+ /// dropItemOnDeath ничего не возвращает и следов не оставляет. Поэтому бросок повторяется
+ /// здесь, а оригинал не запускается. Ветка оригинала, которая при этом теряется, - выброс
+ /// собственного инвентаря для сущностей БЕЗ ИИ (`if (!hasAI)`), и к зомби она не относится
+ /// никогда; на всякий случай она всё равно отдана оригиналу проверкой hasAI ниже.
+ ///
+ /// LootContainer.NoLoot УВАЖАЕТСЯ: если в настройках мира мешки выключены, «Жертва» тоже не
+ /// падает. Это мировая настройка игрока, а не место мода спорить с ней.
///
[HarmonyPatch(typeof(EntityAlive), "dropItemOnDeath")]
public static class Patch_EntityAlive_dropItemOnDeath_VictimBag
- {
- public static bool Prefix(EntityAlive __instance)
- {
- // Diagnostic-only, added 2026-08-28: unconditional, before any branching, to answer
- // definitively whether Harmony is even entering this method at all - "лута всё ещё
- // нет" after the first fix, but with zero sign of even the unconditional part of this
- // Prefix ever running (not even a false-branch silently returning - literally no log
- // line at all), which is otherwise unexplained since decompiling
- // EntityAlive.OnEntityDeath() confirms it calls dropItemOnDeath() directly,
- // unconditionally, right after the exact "Entity X killed by Y" line seen in the log.
- Debug.Log("[NecromancerTome] VictimPatch: dropItemOnDeath Prefix entered for " + __instance.entityId + " (" + __instance.GetType().Name + "), hasVictimBuff=" + (__instance.Buffs != null && __instance.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName)));
- if (__instance.Buffs == null || !__instance.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName))
- {
- return true;
- }
- Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId + " died carrying buffNecroVictim - forcing guaranteed bag, bypassing LootDropProb roll");
- __instance.DropBagServer();
- return false;
- }
- }
-
- [HarmonyPatch(typeof(Entity), "DropBagServer")]
- public static class Patch_Entity_DropBagServer_VictimBag
{
public const string VictimBuffName = "buffNecroVictim";
public const string VictimContainerClassName = "EntityLootContainerVictim";
- public static bool Prefix(Entity __instance)
+ public static bool Prefix(EntityAlive __instance)
{
- if (!(__instance is EntityAlive alive) || alive.Buffs == null || !alive.Buffs.HasBuff(VictimBuffName))
+ if (__instance.Buffs == null || !__instance.Buffs.HasBuff(VictimBuffName))
{
return true;
}
- if (!SingletonMonoBehaviour.Instance.IsServer || __instance is EntityLootContainer)
+ if (!__instance.hasAI)
+ {
+ // Не наш случай: у оригинала для таких сущностей своя работа (выброс инвентаря),
+ // и дублировать её здесь незачем. Зомби сюда не попадают.
+ return true;
+ }
+ if (!SingletonMonoBehaviour.Instance.IsServer)
{
- // Same guard the original method opens with - not our place to override these cases.
return true;
}
+ // Ванильный расчёт, скопированный из EntityAlive.dropItemOnDeath дословно.
+ float prob = __instance.lootDropProb;
+ if ((bool)__instance.entityThatKilledMe)
+ {
+ prob = EffectManager.GetValue(PassiveEffects.LootDropProb,
+ __instance.entityThatKilledMe.inventory.holdingItemItemValue, prob,
+ __instance.entityThatKilledMe);
+ }
+ prob *= LootContainer.LootBagChance;
+
+ if (LootContainer.NoLoot)
+ {
+ Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId +
+ " - мешки выключены настройками мира, «Жертва» не падает");
+ return false;
+ }
+
+ // Сам бросок печатается ВСЕГДА, и это не отладочный мусор, а ответ на вопрос, который
+ // уже задавали: «на 25 Арлин ни одного мешка с обычным лутом». У ванильного зомби
+ // LootDropProb = .04, то есть ноль мешков на два десятка убийств - исход с
+ // вероятностью около 40%, совершенно рядовой. Без этой строки отличить «не повезло»
+ // от «ролл сломан» можно только гаданием.
+ float roll = __instance.rand.RandomFloat;
+ Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId + " - бросок на обычный мешок: шанс " +
+ prob.ToCultureInvariantString("0.000") + " против " + roll.ToCultureInvariantString("0.000") +
+ " -> " + (prob > roll ? "ВЫПАЛ" : "не выпал"));
+ if (prob > roll)
+ {
+ // Поле пишется ПЕРЕД вызовом намеренно, ровно как это делает сам оригинал:
+ // DropBagServer читает lootDropProb (`if (lootDropProb != 0f)`) и по нему решает,
+ // брать ли добычу из entityClass.lootDrops или из инвентаря. Посчитай мы в
+ // локальную и не запиши - редкий зомби с нулевым собственным lootDropProb, чей
+ // убийца поднял шанс оружием, пошёл бы не по той ветке.
+ __instance.lootDropProb = prob;
+ __instance.DropBagServer();
+ Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId +
+ " - обычный мешок выпал по ролу; «Жертва» ждёт в трупе");
+ }
+
+ // «Жертва» БОЛЬШЕ НЕ ПАДАЕТ МЕШКОМ СРАЗУ (указание 2026-09-18): она остаётся внутри
+ // трупа, а труп становится обыскиваемым. Разбор целиком - в CorpseLootPatch.cs.
+ // Обычный мешок при этом по-прежнему выпадает по своему ролу и лежит рядом, так что
+ // прежняя договорённость «выпал рол - два источника добычи» сохраняется.
+ CorpseLoot.MarkCorpse(__instance);
+ return false;
+ }
+
+ /// Спавнит мешок «Жертва». Раньше это жило в префиксе на Entity.DropBagServer и
+ /// подменяло собой обычный мешок; теперь ничего не подменяет, поэтому тот патч снят.
+ ///
+ /// _offsetAside - сдвинуть в сторону, потому что рядом уже лёг обычный мешок. Ваниль
+ /// кладёт свой ровно в GetPosition() + 0.9 по Y (декомпилировано, Entity.DropBagServer),
+ /// то есть без сдвига два мешка встали бы в одну точку и выглядели как один.
+ public static void SpawnVictimBag(EntityAlive _victim, bool _offsetAside)
+ {
int containerClassId = EntityClass.GetId(VictimContainerClassName);
if (containerClassId == -1)
{
- Debug.LogWarning("[NecromancerTome] VictimPatch: entity class '" + VictimContainerClassName + "' not found, falling back to normal loot");
- return true;
+ Debug.LogWarning("[NecromancerTome] VictimPatch: класс '" + VictimContainerClassName +
+ "' не найден, мешок не выпадет");
+ return;
}
- Vector3 pos = __instance.GetPosition();
+ Vector3 pos = _victim.GetPosition();
pos.y += 0.9f;
+ if (_offsetAside)
+ {
+ // Полметра вбок - достаточно, чтобы мешки читались как два и оба брались
+ // прицелом, и мало, чтобы «Жертва» уехала за стену или в соседнюю комнату.
+ pos += _victim.qrotation * new Vector3(0.6f, 0f, 0f);
+ }
Entity spawned = EntityFactory.CreateEntity(containerClassId, pos, Vector3.zero);
if (spawned is EntityLootContainer lootContainer)
{
GameManager.Instance.World.SpawnEntityInWorld(lootContainer);
- Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId + " (victim) dropped guaranteed loot bag " + lootContainer.entityId);
+ Debug.Log("[NecromancerTome] VictimPatch: " + _victim.entityId + " уронил «Жертву» " +
+ lootContainer.entityId + (_offsetAside ? " (вторым мешком, со сдвигом)" : " (единственным мешком)"));
}
else
{
- Debug.LogWarning("[NecromancerTome] VictimPatch: created entity for '" + VictimContainerClassName + "' wasn't an EntityLootContainer");
+ Debug.LogWarning("[NecromancerTome] VictimPatch: созданная сущность для '" +
+ VictimContainerClassName + "' не оказалась EntityLootContainer");
}
- return false;
}
}
}
diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll
index 94dffef..afe5757 100644
Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ
diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb
index 38ab14b..ba3476c 100644
Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ