Добыча «Жертвы» переехала в сам труп: он красится, лежит час и обыскивается
Указание: «зомби после упокоения оставляет свой труп. Можем ли мы подменять этот труп точно таким же, но с другой текстурой и с функцией мешка жертвы?» ПОДМЕНЯТЬ НЕ ПРИШЛОСЬ НИЧЕГО. Труп - не отдельная модель, а та же скелетная модель зомби в позе смерти, так что «такой же, но другой текстуры» делается перекраской уже лежащего трупа. Ни нового префаба, ни новой сущности. Три части, каждая на своём ванильном механизме: НЕ ИСЧЕЗАЕТ. EntityAlive.timeStayAfterDeath - обычное публичное поле в тиках, у зомби 30 секунд, у ванильного мешка 3600. Пишем второе. ОБЫСКИВАЕТСЯ. У сущностей своя система команд, отдельная от блочной: InitLocalActivationCommands / AllowActivationCommand / OnEntityActivated. Объявляем ту же команду "search", что и ванильный мешок (EntityItem), поэтому подпись достаётся готовой и локализованной. Список команд кешируется при первом обращении - пока зомби жив, - поэтому на смерти он обнуляется. ВНУТРИ ЛУТ. Обход сознательный: хранилище живёт внутри EntityLootContainer вместе со своим сейвом и сетевой синхронизацией, и приделывать это трупу значит трогать сейвы. Поэтому обыск ПРЕВРАЩАЕТ труп в мешок «Жертва». ПЕРЕКРАСКА ЧЕРЕЗ ТЕКСТУРУ, А НЕ ЧЕРЕЗ _Color. Первая версия не работала вовсе: у шейдеров Game/Character и Game/Animal/Fur свойства цвета НЕТ (видно в пробе, которую печатает GhostTraderPatch: "tint property: <none>"). Красится сама текстура - прогоном через RenderTexture, как обесцвечиваются торговцы, только вместо сведения к яркости умножение на цвет. Результат кешируется по исходной текстуре. Если модель на кадре смерти не собрана, труп встаёт в очередь и докрашивается односекундным тиком. ПОДСКАЗКА ПРИ НАВЕДЕНИИ - отдельный механизм, и сначала я настроил не тот. Иконка EntityActivationCommand рисуется в РАДИАЛЬНОМ меню, то есть на зажатии. А то, что видно при простом наведении, приходит из Entity.GetActivationText(), и у EntityAlive он для существа возвращает пустую строку. Теперь возвращает то же, что ванильный лутаемый объект: строку lootTooltipNew со значком клавиши из разметки [action:...]. Имя берётся у зомби, так что читается «обыскать Арлин». ОБЫСК НЕ МГНОВЕННЫЙ: три секунды с круговым индикатором (XUiC_Timer, тот же, что при снятии верстака). Поле activateTime у команды не подошло - по нему игра дёргает игровое событие из entity_class, а не наш код. Обычный мешок это не отменяет: бросок вероятности остался как был, и теперь он печатается в лог целиком (шанс против броска) - чтобы «не повезло» отличалось от «сломано» без гадания. У ванильного зомби шанс 4%, то есть ноль мешков на два десятка убийств - исход с вероятностью около 40%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6a892cadd
commit
98ddcd7bce
+123
-59
@@ -4,97 +4,161 @@ using UnityEngine;
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 УВАЖАЕТСЯ: если в настройках мира мешки выключены, «Жертва» тоже не
|
||||
/// падает. Это мировая настройка игрока, а не место мода спорить с ней.
|
||||
/// </summary>
|
||||
[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<ConnectionManager>.Instance.IsServer || __instance is EntityLootContainer)
|
||||
if (!__instance.hasAI)
|
||||
{
|
||||
// Не наш случай: у оригинала для таких сущностей своя работа (выброс инвентаря),
|
||||
// и дублировать её здесь незачем. Зомби сюда не попадают.
|
||||
return true;
|
||||
}
|
||||
if (!SingletonMonoBehaviour<ConnectionManager>.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;
|
||||
}
|
||||
|
||||
/// <summary>Спавнит мешок «Жертва». Раньше это жило в префиксе на Entity.DropBagServer и
|
||||
/// подменяло собой обычный мешок; теперь ничего не подменяет, поэтому тот патч снят.
|
||||
///
|
||||
/// _offsetAside - сдвинуть в сторону, потому что рядом уже лёг обычный мешок. Ваниль
|
||||
/// кладёт свой ровно в GetPosition() + 0.9 по Y (декомпилировано, Entity.DropBagServer),
|
||||
/// то есть без сдвига два мешка встали бы в одну точку и выглядели как один.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user