Уровень навыка игра хранит ОДНИМ БАЙТОМ (ProgressionValue.Write/Read), поэтому старая шкала "одно убийство - один уровень" при max_level=5000 на каждом сохранении откатывала уровень на 256 назад. Снаружи это выглядело как самопроизвольно закрывающиеся рецепты (жалоба со стрима: Слёзы мертвеца открыты, Пир падальщика под замком при 250+ убитых), а тиры 500/2000/3000/5000 были недостижимы в принципе. Подтверждено на двух живых сейвах: sezon8 - 384 убийства при уровне 129, test8 - 303 при уровне 48. Шкала переведена на 20 убийств = 1 уровень, max_level=250 - влезает в байт с запасом. Уровень больше не накапливается, а вычисляется из necroZombieKillsCVar (float, сохраняется честно) и на каждом убийстве, и постфиксом на PlayerDataFile.ToPlayer - последнее чинит старые сейвы само, без команд и новой игры. Открытое при этом не теряется: в старой шкале уровень всегда был не больше счётчика, так что пересчёт может только вернуть украденное переполнением. Отдельно закрыт случай "счётчик пуст, а уровень есть" - счётчик восстанавливается из уровня по старой шкале. Все пороги пересчитаны в уровни, числа убийств не тронуты, кроме воды: 30 на сетку шагом 20 не ложится, по указанию пользователя мод переехал на 20 - туда же, где браслет и Кровавая сфера. Тег necroNecromancyLvl30 удалён. Сходимость всех 18 рецептов (тег -> RecipeTagUnlocked -> unlock_tier) проверена скриптом. Два индикатора: череп в статус-баре показывает уровень (раньше - общее число убийств), фиолетовая шкала над полосой опыта - продвижение внутри уровня, 0..20. Шкала сделана вёрсткой, а не баффом (бафф умеет число, но не полосу), заполнение привязано через XUi-выражение cvar(). Закрывающая скобка у выражения - одна "}", а не "%}": лишний "%" NCalc читает как остаток от деления и ждёт правый операнд, на чём первая проверка в игре и споткнулась. Третья правка: подчинённые зомби больше не дерутся между собой. Девиация выдаёт приказ "бей зомби", а подчинённый сам EntityZombie, и в targetClasses выражается только тип. Постфикс на EAITarget.check вычёркивает подчинённого из кандидатов (зомби выбирает следующего, настоящего врага), префикс на EntityAlive.SetAttackTarget гасит цель на путях мимо выбора - прежде всего месть, когда подчиняют уже дерущихся. Тексты на 13 языках, README (RU+EN), описания для сайта и Nexus приведены к новой шкале. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
14 KiB
C#
234 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using HarmonyLib;
|
|
using UnityEngine;
|
|
|
|
namespace NecromancerTome
|
|
{
|
|
/// <summary>
|
|
/// The core "Deviator" effect: whichever zombie the buff named in CharmBuffName lands on
|
|
/// (via the item's own XML - see Config/items.xml/buffs.xml) stops targeting the player
|
|
/// and starts targeting other zombies instead.
|
|
///
|
|
/// Why this needs Harmony at all: a zombie's valid-target class list (who it's even allowed
|
|
/// to look for/attack) is parsed once from its entity_class XML into per-instance AI task
|
|
/// objects at spawn time (EAIApproachAndAttackTarget / EAISetNearestEntityAsTarget). There is
|
|
/// no XML-level action to change it afterwards - the buff system alone cannot flip a live
|
|
/// zombie's allegiance. This patch is the smallest hook that can: it reacts to our specific
|
|
/// buff being added and then rewrites those same per-instance task objects directly, using
|
|
/// the exact same fields the game's own EAIManager.SetTargetOnlyPlayers() helper uses for the
|
|
/// mirror-image trick (restricting a task to players only). We do the same thing in reverse:
|
|
/// restrict to zombies only.
|
|
/// </summary>
|
|
[HarmonyPatch(typeof(EntityBuffs), "AddBuff", new System.Type[] { typeof(string), typeof(Vector3i), typeof(int), typeof(bool), typeof(bool), typeof(float) })]
|
|
public static class Patch_EntityBuffs_AddBuff_DeviatorCharm
|
|
{
|
|
public const string CharmBuffName = "buffNecroDeviatorCharm";
|
|
|
|
/// <summary>Diagnostic-only, added 2026-08-28 while chasing "the Knife's Victim debuff
|
|
/// never sticks" - this patch already logs every AddBuff call for our other marker buff,
|
|
/// so logging buffNecroVictim's calls here too (no CharmZombie() side effects for it,
|
|
/// just visibility) answers the actual open question directly: does
|
|
/// necroWpnBladeNecroKnife's onSelfAttackedOther trigger ever even call AddBuff at all,
|
|
/// or does it call it but something downstream (target class mismatch, requirement gate,
|
|
/// stacking) rejects it. No log line at all next time means the XML trigger itself isn't
|
|
/// firing (likely because EntityDamage computes to 0 and a 0-damage swing doesn't count
|
|
/// as a landed hit); a log line with a non-Added result narrows it further.</summary>
|
|
public const string VictimBuffName = "buffNecroVictim";
|
|
|
|
public static void Postfix(EntityBuffs __instance, string _name, EntityBuffs.BuffStatus __result)
|
|
{
|
|
if (_name == CharmBuffName || _name == VictimBuffName)
|
|
{
|
|
// entityId added 2026-08-28 while chasing "the zombie that died didn't have the
|
|
// buff even though AddBuff Added fired repeatedly" - logging just the TYPE name
|
|
// couldn't tell whether it landed on the same zombie that later died or a
|
|
// different one nearby (the knife swings a 90-degree arc, plausible it's hitting
|
|
// more than one zombie in a group). This settles it directly against
|
|
// VictimPatch's own "dropItemOnDeath Prefix entered for <id>" line.
|
|
string parentId = __instance.parent != null ? __instance.parent.entityId.ToString() : "?";
|
|
Debug.Log("[NecromancerTome] AddBuff(" + _name + ") result=" + __result + " parent=" + (__instance.parent != null ? __instance.parent.GetType().Name : "null") + " id=" + parentId);
|
|
}
|
|
if (__result != EntityBuffs.BuffStatus.Added)
|
|
{
|
|
return;
|
|
}
|
|
if (_name != CharmBuffName)
|
|
{
|
|
return;
|
|
}
|
|
// HUMANOID-ONLY IS A DELIBERATE DESIGN DECISION, NOT A GAP TO CLOSE (user, 2026-09-07:
|
|
// "Камень духов не годится, он для зомбо-гуманоидов, и пусть так и остаётся").
|
|
// Do not "fix" this by widening the type check.
|
|
//
|
|
// The user had noticed the behaviour in play first ("камень душ на зомбособаках не
|
|
// работает, это я проверил однажды") and was right. The reason is this line: zombie
|
|
// ANIMALS sit on a completely different branch of the C# hierarchy, so the pattern
|
|
// match below is false for every one of them -
|
|
// EntityZombie : EntityHuman <- the only charmable one
|
|
// EntityZombieDog : EntityEnemyAnimal : EntityEnemy
|
|
// EntityEnemyAnimal : EntityEnemy (bear, boar)
|
|
// EntityVulture : EntityFlying (not even EntityEnemy)
|
|
// - while the XML side still lets the buff land on them, because they DO carry the
|
|
// "zombie" tag ("entity,animal,zombie,zombieAnimal,hostile,..."), which is what
|
|
// EntityTagCompare gates on. So the buff is added, this check rejects it, and the log
|
|
// line below fires. That log line is expected on zombie animals and is not an error.
|
|
//
|
|
// Note the same decision applies to the other half of this patch: CharmZombie() below
|
|
// restricts targetClasses to typeof(EntityZombie), so a charmed humanoid also ignores
|
|
// zombie animals as targets. Consistent with "the Spirit Stone is a humanoid tool".
|
|
// Countering zombie animals/birds is meant to be a separate mechanic - open topic,
|
|
// see BACKLOG.md 2026-09-07.
|
|
if (!(__instance.parent is EntityZombie zombie) || zombie.aiManager == null)
|
|
{
|
|
Debug.Log("[NecromancerTome] charm buff added but parent is not a charmable EntityZombie (aiManager null or wrong type)");
|
|
return;
|
|
}
|
|
CharmZombie(zombie);
|
|
}
|
|
|
|
public static void CharmZombie(EntityZombie zombie)
|
|
{
|
|
// AITask: whichever task actually chases/melees the current attack target.
|
|
// Restrict it to EntityZombie only, same shape as SetTargetOnlyPlayers() but reversed.
|
|
List<EAIApproachAndAttackTarget> approachTasks = zombie.aiManager.GetTasks<EAIApproachAndAttackTarget>();
|
|
Debug.Log("[NecromancerTome] CharmZombie: approachTasks=" + (approachTasks != null ? approachTasks.Count.ToString() : "null"));
|
|
if (approachTasks != null)
|
|
{
|
|
foreach (EAIApproachAndAttackTarget task in approachTasks)
|
|
{
|
|
task.targetClasses.Clear();
|
|
task.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass
|
|
{
|
|
type = typeof(EntityZombie),
|
|
chaseTimeMax = 0f
|
|
});
|
|
}
|
|
}
|
|
|
|
// AITarget: whichever task picks the nearest valid entity to go attack in the first
|
|
// place. Same restriction here, or the zombie would never even pick a target to hand
|
|
// off to the approach task above.
|
|
List<EAISetNearestEntityAsTarget> targetTasks = zombie.aiManager.GetTargetTasks<EAISetNearestEntityAsTarget>();
|
|
Debug.Log("[NecromancerTome] CharmZombie: targetTasks=" + (targetTasks != null ? targetTasks.Count.ToString() : "null"));
|
|
if (targetTasks != null)
|
|
{
|
|
foreach (EAISetNearestEntityAsTarget task in targetTasks)
|
|
{
|
|
task.targetClasses.Clear();
|
|
task.targetClasses.Add(new EAISetNearestEntityAsTarget.TargetClass
|
|
{
|
|
type = typeof(EntityZombie),
|
|
hearDistMax = 50f,
|
|
seeDistMax = 0f
|
|
});
|
|
task.playerTargetClassIndex = -1;
|
|
}
|
|
}
|
|
|
|
// Drop whatever it was mid-attack on (almost certainly the player who just threw the
|
|
// book at it) so the switch takes effect immediately instead of after its current target dies/despawns.
|
|
zombie.SetAttackTarget(null, 0);
|
|
Debug.Log("[NecromancerTome] CharmZombie: done, attack target cleared for " + zombie.EntityName);
|
|
}
|
|
}
|
|
/// <summary>
|
|
/// ПОДЧИНЁННЫЕ НЕ ДЕРУТСЯ МЕЖДУ СОБОЙ, 2026-09-17. Баг-репорт пользователя: "Камень духов и
|
|
/// прочие вешают на зомби девиацию. С этим есть баг. Зомби под девиацией не должны бить других
|
|
/// зомби под девиацией."
|
|
///
|
|
/// ОТКУДА БАГ. CharmZombie() выше переписывает обе задачи ИИ на targetClasses =
|
|
/// typeof(EntityZombie) - "бей зомби". Подчинённый зомби сам остаётся EntityZombie, и никакого
|
|
/// признака "свой" в этом списке классов выразить нельзя: targetClasses оперирует ТИПАМИ, а
|
|
/// подчинение - это бафф на конкретной особи. Поэтому два подчинённых видели друг в друге
|
|
/// законную цель, и чем больше игрок подчинял, тем чаще они дрались между собой вместо
|
|
/// настоящих врагов.
|
|
///
|
|
/// ПОЧЕМУ ЗАПЛАТКИ ДВЕ, А НЕ ОДНА. Цель у зомби появляется двумя разными путями, и закрыть
|
|
/// надо оба, иначе починится половина:
|
|
///
|
|
/// 1. ВЫБОР цели. EAISetNearestEntityAsTarget.FindTarget() собирает всех подходящих по типу
|
|
/// через GetEntitiesInBounds, сортирует и берёт ПЕРВОГО, кто прошёл EAITarget.check(_e).
|
|
/// Постфикс на check - самое точное место: подчинённый просто не считается кандидатом, и
|
|
/// цикл идёт дальше по списку, то есть зомби выбирает СЛЕДУЮЩЕГО, настоящего врага, а не
|
|
/// остаётся без цели. Фильтровать позже, на присвоении, так не получится: там уже некуда
|
|
/// "идти дальше", кандидат один.
|
|
///
|
|
/// 2. ПРИСВОЕНИЕ цели мимо выбора. Главный такой путь - месть: EntityAlive.DamageEntity на
|
|
/// получателе урона зовёт SetRevengeTarget(бивший) и aiManager.DamagedByEntity(), после
|
|
/// чего задача мести ставит обидчика целью. Сюда же любые внешние вызовы. Все они
|
|
/// сходятся в одну точку - EntityAlive.SetAttackTarget, - и префикс на ней гасит цель,
|
|
/// если и бьющий, и цель подчинены.
|
|
///
|
|
/// ЗАЧЕМ ВТОРАЯ, ЕСЛИ ПЕРВАЯ УЖЕ НЕ ДАЁТ ИМ СЦЕПИТЬСЯ. Затем, что подчинить можно зомби,
|
|
/// которые УЖЕ дерутся друг с другом (Пирамида духов подчиняет пачкой, Рой кусает по одному).
|
|
/// CharmZombie() сбрасывает цель тому, кого подчинили прямо сейчас, но не второму участнику
|
|
/// драки - его цель погасит именно префикс.
|
|
///
|
|
/// ЧЕГО ЗДЕСЬ НАМЕРЕННО НЕТ. Урон между подчинёнными не блокируется отдельно: если они друг
|
|
/// друга не выбирают и не получают целью, бить им друг друга нечем. Блокировка урона поверх
|
|
/// этого спрятала бы будущие дыры в прицеливании вместо того, чтобы их показать.
|
|
///
|
|
/// ЦЕНА НА ГОРЯЧЕМ ПУТИ. check() зовётся для каждого кандидата каждого ищущего зомби в мире,
|
|
/// поэтому порядок проверок в постфиксе - от самой дешёвой к самой дорогой: сначала отсев по
|
|
/// типу (кандидат вообще не зомби - выходим, а для обычного зомби, который ищет игрока, это
|
|
/// как раз общий случай), только потом два обращения к баффам.
|
|
/// </summary>
|
|
public static class NecroCharmSide
|
|
{
|
|
public static bool IsCharmed(EntityAlive _entity)
|
|
{
|
|
return _entity != null && _entity.Buffs != null &&
|
|
_entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
|
|
}
|
|
}
|
|
|
|
[HarmonyPatch(typeof(EAITarget), "check")]
|
|
public static class Patch_EAITarget_check_CharmedIgnoresCharmed
|
|
{
|
|
public static void Postfix(EAITarget __instance, EntityAlive _e, ref bool __result)
|
|
{
|
|
if (!__result || __instance == null)
|
|
{
|
|
return;
|
|
}
|
|
// Дешёвый отсев первым: подчиняется (и, значит, может оказаться "своим") только
|
|
// EntityZombie - зомби-звери на другой ветке иерархии и под девиацию не попадают,
|
|
// см. большой комментарий о humanoid-only выше.
|
|
if (!(_e is EntityZombie))
|
|
{
|
|
return;
|
|
}
|
|
if (!NecroCharmSide.IsCharmed(__instance.theEntity))
|
|
{
|
|
return;
|
|
}
|
|
if (!NecroCharmSide.IsCharmed(_e))
|
|
{
|
|
return;
|
|
}
|
|
__result = false;
|
|
}
|
|
}
|
|
|
|
[HarmonyPatch(typeof(EntityAlive), "SetAttackTarget", new System.Type[] { typeof(EntityAlive), typeof(int) })]
|
|
public static class Patch_EntityAlive_SetAttackTarget_CharmedIgnoresCharmed
|
|
{
|
|
public static void Prefix(EntityAlive __instance, ref EntityAlive _attackTarget)
|
|
{
|
|
if (__instance == null || _attackTarget == null)
|
|
{
|
|
return;
|
|
}
|
|
if (!NecroCharmSide.IsCharmed(__instance) || !NecroCharmSide.IsCharmed(_attackTarget))
|
|
{
|
|
return;
|
|
}
|
|
// null, а не "оставить как было": цель именно гасится, чтобы задача выбора на
|
|
// следующем тике пошла искать настоящего врага. Вторая заплатка на этом же методе
|
|
// (SwarmTargetPatch, перенацеливание Роя с игрока на зомби) с этой не пересекается:
|
|
// Рой сам никогда не подчинён, так что до этой строки он не доходит.
|
|
_attackTarget = null;
|
|
}
|
|
}
|
|
}
|