1.3.0: шкала 20 убийств за уровень, два индикатора, мир между подчинёнными

Уровень навыка игра хранит ОДНИМ БАЙТОМ (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>
This commit is contained in:
AlexCube
2026-09-17 23:24:25 +03:00
co-authored by Claude Opus 5
parent fbd58ad018
commit ed5fea5192
14 changed files with 613 additions and 145 deletions
+99
View File
@@ -131,4 +131,103 @@ namespace NecromancerTome
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;
}
}
}