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