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:
co-authored by
Claude Opus 5
parent
fbd58ad018
commit
ed5fea5192
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,31 @@ namespace NecromancerTome
|
||||
// another in-game death.
|
||||
VerifyPrefixAttached(typeof(EntityAlive), "dropItemOnDeath");
|
||||
VerifyPrefixAttached(typeof(Entity), "DropBagServer");
|
||||
|
||||
// Добавлено 2026-09-17 вместе с правкой "подчинённые не дерутся между собой"
|
||||
// (CharmPatch.cs). Метод EAITarget.check в исходнике protected и назван со строчной
|
||||
// буквы - если он когда-нибудь переименуется или сменит сигнатуру, PatchAll упадёт
|
||||
// ещё на загрузке, но эта строка отвечает на тот же вопрос в логе явно и без
|
||||
// раскопок: резолвится ли метод и висит ли на нём наш постфикс.
|
||||
VerifyPatchAttached(typeof(EAITarget), "check");
|
||||
}
|
||||
|
||||
/// <summary>То же, что VerifyPrefixAttached, но печатает и префиксы, и постфиксы - для
|
||||
/// заплаток, которые стоят постфиксом (у VerifyPrefixAttached постфикс всегда выглядел бы
|
||||
/// как "0 prefix patch(es)", то есть как ненайденная заплатка).</summary>
|
||||
public static void VerifyPatchAttached(System.Type type, string methodName)
|
||||
{
|
||||
MethodBase method = AccessTools.Method(type, methodName);
|
||||
if (method == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] ModEntry: could not resolve " + type.Name + "." + methodName + " via AccessTools - method not found");
|
||||
return;
|
||||
}
|
||||
Patches info = Harmony.GetPatchInfo(method);
|
||||
int prefixCount = info != null && info.Prefixes != null ? info.Prefixes.Count : 0;
|
||||
int postfixCount = info != null && info.Postfixes != null ? info.Postfixes.Count : 0;
|
||||
Debug.Log("[NecromancerTome] ModEntry: " + type.Name + "." + methodName + " resolved, has " +
|
||||
prefixCount + " prefix and " + postfixCount + " postfix patch(es) attached after PatchAll");
|
||||
}
|
||||
|
||||
public static void VerifyPrefixAttached(System.Type type, string methodName)
|
||||
|
||||
@@ -61,6 +61,52 @@ namespace NecromancerTome
|
||||
/// follows vanilla's decision, it does not make it. Whether it does is NOT verified and is the
|
||||
/// specific thing to watch for in game; if pets turn out not to be credited, that is a separate
|
||||
/// piece of work (giving the pet's DamageSource an owner), not a bug in this file.
|
||||
///
|
||||
///
|
||||
/// ============================================================================================
|
||||
/// ШКАЛА ПЕРЕДЕЛАНА 2026-09-17: 20 УБИЙСТВ = 1 УРОВЕНЬ, И УРОВЕНЬ БОЛЬШЕ НЕ ХРАНИТСЯ
|
||||
/// ============================================================================================
|
||||
///
|
||||
/// Баг, найденный на стриме: "Рецепты отображались в скилле серым и с замком, хотя при этом
|
||||
/// должен был бы быть доступным" - при 250+ убитых зомби Слёзы мертвеца (30) были открыты, а
|
||||
/// Пир падальщика (60) стоял под замком.
|
||||
///
|
||||
/// ПРИЧИНА - ВАНИЛЬНАЯ СЕРИАЛИЗАЦИЯ, А НЕ НАША РАСКЛАДКА. ProgressionValue пишет и читает
|
||||
/// уровень ОДНИМ БАЙТОМ:
|
||||
///
|
||||
/// public void Write(BinaryWriter _writer, bool _IsNetwork) { ... _writer.Write((byte)level); ... }
|
||||
/// public void Read(BinaryReader _reader) { ... level = _reader.ReadByte(); ... }
|
||||
///
|
||||
/// Всё выше 255 при сохранении обрезается по модулю 256. Подтверждено не только декомпиляцией,
|
||||
/// но и на живом сейве пользователя (New Xisema Mountains/sezon8, 17.09.2026): в файле игрока
|
||||
/// necroZombieKillsCVar = 384, а уровень craftingNecroNecromancy = 129, то есть ровно 384-256.
|
||||
/// Со старой шкалой "одно убийство - один уровень" (max_level 5000) это означало, что уровень
|
||||
/// откатывался назад на каждом переходе через 256, панель скилла заново вешала замки на уже
|
||||
/// открытые рецепты, а группы 500/2000/3000/5000 были недостижимы в принципе. В ванили предел
|
||||
/// не всплывает: атрибуты идут до 10, перки до 5, крафтовые скиллы до 100.
|
||||
///
|
||||
/// РЕШЕНИЕ (продиктовано пользователем): "пусть уровень навыка будет 1/20 от количества убитых
|
||||
/// зомби", то есть 20 убийств = 1 уровень, максимум 250 - влезает в байт с запасом. Чинится
|
||||
/// сама шкала, а не сериализация поверх неё.
|
||||
///
|
||||
/// ЕДИНСТВЕННЫЙ ИСТОЧНИК ПРАВДЫ - necroZombieKillsCVar. Это float, он сохраняется честно (те
|
||||
/// самые 384 в сейве) и переполнению не подвержен. Уровень из него ВЫЧИСЛЯЕТСЯ, а не
|
||||
/// накапливается: и на каждом убийстве (ниже), и при загрузке игрока
|
||||
/// (Patch_PlayerDataFile_ToPlayer_NecromancyLevel). Второе важнее, чем кажется: оно чинит уже
|
||||
/// испорченные сейвы без ручного вмешательства - тот же sezon8 при первой же загрузке получит
|
||||
/// уровень 19 вместо сломанных 129. Именно поэтому здесь не "+1 к уровню", а "уровень =
|
||||
/// убийства / 20": прибавка к испорченному значению оставила бы его испорченным навсегда.
|
||||
///
|
||||
/// ДВА ИНДИКАТОРА (указание пользователя от 2026-09-17). Оба значения пишутся здесь же, в
|
||||
/// CVar'ы, а рисуются данными:
|
||||
/// necroNecromancyLevelCVar - уровень Некромантии, показывает бафф с черепом
|
||||
/// (buffs.xml, buffNecroZombieKillTrackerDisplay).
|
||||
/// necroNecromancyProgressCVar - сколько зомби упокоено внутри текущего уровня, 0..19.
|
||||
/// Это фиолетовая шкала в HUD рядом с полосой опыта
|
||||
/// (Config/XUi_InGame/windows.xml), она заполняется каждые
|
||||
/// 20 зомби и обнуляется вместе с повышением уровня.
|
||||
/// Оба пишутся ВСЕГДА, в том числе когда уровень не изменился - иначе шкала стояла бы на
|
||||
/// месте девятнадцать убийств подряд и дёргалась раз в двадцатое.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EntityPlayer), "AddKillXP")]
|
||||
public static class Patch_EntityPlayer_AddKillXP_NecromancyCount
|
||||
@@ -68,6 +114,22 @@ namespace NecromancerTome
|
||||
public const string NecromancySkillName = "craftingNecroNecromancy";
|
||||
public const string KillsCVarName = "necroZombieKillsCVar";
|
||||
|
||||
/// <summary>Сколько упокоенных зомби стоит один уровень Некромантии. Менять это число в
|
||||
/// одиночку НЕЛЬЗЯ: на нём завязаны и max_level="250" скилла, и все пороги
|
||||
/// RecipeTagUnlocked/unlock_level в Config/progression.xml (они записаны в уровнях), и
|
||||
/// делитель фиолетовой шкалы в Config/XUi_InGame/windows.xml. Двадцатка выбрана не на
|
||||
/// глаз: 5000 убийств / 20 = 250 уровней, а 250 - это максимум, который переживает
|
||||
/// однобайтовую сериализацию уровня (см. большой комментарий выше).</summary>
|
||||
public const int KillsPerLevel = 20;
|
||||
|
||||
/// <summary>Значения для двух индикаторов. Держатся в CVar'ах игрока, а не вычисляются в
|
||||
/// XML, по двум причинам: (1) уровень обязан совпадать с ProgressionValue.Level бит в бит,
|
||||
/// иначе череп и панель скилла разойдутся; (2) деление в ModifyCVar дало бы дробь (19.2), а
|
||||
/// display_value показывает значение как есть.</summary>
|
||||
public const string LevelCVarName = "necroNecromancyLevelCVar";
|
||||
|
||||
public const string ProgressCVarName = "necroNecromancyProgressCVar";
|
||||
|
||||
/// <summary>The tag every zombie carries, humanoid and animal alike. Checked against the
|
||||
/// real data rather than assumed: zombieBiker/zombieArlene/zombieBoe/zombieSpider all
|
||||
/// declare "entity,zombie,..." and the five zombie animals declare
|
||||
@@ -92,31 +154,34 @@ namespace NecromancerTome
|
||||
return;
|
||||
}
|
||||
|
||||
AddKillsCVar(__instance);
|
||||
AddNecromancyLevel(__instance);
|
||||
float kills = AddKillsCVar(__instance);
|
||||
SyncNecromancyLevel(__instance, kills);
|
||||
}
|
||||
|
||||
/// <summary>necroZombieKillsCVar += 1 - the same thing the removed ModifyCVar action did,
|
||||
/// and the reason it is here rather than left in XML is that it shared the broken
|
||||
/// requirement with the progression effect. GetCVar/SetCVar are public on EntityAlive and
|
||||
/// are the same storage the buffs.xml formula reads.</summary>
|
||||
private static void AddKillsCVar(EntityPlayer _player)
|
||||
/// are the same storage the buffs.xml formula reads. Возвращает новое значение, чтобы
|
||||
/// уровень считался ровно от него, а не от повторного чтения.</summary>
|
||||
private static float AddKillsCVar(EntityPlayer _player)
|
||||
{
|
||||
_player.SetCVar(KillsCVarName, _player.GetCVar(KillsCVarName) + 1f);
|
||||
float kills = _player.GetCVar(KillsCVarName) + 1f;
|
||||
_player.SetCVar(KillsCVarName, kills);
|
||||
return kills;
|
||||
}
|
||||
|
||||
/// <summary>+1 level of Necromancy, replicating MinEventActionAddProgressionLevel.Execute
|
||||
/// step for step rather than inventing a shorter version of it - its IL was read for this:
|
||||
/// GetProgressionValue, Level + amount, clamp to ProgressionClass.MaxLevel, then (for a
|
||||
/// crafting skill) the level-up toast and HandleCheckCrafting, then the two dirty flags.
|
||||
/// <summary>Приводит уровень Некромантии и оба индикатора в соответствие числу убийств.
|
||||
/// Идемпотентна: вызывай сколько угодно раз, результат зависит только от _kills.
|
||||
///
|
||||
/// HandleCheckCrafting is the part that would be easy to drop and expensive to miss: it is
|
||||
/// what the game calls on a crafting-skill level change, and skipping it risks recipes not
|
||||
/// noticing they became available. Both it and AddCraftingSkillNotification are public.
|
||||
/// Тело повторяет MinEventActionAddProgressionLevel.Execute шаг в шаг (его IL для этого
|
||||
/// читался): GetProgressionValue, новое значение, кламп по ProgressionClass.MaxLevel,
|
||||
/// затем - для крафтового скилла - тост о повышении и HandleCheckCrafting, затем два
|
||||
/// флага "изменилось".
|
||||
///
|
||||
/// The clamp matters for a different reason than it looks: max_level is 5000, and without
|
||||
/// the clamp Level would keep climbing past it forever, because nothing else limits it.</summary>
|
||||
private static void AddNecromancyLevel(EntityPlayer _player)
|
||||
/// HandleCheckCrafting - та часть, которую легко выкинуть и дорого не заметить: именно её
|
||||
/// игра зовёт при смене уровня крафтового скилла, и без неё рецепты рискуют не заметить,
|
||||
/// что стали доступны. И она, и AddCraftingSkillNotification публичные.</summary>
|
||||
public static void SyncNecromancyLevel(EntityPlayer _player, float _kills)
|
||||
{
|
||||
Progression progression = _player.Progression;
|
||||
if (progression == null)
|
||||
@@ -135,18 +200,31 @@ namespace NecromancerTome
|
||||
return;
|
||||
}
|
||||
|
||||
int oldLevel = pv.Level;
|
||||
int maxLevel = pv.ProgressionClass.MaxLevel;
|
||||
int newLevel = oldLevel + 1;
|
||||
if (newLevel > maxLevel)
|
||||
int kills = (int)_kills;
|
||||
if (kills < 0)
|
||||
{
|
||||
newLevel = maxLevel;
|
||||
kills = 0;
|
||||
}
|
||||
|
||||
int maxLevel = pv.ProgressionClass.MaxLevel;
|
||||
int newLevel = kills / KillsPerLevel;
|
||||
int progressInLevel = kills - newLevel * KillsPerLevel;
|
||||
if (newLevel >= maxLevel)
|
||||
{
|
||||
// На потолке шкала остаётся залитой доверху, а не сбрасывается в ноль: уровней
|
||||
// больше не будет, и пустая полоса читалась бы как "вот-вот повысишься".
|
||||
newLevel = maxLevel;
|
||||
progressInLevel = KillsPerLevel;
|
||||
}
|
||||
|
||||
// Оба индикатора обновляются независимо от того, сменился уровень или нет - шкала
|
||||
// должна ползти на каждом убийстве.
|
||||
SetCVarSafe(_player, LevelCVarName, newLevel);
|
||||
SetCVarSafe(_player, ProgressCVarName, progressInLevel);
|
||||
|
||||
int oldLevel = pv.Level;
|
||||
if (newLevel == oldLevel)
|
||||
{
|
||||
// Already at 5000. The CVar above still went up on purpose - the knife's damage is
|
||||
// not capped by the skill's max_level, and the player who is past the cap should
|
||||
// keep getting stronger knives.
|
||||
return;
|
||||
}
|
||||
pv.Level = newLevel;
|
||||
@@ -154,9 +232,14 @@ namespace NecromancerTome
|
||||
EntityPlayerLocal local = _player as EntityPlayerLocal;
|
||||
if (pv.ProgressionClass.IsCrafting && local != null)
|
||||
{
|
||||
// true = add the notification only if one is not already up, so a horde night does
|
||||
// not stack a fresh toast per corpse.
|
||||
local.PlayerUI?.xui?.CollectedItemList?.AddCraftingSkillNotification(pv, true);
|
||||
if (newLevel > oldLevel)
|
||||
{
|
||||
// true = add the notification only if one is not already up, so a horde night
|
||||
// does not stack a fresh toast per corpse. Только на РОСТЕ уровня: при
|
||||
// загрузке испорченного сейва уровень может поехать вниз (129 -> 19), и
|
||||
// поздравлять с этим игрока не за что.
|
||||
local.PlayerUI?.xui?.CollectedItemList?.AddCraftingSkillNotification(pv, true);
|
||||
}
|
||||
pv.ProgressionClass.HandleCheckCrafting(local, oldLevel, newLevel);
|
||||
}
|
||||
|
||||
@@ -168,5 +251,87 @@ namespace NecromancerTome
|
||||
_player.bPlayerStatsChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SetCVar идёт через EntityBuffs, а он на момент загрузки игрока может быть ещё
|
||||
/// не создан - в ToPlayer буфы читаются отдельным блоком и только если они в файле есть.
|
||||
/// Ронять из-за индикатора загрузку персонажа нельзя, поэтому проверка явная.</summary>
|
||||
private static void SetCVarSafe(EntityPlayer _player, string _name, float _value)
|
||||
{
|
||||
if (_player.Buffs == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_player.SetCVar(_name, _value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пересчёт уровня Некромантии при загрузке игрока - вторая половина фикса однобайтового
|
||||
/// уровня (см. большой комментарий в Patch_EntityPlayer_AddKillXP_NecromancyCount).
|
||||
///
|
||||
/// ПОЧЕМУ ИМЕННО PlayerDataFile.ToPlayer И ИМЕННО POSTFIX. Уровень восстанавливается из
|
||||
/// necroZombieKillsCVar, а CVar'ы лежат в EntityBuffs. В теле ToPlayer порядок жёсткий:
|
||||
/// сначала Progression.Read, следом Buffs.Read. Postfix - единственная точка, где уже готовы
|
||||
/// ОБА, и заодно это уже проверенный в этом моде хук: на том же методе висит
|
||||
/// SpatialVaultPersistence (две разные заплатки на один метод Harmony складывает без
|
||||
/// конфликта).
|
||||
///
|
||||
/// ЧТО ЭТО ДАЁТ. Сейв, испорченный старой шкалой, чинится сам при первом входе: было 384
|
||||
/// убийства и уровень 129 - станет уровень 19 и все четыре мода ножа снова открыты. Ручных
|
||||
/// команд, сброса скилла или новой игры не требуется. Проверено на двух реальных сейвах
|
||||
/// пользователя (17.09): sezon8 - 384 убийства при уровне 129, test8 - 303 при уровне 48.
|
||||
/// Ни в одном из них счётчик убийств не пострадал, потому что он float и переполняться ему
|
||||
/// нечем; портился только уровень.
|
||||
///
|
||||
/// СТАРЫЙ СЕЙВ НИКОГДА НЕ ТЕРЯЕТ ОТКРЫТОЕ. В прежней шкале уровень был равен числу убийств
|
||||
/// (с поправкой на переполнение), то есть уровень ВСЕГДА был не больше счётчика. Пересчёт из
|
||||
/// счётчика поэтому может только вернуть украденное переполнением, но не отнять: тот же test8
|
||||
/// на 303 убийствах получает Тёмное чутьё (порог 300), которое сломанный уровень 48 держал
|
||||
/// под замком.
|
||||
///
|
||||
/// ЕДИНСТВЕННЫЙ СЛУЧАЙ, КОГДА ПЕРЕСЧЁТ МОГ БЫ НАВРЕДИТЬ, - счётчик пуст, а уровень есть.
|
||||
/// Тогда "уровень = убийства / 20" дало бы ноль и стёрло прогресс. Живьём такого сейва не
|
||||
/// видели (счётчик и уровень всегда росли одной и той же строкой кода, а CVar'ы при смерти не
|
||||
/// чистятся - в EntityBuffs нет ни одного сброса словаря CVars), но цена ошибки тут - чужой
|
||||
/// прогресс, поэтому случай обработан явно: счётчик восстанавливается из старого уровня по
|
||||
/// прежнему правилу "1 убийство = 1 уровень" и дальше всё идёт обычным путём. Оценка выйдет
|
||||
/// заниженной (переполнение из уровня уже не вытащить), но это лучше, чем ноль.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(PlayerDataFile), "ToPlayer")]
|
||||
public static class Patch_PlayerDataFile_ToPlayer_NecromancyLevel
|
||||
{
|
||||
public static void Postfix(EntityPlayer _player)
|
||||
{
|
||||
if (_player == null || _player.Buffs == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float kills = _player.GetCVar(Patch_EntityPlayer_AddKillXP_NecromancyCount.KillsCVarName);
|
||||
ProgressionValue pv = _player.Progression != null
|
||||
? _player.Progression.GetProgressionValue(Patch_EntityPlayer_AddKillXP_NecromancyCount.NecromancySkillName)
|
||||
: null;
|
||||
int oldLevel = pv != null ? pv.Level : 0;
|
||||
|
||||
if (kills < 1f && oldLevel > 0)
|
||||
{
|
||||
kills = oldLevel;
|
||||
_player.SetCVar(Patch_EntityPlayer_AddKillXP_NecromancyCount.KillsCVarName, kills);
|
||||
Debug.LogWarning("[NecromancerTome] NecromancyLevel: счётчик убийств пуст при уровне " +
|
||||
oldLevel + " - восстановлен из уровня по старой шкале");
|
||||
}
|
||||
|
||||
Patch_EntityPlayer_AddKillXP_NecromancyCount.SyncNecromancyLevel(_player, kills);
|
||||
|
||||
// Одна строка в лог на загрузку игрока - по ней видно, что конверсия старого сейва
|
||||
// произошла и во что именно (вопрос пользователя 2026-09-17: "не сломают ли новые
|
||||
// правки старые сейвы").
|
||||
int newLevel = pv != null ? pv.Level : 0;
|
||||
if (newLevel != oldLevel)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] NecromancyLevel: уровень пересчитан из счётчика убийств " +
|
||||
(int)kills + ": было " + oldLevel + ", стало " + newLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user