Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d97f83e972 | ||
|
|
15c59dd37a | ||
|
|
f5f9c9b9ff | ||
|
|
e2046a1866 | ||
|
|
7fa68618b2 | ||
|
|
7da12f562a | ||
|
|
7ea1a63fd9 | ||
|
|
ed5fea5192 | ||
|
|
fbd58ad018 | ||
|
|
e71cd69d1e | ||
|
|
d8f06cd53a | ||
|
|
6301a62a54 | ||
|
|
156e046cc2 | ||
|
|
90d8fe331b | ||
|
|
03b4b8f164 | ||
|
|
c832f734d7 | ||
|
|
8c5636b523 | ||
|
|
229b436420 | ||
|
|
e890999391 | ||
|
|
5a512260cc | ||
|
|
896501dcd8 | ||
|
|
e362c627e7 | ||
|
|
20af2bbe6c | ||
|
|
7172681353 | ||
|
|
a6e19f9b97 | ||
|
|
275a739646 | ||
|
|
7768541f12 | ||
|
|
9ac575075a | ||
|
|
4801341676 | ||
|
|
29431990f6 | ||
|
|
a5f8592903 | ||
|
|
ccf58a2ec8 | ||
|
|
a645c53ff3 | ||
|
|
ab1b3eadaf | ||
|
|
026e006c9c | ||
|
|
6e1f42f053 |
+53
-15
File diff suppressed because one or more lines are too long
@@ -116,4 +116,50 @@
|
|||||||
<sprite name="blackout" depth="0" sprite="menu_empty" type="sliced" color="[black]" anchor_left="#cam,0,-10" anchor_right="#cam,1,10" anchor_bottom="#cam,0,-10" anchor_top="#cam,1,10" globalopacitymod="0"/>
|
<sprite name="blackout" depth="0" sprite="menu_empty" type="sliced" color="[black]" anchor_left="#cam,0,-10" anchor_right="#cam,1,10" anchor_bottom="#cam,0,-10" anchor_top="#cam,1,10" globalopacitymod="0"/>
|
||||||
</window>
|
</window>
|
||||||
</append>
|
</append>
|
||||||
|
<!-- ФИОЛЕТОВАЯ ШКАЛА НЕКРОМАНТИИ, 2026-09-17. Продиктовано: "Есть индикатор опыта. Это шкала
|
||||||
|
до уровня. А мы сделаем ещё одну шкалу, которая заполняется каждые 20 зомби... Каждое
|
||||||
|
заполнение индикатора пусть добавляет уровень некромантии, а индикатор обнуляет."
|
||||||
|
|
||||||
|
Заполнение считает не вёрстка: necroNecromancyProgressCVar (0..19) пишет
|
||||||
|
HarmonySrc/NecromancyKillCreditPatch.cs на каждом убийстве и при загрузке игрока, деля
|
||||||
|
общее число упокоенных зомби на 20 с остатком. Шкала обнуляется сама собой - вместе с
|
||||||
|
переходом остатка через двадцатку растёт уровень Некромантии (череп в статус-баре).
|
||||||
|
|
||||||
|
ПОЧЕМУ ЭТО ВЁРСТКА, А НЕ ЕЩЁ ОДИН БАФФ. Бафф умеет показать ЧИСЛО (display_value), но не
|
||||||
|
полосу. Полоса - это sprite type="filled" с атрибутом fill 0..1, и живёт она в окне.
|
||||||
|
Взято ровно то окно и ровно тот приём, которым нарисована ванильная полоса опыта:
|
||||||
|
windowToolbelt, три спрайта друг на друге (серая подложка, чёрная рамка fillcenter="false",
|
||||||
|
заливка) - см. Data/Config/XUi_InGame/windows.xml, строки с fill="{xp}". Наша полоса
|
||||||
|
стоит на 12 пикселей выше опыта (pos 0,20 против 0,8) и той же ширины 750.
|
||||||
|
|
||||||
|
ПРИВЯЗКА. fill читает CVar через функцию выражений cvar() - она есть в самой игре
|
||||||
|
(BindingNcalcFunctions.cvar, регистрирует BindingInfoNcalc.VariableStateCVar, который
|
||||||
|
сам перечитывает значение, а не застывает на первом).
|
||||||
|
|
||||||
|
ЗАКРЫВАЮЩАЯ СКОБКА - ОДНА "}", А НЕ "%}". Это не стилистика, а единственная рабочая форма,
|
||||||
|
и первая же проверка в игре (17.09, лог 22-36-46) на этом и споткнулась:
|
||||||
|
|
||||||
|
ERR [XUi] Binding expression can not be evaluated. Binding fill="{% cvar(...) / 20 %}"
|
||||||
|
EXC no viable alternative at input '<EOF>' at line 1:44
|
||||||
|
|
||||||
|
Открывается выражение как "{%", а закрывается просто "}" - так во всех ванильных
|
||||||
|
примерах (Data/Config/XUi_InGame/windows.xml: visible="{% int(windowWidth) >= 300 }",
|
||||||
|
text="{% dictvalue(sandboxvalues, '1') }"). Лишний "%" остаётся ВНУТРИ выражения, а в
|
||||||
|
NCalc это оператор остатка от деления, поэтому парсер честно ждёт после него правый
|
||||||
|
операнд и упирается в конец строки - позиция 44 в сообщении указывает ровно на него.
|
||||||
|
Ошибка разовая, не в каждый кадр: привязка, не сумев вычислиться, гаснет насовсем, то
|
||||||
|
есть полоса просто не работает молча.
|
||||||
|
|
||||||
|
Если полоса когда-нибудь опять не появится или застынет - запасной путь такой: писать из
|
||||||
|
C# готовую долю 0..1 в отдельный CVar и подставлять её без арифметики в вёрстке. Своего XUi-контроллера тут сознательно
|
||||||
|
нет: находит ли движок контроллер из сборки мода, в этом моде так и не проверено (см.
|
||||||
|
комментарий к финальным слайдам выше), а окно с контроллером было бы риском без нужды.
|
||||||
|
|
||||||
|
Цвет 150,60,220 - фиолетовый, как просили; альфа 200, чтобы полоса читалась поверх
|
||||||
|
тёмного HUD, но не спорила с опытом яркостью. -->
|
||||||
|
<append xpath="/windows/window[@name='windowToolbelt']/rect">
|
||||||
|
<sprite depth="1" pos="0,20" height="8" color="[mediumGrey]" sprite="menu_empty2px" globalopacitymod="0" type="sliced" />
|
||||||
|
<sprite depth="9" pos="0,20" height="8" color="[black]" sprite="menu_empty2px" globalopacitymod="0" type="sliced" fillcenter="false" />
|
||||||
|
<sprite depth="4" pos="0,20" height="8" width="750" color="150,60,220,200" globalopacitymod="0" type="filled" fill="{% cvar('necroNecromancyProgressCVar') / 20 }" />
|
||||||
|
</append>
|
||||||
</windows>
|
</windows>
|
||||||
|
|||||||
+95
-2
@@ -66,10 +66,103 @@
|
|||||||
<property class="TEFeaturePyramidWard" />
|
<property class="TEFeaturePyramidWard" />
|
||||||
</property>
|
</property>
|
||||||
<property name="Material" value="Msteel_shapes" />
|
<property name="Material" value="Msteel_shapes" />
|
||||||
|
<!-- ВЕРНУТО НА Shape="New" 2026-09-10 по решению пользователя: «пока верни пирамиду,
|
||||||
|
которая была у нас изначально».
|
||||||
|
|
||||||
|
Своя модель со своей текстурой требует Shape="ModelEntity", и на этом пути осталось
|
||||||
|
два невылеченных дефекта - разбор и все замеры в BACKLOG.md, раздел про пирамиду:
|
||||||
|
- нет столкновений: при "New" их считает воксельная система блока, а "ModelEntity"
|
||||||
|
берёт от модели, и одного BoxCollider на корне в слое 16 не хватило;
|
||||||
|
- дальние экземпляры рисуются фиолетовым (маджента = не найден шейдер), ближние
|
||||||
|
при этом верны; добавленный LODGroup положения не исправил.
|
||||||
|
|
||||||
|
Всё, что сделано для того пути, сохранено и работает: генератор текстуры
|
||||||
|
_private/tools/make_pyramid_textures.py, сборка _private/Extracted/ShapesUnityProject,
|
||||||
|
бандл Resources/necropyramid. Чтобы вернуться к нему, достаточно заменить Shape на
|
||||||
|
ModelEntity и раскомментировать строку Model ниже. -->
|
||||||
<property name="Shape" value="New" />
|
<property name="Shape" value="New" />
|
||||||
|
<!-- Своя модель отключена вместе с возвратом на Shape="New" (см. комментарий выше).
|
||||||
|
Строка сохранена: она рабочая, ею подключается наш бандл. -->
|
||||||
<property name="Model" value="@:Shapes/pyramid.fbx" />
|
<property name="Model" value="@:Shapes/pyramid.fbx" />
|
||||||
<property name="Texture" value="356" />
|
<!-- <property name="Model" value="#@modfolder(NecromancerTome):Resources/necropyramid?necroPyramidPrefab.prefab" /> -->
|
||||||
<property name="TintColor" value="8A4142" />
|
<!-- ModelOffset - ПОЛОЖЕНИЕ МОДЕЛИ, и задавать его надо именно здесь.
|
||||||
|
|
||||||
|
Меш формы из shapes-бандла сделан под воксельную систему: его границы лежат не вокруг
|
||||||
|
начала координат, а в углу (центр -0.50, 0.13, -0.50 при размере 0.51 x 0.26 x 0.50).
|
||||||
|
Как ModelEntity такая модель вылезает из своего куба - что и наблюдалось: «поднялась,
|
||||||
|
вершиной упирается в верхний угол».
|
||||||
|
|
||||||
|
Сначала это лечили сдвигом детей ВНУТРИ префаба. Модель встала верно, но по блоку
|
||||||
|
по-прежнему нельзя было попасть и не работало наведение по E: о сдвиге знал только
|
||||||
|
префаб, а игра держала свой объём для попаданий там, где модель была бы без сдвига.
|
||||||
|
Игрок целился в одно, а попадал мимо.
|
||||||
|
|
||||||
|
Ваниль двигает модель именно этим свойством (у верстака стоит "0,.5,0"), и его игра
|
||||||
|
учитывает целиком - и в отрисовке, и в попаданиях. Значение посчитано скриптом сборки
|
||||||
|
по фактическим границам меша: по X и Z в центр куба, по Y основанием на нижнюю грань.
|
||||||
|
Если меш заменят, скрипт напечатает новое значение в лог. -->
|
||||||
|
<!-- ModelOffset нужен только при ModelEntity; при "New" положение задаёт сама форма.
|
||||||
|
Значение верное, посчитано скриптом сборки - сохранено для возврата.
|
||||||
|
<property name="ModelOffset" value="0.5,-0.495,0.5" /> -->
|
||||||
|
<!-- ПОКРАСКА ВМЕСТО СВОЕЙ ТЕКСТУРЫ, 2026-09-10. Мысль пользователя: «в игре есть
|
||||||
|
кисточка, позволяющая перекрашивать блоки, можем ли мы наложить текстуру через
|
||||||
|
покраску, только заранее?» Можем - именно это свойство Texture и делает.
|
||||||
|
|
||||||
|
Data/Config/painting.xml сопоставляет краски из кисточки с номерами текстур атласа
|
||||||
|
(поле TextureId), а Texture у блока задаёт краску заранее. Доступно 156 красок;
|
||||||
|
выписаны с названиями в _private/Extracted/paints.txt.
|
||||||
|
|
||||||
|
Было 356 (txName_Steel_wall, «оцинкованная стальная стена») - выбор достался от
|
||||||
|
steelShapes вместе с парой Material/Texture и к праху отношения не имел.
|
||||||
|
Пробовали 11 (txName_Gravel, «гравий») - в игре прочиталось как земля.
|
||||||
|
Сейчас 552 (txName_GraniteBlack, «гранит чёрный»): тёмный камень ближе к
|
||||||
|
спрессованному праху, чем крупная осыпь гравия.
|
||||||
|
|
||||||
|
СВОЮ текстуру сюда подставить НЕЛЬЗЯ, проверено по коду игры: у записи краски есть
|
||||||
|
только TextureId, PaintCost, Group, SortIndex - поля с путём к своей картинке нет.
|
||||||
|
TextureId это индекс в уже собранном атласе, а сам атлас лежит в
|
||||||
|
blocktextureatlases_assets_all.bundle среди Addressables игры (рядом с
|
||||||
|
TerrainTextures), и крючка для подмены его модом не нашлось. Своя текстура
|
||||||
|
возможна только через путь ModelEntity со своим бандлом - см. BACKLOG.md.
|
||||||
|
|
||||||
|
Подбирать краску удобнее всего кисточкой прямо в игре: доступны все 156, перезапуск
|
||||||
|
не нужен. Найденный номер прописывается сюда.
|
||||||
|
|
||||||
|
СЕЙЧАС ЭТИ ДВА СВОЙСТВА НЕ РАБОТАЮТ. Блок снова на Shape="ModelEntity" со своей
|
||||||
|
моделью (2026-09-10, после того как префаб довели до вида ванильного): при
|
||||||
|
ModelEntity поверхность даёт материал префаба, а не атлас, и Texture с TintColor
|
||||||
|
не участвуют. Оставлены нетронутыми, чтобы возврат к покраске был ровно двумя
|
||||||
|
правками - Shape на "New" и переключить строку Model ниже.
|
||||||
|
|
||||||
|
TintColor - множитель поверх краски. Взят пепельно-серый с уходом в фиолет, в тон
|
||||||
|
остальному мод-набору. Прежний 8A4142 давал ржаво-красный.
|
||||||
|
|
||||||
|
Texture принимает и ШЕСТЬ значений через запятую - по одному на грань (в ванили есть
|
||||||
|
например value="195,570,570,570,570,570"). Если захочется основание отличать от
|
||||||
|
скатов - делается здесь же, без всякого бандла.
|
||||||
|
|
||||||
|
Хорошие запасные варианты из того же списка: 606 concrete_broken (потрескавшийся
|
||||||
|
бетон), 552 GraniteBlack (чёрный гранит), 443 Rust_black (чёрная ржавчина),
|
||||||
|
385 Green_rusty_metal (зелёная ржавчина, в тон вкраплениям). -->
|
||||||
|
<!-- СВОЯ КРАСКА, 2026-09-10. Это уже НЕ ванильная краска из атласа: текстуру пирамиды
|
||||||
|
добавляет в атлас наш собственный Harmony-патч, HarmonySrc/CustomBlockPaintPatch.cs.
|
||||||
|
Блок при этом остаётся обычным Shape="New" - со всеми работающими столкновениями,
|
||||||
|
наведением по E и правильной посадкой, - а поверхность у него своя.
|
||||||
|
|
||||||
|
608 - это НОМЕР ЗАПИСИ В uvMapping, а не номер краски. Патч печатает его в лог при
|
||||||
|
каждом запуске: «uvMapping entry 608 points at slice 407». Краска дополнительно
|
||||||
|
регистрируется под именем txName_NecroAsh («Некротический прах») и занимает слот 13,
|
||||||
|
но блоку нужен именно номер записи.
|
||||||
|
|
||||||
|
ХРУПКОЕ МЕСТО: 608 - это длина uvMapping ванильной игры, то есть наша запись просто
|
||||||
|
дописывается в конец. Число верно, пока атлас игры не изменился; после обновления игры
|
||||||
|
оно может сдвинуться. Патч печатает фактический номер в лог, поэтому расхождение видно
|
||||||
|
сразу - сверяться при обновлениях.
|
||||||
|
|
||||||
|
TintColor убран намеренно: цвет несёт сама текстура, а тинт - множитель, он бы её
|
||||||
|
исказил. Тот же вывод, что уже сделан по ножу и по банке крови.
|
||||||
|
<property name="TintColor" value="C8BCD2" /> -->
|
||||||
|
<property name="Texture" value="608" />
|
||||||
<!-- Real generated art (exch/pyramidOfSpirit.png, copied to
|
<!-- Real generated art (exch/pyramidOfSpirit.png, copied to
|
||||||
UIAtlases/ItemIconAtlas/) added 2026-09-02, per direct instruction - no
|
UIAtlases/ItemIconAtlas/) added 2026-09-02, per direct instruction - no
|
||||||
CustomIconTint alongside it (same lesson as every other real icon in this mod,
|
CustomIconTint alongside it (same lesson as every other real icon in this mod,
|
||||||
|
|||||||
+143
-4
@@ -10,12 +10,28 @@
|
|||||||
</append>
|
</append>
|
||||||
|
|
||||||
<append xpath="/buffs">
|
<append xpath="/buffs">
|
||||||
<!-- Never expires (duration 0): a persistent lifetime total, not a per-day/session counter. -->
|
<!-- Never expires (duration 0): a persistent lifetime total, not a per-day/session counter.
|
||||||
<buff name="buffNecroZombieKillTrackerDisplay" icon="ui_game_symbol_zombie" icon_color="150,0,0" name_key="buffNecroZombieKillTrackerDisplayName" description_key="buffNecroZombieKillTrackerDisplayDesc">
|
|
||||||
|
ЧТО ПОКАЗЫВАЕТ, ИЗМЕНЕНО 2026-09-17. Раньше бафф показывал общее число убитых зомби
|
||||||
|
(necroZombieKillsCVar). Теперь - УРОВЕНЬ НЕКРОМАНТИИ (necroNecromancyLevelCVar), по
|
||||||
|
указанию пользователя: "череп - уровень некромантии... 20 зомби за уровень". Само
|
||||||
|
число убийств никуда не делось, оно по-прежнему копится в necroZombieKillsCVar (и
|
||||||
|
по-прежнему задаёт урон ножа ниже) - просто на экране его место занял уровень, а
|
||||||
|
продвижение внутри уровня показывает отдельная фиолетовая шкала в HUD рядом с полосой
|
||||||
|
опыта (Config/XUi_InGame/windows.xml, CVar necroNecromancyProgressCVar).
|
||||||
|
|
||||||
|
Оба CVar'а пишет HarmonySrc/NecromancyKillCreditPatch.cs - и на каждом убийстве, и при
|
||||||
|
загрузке игрока. В XML их считать нельзя: уровень обязан совпадать с
|
||||||
|
ProgressionValue.Level бит в бит, а деление в ModifyCVar дало бы дробь (19.2).
|
||||||
|
|
||||||
|
Ключ имени/описания (buffNecroZombieKillTrackerDisplayName/Desc) переписан в
|
||||||
|
Localization.csv на всех 13 языках той же правкой. Имя самого баффа НЕ трогалось
|
||||||
|
намеренно: по нему бафф лежит в сейвах у всех, кто уже играет. -->
|
||||||
|
<buff name="buffNecroZombieKillTrackerDisplay" icon="ui_game_symbol_skull" icon_color="150,0,0" name_key="buffNecroZombieKillTrackerDisplayName" description_key="buffNecroZombieKillTrackerDisplayDesc">
|
||||||
<stack_type value="ignore"/>
|
<stack_type value="ignore"/>
|
||||||
<duration value="0"/>
|
<duration value="0"/>
|
||||||
<update_rate value=".1"/>
|
<update_rate value=".1"/>
|
||||||
<display_value value="necroZombieKillsCVar"/>
|
<display_value value="necroNecromancyLevelCVar"/>
|
||||||
|
|
||||||
<!-- Нож некроманта (BACKLOG.md item 5, user request 2026-08-28): "урон умножается на
|
<!-- Нож некроманта (BACKLOG.md item 5, user request 2026-08-28): "урон умножается на
|
||||||
скилл некроманта ... и делится на 10". Computed here (this buff already ticks
|
скилл некроманта ... и делится на 10". Computed here (this buff already ticks
|
||||||
@@ -172,7 +188,7 @@
|
|||||||
состояние кадра верное, но пульсация иконки может подёргиваться раз в секунду. Если
|
состояние кадра верное, но пульсация иконки может подёргиваться раз в секунду. Если
|
||||||
будет заметно - убрать строку снятия из onSelfBuffUpdate и оставить чистку только на
|
будет заметно - убрать строку снятия из onSelfBuffUpdate и оставить чистку только на
|
||||||
onSelfBuffRemove, ценой залипших меток на ушедших зомби. -->
|
onSelfBuffRemove, ценой залипших меток на ушедших зомби. -->
|
||||||
<buff name="buffNecroDarkSense" name_key="buffNecroDarkSenseName" description_key="buffNecroDarkSenseDesc" icon="ui_game_symbol_zombie" icon_color="150,0,255">
|
<buff name="buffNecroDarkSense" name_key="buffNecroDarkSenseName" description_key="buffNecroDarkSenseDesc" icon="ui_game_symbol_skull" icon_color="150,0,255">
|
||||||
<stack_type value="replace"/>
|
<stack_type value="replace"/>
|
||||||
<duration value="0"/>
|
<duration value="0"/>
|
||||||
<update_rate value="1"/>
|
<update_rate value="1"/>
|
||||||
@@ -184,5 +200,128 @@
|
|||||||
</effect_group>
|
</effect_group>
|
||||||
</buff>
|
</buff>
|
||||||
|
|
||||||
|
<!-- "Путы духа крысы" - укус Духа крысы, 2026-09-18.
|
||||||
|
|
||||||
|
БЫЛО НОКДАУНОМ, СТАЛО ЗАМЕДЛЕНИЕМ (в тот же день, по игровой проверке: "ронять зомби
|
||||||
|
при каждом ударе смотрится скорее смешно"). Первая версия ставила из кода
|
||||||
|
bodyDamage.CurrentStun = Prone на 5 секунд - механизм рабочий, но зомби валился на
|
||||||
|
землю от каждого укуса мелкого духа, и выглядело это комично.
|
||||||
|
|
||||||
|
ПОЧЕМУ ТЕПЕРЬ ХВАТАЕТ ЧИСТОГО XML, А РАНЬШЕ НЕ ХВАТАЛО. Открытым был ровно один вопрос:
|
||||||
|
доходят ли RunSpeed/WalkSpeed до ИИ, или это чисто игроцкие величины. Ответ нашёлся в
|
||||||
|
EntityAlive:
|
||||||
|
|
||||||
|
public virtual float GetMoveSpeedAggro() {
|
||||||
|
if (IsBloodMoon || world.IsDark())
|
||||||
|
return EffectManager.GetValue(PassiveEffects.RunSpeed, null, moveSpeedAggroMax, this);
|
||||||
|
return EffectManager.GetValue(PassiveEffects.WalkSpeed, null, moveSpeedAggro, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
Скорость зомби берётся через EffectManager, а EntityMoveHelper.SetMoveTo читает её
|
||||||
|
оттуда же. Значит обычный пассивный эффект работает, и весь Harmony-код (RatGripPatch.cs)
|
||||||
|
оказался не нужен - удалён.
|
||||||
|
|
||||||
|
ОБЕ ВЕЛИЧИНЫ ОБЯЗАТЕЛЬНЫ. Ветка в GetMoveSpeedAggro выбирается по времени суток: ночью и
|
||||||
|
в кровавую луну читается RunSpeed, днём - WalkSpeed. Прописать одну - значит получить
|
||||||
|
замедление, которое молча отваливается на полсуток. CrouchSpeed добавлен за компанию,
|
||||||
|
для крадущихся спящих.
|
||||||
|
|
||||||
|
ЗАТУХАНИЕ, А НЕ РОВНАЯ ПОЛКА: value="0.7,.1" с duration="0,5" - это -70% в момент укуса,
|
||||||
|
плавно сходящие к -10% за 5 секунд, тем же приёмом, что у ванильного buffInjurySlow.
|
||||||
|
stack_type replace: каждый следующий укус начинает отсчёт заново, поэтому пока крыса
|
||||||
|
грызёт, зомби так и держится возле -70%, а стоит ей отвлечься - он разгоняется обратно.
|
||||||
|
Кулдаун, который был нужен нокдауну (иначе один питомец держал бы зомби лежачим вечно),
|
||||||
|
здесь не нужен и удалён вместе с ним: замедление, обновляемое укусами, - это ровно то
|
||||||
|
поведение, которое и требовалось. -->
|
||||||
|
<buff name="buffNecroRatGrip" name_key="buffNecroRatGripName" description_key="buffNecroRatGripDesc" icon="ui_game_symbol_twitch_slow" icon_color="140,200,255">
|
||||||
|
<stack_type value="replace"/>
|
||||||
|
<duration value="5"/>
|
||||||
|
<effect_group>
|
||||||
|
<passive_effect name="RunSpeed" operation="perc_subtract" value="0.7,.1" duration="0,5"/>
|
||||||
|
<passive_effect name="WalkSpeed" operation="perc_subtract" value="0.7,.1" duration="0,5"/>
|
||||||
|
<passive_effect name="CrouchSpeed" operation="perc_subtract" value="0.7,.1" duration="0,5"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
|
||||||
|
<!-- "Метка духа" - вторая половина укуса Духа крысы, 2026-09-18 (указания "предложи дебаф
|
||||||
|
поинтереснее" -> выбраны варианты 2 и 3, метка и гниль, вместе).
|
||||||
|
|
||||||
|
ДВА ЭФФЕКТА, ОДИН БАФФ, И ЖИВЁТ ОН ДОЛЬШЕ ЗАМЕДЛЕНИЯ. Замедление (buffNecroRatGrip
|
||||||
|
выше) осмысленно только пока крыса грызёт, поэтому оно короткое и обновляется каждым
|
||||||
|
укусом. Метка - наоборот: её ценность в том, что она ПЕРЕЖИВАЕТ драку. Тридцать секунд
|
||||||
|
достаточно, чтобы добежать до помеченного и добить его, и мало, чтобы карта превратилась
|
||||||
|
в ёлку.
|
||||||
|
|
||||||
|
1. ВИДНО. SetNavObject на самого носителя - тот же механизм, которым сделано Тёмное
|
||||||
|
чутьё (necroModKnifeDarkSense ниже), только там selfAOE по площади, а здесь
|
||||||
|
target="self": метится ровно тот, кого укусили. Класс метки свой и зелёный, не
|
||||||
|
красный ванильный, - иначе добыча крысы была бы неотличима от зомби, подсвеченных
|
||||||
|
чутьём (см. комментарий в nav_objects.xml).
|
||||||
|
Снимается тремя триггерами, а не одним: по истечении баффа, по смерти носителя и на
|
||||||
|
всякий случай - тем же способом, что RemoveParticleEffectFromEntity у девиации выше.
|
||||||
|
Метка на трупе - это метка, которая не уйдёт уже никогда.
|
||||||
|
|
||||||
|
2. ГНИЁТ. GeneralDamageResist в МИНУС - помеченный получает на четверть больше урона от
|
||||||
|
всего подряд. Это не выдумка и не побочный эффект: сопротивление читается со стороны
|
||||||
|
ЖЕРТВЫ, в EntityAlive.DamageEntity:
|
||||||
|
|
||||||
|
float num = Utils.FastMin(1f, EffectManager.GetValue(PassiveEffects.GeneralDamageResist, null, 0f, this));
|
||||||
|
float num2 = (float)_strength * num + accumulatedDamageResisted;
|
||||||
|
int num3 = Utils.FastMin(_strength, (int)num2);
|
||||||
|
_strength -= num3;
|
||||||
|
|
||||||
|
при отрицательном сопротивлении num3 отрицателен, и вычитание его УВЕЛИЧИВАЕТ урон.
|
||||||
|
Ограничение FastMin(1f, ...) режет только верх (100% сопротивления), низ открыт.
|
||||||
|
|
||||||
|
Вместе это и есть роль Духа крысы: он не убивает сам (урон 5), он находит добычу и
|
||||||
|
делает её уязвимой для хозяина. -->
|
||||||
|
<buff name="buffNecroRatMark" name_key="buffNecroRatMarkName" description_key="buffNecroRatMarkDesc" icon="ui_game_symbol_skull" icon_color="150,220,70">
|
||||||
|
<stack_type value="replace"/>
|
||||||
|
<duration value="30"/>
|
||||||
|
<effect_group>
|
||||||
|
<passive_effect name="GeneralDamageResist" operation="base_subtract" value="0.25"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffStart" action="SetNavObject" target="self" nav_object="necroRatSpiritMark" add="true"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffRemove" action="SetNavObject" target="self" nav_object="necroRatSpiritMark" add="false"/>
|
||||||
|
<triggered_effect trigger="onSelfDied" action="SetNavObject" target="self" nav_object="necroRatSpiritMark" add="false"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
|
||||||
|
<!-- "Кровоточащая рана" - кровотечение от укуса Духа крысы, 2026-09-18.
|
||||||
|
|
||||||
|
ВАНИЛЬНОЕ buffInjuryBleeding НА ЗОМБИ НЕ РАБОТАЕТ ВООБЩЕ, и стояло оно тут ошибочно.
|
||||||
|
Замечено пользователем по игре ("при такой слабой атаке байкера она била минут 5"),
|
||||||
|
подтверждено чтением Data/Config/buffs.xml. Урон там берётся не из самого баффа, а из
|
||||||
|
переменной игрока:
|
||||||
|
|
||||||
|
<triggered_effect trigger="onSelfBuffStart" action="ModifyCVar" cvar="$bleedAmount"
|
||||||
|
operation="set" value="@bleedCounter"/>
|
||||||
|
<passive_effect name="HealthChangeOT" operation="base_subtract" value="@$bleedAmount"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="RemoveBuff" buff="buffInjuryBleeding">
|
||||||
|
<requirement name="CVarCompare" cvar="bleedCounter" operation="Equals" value="0"/>
|
||||||
|
</triggered_effect>
|
||||||
|
|
||||||
|
bleedCounter накручивают ДРУГИЕ баффы-обёртки (buffInjuryBleedingOne/Two,
|
||||||
|
buffInjuryBleedingBarbedWire), и это часть игроцкой системы травм. Повесить
|
||||||
|
buffInjuryBleeding напрямую на зомби - значит получить счётчик 0: урон ноль, и бафф
|
||||||
|
снимает сам себя на первом же обновлении. Ровно ноль эффекта, молча.
|
||||||
|
|
||||||
|
Поэтому здесь своё, без единой внешней переменной: сколько написано, столько и капает.
|
||||||
|
|
||||||
|
ЧИСЛА. 5 в секунду на 15 секунд. Укус крысы даёт 5 урона и проходит примерно раз в
|
||||||
|
секунду, то есть кровотечение РОВНО УДВАИВАЕТ её вклад, а stack_type replace означает,
|
||||||
|
что пока она грызёт, рана не заживает. Отвлеклась - зомби живёт ещё 15 секунд и
|
||||||
|
перестаёт течь. Если в игре окажется мало или много, править надо эти две цифры и
|
||||||
|
больше ничего. -->
|
||||||
|
<buff name="buffNecroRatBleed" name_key="buffNecroRatBleedName" description_key="buffNecroRatBleedDesc" icon="ui_game_symbol_critical" icon_color="200,40,40" icon_blink="true">
|
||||||
|
<damage_type value="bloodloss"/>
|
||||||
|
<damage_source value="Internal"/>
|
||||||
|
<stack_type value="replace"/>
|
||||||
|
<duration value="15"/>
|
||||||
|
<update_rate value="1"/>
|
||||||
|
<effect_group>
|
||||||
|
<passive_effect name="HealthChangeOT" operation="base_subtract" value="5"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
|
||||||
</append>
|
</append>
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<config>
|
||||||
|
<!-- ВКЛАДКА ИСПЫТАНИЙ "Некромантия" (указание 2026-09-16: "Сделать вкладку квестов для
|
||||||
|
некромантии. Типа тех вкладок, где дают опыт за установку первой двери, за установку
|
||||||
|
клеймблока, за повешение факела и сбор ресурсов").
|
||||||
|
|
||||||
|
ОБА ЗАКАЗАННЫХ ИСПЫТАНИЯ НА МЕСТЕ с 2026-09-16. Сначала тут было одно ("Первым делом -
|
||||||
|
нож"): challenges.xml мод не трогал ни разу, и сперва надо было увидеть в игре, что вкладка
|
||||||
|
вообще появляется. Нож выбран для этой проверки потому, что крафтится сразу и без верстака,
|
||||||
|
то есть проверка занимала минуту, а не сессию. Вкладка отобразилась, испытание закрылось,
|
||||||
|
лог чистый - после чего добавлен "Безумный донор".
|
||||||
|
|
||||||
|
РАЗРОЗНЕННО, НЕ ЦЕПОЧКОЙ (указание: "цепочку ивентов там делать не нужно. Пусть будет
|
||||||
|
разрозненно. Что сделано то и отмечаем"). Поэтому у группы НЕТ ни link_challenges="true",
|
||||||
|
ни hidden_by - оба атрибута стоят у ванильных Basics/Homesteading именно затем, чтобы
|
||||||
|
открывать записи по очереди. Без них испытания видны сразу и закрываются в любом порядке.
|
||||||
|
Побочная польза: снимается противоречие порядка - Кровь некроманта нельзя скрафтить без
|
||||||
|
ножа в инвентаре, так что "донор" первым в цепочке был бы невыполним.
|
||||||
|
|
||||||
|
ОТКУДА ИКОНКИ - разобрано 2026-09-16, и вывод ограничивающий: ТОЛЬКО из символьного
|
||||||
|
атласа игры, свой спрайт туда положить нельзя.
|
||||||
|
- В Data/Config/XUi_InGame/templates.xml оба места, где эти иконки рисуются, объявлены
|
||||||
|
БЕЗ атрибута atlas: строка испытания (challenge_entry, sprite="{iconname}") и кнопка
|
||||||
|
вкладки (category_icon, sprite="{categoryicon}"). Нет atlas - значит атлас по
|
||||||
|
умолчанию, тот, где живут все ui_game_symbol_*.
|
||||||
|
- Подтверждение с другой стороны: в ванильном challenges.xml ВСЕ 215 иконок имеют вид
|
||||||
|
ui_game_symbol_* без единого исключения (проверено вычитанием).
|
||||||
|
- Свой атлас мода (UIAtlases/NecroFinal) тут не поможет: механизм UIAtlases/<Имя>
|
||||||
|
создаёт НОВЫЙ атлас, и обратиться к нему можно только явным atlas="NecroFinal" на
|
||||||
|
спрайте, которого в этих шаблонах нет.
|
||||||
|
- Дописать atlas= в сам шаблон НЕЛЬЗЯ: шаблон один на все испытания, и тогда ванильные
|
||||||
|
215 иконок начнут искаться в нашем атласе и пропадут.
|
||||||
|
Выбирали из 359 доступных ui_game_symbol_*, собранных по ванильным конфигам. -->
|
||||||
|
<append xpath="/challenges">
|
||||||
|
|
||||||
|
<!-- ВКЛАДКА. Череп, и это ОДНО РЕШЕНИЕ С ИКОНКОЙ САМОГО СКИЛЛА "Некромантия" в
|
||||||
|
progression.xml: вкладка и скилл обязаны читаться как одно целое, поэтому меняются
|
||||||
|
только парой. Разойдутся - и панель скилла с вкладкой испытаний перестанут выглядеть
|
||||||
|
как одна система.
|
||||||
|
|
||||||
|
Сначала (16.09) здесь стоял ui_game_symbol_zombie - по тому же принципу совпадения со
|
||||||
|
скиллом, у которого он тогда и стоял. В игре вкладка отобразилась и испытание
|
||||||
|
сработало, после чего пользователь заменил символ на череп: "Для некроманта это более
|
||||||
|
атмосферно". Заменено разом во всех четырёх живых местах мода (здесь, скилл в
|
||||||
|
progression.xml и два баффа в buffs.xml), разбор - в BACKLOG.md.
|
||||||
|
|
||||||
|
Годные альтернативы, если череп когда-нибудь разонравится:
|
||||||
|
ui_game_symbol_specters_grace, ui_game_symbol_7th_curse,
|
||||||
|
ui_game_symbol_near_death_trauma. -->
|
||||||
|
<challenge_category name="NecroNecromancy" title_key="challengeCatNecroNecromancy" icon="ui_game_symbol_skull"/>
|
||||||
|
|
||||||
|
<!-- ГРУППА. Сознательно БЕЗ reward_text_key и reward_event.
|
||||||
|
|
||||||
|
Это осознанный риск скелета, а не забывчивость: у всех 20 ванильных групп они есть, но
|
||||||
|
корень <challenges> объявлен с default_reward="challenge_reward_default" и
|
||||||
|
default_reward_text_key="challenge_reward_100xp", и мы добавляемся именно в этот
|
||||||
|
корень. Если умолчания на группы не распространяются, игра скажет об этом в логе - и
|
||||||
|
это ровно то, ради чего скелет и запускается. Заводить своё событие награды вслепую
|
||||||
|
дороже, чем один раз посмотреть в лог. -->
|
||||||
|
<challenge_group category="NecroNecromancy" name="NecroPath" title_key="challengeGroupNecroPath"/>
|
||||||
|
|
||||||
|
<!-- ИСПЫТАНИЕ. objective type="Craft" без count - ножа нужен один.
|
||||||
|
hint не задан: он необязателен - в ванили одно испытание из 188 обходится без него, а
|
||||||
|
тут подсказка и не нужна, название говорит само за себя.
|
||||||
|
|
||||||
|
500 XP, А НЕ УМОЛЧАНИЕ. Сначала тут не стояло награды вовсе, то есть работал
|
||||||
|
challenge_reward_default (100 XP), по аналогии с ванильным craftStoneAxe: один крафт в
|
||||||
|
самом начале. Аналогия оказалась неверной, и это выяснилось только при чтении рецепта:
|
||||||
|
в ножа входит 1 Кровь некроманта, а её собственный крафт снимает 90% ТЕКУЩЕГО здоровья.
|
||||||
|
То есть нож стоит одного захода на порог смерти, чего у каменного топора нет и близко.
|
||||||
|
500 - ванильный тир простых, но небесплатных дел (craftCharredMeat, huntAnimals). -->
|
||||||
|
<challenge name="necroCraftKnife" title_key="challengeNecroCraftKnifeTitle" icon="ui_game_symbol_knife" group="NecroPath"
|
||||||
|
short_description_key="challengeNecroCraftKnifeShort" description_key="challengeNecroCraftKnifeDesc"
|
||||||
|
reward_text_key="challenge_reward_500xp" reward_event="challenge_reward_500">
|
||||||
|
<objective type="Craft" item="necroWpnBladeNecroKnife"/>
|
||||||
|
</challenge>
|
||||||
|
|
||||||
|
<!-- ИСПЫТАНИЕ 2: "Безумный донор" (указание: "добыть 15 флаконов крови некроманта").
|
||||||
|
Кровь не добывается из мира, она крафтится, поэтому тип Craft, а не Gather.
|
||||||
|
|
||||||
|
count="15" - у Craft счётчик поддерживается, и это стоило отдельной проверки: в самых
|
||||||
|
наглядных ванильных примерах (craftStoneAxe, craftClothes) он не указан, и легко
|
||||||
|
решить, что его нет. Есть - 18 ванильных Craft-целей его несут, вплоть до
|
||||||
|
resourceForgedIron count="100" и thrownAmmoPipeBomb count="20".
|
||||||
|
|
||||||
|
ИМЯ ОПРАВДАНО МЕХАНИКОЙ, А НЕ ПРОСТО КРАСИВОЕ: каждый крафт Крови некроманта снимает
|
||||||
|
90% ТЕКУЩЕГО здоровья (NecromancerBloodPatch.cs) и требует нож в инвентаре. Пятнадцать
|
||||||
|
раз подряд - это пятнадцать раз довести себя до полусмерти, и "Безумный донор" описывает
|
||||||
|
это точно.
|
||||||
|
|
||||||
|
Зависимости от первого испытания нет и быть не должно (указание: "разрозненно"), хотя
|
||||||
|
фактически кровь без ножа не скрафтить. Порядка у группы нет, так что игрок сам
|
||||||
|
упрётся в нож, когда попробует начать с крови, - и это честнее замка.
|
||||||
|
|
||||||
|
icon="ui_game_symbol_siphoning_strikes" - "высасывающие удары", символ выкачивания
|
||||||
|
жизни. Найден при разборе всех 359 доступных ui_game_symbol_*; под механику "плати
|
||||||
|
собственной кровью" попадает точнее любого медицинского символа. -->
|
||||||
|
<challenge name="necroMadDonor" title_key="challengeNecroMadDonorTitle" icon="ui_game_symbol_siphoning_strikes" group="NecroPath"
|
||||||
|
short_description_key="challengeNecroMadDonorShort" description_key="challengeNecroMadDonorDesc"
|
||||||
|
reward_text_key="challenge_reward_2500xp" reward_event="challenge_reward_2500">
|
||||||
|
<objective type="Craft" item="resourceNecromancerBlood" count="15"/>
|
||||||
|
</challenge>
|
||||||
|
|
||||||
|
<!-- ИСПЫТАНИЕ 3: "Первый подданный" - Камень духов (указание 2026-09-17: "И первый камень
|
||||||
|
духов. Сам посчитай и придумай").
|
||||||
|
|
||||||
|
Имя про механику: Камень духов подчиняет зомби, и подчинённый бьётся за игрока. Это
|
||||||
|
первое существо, которое слушается некроманта, - отсюда "подданный".
|
||||||
|
|
||||||
|
100 XP, то есть challenge_reward_default, награда НЕ ЗАДАНА СОЗНАТЕЛЬНО. Рецепт -
|
||||||
|
1 камень + 20 волокон травы, ни праха, ни крови. Это самая дешёвая вещь мода и
|
||||||
|
буквальный аналог ванильного craftStoneAxe, за который ваниль платит ровно умолчание.
|
||||||
|
Здесь 100 - не занижение, а единственное честное число: поднять его выше значило бы
|
||||||
|
платить за десять минут игры больше, чем ваниль платит за то же самое.
|
||||||
|
|
||||||
|
icon="ui_game_symbol_specters_grace" ("милость призрака") - предмет называется Камнем
|
||||||
|
ДУХОВ и подчиняет мёртвого, так что символ совпадает и по названию, и по смыслу. -->
|
||||||
|
<challenge name="necroSpiritStone" title_key="challengeNecroSpiritStoneTitle" icon="ui_game_symbol_specters_grace" group="NecroPath"
|
||||||
|
short_description_key="challengeNecroSpiritStoneShort" description_key="challengeNecroSpiritStoneDesc">
|
||||||
|
<objective type="Craft" item="thrownStoneSpirit"/>
|
||||||
|
</challenge>
|
||||||
|
|
||||||
|
<!-- ИСПЫТАНИЕ 4: "Короткая дорога домой" - Синий портальный камень (указание 2026-09-17).
|
||||||
|
|
||||||
|
Имя про механику: камень телепортирует к спальному мешку за десятисекундный канал и не
|
||||||
|
расходуется. Это в буквальном смысле короткий путь домой, и никакой другой предмет мода
|
||||||
|
этого не делает.
|
||||||
|
|
||||||
|
2000 XP - и это САМОЕ ДОРОГОЕ ИСПЫТАНИЕ ВКЛАДКИ ПО ГРАЙНДУ, что видно только из
|
||||||
|
рецепта: 1 камень + 3 минералки + 15 волокон + 150 ПРАХА ЗОМБИ. Прах падает с Жертвы,
|
||||||
|
помеченной Ножом некроманта, по 1-4 с вероятностью 0.4 (loot.xml), то есть в среднем
|
||||||
|
около одной единицы за убийство - значит 150 праха это порядка ста пятидесяти
|
||||||
|
ритуальных убийств ножом. Для сравнения, ванильный craftForgedIron (сто крафтов
|
||||||
|
кованого железа) платит 2000, и это чистый грайнд без риска - ровно тот же случай.
|
||||||
|
Выше не ставим: 2500 у "Безумного донора" оплачивает смертельный риск, которого здесь
|
||||||
|
нет, а 5000 ваниль держит за химстанцией и тирами квестов.
|
||||||
|
|
||||||
|
icon="ui_game_symbol_map_house" - дом, к которому камень и возвращает. -->
|
||||||
|
<challenge name="necroPortalStoneBlue" title_key="challengeNecroPortalBlueTitle" icon="ui_game_symbol_map_house" group="NecroPath"
|
||||||
|
short_description_key="challengeNecroPortalBlueShort" description_key="challengeNecroPortalBlueDesc"
|
||||||
|
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
|
||||||
|
<objective type="Craft" item="thrownStonePortalBlue"/>
|
||||||
|
</challenge>
|
||||||
|
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
+186
-61
@@ -1,17 +1,29 @@
|
|||||||
<config>
|
<config>
|
||||||
<!-- Step 1: lifetime zombie kill counter.
|
<!-- СЧЁТ УБИЙСТВ ПЕРЕЕХАЛ В КОД 2026-09-16. Здесь СОЗНАТЕЛЬНО ничего нет, и вернуть это
|
||||||
zombieTemplateMale is the root template every zombie entity_class extends
|
обратно нельзя - см. HarmonySrc/NecromancyKillCreditPatch.cs.
|
||||||
(directly, or indirectly via zombieTemplateShort), so patching it here covers
|
|
||||||
every zombie variant in the game without listing them individually. -->
|
Тут стоял append на zombieTemplateMale с двумя onOtherKilledSelf-эффектами
|
||||||
<append xpath="/entity_classes/entity_class[@name='zombieTemplateMale']">
|
(ModifyCVar necroZombieKillsCVar и AddProgressionLevel craftingNecroNecromancy) под общим
|
||||||
<effect_group>
|
требованием EntityTagCompare target="other" tags="player". Он был сломан дважды:
|
||||||
<requirement name="EntityTagCompare" target="other" tags="player"/>
|
|
||||||
<triggered_effect trigger="onOtherKilledSelf" action="ModifyCVar" target="other" cvar="necroZombieKillsCVar" operation="add" value="1"/>
|
1. Один класс - не все зомби. Пять зомби-ЗВЕРЕЙ наследуют животную ветку и до
|
||||||
<!-- "Некромантия" skill: +1 level per zombie kill, capped by its own max_level.
|
zombieTemplateMale не доходят вовсе (animalZombieBear extends animalBear,
|
||||||
See progression.xml for why this drives the skill instead of reading books. -->
|
animalZombieBoar extends animalBoar, animalZombieDog extends animalWolf,
|
||||||
<triggered_effect trigger="onOtherKilledSelf" action="AddProgressionLevel" target="other" progression_name="craftingNecroNecromancy" level="1"/>
|
animalZombieVulture extends animalTemplateHostile, animalZombieVultureRadiated).
|
||||||
</effect_group>
|
Убийство зомбопса, зомбомедведя, зомбокабана и зомбоворона не считалось никак.
|
||||||
</append>
|
2. target="other" - это БУКВАЛЬНЫЙ убийца, а не тот, кому ваниль зачла убийство.
|
||||||
|
Робомолот, кровотечение и питомцы требование tags="player" не проходят, хотя опыт
|
||||||
|
игрок за них получает: ваниль определяет получателя по DamageSource, в
|
||||||
|
EntityAlive.AwardKillXPServer.
|
||||||
|
|
||||||
|
Требование было одно на оба эффекта, поэтому вместе со скиллом недосчитывался и
|
||||||
|
necroZombieKillsCVar - то есть занижался урон Ножа некроманта (items.xml: Damage =
|
||||||
|
necroZombieKillsCVar / 10).
|
||||||
|
|
||||||
|
ОБА эффекта теперь делает Postfix на EntityPlayer.AddKillXP - единственной точке, где
|
||||||
|
ваниль уже решила, чей это фраг. ЕСЛИ ВЕРНУТЬ ЭТОТ append НА МЕСТО, убийство своей рукой
|
||||||
|
будет засчитано ДВАЖДЫ: и здесь, и в патче. Ровно это и проверять, если уровень вдруг
|
||||||
|
начнёт расти по два за труп. -->
|
||||||
|
|
||||||
<!-- "Зомбособака" (Zombie Dog pet): BACKLOG.md item 3. Extends the vanilla hostile
|
<!-- "Зомбособака" (Zombie Dog pet): BACKLOG.md item 3. Extends the vanilla hostile
|
||||||
animalZombieDog (same prefab/physics/sounds - a real zombie dog model, not a reskinned
|
animalZombieDog (same prefab/physics/sounds - a real zombie dog model, not a reskinned
|
||||||
@@ -46,9 +58,41 @@
|
|||||||
originally had before its own rework - see necroMeleeHandZombieDog in items.xml. -->
|
originally had before its own rework - see necroMeleeHandZombieDog in items.xml. -->
|
||||||
<property name="HandItem" value="necroMeleeHandZombieDog"/>
|
<property name="HandItem" value="necroMeleeHandZombieDog"/>
|
||||||
|
|
||||||
<property name="AITask-3" value="ApproachAndAttackTarget" data="class=EntityZombie,20"/>
|
<!-- ИИ ПЕРЕПИСАН 2026-09-18 ПОД ОБРАЗЕЦ ДУХА КРЫСЫ, прямое указание: "поведение
|
||||||
<property name="AITarget-1" value="SetAsTargetIfHurt" data="class=EntityZombie"/>
|
других призванных животных зомби тоже подгони под крысу. По сути это те же
|
||||||
<property name="AITarget-4" value="SetNearestEntityAsTarget" data="class=EntityZombie,22,20"/>
|
крысы, но размер модели оригинальный, урон нарастает от зомбособаки до
|
||||||
|
зомбоволка". Правка стоит здесь, у necroZombieDog, и этого достаточно:
|
||||||
|
Зомбомедведь, Зомбоволк и Зомбогриф расширяют именно его и унаследуют всё.
|
||||||
|
|
||||||
|
БЫЛО (и разобрано в BACKLOG.md 18.09 как "питомцы унаследовали ИИ враждебной
|
||||||
|
твари"): AITask-3 переопределял только погоню, а от animalZombieDog оставались
|
||||||
|
BreakBlock (грыз базу игрока), Territorial (держался точки призыва, а не
|
||||||
|
хозяина), ApproachSpot и Wander (бродил сам по себе), BlockingTargetTask и
|
||||||
|
SetNearestCorpseAsTarget (бросал хозяина ради ближайшего трупа), а
|
||||||
|
SetNearestEntityAsTarget заставлял его самому лезть в драку в 22 метрах.
|
||||||
|
|
||||||
|
СТАЛО, ровно как у крысы:
|
||||||
|
- AITask-3 пустой обрывает перебор, поэтому BreakBlock/Territorial/ApproachSpot/
|
||||||
|
Wander не грузятся вовсе (CopyPropertiesFromEntityClass идёт по индексам и
|
||||||
|
встаёт на первом пустом);
|
||||||
|
- AITarget-2 пустой так же убирает BlockingTargetTask и поедание трупов;
|
||||||
|
- цель НЕ ВЫБИРАЕТСЯ самостоятельно никогда: SetNearestEntityAsTarget больше
|
||||||
|
нет. Цель приходит только приказом игрока (PetCommandPatch.cs);
|
||||||
|
- AITarget-1 остаётся только как ответ на удар, и список классов без
|
||||||
|
EntityPlayer: питомец огрызается на зомби и враждебных зверей, но никогда на
|
||||||
|
хозяина;
|
||||||
|
- class=EntityAlive в AITask-1 - это разрешение задаче подхватить ту цель,
|
||||||
|
которую ей ДАЛИ, а не "бей всё живое" (EAIApproachAndAttackTarget.CanExecute
|
||||||
|
сверяет тип цели со списком и без совпадения не запускается). Кого можно
|
||||||
|
назначать, решает PetCommandPatch.cs, и торговец там отсеян.
|
||||||
|
|
||||||
|
Задача следования (NecroFollowOwnerTask) вешается в рантайме из SummonPatch.cs -
|
||||||
|
всем, у кого UsesFollowTask, то есть теперь и этим четверым. -->
|
||||||
|
<property name="AITask-1" value="ApproachAndAttackTarget" data="class=EntityAlive,0"/>
|
||||||
|
<property name="AITask-2" value="Look"/>
|
||||||
|
<property name="AITask-3" value=""/>
|
||||||
|
<property name="AITarget-1" value="SetAsTargetIfHurt" data="class=EntityZombie,EntityEnemyAnimal"/>
|
||||||
|
<property name="AITarget-2" value=""/>
|
||||||
</entity_class>
|
</entity_class>
|
||||||
</append>
|
</append>
|
||||||
|
|
||||||
@@ -144,6 +188,9 @@
|
|||||||
<property name="PrefabCombined" value="true"/>
|
<property name="PrefabCombined" value="true"/>
|
||||||
<property name="PhysicsBody" value="bear"/>
|
<property name="PhysicsBody" value="bear"/>
|
||||||
<property name="Mass" value="600"/>
|
<property name="Mass" value="600"/>
|
||||||
|
<!-- 80% от ванильного, указание 2026-09-18. У animalZombieBear своего SizeScale нет,
|
||||||
|
то есть ванильный размер - единица, отсюда 0.8. -->
|
||||||
|
<property name="SizeScale" value="0.8"/>
|
||||||
<property name="Tags" value="entity,animal,bear"/>
|
<property name="Tags" value="entity,animal,bear"/>
|
||||||
<!-- FIXED 2026-08-30 (user report: "зомбомедведь лает" - swapping the Prefab only
|
<!-- FIXED 2026-08-30 (user report: "зомбомедведь лает" - swapping the Prefab only
|
||||||
changes the visible model, sound properties are a totally separate set of
|
changes the visible model, sound properties are a totally separate set of
|
||||||
@@ -161,7 +208,7 @@
|
|||||||
<property name="SoundStepType" value="animalhvystep"/>
|
<property name="SoundStepType" value="animalhvystep"/>
|
||||||
<!-- Real vanilla hand item (claw damage 60, vs. the Dog's own bite at 8) - reused
|
<!-- Real vanilla hand item (claw damage 60, vs. the Dog's own bite at 8) - reused
|
||||||
as-is, not customized further (no slow debuff etc. - not asked for). -->
|
as-is, not customized further (no slow debuff etc. - not asked for). -->
|
||||||
<property name="HandItem" value="meleeHandAnimalZombieBear"/>
|
<property name="HandItem" value="necroMeleeHandZombieBear"/>
|
||||||
<effect_group name="Base Effects">
|
<effect_group name="Base Effects">
|
||||||
<!-- 1500 is a guess between the Dog's 200 and real animalZombieBear's own 4000 -
|
<!-- 1500 is a guess between the Dog's 200 and real animalZombieBear's own 4000 -
|
||||||
a tough pet, not necessarily boss-tier tanky. Say if it should be higher/lower. -->
|
a tough pet, not necessarily boss-tier tanky. Say if it should be higher/lower. -->
|
||||||
@@ -177,7 +224,8 @@
|
|||||||
<property name="PrefabCombined" value="true"/>
|
<property name="PrefabCombined" value="true"/>
|
||||||
<property name="PhysicsBody" value="AWolf"/>
|
<property name="PhysicsBody" value="AWolf"/>
|
||||||
<property name="Mass" value="180"/>
|
<property name="Mass" value="180"/>
|
||||||
<property name="SizeScale" value="1.4"/>
|
<!-- 1.12 = 80% от ванильных 1.4 у animalDireWolf, указание 2026-09-18. -->
|
||||||
|
<property name="SizeScale" value="1.12"/>
|
||||||
<property name="Tags" value="entity,animal,wolf"/>
|
<property name="Tags" value="entity,animal,wolf"/>
|
||||||
<!-- FIXED 2026-08-30, same oversight as the Bear above (see its comment) - real
|
<!-- FIXED 2026-08-30, same oversight as the Bear above (see its comment) - real
|
||||||
animalDireWolf's own sound set. -->
|
animalDireWolf's own sound set. -->
|
||||||
@@ -190,7 +238,7 @@
|
|||||||
<property name="SoundGiveUp" value="wolfdiregiveup"/>
|
<property name="SoundGiveUp" value="wolfdiregiveup"/>
|
||||||
<property name="SoundStepType" value="animalpawstep"/>
|
<property name="SoundStepType" value="animalpawstep"/>
|
||||||
<!-- Real vanilla hand item (bite damage 60, vs. the Dog's own 8). -->
|
<!-- Real vanilla hand item (bite damage 60, vs. the Dog's own 8). -->
|
||||||
<property name="HandItem" value="meleeHandAnimalDireWolf"/>
|
<property name="HandItem" value="necroMeleeHandZombieWolf"/>
|
||||||
<effect_group name="Base Effects">
|
<effect_group name="Base Effects">
|
||||||
<!-- 1200 is a guess between the Dog's 200 and real animalDireWolf's own 3000 -
|
<!-- 1200 is a guess between the Dog's 200 and real animalDireWolf's own 3000 -
|
||||||
slightly below the Bear, faster/leaner theme. Say if it should be different. -->
|
slightly below the Bear, faster/leaner theme. Say if it should be different. -->
|
||||||
@@ -199,56 +247,133 @@
|
|||||||
</entity_class>
|
</entity_class>
|
||||||
</append>
|
</append>
|
||||||
|
|
||||||
<!-- Griffin CONVERTED 2026-08-29 to the same Dog-reskin trick as Bear/Wolf, after the user
|
<!-- "Зомбогриф" ОТКАЧЕН 2026-09-18 К ЛЕТАЮЩЕЙ ВЕТКЕ.
|
||||||
confirmed live in-game that the animalZombieVulture+Harmony-redirect approach genuinely
|
|
||||||
doesn't work ("летает где-то в небе, и зомби его вообще не интересуют" - flies around
|
|
||||||
doing EntityVulture's own default Wander behavior, never engaging anything). Not worth
|
|
||||||
debugging the Harmony redirect further without another live test cycle - switched
|
|
||||||
straight to the proven-reliable pattern instead, same as Wolf (confirmed working) and
|
|
||||||
Bear (not yet confirmed, same trick).
|
|
||||||
|
|
||||||
No real griffin exists in this game and a bird's skeleton/rig was flagged as a real risk
|
История у него длинная, и важно не потерять её конец. До 29.08 он расширял
|
||||||
for reusing necroZombieDog's own quadruped rig (see the removed comment this replaces) -
|
animalZombieVulture и просто бестолково летал ("летает где-то в небе, и зомби его вообще
|
||||||
picked animalMountainLion as the substitute model instead of a bird: it EXTENDS
|
не интересуют") - тогда его перевели на necroZombieDog, то есть сделали наземным со
|
||||||
animalWolf (Data/Config/entityclasses.xml, confirmed by reading it directly) - the SAME
|
сменённой моделью. 18.09 пользователь заметил результат прямо: "зомбогриф у тебя это серая
|
||||||
immediate parent animalZombieDog itself extends - almost certainly sharing the exact same
|
пума". Попробовали натянуть на тот же наземный класс префаб стервятника - вышло хуже
|
||||||
quadruped skeleton/rig family the Dog's own model already uses, the lowest-risk pick
|
всего: "выглядит жутко, прозрачное только туловище и голова, по земле он ползёт на
|
||||||
available (lower risk than the Bear, which comes from a totally different
|
крыльях". Это и был ответ на висевший с 29.08 вопрос, можно ли подменить ОДИН префаб:
|
||||||
animalBear->animalTemplateHostile lineage). Not a literal griffin visually any more (a
|
нельзя, птичья модель на аниматоре наземного квадрупа не работает.
|
||||||
mountain lion, not a bird/lion-eagle hybrid) - say if a different substitute or the name
|
|
||||||
itself should change; kept "Зомбогриф"/"Summon Zombie Griffin" as-is for now since
|
Указание: "зомбогрифа откати, пусть ведёт себя как раньше и не делай его прозрачным.
|
||||||
renaming would touch items.xml/recipes.xml/Localization.csv too and wasn't asked for. -->
|
Пусть пока бестолково летает как раньше, но с возможностью нацелить его на противника...
|
||||||
|
Потом сделаем грифа как надо. Но потом."
|
||||||
|
|
||||||
|
Поэтому здесь снова ванильная ЛЕТАЮЩАЯ ветка со всем, что к ней прилагается, и это
|
||||||
|
сознательный шаг назад до отдельной работы над ним (замысел разведдрона - BACKLOG.md).
|
||||||
|
|
||||||
|
ЧТО ПРИШЛОСЬ СНЯТЬ И ПОЧЕМУ:
|
||||||
|
- EntityFlags и EntityType уходят с "animal,zombie"/"Zombie" на чистое "animal"/"Animal".
|
||||||
|
Иначе EntityAlive.DamageEntity не даст ему бить зомби вовсе: там жёсткое правило, что
|
||||||
|
две сущности с флагом Zombie не могут повредить друг другу. Тот же приём и по той же
|
||||||
|
причине применён к Рою жуков выше.
|
||||||
|
- Tags теряют zombie/hostile, IsEnemyEntity=false, Faction=none - форма "свой игроку",
|
||||||
|
как у всех остальных питомцев.
|
||||||
|
- AITask/AITarget НЕ ЗАДАЮТСЯ ВОВСЕ, и это не упущение: Class="EntityVulture" не
|
||||||
|
использует систему AITask ни в каком виде, у него собственный захардкоженный поиск
|
||||||
|
целей на C# (см. HarmonySrc/SwarmTargetPatch.cs, там это разобрано целиком). Писать
|
||||||
|
сюда задачи - ровно та ошибка, которую уже совершали 28.08 с Роем.
|
||||||
|
|
||||||
|
ЧТОБЫ НЕ АТАКОВАЛ ХОЗЯИНА, он добавлен в SwarmTargetPatch.SpeciesByName: префикс на
|
||||||
|
EntityAlive.SetAttackTarget подменяет игрока ближайшим зомби. Это единственная точка, через
|
||||||
|
которую проходят все цели EntityVulture.
|
||||||
|
|
||||||
|
ПРИКАЗ АТАКОВАТЬ у него остаётся (CommandsAttack в SummonPatch.cs), но с оговоркой:
|
||||||
|
EntityVulture перебирает цели сам, своим циклом, и может сбросить наш приказ. Задачу
|
||||||
|
следования ему НЕ вешаем - она наземная и с полётом только подерётся. -->
|
||||||
<append xpath="/entity_classes">
|
<append xpath="/entity_classes">
|
||||||
<entity_class name="necroZombieGriffin" extends="necroZombieDog">
|
<entity_class name="necroZombieGriffin" extends="animalZombieVulture">
|
||||||
<!-- Real vanilla animalMountainLion's own Prefab/PhysicsBody/Mass/SizeScale. -->
|
<property name="EntityFlags" value="animal"/>
|
||||||
<property name="Prefab" value="@:Entities/Animals/Cat/animalMountainLion.prefab"/>
|
<property name="EntityType" value="Animal"/>
|
||||||
<property name="PrefabCombined" value="true"/>
|
<property name="Tags" value="entity,animal,vulture"/>
|
||||||
<property name="PhysicsBody" value="MountainLion"/>
|
<property name="IsEnemyEntity" value="false"/>
|
||||||
<property name="Mass" value="125"/>
|
<property name="Faction" value="none"/>
|
||||||
<property name="SizeScale" value="1"/>
|
<property name="HandItem" value="necroMeleeHandZombieGriffin"/>
|
||||||
<property name="Tags" value="entity,animal,cat"/>
|
|
||||||
<!-- FIXED 2026-08-30, same oversight as the Bear above (see its comment) - real
|
|
||||||
animalMountainLion's own sound set. -->
|
|
||||||
<property name="SoundRandom" value="mlionroam"/>
|
|
||||||
<property name="SoundAlert" value="mlionalert"/>
|
|
||||||
<property name="SoundHurt" value="mlionpain"/>
|
|
||||||
<property name="SoundDeath" value="mliondeath"/>
|
|
||||||
<property name="SoundAttack" value="mlionattack"/>
|
|
||||||
<property name="SoundSense" value="mlionsense"/>
|
|
||||||
<property name="SoundGiveUp" value="mliongiveup"/>
|
|
||||||
<property name="SoundStepType" value="animalpawstep"/>
|
|
||||||
<!-- Real vanilla hand item (claw damage 22 - lighter than the Bear/Wolf's 60, a
|
|
||||||
faster/leaner theme fitting a big cat rather than a heavyweight brawler). -->
|
|
||||||
<property name="HandItem" value="meleeHandAnimalMountainLion"/>
|
|
||||||
<effect_group name="Base Effects">
|
<effect_group name="Base Effects">
|
||||||
<!-- Real vanilla animalMountainLion's own HealthMax (750) - used as-is, not
|
<!-- У ванильного зомбостервятника 30 HP - для питомца, которого не отозвать до
|
||||||
scaled further, since this pet is meant to be the "fast/agile" one, not the
|
следующего призыва, это ничто. 750 оставлено с прежней, наземной версии. -->
|
||||||
tankiest of the three. -->
|
|
||||||
<passive_effect name="HealthMax" operation="base_set" value="750"/>
|
<passive_effect name="HealthMax" operation="base_set" value="750"/>
|
||||||
</effect_group>
|
</effect_group>
|
||||||
</entity_class>
|
</entity_class>
|
||||||
</append>
|
</append>
|
||||||
|
|
||||||
|
<!-- "Дух крысы" (Rat Spirit) - питомец начального уровня, указание 2026-09-18. Первый
|
||||||
|
питомец, у которого поведение задано НАМЕРЕННО, а не унаследовано: на нём и отлаживается
|
||||||
|
ИИ питомцев вообще (см. разбор в BACKLOG.md от 2026-09-18).
|
||||||
|
|
||||||
|
МОДЕЛЬ. Extends necroZombieWolf, то есть тот же самый ванильный префаб лютоволка, только
|
||||||
|
SizeScale с 1.4 на 0.28 - "уменьшить в 5 раз". Хитбокс отдельно задавать НЕ НАДО и брать
|
||||||
|
кроличий НЕЛЬЗЯ: коллайдеры в physicsbodies.xml привязаны к костям по имени пути, а риг
|
||||||
|
кролика с волчьим не совпадает (у кролика корпус Hips/LowerBack/Spine1 и голова под
|
||||||
|
Spine2/Neck/Head, у волка Hips/LowerBack/Spine/Spine1/Spine2 и голова под Neck/Neck1/Head)
|
||||||
|
- кроличье тело нашло бы на волчьей модели только таз и ноги, а корпус и голова остались
|
||||||
|
бы вообще без хитбокса. Унаследованный PhysicsBody="AWolf" при этом ужимается сам:
|
||||||
|
Entity.SetScale ставит ModelTransform.localScale, а костяные коллайдеры лежат внутри него.
|
||||||
|
|
||||||
|
ЗВУКИ - кроличьи ("он тоже грызун"). На АТАКУ поставлен rabbitpain: это буквально вопль
|
||||||
|
кролика от боли, и он же остаётся на SoundHurt - крыса вопит и когда кусает, и когда бьют
|
||||||
|
её. Остальные звуки ГЛУШАТСЯ ПУСТЫМИ НАМЕРЕННО: у animalRabbit нет ни roam, ни alert, ни
|
||||||
|
sense, ни giveup, и если их не погасить, крыса унаследует волчьи от necroZombieWolf -
|
||||||
|
ровно те грабли, что дали "зомбомедведь лает" (см. necroZombieBear выше). Дух рыщет молча.
|
||||||
|
|
||||||
|
ИИ. Здесь снято ВСЁ, что питомцы до сих пор тащили от враждебного зомбопса:
|
||||||
|
- нет BreakBlock и BlockingTargetTask - крыса физически не может грызть блоки, и это же
|
||||||
|
продублировано в коде (EAINecroFollowOwner зовёт FindPath с canBreak:false). Упёршись
|
||||||
|
в препятствие, дух проходит СКВОЗЬ него - см. Entity.IsStuck в PetFollowTask.cs;
|
||||||
|
- нет Territorial, ApproachSpot и Wander - крыса не бродит сама, она ходит за хозяином
|
||||||
|
(задача следования добавляется в рантайме из SummonPatch.cs, отдельным классом, потому
|
||||||
|
что задачи "иди за сущностью" в игре нет вовсе - все 32 типа EAI* проверены);
|
||||||
|
- нет SetNearestCorpseAsTarget - она не бросает хозяина ради ближайшего трупа;
|
||||||
|
- AITarget-1 оставлен ТОЛЬКО как ответ на удар, и список классов без EntityPlayer:
|
||||||
|
крыса огрызается на зомби и враждебных зверей, но никогда на хозяина, даже если он
|
||||||
|
заденет её в свалке (прямое указание 2026-09-18).
|
||||||
|
Сама она цель не ВЫБИРАЕТ никогда: SetNearestEntityAsTarget здесь нет, а AITarget-2
|
||||||
|
пустой обрывает перебор (CopyPropertiesFromEntityClass идёт по индексам и встаёт на первом
|
||||||
|
пустом - тем же приёмом ваниль глушит задачи у animalInsectSwarm). Цель приходит снаружи:
|
||||||
|
по команде игрока из PetCommandPatch.cs.
|
||||||
|
|
||||||
|
class=EntityAlive в AITask-1 - это НЕ "бей всё живое" само по себе, а разрешение задаче
|
||||||
|
подхватить ту цель, которую ей дали (EAIApproachAndAttackTarget.CanExecute сверяет тип
|
||||||
|
цели с этим списком и без совпадения просто не запускается). Кого можно назначить целью,
|
||||||
|
решает PetCommandPatch.cs, и торговец там отсеян. -->
|
||||||
|
<append xpath="/entity_classes">
|
||||||
|
<entity_class name="necroRatSpirit" extends="necroZombieWolf">
|
||||||
|
<property name="SizeScale" value="0.28"/>
|
||||||
|
<property name="Mass" value="33"/>
|
||||||
|
<property name="Weight" value="11"/>
|
||||||
|
<property name="Tags" value="entity,animal,rat"/>
|
||||||
|
<property name="HandItem" value="necroMeleeHandRatSpirit"/>
|
||||||
|
|
||||||
|
<property name="SoundRandom" value=""/>
|
||||||
|
<property name="SoundAlert" value=""/>
|
||||||
|
<property name="SoundSense" value=""/>
|
||||||
|
<property name="SoundGiveUp" value=""/>
|
||||||
|
<property name="SoundHurt" value="rabbitpain"/>
|
||||||
|
<property name="SoundDeath" value="rabbitdeath"/>
|
||||||
|
<property name="SoundAttack" value="rabbitpain"/>
|
||||||
|
<property name="SoundStepType" value="animallightstep"/>
|
||||||
|
|
||||||
|
<!-- Жёлтая метка на карте и компасе - указание 2026-09-18. Класс метки наш,
|
||||||
|
Config/nav_objects.xml; ванильные animaltracking_* не подошли, они завязаны
|
||||||
|
на перк следопыта, а не на прямое назначение. -->
|
||||||
|
<property name="NavObject" value="necroRatSpiritPet"/>
|
||||||
|
|
||||||
|
<property name="AITask-1" value="ApproachAndAttackTarget" data="class=EntityAlive,0"/>
|
||||||
|
<property name="AITask-2" value="Look"/>
|
||||||
|
<property name="AITask-3" value=""/>
|
||||||
|
<property name="AITarget-1" value="SetAsTargetIfHurt" data="class=EntityZombie,EntityEnemyAnimal"/>
|
||||||
|
<property name="AITarget-2" value=""/>
|
||||||
|
|
||||||
|
<effect_group name="Base Effects">
|
||||||
|
<!-- Дух-разведчик, а не танк: меньше собаки (200). Урон у неё и так 5. -->
|
||||||
|
<passive_effect name="HealthMax" operation="base_set" value="120"/>
|
||||||
|
</effect_group>
|
||||||
|
</entity_class>
|
||||||
|
</append>
|
||||||
|
|
||||||
<!-- "Жертва" loot bag - BACKLOG.md item 5, Necromancer's Knife. Same shape as vanilla's own
|
<!-- "Жертва" loot bag - BACKLOG.md item 5, Necromancer's Knife. Same shape as vanilla's own
|
||||||
EntityLootContainerStrong ("BLUE")/EntityLootContainerBoss ("RED") right above these in
|
EntityLootContainerStrong ("BLUE")/EntityLootContainerBoss ("RED") right above these in
|
||||||
Data/Config/entityclasses.xml - only the Mesh and LootList differ.
|
Data/Config/entityclasses.xml - only the Mesh and LootList differ.
|
||||||
|
|||||||
+210
-7
@@ -131,11 +131,21 @@
|
|||||||
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
|
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
|
||||||
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
|
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
|
||||||
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
|
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
|
||||||
T3 4.6->6 на ОДИН элемент брони, а элементов четыре. Взято 5 - примерно уровень
|
T3 4.6->6 на ОДИН элемент брони, а элементов четыре.
|
||||||
одной детали брони с T3-подкладкой, и ровно то число, которое ваниль использовала во
|
|
||||||
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh (там 5 на холод и 5 на
|
ЗНАЧЕНИЕ 5 -> 50, 2026-09-13, прямое указание пользователя ("по факту она поднимает
|
||||||
жару, но двумя РАЗНЫМИ модами; здесь оба в одном, что щедрее - но это стоит слота из
|
сопротивление всего на 5, а надо на 50"). Изначально стояло 5 - примерно уровень одной
|
||||||
четырёх и работает только с ножом в руках, см. ниже). Крутить это число - одна правка.
|
детали брони с T3-подкладкой, и ровно то число, которым ваниль пользуется во
|
||||||
|
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh. Это было осознанно
|
||||||
|
скромно; пользователь хочет иначе, и его решение тут главнее моей балансной оценки.
|
||||||
|
|
||||||
|
ЧТО 50 ОЗНАЧАЕТ НА САМОМ ДЕЛЕ, раз единица - градусы, а не проценты (формула ниже):
|
||||||
|
любая уличная температура в пределах 50 градусов от комфортных 70 подтягивается К 70
|
||||||
|
ЦЕЛИКОМ, потому что там стоит min/max-ограничение. То есть от 20 до 120 по шкале игры
|
||||||
|
это не "сильная защита", а полный иммунитет: и снежная вершина, и пустынный полдень
|
||||||
|
перестают быть угрозой. Это примерно в 10 раз больше, чем даёт набор брони с
|
||||||
|
T3-подкладками на всех четырёх деталях. Записано не в укор, а чтобы через месяц не
|
||||||
|
пришлось гадать, почему термометр перестал что-либо значить.
|
||||||
|
|
||||||
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
|
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
|
||||||
комфортной, а не проценты (PlayerEntityStats, декомпиляция):
|
комфортной, а не проценты (PlayerEntityStats, декомпиляция):
|
||||||
@@ -173,8 +183,8 @@
|
|||||||
<property name="SellableToTrader" value="false"/>
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
<effect_group tiered="false">
|
<effect_group tiered="false">
|
||||||
<passive_effect name="HypothermalResist" operation="base_add" value="5"/>
|
<passive_effect name="HypothermalResist" operation="base_add" value="50"/>
|
||||||
<passive_effect name="HyperthermalResist" operation="base_add" value="5"/>
|
<passive_effect name="HyperthermalResist" operation="base_add" value="50"/>
|
||||||
</effect_group>
|
</effect_group>
|
||||||
</item_modifier>
|
</item_modifier>
|
||||||
|
|
||||||
@@ -314,4 +324,197 @@
|
|||||||
</item_modifier>
|
</item_modifier>
|
||||||
|
|
||||||
</append>
|
</append>
|
||||||
|
|
||||||
|
<!-- "Кровавая сфера" (Blood Sphere) - расходный заряд Пространственного браслета.
|
||||||
|
Название и рецепт продиктованы 2026-09-15: «Кровавая сфера. Доступна на первом грейде.
|
||||||
|
Станки не нужны. Ингридиенты: Кровь некроманта, 5 праха зомби. По одному рецепту
|
||||||
|
изготавливается две сферы. Прочность сферы 500.»
|
||||||
|
|
||||||
|
ЗАЧЕМ ОНА ВООБЩЕ СУЩЕСТВУЕТ - и почему это НЕ «кровь, вставляемая в браслет». 15.09.2026
|
||||||
|
кровь перенесли в этот файл, чтобы она вставлялась в браслет, и это уничтожило персонажа в
|
||||||
|
сейве вместе с бэкапом: класс предмета определяет байтовую раскладку его стака
|
||||||
|
(ItemValue.Read:1094 / Write:1228 - обычный предмет пишет байт числа модификаций,
|
||||||
|
ItemClassModifier не пишет), и старый сейв стал нечитаемым. Полный разбор - в BACKLOG.md и
|
||||||
|
в большом предупреждении у крови в items.xml.
|
||||||
|
|
||||||
|
Сфера обходит это тем, что она НОВЫЙ предмет: в старых сейвах её нет, значит нет и ни
|
||||||
|
одного стака, который читался бы по другой раскладке. Это общее правило, а не уловка:
|
||||||
|
нужна модификация - заводи новый предмет, никогда не переводи существующий.
|
||||||
|
|
||||||
|
Атрибуты - по образцу модов ножа:
|
||||||
|
installable_tags="necroBracelet" - иначе модификация лезла бы в ЛЮБОЙ предмет
|
||||||
|
(CanSwap короткозамыкается на InstallableTags.IsEmpty);
|
||||||
|
modifier_tags="necroBraceletSphere" - свой, чтобы не конкурировать с будущим Кровавым
|
||||||
|
камнем через MaxModsAllowed;
|
||||||
|
blocked_tags НЕ задан - у браслета в тегах "noMods", объявить его тут значило бы
|
||||||
|
заблокировать самому себе установку;
|
||||||
|
type="attachment" - сферу можно вынуть обратно. -->
|
||||||
|
<append xpath="/item_modifiers">
|
||||||
|
<item_modifier name="resourceBloodSphere" installable_tags="necroBracelet" modifier_tags="necroBraceletSphere" type="attachment">
|
||||||
|
<!-- Extends на modGeneralMaster - та же база, что у шести модов ножа: даёт Group
|
||||||
|
"Mods", звуки mod_grab/mod_place, Stacknumber 1 и CreativeMode None.
|
||||||
|
param1="CustomIcon" исключает наследование родительского missingIcon.
|
||||||
|
|
||||||
|
РАНЬШЕ ЗДЕСЬ СТОЯЛ Extends="resourceRockSmall", И ЭТО БЫЛА ОШИБКА. Камень нужен
|
||||||
|
был только ради вида, а вместе с ним приезжали Action0 Class="ThrowAway",
|
||||||
|
ThrowableDecoy="true" и DistractionTags - то есть сферу можно было бы метать как
|
||||||
|
отвлекающий камень. Меш берётся строкой Meshfile ниже; наследовать ради него
|
||||||
|
весь предмет не нужно. -->
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="DescriptionKey" value="resourceBloodSphereDesc"/>
|
||||||
|
|
||||||
|
<!-- В РУКЕ - КАМЕНЬ С АЛЫМ ТИНТОМ (указание 2026-09-15: «в руке и сфера и кровавый
|
||||||
|
камень пусть будут как камень с алым тинтом»).
|
||||||
|
|
||||||
|
Это уже проверенный в этом моде путь, а не догадка: Камень духов, Синий и Чёрный
|
||||||
|
порталы (items.xml) сидят ровно на этом меше с ровно таким тинтом и в игре
|
||||||
|
работают - зелёный, синий и чёрный камни соответственно. Поэтому взят их набор
|
||||||
|
целиком: HoldType 40 плюс ВСЕ ТРИ меша. Три, а не один, потому что это три разные
|
||||||
|
ситуации - Meshfile общий, HandMeshfile в руке, DropMeshfile лежащим на земле, и
|
||||||
|
у тех трёх предметов они выписаны явно именно поэтому.
|
||||||
|
|
||||||
|
TintColor - ТРИПЛЕТ "R, G, B", а НЕ hex. Это другая ручка, чем CustomIconTint
|
||||||
|
выше (там hex): ItemClass парсит их разными путями - Color32 через запятые против
|
||||||
|
ParseHexColor. Перепутать легко, и на модах ножа это уже стоило круга.
|
||||||
|
|
||||||
|
ПОЧЕМУ ЗДЕСЬ ТИНТ РАБОТАЕТ, А НА БАНКЕ КРОВИ НЕ СРАБОТАЛ. 10.09 тинт предмета на
|
||||||
|
меш чая из золотарника не подействовал вообще: у шейдера Game_EntityTintMaskSSS
|
||||||
|
выигрывает собственный _Color материала. У rock_smallPrefab такого конфликта нет -
|
||||||
|
доказательство лежит в самом моде, три перекрашенных камня в игре видны. -->
|
||||||
|
<property name="HoldType" value="40"/>
|
||||||
|
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
|
<property name="HandMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
|
<property name="DropMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
|
<property name="TintColor" value="220, 30, 45"/>
|
||||||
|
|
||||||
|
<!-- Своя рисованная иконка, получена 2026-09-15 (exch/bloodSphere.png, 160x160 RGBA,
|
||||||
|
как все остальные 28). Заглушка со спрайтом Камня духов и багровым тинтом,
|
||||||
|
стоявшая тут несколько часов, снята.
|
||||||
|
|
||||||
|
CustomIconTint НЕ ЗАДАЁТСЯ, и это тот же принцип, что у всех рисованных иконок
|
||||||
|
мода (шесть модов ножа, Кровь, Жертвенная кожа): тинт существует, чтобы
|
||||||
|
заимствованный ванильный спрайт не читался как предмет, из которого он взят. На
|
||||||
|
готовой работе он бы просто её затемнил. -->
|
||||||
|
<property name="CustomIcon" value="BloodSphere"/>
|
||||||
|
|
||||||
|
<!-- Прочность 500 (указание). Две ручки, обе обязательны - тот же разбор, что у крови
|
||||||
|
в items.xml: ShowQuality рисует полоску, а само число идёт пассивкой
|
||||||
|
DegradationMax. Без пассивки прочность равна нулю, а полоска при MaxUseTimes == 0
|
||||||
|
рисуется ПОЛНОЙ, то есть забытый эффект выглядит как "всё работает".
|
||||||
|
|
||||||
|
tiered="false" обязателен: HasQuality читается как Effects.IsOwnerTiered(), и
|
||||||
|
тированная группа превратила бы сферу в предмет с качеством - тиры, рамка.
|
||||||
|
|
||||||
|
Гейта по тегу здесь, в отличие от крови, НЕ НУЖНО. У модификации effect_group
|
||||||
|
применяется к предмету-хозяину, и у крови пассивку приходилось гейтить, чтобы
|
||||||
|
1000 прочности не досталась браслету. Сфера же и есть расходник браслета: пусть
|
||||||
|
он её и тратит. Браслету от DegradationMax ничего не будет - ему эту прочность
|
||||||
|
никто не списывает (SpendCharge трогает только сферу), а полоски у него нет. -->
|
||||||
|
<property name="ShowQuality" value="true"/>
|
||||||
|
<!-- true: опустевшая сфера должна исчезать, а не лежать "сломанной" в ожидании
|
||||||
|
ремонта. Само удаление из слота делает SpendCharge - см. SpatialVaultPickupPatch. -->
|
||||||
|
<property name="DegradationBreaksAfter" value="true"/>
|
||||||
|
<effect_group name="resourceBloodSphere" tiered="false">
|
||||||
|
<passive_effect name="DegradationMax" operation="base_set" value="500"/>
|
||||||
|
</effect_group>
|
||||||
|
|
||||||
|
<!-- Stacknumber 1 приходит из modGeneralMaster, своя строка не нужна. -->
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
</item_modifier>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- КРОВАВЫЙ КАМЕНЬ (Blood Stone), продиктовано 2026-09-15, сделано 2026-09-16: «По сути это
|
||||||
|
будет тот же флакон, но с бесконечной прочностью и иконкой камня с алым тинтом.»
|
||||||
|
Эндгейм-версия расходника браслета: то же, что Кровавая сфера, только не тратится.
|
||||||
|
|
||||||
|
ПОЧЕМУ ЭТО БЫЛО СРОЧНО, А НЕ «ФИЧА ИЗ СПИСКА». Сообщение о пустом слоте
|
||||||
|
(braceletSpatialVaultNoMod) и описание браслета с 15.09 обещают игроку «кровавую сферу
|
||||||
|
ИЛИ кровавый камень» на всех 13 языках. Пока камня не было, отгруженный текст отправлял
|
||||||
|
за предметом, которого в игре нет. Так что эта правка - ещё и починка текста.
|
||||||
|
|
||||||
|
ПОЧЕМУ ЭТО БЕЗОПАСНО ДЛЯ СЕЙВОВ, и это здесь главный вопрос, а не побочный.
|
||||||
|
15.09 перенос Крови некроманта из items.xml сюда уничтожил персонажа в сейве: ItemValue
|
||||||
|
гейтит блок модификаций одним условием на чтении (Read, ~1094) и на записи (Write, ~1228)
|
||||||
|
- "!(itemClass is ItemClassModifier)", то есть КЛАСС ПРЕДМЕТА ОПРЕДЕЛЯЕТ БАЙТОВУЮ
|
||||||
|
РАСКЛАДКУ КАЖДОГО ЕГО СТАКА В СЕЙВЕ. Обычный предмет пишет байт "сколько модификаций",
|
||||||
|
ItemClassModifier не пишет ничего; после переезда читатель этот байт пропустил, поток
|
||||||
|
съехал, ближайший ReadString() упал, и .ttp с .ttp.bak умерли оба.
|
||||||
|
resourceBloodStone - НОВОЕ имя. В сейвах, записанных до этой правки, нет ни одного его
|
||||||
|
стака, значит нет и ни одной записи, которую пришлось бы читать по другой раскладке.
|
||||||
|
Именно поэтому камень заведён новым предметом, а не переводом уже существующего - тем же
|
||||||
|
решением, что и Кровавая сфера. Правило целиком: раздел про аварию в BACKLOG.md.
|
||||||
|
|
||||||
|
Атрибуты - по образцу сферы:
|
||||||
|
- installable_tags="necroBracelet" - обязательно. У CanSwap есть короткое замыкание
|
||||||
|
"InstallableTags.IsEmpty || ...", то есть модификация БЕЗ этого атрибута лезет в любой
|
||||||
|
предмет игры.
|
||||||
|
- modifier_tags СВОЙ (necroBraceletStone, не ...Sphere). Мод с совпадающим modifier_tags
|
||||||
|
считается против ItemClass.MaxModsAllowed - разбор в шапке этого файла. Слот у
|
||||||
|
браслета сейчас один, так что практической разницы нет, но правило "свой тег на
|
||||||
|
каждую модификацию" в моде записано, и ломать его незачем.
|
||||||
|
- blocked_tags НЕ задаётся: у браслета в тегах noMods, отрицательную половину он
|
||||||
|
обеспечивает сам.
|
||||||
|
-->
|
||||||
|
<append xpath="/item_modifiers">
|
||||||
|
<item_modifier name="resourceBloodStone" installable_tags="necroBracelet" modifier_tags="necroBraceletStone" type="attachment">
|
||||||
|
<!-- Та же база, что у сферы и у шести модов ножа: Group "Mods", звуки
|
||||||
|
mod_grab/mod_place, Stacknumber 1, CreativeMode None. param1="CustomIcon"
|
||||||
|
исключает наследование родительского missingIcon.
|
||||||
|
НЕ наследовать resourceRockSmall ради вида камня: вместе с видом приедут
|
||||||
|
Action0 Class="ThrowAway", ThrowableDecoy="true" и DistractionTags, то есть
|
||||||
|
камень можно будет метать как отвлекающий. Меш берётся строками ниже. На сфере
|
||||||
|
эта ошибка уже была и была исправлена 15.09. -->
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="DescriptionKey" value="resourceBloodStoneDesc"/>
|
||||||
|
|
||||||
|
<!-- В РУКЕ - КАМЕНЬ С АЛЫМ ТИНТОМ (указание 2026-09-15: «в руке и сфера и кровавый
|
||||||
|
камень пусть будут как камень с алым тинтом»). Набор скопирован со сферы, а та -
|
||||||
|
с трёх камней мода (Камень духов, Синий и Чёрный порталы), которые на этом же
|
||||||
|
меше с этим же тинтом в игре видны зелёным, синим и чёрным.
|
||||||
|
|
||||||
|
Три меша, а не один, потому что это три разные ситуации: Meshfile общий,
|
||||||
|
HandMeshfile в руке, DropMeshfile лежащим на земле.
|
||||||
|
|
||||||
|
TintColor - ТРИПЛЕТ "R, G, B", а не hex; hex берёт CustomIconTint, и это другая
|
||||||
|
ручка (Color32 через запятые против ParseHexColor). Перепутать легко, на модах
|
||||||
|
ножа это уже стоило круга. -->
|
||||||
|
<property name="HoldType" value="40"/>
|
||||||
|
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
|
<property name="HandMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
|
<property name="DropMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
|
<property name="TintColor" value="220, 30, 45"/>
|
||||||
|
|
||||||
|
<!-- Своя рисованная иконка, лежит с 15.09 в обеих папках атласа
|
||||||
|
(UIAtlases/ItemIconAtlas/BloodStone.png + ItemIconAtlasGreyscale/BloodStone.png).
|
||||||
|
Серая копия обязательна: заблокированная запись в панели скилла рисуется из
|
||||||
|
greyscale-атласа, без неё у записи не было бы картинки вообще.
|
||||||
|
CustomIconTint НЕ ЗАДАЁТСЯ - тинт нужен заимствованным ванильным спрайтам, на
|
||||||
|
готовой работе он бы её просто затемнил. Тот же принцип, что у сферы. -->
|
||||||
|
<property name="CustomIcon" value="BloodStone"/>
|
||||||
|
|
||||||
|
<!-- БЕСКОНЕЧНАЯ ПРОЧНОСТЬ СДЕЛАНА ОТСУТСТВИЕМ СТРОК, А НЕ БОЛЬШИМ ЧИСЛОМ.
|
||||||
|
Здесь СОЗНАТЕЛЬНО нет ни ShowQuality, ни DegradationBreaksAfter, ни effect_group
|
||||||
|
с DegradationMax - сравнить со сферой выше, у которой все три есть.
|
||||||
|
|
||||||
|
Почему этого достаточно: SpendCharge (HarmonySrc/SpatialVaultPickupPatch.cs)
|
||||||
|
списывает прочность ТОЛЬКО у модификации, чьё имя совпадает с ChargeItemName
|
||||||
|
("resourceBloodSphere"). Камень под это условие не попадает по определению -
|
||||||
|
цикл делает continue, UseTimes камня не трогает никто и никогда. Кода менять не
|
||||||
|
пришлось вообще; комментарий в SpendCharge это заранее и обещает («a mod that is
|
||||||
|
not the charge pays nothing, is not emptied, and the pull still happens»).
|
||||||
|
|
||||||
|
Почему НЕ выставлять огромный DegradationMax: полоска прочности, которая никогда
|
||||||
|
не двигается, хуже её отсутствия. Плюс ловушка, уже разобранная на флаконе крови:
|
||||||
|
при MaxUseTimes == 0 полоска рисуется ПОЛНОЙ, то есть забытая пассивка выглядит
|
||||||
|
как "всё работает".
|
||||||
|
|
||||||
|
Тег necroBloodFlask здесь тоже не нужен, но уже по другой причине, чем было
|
||||||
|
записано в спеке: после полного отката Крови некроманта (15.09) этого тега в моде
|
||||||
|
не осталось совсем - он существовал только чтобы гейтить пассивку прочности
|
||||||
|
флакона. Пункт снят сам собой. -->
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
</item_modifier>
|
||||||
|
</append>
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
+573
-41
@@ -214,7 +214,31 @@
|
|||||||
<triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm">
|
<triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm">
|
||||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
</triggered_effect>
|
</triggered_effect>
|
||||||
|
<!-- Generic stone-on-flesh thud, deliberately left UNCONDITIONAL: it fires on every
|
||||||
|
impact (ground, wall, zombie) so a miss still sounds like something landed. -->
|
||||||
<triggered_effect trigger="onProjectileImpact" action="PlaySound" sound="stonehitorganic"/>
|
<triggered_effect trigger="onProjectileImpact" action="PlaySound" sound="stonehitorganic"/>
|
||||||
|
<!-- The charm's OWN cue - fires only on a hit that actually charms, gated by the exact
|
||||||
|
same "other is a zombie" requirement the AddBuff above uses, so it can never fire on
|
||||||
|
a miss. Requirements on a PlaySound effect are the vanilla stun baton pattern
|
||||||
|
(Data/Config/items.xml:4005 - IsAlive/EntityTagCompare on target="other"), not
|
||||||
|
invented here; onProjectileImpact populating "other" with the hit entity is proven
|
||||||
|
by the AddBuff right above, which already works in game.
|
||||||
|
|
||||||
|
target="other" is meant to play the clip FROM the zombie, i.e. positional at the
|
||||||
|
point of impact instead of at the thrower's head (the attribute itself is vanilla -
|
||||||
|
items.xml:5514 uses target="self"). If it turns out silent in game, drop just the
|
||||||
|
attribute: the effect then plays on self and the gating still holds.
|
||||||
|
|
||||||
|
THE MOD'S OWN SOUND, the first one in the whole mod that is not a borrowed vanilla
|
||||||
|
id: "necroSpiritStoneHit" is defined in this mod's Config/sounds.xml and its clip
|
||||||
|
lives in Resources/necrosounds, built from the Unity project (see that file's header
|
||||||
|
for why a plain wav next to the XML cannot work). If the sound is missing in game,
|
||||||
|
the failure is SILENT - look for "AudioManager LoadAudio failed to load audio clip"
|
||||||
|
in the game log, and note that the generic stonehitorganic above will still play, so
|
||||||
|
"I heard something" is not proof this one fired. -->
|
||||||
|
<triggered_effect trigger="onProjectileImpact" action="PlaySound" target="other" sound="necroSpiritStoneHit">
|
||||||
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
|
</triggered_effect>
|
||||||
</effect_group>
|
</effect_group>
|
||||||
</item>
|
</item>
|
||||||
</append>
|
</append>
|
||||||
@@ -494,7 +518,10 @@
|
|||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="bookSummonZombieDog">
|
<item name="bookSummonZombieDog">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||||
<property name="ItemTypeIcon" value="book"/>
|
<!-- ItemTypeIcon="book" УБРАН 2026-09-18 со ВСЕХ свитков и книг призыва разом,
|
||||||
|
прямое указание: "свитки призыва и книги призыва не должны содержать белую
|
||||||
|
иконку книги поверх. Они ПРЕДМЕТЫ, а не рецепты." Полный разбор виджета - у
|
||||||
|
bookSummonRatSpirit ниже и у Пространственного браслета (07.09). -->
|
||||||
<!-- ICON REPLACED 2026-08-29: swapped the user's earlier hand-drawn
|
<!-- ICON REPLACED 2026-08-29: swapped the user's earlier hand-drawn
|
||||||
schematicDogSummon.png for the new AI-generated SummonZombieDog.png, to match
|
schematicDogSummon.png for the new AI-generated SummonZombieDog.png, to match
|
||||||
the rest of the item set's unified style (same prompt/generator as the other 12
|
the rest of the item set's unified style (same prompt/generator as the other 12
|
||||||
@@ -522,7 +549,22 @@
|
|||||||
<property class="Action0">
|
<property class="Action0">
|
||||||
<property name="Class" value="SpawnEntity"/>
|
<property name="Class" value="SpawnEntity"/>
|
||||||
<property name="AnimType" value="4"/>
|
<property name="AnimType" value="4"/>
|
||||||
<property name="AnimWait" value="0.3"/>
|
<!-- 0.05, а не 0.3 - выровнено со Свитком духа крысы 2026-09-18, после того как
|
||||||
|
в игре не призвались НИ ГРИФ, НИ ВОЛК, НИ МЕДВЕДЬ, ни пёс, а крыса
|
||||||
|
призвалась. В логе у всех четверых было только "action index=1" (отзыв) и ни
|
||||||
|
одного index=0: до Spawn основной клик не доходил вовсе.
|
||||||
|
|
||||||
|
ItemActionSpawnEntity.OnHoldingUpdate копит stateTime по 0.05 за тик и
|
||||||
|
сравнивает с animWait, а ExecuteAction(_bReleased: true) при отпускании
|
||||||
|
кнопки сбрасывает состояние в None. То есть 0.3 означало "держать кнопку
|
||||||
|
треть секунды", шесть тиков подряд, и обычный клик до спавна не доживал -
|
||||||
|
молча, без ошибки и без строки в логе.
|
||||||
|
|
||||||
|
Раньше это сходило с рук: призыв делают раз за сессию и кнопку держали. Но
|
||||||
|
теперь на этом же слоте сидит КОМАНДА АТАКОВАТЬ, которую отдают быстро и
|
||||||
|
часто, - и требовать под неё удержание нельзя. У крысы это было исправлено
|
||||||
|
сразу, у остальных - забыто; теперь у всех одинаково. -->
|
||||||
|
<property name="AnimWait" value="0.05"/>
|
||||||
<property name="SoundWarn" value="zombiedogalert"/>
|
<property name="SoundWarn" value="zombiedogalert"/>
|
||||||
<property name="SoundAttack" value="zombiedogattack"/>
|
<property name="SoundAttack" value="zombiedogattack"/>
|
||||||
<property name="Entity" value="necroZombieDog"/>
|
<property name="Entity" value="necroZombieDog"/>
|
||||||
@@ -563,15 +605,99 @@
|
|||||||
since it doesn't mean much against a zombie, kept only the slow that was asked
|
since it doesn't mean much against a zombie, kept only the slow that was asked
|
||||||
for. Gated to zombies only, same EntityTagCompare pattern as everywhere else in
|
for. Gated to zombies only, same EntityTagCompare pattern as everywhere else in
|
||||||
this mod that adds a buff on hit. -->
|
this mod that adds a buff on hit. -->
|
||||||
|
<!-- УРОН ПОДНЯТ С ВАНИЛЬНЫХ 8 ДО 35, 2026-09-18. Указание: зомбоживотные - это "по
|
||||||
|
сути те же крысы, но размер модели оригинальный, урон нарастает от зомбособаки до
|
||||||
|
зомбоволка... они уже реально убивают зомби эффективно", и следом уточнение
|
||||||
|
"зомбособаке урон 35 и шанс расчленения, как и волку, как и медведю".
|
||||||
|
|
||||||
|
Лестница целиком: Дух крысы 5 -> Зомбогриф 20 -> Зомбопёс 35 ->
|
||||||
|
Зомбомедведь 45 -> Зомбоволк 60. Верхняя ступень - ванильный урон лютоволка,
|
||||||
|
ниже неё всё выстроено вручную; ванильные значения были 8 / 22 / 60 / 60, то
|
||||||
|
есть лестницы не было вовсе (медведь и волк совпадали).
|
||||||
|
|
||||||
|
ГРИФ НАМЕРЕННО САМЫЙ СЛАБЫЙ ИЗ ЗВЕРЕЙ, и это не просчёт: "для атаки он не особо
|
||||||
|
подходит", роль у него будет другая - см. BACKLOG.md 18.09, замысел сделать из
|
||||||
|
него аналог разведдрона (запустить в небо и смотреть его глазами).
|
||||||
|
|
||||||
|
РАСЧЛЕНЕНИЕ - у Пса, Медведя и Волка. Ни у Грифа, ни у Духа крысы его нет.
|
||||||
|
Величина 0.25 одна на всех троих: конкретной цифры названо не было, а разводить
|
||||||
|
её по видам незачем - они уже разведены уроном. -->
|
||||||
|
<property class="Action0">
|
||||||
|
<property name="DamageEntity" value="35"/>
|
||||||
|
</property>
|
||||||
<effect_group name="necroMeleeHandZombieDog" tiered="false">
|
<effect_group name="necroMeleeHandZombieDog" tiered="false">
|
||||||
<passive_effect name="ModSlots" operation="base_set" value="0"/>
|
<passive_effect name="ModSlots" operation="base_set" value="0"/>
|
||||||
|
<passive_effect name="DismemberChance" operation="base_add" value="0.25"/>
|
||||||
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffInjurySlow">
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffInjurySlow">
|
||||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
</triggered_effect>
|
</triggered_effect>
|
||||||
|
<!-- Кровотечение добавлено 2026-09-18 всем призванным зомбоживотным. Метку духа
|
||||||
|
и ослабление (buffNecroRatMark) они НЕ ставят по прямому указанию - это
|
||||||
|
остаётся особенностью крысы, у которой своего урона почти нет. Замедление
|
||||||
|
buffInjurySlow выше - НЕ то же самое, что "ослабление": оно стоит здесь с
|
||||||
|
28.08 по отдельной просьбе ("укус применяет buffInjurySlow") и не трогалось.
|
||||||
|
Скажите, если его тоже убрать. -->
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroRatBleed"/>
|
||||||
</effect_group>
|
</effect_group>
|
||||||
</item>
|
</item>
|
||||||
</append>
|
</append>
|
||||||
|
|
||||||
|
<!-- Оружие ближнего боя трёх остальных зомбоживотных, 2026-09-18. До сих пор они брали
|
||||||
|
ванильные предметы напрямую (meleeHandAnimalMountainLion / meleeHandAnimalZombieBear /
|
||||||
|
meleeHandAnimalDireWolf), и своих эффектов у них не было вовсе. Теперь нужны свои: и
|
||||||
|
чтобы выстроить лестницу урона, и чтобы повесить кровотечение.
|
||||||
|
|
||||||
|
effect_group НЕ наследуется через Extends (см. заметку в шапке файла), поэтому у каждого
|
||||||
|
он свой, а не добавленный к родительскому. Всё остальное - задержка, радиус, звуки удара -
|
||||||
|
наследуется от ванильного предмета как есть. -->
|
||||||
|
<append xpath="/items">
|
||||||
|
<item name="necroMeleeHandZombieGriffin">
|
||||||
|
<property name="Extends" value="meleeHandAnimalZombieVulture"/>
|
||||||
|
<property name="CreativeMode" value="None"/>
|
||||||
|
<!-- 20, а не 35: понижено 18.09 по прямому указанию. Гриф - будущий разведчик, а не
|
||||||
|
боец (см. комментарий к лестнице выше). Расчленения у него тоже нет. -->
|
||||||
|
<property class="Action0">
|
||||||
|
<property name="DamageEntity" value="20"/>
|
||||||
|
</property>
|
||||||
|
<effect_group name="necroMeleeHandZombieGriffin" tiered="false">
|
||||||
|
<passive_effect name="ModSlots" operation="base_set" value="0"/>
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroRatBleed"/>
|
||||||
|
</effect_group>
|
||||||
|
</item>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<append xpath="/items">
|
||||||
|
<item name="necroMeleeHandZombieBear">
|
||||||
|
<property name="Extends" value="meleeHandAnimalZombieBear"/>
|
||||||
|
<property name="CreativeMode" value="None"/>
|
||||||
|
<property class="Action0">
|
||||||
|
<property name="DamageEntity" value="45"/>
|
||||||
|
</property>
|
||||||
|
<effect_group name="necroMeleeHandZombieBear" tiered="false">
|
||||||
|
<passive_effect name="ModSlots" operation="base_set" value="0"/>
|
||||||
|
<passive_effect name="DismemberChance" operation="base_add" value="0.25"/>
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroRatBleed"/>
|
||||||
|
</effect_group>
|
||||||
|
</item>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<append xpath="/items">
|
||||||
|
<item name="necroMeleeHandZombieWolf">
|
||||||
|
<property name="Extends" value="meleeHandAnimalDireWolf"/>
|
||||||
|
<property name="CreativeMode" value="None"/>
|
||||||
|
<!-- 60 - ванильный урон лютоволка, верхняя ступень лестницы. Оставлен как есть. -->
|
||||||
|
<property class="Action0">
|
||||||
|
<property name="DamageEntity" value="60"/>
|
||||||
|
</property>
|
||||||
|
<effect_group name="necroMeleeHandZombieWolf" tiered="false">
|
||||||
|
<passive_effect name="ModSlots" operation="base_set" value="0"/>
|
||||||
|
<passive_effect name="DismemberChance" operation="base_add" value="0.25"/>
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroRatBleed"/>
|
||||||
|
</effect_group>
|
||||||
|
</item>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
|
||||||
<!-- "Жуки Властелина" (Insect Swarm summon): user request 2026-08-28, added alongside the
|
<!-- "Жуки Властелина" (Insect Swarm summon): user request 2026-08-28, added alongside the
|
||||||
Zombie Dog bugfix above since it reuses the exact same summon plumbing (SpawnEntity,
|
Zombie Dog bugfix above since it reuses the exact same summon plumbing (SpawnEntity,
|
||||||
HarmonySrc/SummonPatch.cs ownership/limit).
|
HarmonySrc/SummonPatch.cs ownership/limit).
|
||||||
@@ -632,7 +758,10 @@
|
|||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="bookSummonInsectSwarm">
|
<item name="bookSummonInsectSwarm">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||||
<property name="ItemTypeIcon" value="book"/>
|
<!-- ItemTypeIcon="book" УБРАН 2026-09-18 со ВСЕХ свитков и книг призыва разом,
|
||||||
|
прямое указание: "свитки призыва и книги призыва не должны содержать белую
|
||||||
|
иконку книги поверх. Они ПРЕДМЕТЫ, а не рецепты." Полный разбор виджета - у
|
||||||
|
bookSummonRatSpirit ниже и у Пространственного браслета (07.09). -->
|
||||||
<!-- ICON REPLACED 2026-08-29: same reasoning as bookSummonZombieDog above - swapped
|
<!-- ICON REPLACED 2026-08-29: same reasoning as bookSummonZombieDog above - swapped
|
||||||
the earlier hand-drawn schematicInsectoSummon.png for BeetlesOfTheLord.png. -->
|
the earlier hand-drawn schematicInsectoSummon.png for BeetlesOfTheLord.png. -->
|
||||||
<property name="CustomIcon" value="BeetlesOfTheLord"/>
|
<property name="CustomIcon" value="BeetlesOfTheLord"/>
|
||||||
@@ -657,7 +786,22 @@
|
|||||||
<property class="Action0">
|
<property class="Action0">
|
||||||
<property name="Class" value="SpawnEntity"/>
|
<property name="Class" value="SpawnEntity"/>
|
||||||
<property name="AnimType" value="4"/>
|
<property name="AnimType" value="4"/>
|
||||||
<property name="AnimWait" value="0.3"/>
|
<!-- 0.05, а не 0.3 - выровнено со Свитком духа крысы 2026-09-18, после того как
|
||||||
|
в игре не призвались НИ ГРИФ, НИ ВОЛК, НИ МЕДВЕДЬ, ни пёс, а крыса
|
||||||
|
призвалась. В логе у всех четверых было только "action index=1" (отзыв) и ни
|
||||||
|
одного index=0: до Spawn основной клик не доходил вовсе.
|
||||||
|
|
||||||
|
ItemActionSpawnEntity.OnHoldingUpdate копит stateTime по 0.05 за тик и
|
||||||
|
сравнивает с animWait, а ExecuteAction(_bReleased: true) при отпускании
|
||||||
|
кнопки сбрасывает состояние в None. То есть 0.3 означало "держать кнопку
|
||||||
|
треть секунды", шесть тиков подряд, и обычный клик до спавна не доживал -
|
||||||
|
молча, без ошибки и без строки в логе.
|
||||||
|
|
||||||
|
Раньше это сходило с рук: призыв делают раз за сессию и кнопку держали. Но
|
||||||
|
теперь на этом же слоте сидит КОМАНДА АТАКОВАТЬ, которую отдают быстро и
|
||||||
|
часто, - и требовать под неё удержание нельзя. У крысы это было исправлено
|
||||||
|
сразу, у остальных - забыто; теперь у всех одинаково. -->
|
||||||
|
<property name="AnimWait" value="0.05"/>
|
||||||
<property name="SoundWarn" value="swarmalert"/>
|
<property name="SoundWarn" value="swarmalert"/>
|
||||||
<property name="SoundAttack" value="swarmattack"/>
|
<property name="SoundAttack" value="swarmattack"/>
|
||||||
<property name="Entity" value="necroInsectSwarm"/>
|
<property name="Entity" value="necroInsectSwarm"/>
|
||||||
@@ -679,7 +823,10 @@
|
|||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="bookSummonZombieGriffin">
|
<item name="bookSummonZombieGriffin">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||||
<property name="ItemTypeIcon" value="book"/>
|
<!-- ItemTypeIcon="book" УБРАН 2026-09-18 со ВСЕХ свитков и книг призыва разом,
|
||||||
|
прямое указание: "свитки призыва и книги призыва не должны содержать белую
|
||||||
|
иконку книги поверх. Они ПРЕДМЕТЫ, а не рецепты." Полный разбор виджета - у
|
||||||
|
bookSummonRatSpirit ниже и у Пространственного браслета (07.09). -->
|
||||||
<!-- ICON ADDED 2026-08-29: real generated art (SummonZombieGriffin.png). -->
|
<!-- ICON ADDED 2026-08-29: real generated art (SummonZombieGriffin.png). -->
|
||||||
<property name="CustomIcon" value="SummonZombieGriffin"/>
|
<property name="CustomIcon" value="SummonZombieGriffin"/>
|
||||||
<property name="DescriptionKey" value="bookSummonZombieGriffinDesc"/>
|
<property name="DescriptionKey" value="bookSummonZombieGriffinDesc"/>
|
||||||
@@ -697,9 +844,28 @@
|
|||||||
<property class="Action0">
|
<property class="Action0">
|
||||||
<property name="Class" value="SpawnEntity"/>
|
<property name="Class" value="SpawnEntity"/>
|
||||||
<property name="AnimType" value="4"/>
|
<property name="AnimType" value="4"/>
|
||||||
<property name="AnimWait" value="0.3"/>
|
<!-- 0.05, а не 0.3 - выровнено со Свитком духа крысы 2026-09-18, после того как
|
||||||
<property name="SoundWarn" value="mlionalert"/>
|
в игре не призвались НИ ГРИФ, НИ ВОЛК, НИ МЕДВЕДЬ, ни пёс, а крыса
|
||||||
<property name="SoundAttack" value="mlionattack"/>
|
призвалась. В логе у всех четверых было только "action index=1" (отзыв) и ни
|
||||||
|
одного index=0: до Spawn основной клик не доходил вовсе.
|
||||||
|
|
||||||
|
ItemActionSpawnEntity.OnHoldingUpdate копит stateTime по 0.05 за тик и
|
||||||
|
сравнивает с animWait, а ExecuteAction(_bReleased: true) при отпускании
|
||||||
|
кнопки сбрасывает состояние в None. То есть 0.3 означало "держать кнопку
|
||||||
|
треть секунды", шесть тиков подряд, и обычный клик до спавна не доживал -
|
||||||
|
молча, без ошибки и без строки в логе.
|
||||||
|
|
||||||
|
Раньше это сходило с рук: призыв делают раз за сессию и кнопку держали. Но
|
||||||
|
теперь на этом же слоте сидит КОМАНДА АТАКОВАТЬ, которую отдают быстро и
|
||||||
|
часто, - и требовать под неё удержание нельзя. У крысы это было исправлено
|
||||||
|
сразу, у остальных - забыто; теперь у всех одинаково. -->
|
||||||
|
<property name="AnimWait" value="0.05"/>
|
||||||
|
<!-- Звуки призыва сменены 2026-09-18: "при появлении слышен крик льва, а не
|
||||||
|
грифа". Остались с той поры, когда Гриф был горным львом; модель откатили на
|
||||||
|
стервятника, а свиток забыли - те же грабли, что "зомбомедведь лает", только
|
||||||
|
теперь на предмете, а не на сущности. -->
|
||||||
|
<property name="SoundWarn" value="vulturealert"/>
|
||||||
|
<property name="SoundAttack" value="vultureattack"/>
|
||||||
<property name="Entity" value="necroZombieGriffin"/>
|
<property name="Entity" value="necroZombieGriffin"/>
|
||||||
<property name="EntityOffset" value="0, -1, 2.5"/>
|
<property name="EntityOffset" value="0, -1, 2.5"/>
|
||||||
</property>
|
</property>
|
||||||
@@ -717,7 +883,10 @@
|
|||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="bookSummonZombieBear">
|
<item name="bookSummonZombieBear">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||||
<property name="ItemTypeIcon" value="book"/>
|
<!-- ItemTypeIcon="book" УБРАН 2026-09-18 со ВСЕХ свитков и книг призыва разом,
|
||||||
|
прямое указание: "свитки призыва и книги призыва не должны содержать белую
|
||||||
|
иконку книги поверх. Они ПРЕДМЕТЫ, а не рецепты." Полный разбор виджета - у
|
||||||
|
bookSummonRatSpirit ниже и у Пространственного браслета (07.09). -->
|
||||||
<!-- ICON ADDED 2026-08-29: real generated art (SummonZombieBear.png). -->
|
<!-- ICON ADDED 2026-08-29: real generated art (SummonZombieBear.png). -->
|
||||||
<property name="CustomIcon" value="SummonZombieBear"/>
|
<property name="CustomIcon" value="SummonZombieBear"/>
|
||||||
<property name="DescriptionKey" value="bookSummonZombieBearDesc"/>
|
<property name="DescriptionKey" value="bookSummonZombieBearDesc"/>
|
||||||
@@ -735,7 +904,22 @@
|
|||||||
<property class="Action0">
|
<property class="Action0">
|
||||||
<property name="Class" value="SpawnEntity"/>
|
<property name="Class" value="SpawnEntity"/>
|
||||||
<property name="AnimType" value="4"/>
|
<property name="AnimType" value="4"/>
|
||||||
<property name="AnimWait" value="0.3"/>
|
<!-- 0.05, а не 0.3 - выровнено со Свитком духа крысы 2026-09-18, после того как
|
||||||
|
в игре не призвались НИ ГРИФ, НИ ВОЛК, НИ МЕДВЕДЬ, ни пёс, а крыса
|
||||||
|
призвалась. В логе у всех четверых было только "action index=1" (отзыв) и ни
|
||||||
|
одного index=0: до Spawn основной клик не доходил вовсе.
|
||||||
|
|
||||||
|
ItemActionSpawnEntity.OnHoldingUpdate копит stateTime по 0.05 за тик и
|
||||||
|
сравнивает с animWait, а ExecuteAction(_bReleased: true) при отпускании
|
||||||
|
кнопки сбрасывает состояние в None. То есть 0.3 означало "держать кнопку
|
||||||
|
треть секунды", шесть тиков подряд, и обычный клик до спавна не доживал -
|
||||||
|
молча, без ошибки и без строки в логе.
|
||||||
|
|
||||||
|
Раньше это сходило с рук: призыв делают раз за сессию и кнопку держали. Но
|
||||||
|
теперь на этом же слоте сидит КОМАНДА АТАКОВАТЬ, которую отдают быстро и
|
||||||
|
часто, - и требовать под неё удержание нельзя. У крысы это было исправлено
|
||||||
|
сразу, у остальных - забыто; теперь у всех одинаково. -->
|
||||||
|
<property name="AnimWait" value="0.05"/>
|
||||||
<property name="SoundWarn" value="bearalert"/>
|
<property name="SoundWarn" value="bearalert"/>
|
||||||
<property name="SoundAttack" value="bearattack"/>
|
<property name="SoundAttack" value="bearattack"/>
|
||||||
<property name="Entity" value="necroZombieBear"/>
|
<property name="Entity" value="necroZombieBear"/>
|
||||||
@@ -755,7 +939,10 @@
|
|||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="bookSummonZombieWolf">
|
<item name="bookSummonZombieWolf">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||||
<property name="ItemTypeIcon" value="book"/>
|
<!-- ItemTypeIcon="book" УБРАН 2026-09-18 со ВСЕХ свитков и книг призыва разом,
|
||||||
|
прямое указание: "свитки призыва и книги призыва не должны содержать белую
|
||||||
|
иконку книги поверх. Они ПРЕДМЕТЫ, а не рецепты." Полный разбор виджета - у
|
||||||
|
bookSummonRatSpirit ниже и у Пространственного браслета (07.09). -->
|
||||||
<!-- ICON ADDED 2026-08-29: real generated art (SummonZombieWolf.png). -->
|
<!-- ICON ADDED 2026-08-29: real generated art (SummonZombieWolf.png). -->
|
||||||
<property name="CustomIcon" value="SummonZombieWolf"/>
|
<property name="CustomIcon" value="SummonZombieWolf"/>
|
||||||
<property name="DescriptionKey" value="bookSummonZombieWolfDesc"/>
|
<property name="DescriptionKey" value="bookSummonZombieWolfDesc"/>
|
||||||
@@ -773,7 +960,22 @@
|
|||||||
<property class="Action0">
|
<property class="Action0">
|
||||||
<property name="Class" value="SpawnEntity"/>
|
<property name="Class" value="SpawnEntity"/>
|
||||||
<property name="AnimType" value="4"/>
|
<property name="AnimType" value="4"/>
|
||||||
<property name="AnimWait" value="0.3"/>
|
<!-- 0.05, а не 0.3 - выровнено со Свитком духа крысы 2026-09-18, после того как
|
||||||
|
в игре не призвались НИ ГРИФ, НИ ВОЛК, НИ МЕДВЕДЬ, ни пёс, а крыса
|
||||||
|
призвалась. В логе у всех четверых было только "action index=1" (отзыв) и ни
|
||||||
|
одного index=0: до Spawn основной клик не доходил вовсе.
|
||||||
|
|
||||||
|
ItemActionSpawnEntity.OnHoldingUpdate копит stateTime по 0.05 за тик и
|
||||||
|
сравнивает с animWait, а ExecuteAction(_bReleased: true) при отпускании
|
||||||
|
кнопки сбрасывает состояние в None. То есть 0.3 означало "держать кнопку
|
||||||
|
треть секунды", шесть тиков подряд, и обычный клик до спавна не доживал -
|
||||||
|
молча, без ошибки и без строки в логе.
|
||||||
|
|
||||||
|
Раньше это сходило с рук: призыв делают раз за сессию и кнопку держали. Но
|
||||||
|
теперь на этом же слоте сидит КОМАНДА АТАКОВАТЬ, которую отдают быстро и
|
||||||
|
часто, - и требовать под неё удержание нельзя. У крысы это было исправлено
|
||||||
|
сразу, у остальных - забыто; теперь у всех одинаково. -->
|
||||||
|
<property name="AnimWait" value="0.05"/>
|
||||||
<property name="SoundWarn" value="wolfdirealert"/>
|
<property name="SoundWarn" value="wolfdirealert"/>
|
||||||
<property name="SoundAttack" value="wolfdireattack"/>
|
<property name="SoundAttack" value="wolfdireattack"/>
|
||||||
<property name="Entity" value="necroZombieWolf"/>
|
<property name="Entity" value="necroZombieWolf"/>
|
||||||
@@ -790,6 +992,121 @@
|
|||||||
</item>
|
</item>
|
||||||
</append>
|
</append>
|
||||||
|
|
||||||
|
<!-- "Дух крысы" (Rat Spirit): питомец начального уровня, указание 2026-09-18.
|
||||||
|
|
||||||
|
УРОН 5 - прямо по указанию, и это почти ничего (у волчьего укуса, от которого предмет
|
||||||
|
наследуется, 60). Крыса не оружие: она вешает дебафы. Их два, и оба БЕЗ гейта по тегу
|
||||||
|
"zombie", в отличие от укуса зомбопса выше - крысе разрешено атаковать любое существо,
|
||||||
|
значит и дебафы должны ложиться на то, что она укусила, а не только на зомби.
|
||||||
|
|
||||||
|
ЗАМЕДЛЕНИЕ СДЕЛАНО СВОЁ (buffNecroRatGrip, см. buffs.xml), а не взято из ванили: все
|
||||||
|
готовые "слоу" - это эффекты игроцкой системы травм, половина из них скрыта и написана
|
||||||
|
заодно под транспорт. Своё - это ровно RunSpeed/WalkSpeed/CrouchSpeed с затуханием, без
|
||||||
|
лишнего. Что эти величины вообще доходят до ИИ зомби, подтверждено декомпиляцией
|
||||||
|
EntityAlive.GetMoveSpeedAggro - разбор целиком в комментарии к самому баффу. -->
|
||||||
|
<append xpath="/items">
|
||||||
|
<item name="necroMeleeHandRatSpirit">
|
||||||
|
<property name="Extends" value="meleeHandAnimalDireWolf"/>
|
||||||
|
<property name="CreativeMode" value="None"/>
|
||||||
|
<property class="Action0">
|
||||||
|
<property name="DamageEntity" value="5"/>
|
||||||
|
</property>
|
||||||
|
<!-- effect_group НЕ наследуется через Extends (см. заметку в шапке файла) - триггеры
|
||||||
|
родителя пришлось бы переписывать целиком, и здесь они и не нужны: у крысы свои. -->
|
||||||
|
<effect_group name="necroMeleeHandRatSpirit" tiered="false">
|
||||||
|
<passive_effect name="ModSlots" operation="base_set" value="0"/>
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroRatGrip"/>
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroRatMark"/>
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroRatBleed"/>
|
||||||
|
</effect_group>
|
||||||
|
</item>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Свиток духа крысы": тот же свиток, что у Зомбоволка, но серее - указание 2026-09-18
|
||||||
|
("выглядит как свиток призыва зомбоволка, но с более серым тинтом"). Иконка
|
||||||
|
переиспользуется целиком (CustomIcon="SummonZombieWolf"), своей рисовать не пришлось;
|
||||||
|
CustomIconTint обесцвечивает её, TintColor - 3D-модель в руке. Оба множатся на базовый
|
||||||
|
цвет, как у Камня духов.
|
||||||
|
|
||||||
|
ДВА ДЕЙСТВИЯ, И ОНИ НЕ ТЕ ЖЕ, ЧТО У ОСТАЛЬНЫХ ПИТОМЦЕВ. Action1 (силовая атака) - отзыв,
|
||||||
|
как везде. А вот Action0 у крысы ТРЁХзначный, и разбирает это HarmonySrc/PetCommandPatch.cs:
|
||||||
|
если крыса не призвана - призыв; если призвана - КОМАНДА АТАКОВАТЬ того, на кого смотрит
|
||||||
|
прицел; если под прицелом некого атаковать - "Нет цели для атаки" и больше ничего.
|
||||||
|
Class="SpawnEntity" в обоих слотах при этом обязателен: наш префикс висит именно на
|
||||||
|
ItemActionSpawnEntity.Spawn, и без него клик до нас не дойдёт.
|
||||||
|
|
||||||
|
Книга НЕ расходуется (ConsumesBook=false в SummonPatch.cs): иначе командовать атакой
|
||||||
|
означало бы тратить свиток на каждый приказ. -->
|
||||||
|
<append xpath="/items">
|
||||||
|
<item name="bookSummonRatSpirit">
|
||||||
|
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||||
|
<!-- ItemTypeIcon="book" НЕ СТАВИТСЯ, хотя стоит у всех шести книг призыва выше.
|
||||||
|
Баг-репорт 2026-09-18: "у книги призыва есть ещё одна белая иконка книги поверх.
|
||||||
|
Такая обычно появляется на неизученных рецептах, но тут у нас не рецепт и не
|
||||||
|
книга для изучения. Это предмет."
|
||||||
|
|
||||||
|
Это тот же виджет, что уже разбирался 07.09 у Пространственного браслета (см.
|
||||||
|
его комментарий ниже, там полностью): значок 12x12 в ЛЕВОМ ВЕРХНЕМ углу поверх
|
||||||
|
иконки предмета, XUi_InGame/templates.xml, recipe_entry:
|
||||||
|
|
||||||
|
<sprite name="itemtypeicon" sprite="ui_game_symbol_{itemtypeicon}" .../>
|
||||||
|
|
||||||
|
Значение подставляется прямо в имя спрайта, поэтому "book" даёт белую книжку. У
|
||||||
|
ванили этот значок означает "куда/чем крафтится" (bundle, forge, campfire), и
|
||||||
|
единственный ванильный предмет с value="book" - это настоящая схема. Наш свиток
|
||||||
|
схемой не является: его не изучают, им пользуются. Значок вводил в заблуждение.
|
||||||
|
|
||||||
|
Убрано целиком, а не заменено: ItemClass.ItemTypeIcon по умолчанию "", виджет
|
||||||
|
закрыт гейтом {hasitemtypeicon}, и без свойства он просто не рисуется.
|
||||||
|
|
||||||
|
У ОСТАЛЬНЫХ ШЕСТИ КНИГ ПРИЗЫВА ЭТОТ ЖЕ ЗНАЧОК ПОКА ОСТАЛСЯ - трогать их без
|
||||||
|
указания не стал, но замечание к ним относится ровно так же. -->
|
||||||
|
<property name="CustomIcon" value="SummonZombieWolf"/>
|
||||||
|
<property name="CustomIconTint" value="8a9a8a"/>
|
||||||
|
<property name="DescriptionKey" value="bookSummonRatSpiritDesc"/>
|
||||||
|
<property name="DisplayType" value="ammoGrenadeFire"/>
|
||||||
|
<property name="HoldType" value="21"/>
|
||||||
|
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
|
||||||
|
<property name="Material" value="Mpaper"/>
|
||||||
|
<property name="TintColor" value="120, 135, 120"/>
|
||||||
|
<property name="Weight" value="0"/>
|
||||||
|
<property name="Stacknumber" value="10"/>
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="Group" value="Ammo/Weapons,Ammo"/>
|
||||||
|
<property name="SoundPickup" value="schematics_grab"/>
|
||||||
|
<property name="SoundPlace" value="schematics_place"/>
|
||||||
|
<property class="Action0">
|
||||||
|
<property name="Class" value="SpawnEntity"/>
|
||||||
|
<property name="AnimType" value="4"/>
|
||||||
|
<!-- 0.05, а НЕ 0.3, как у остальных призывных книг. Исправлено 2026-09-18 по первому же
|
||||||
|
игровому запуску: в логе шесть нажатий подряд и все с action index=1 (отзыв), ни
|
||||||
|
одного с index=0 - то есть до Spawn основной клик не доходил вовсе.
|
||||||
|
|
||||||
|
Причина в ItemActionSpawnEntity.OnHoldingUpdate: stateTime растёт по 0.05 за тик и
|
||||||
|
сравнивается с animWait, а ExecuteAction(_bReleased: true) при отпускании кнопки
|
||||||
|
сбрасывает состояние обратно в None. Значит 0.3 - это "держать кнопку треть
|
||||||
|
секунды", шесть тиков; обычный клик короче и не доходит до спавна, причём МОЛЧА -
|
||||||
|
ни ошибки, ни строки в логе.
|
||||||
|
|
||||||
|
Для остальных питомцев удержание терпимо (призыв делают раз в сессию), а у крысы
|
||||||
|
на этом же слоте сидит КОМАНДА АТАКОВАТЬ - приказ посреди боя, который отдают
|
||||||
|
быстро и часто. Требовать под него удержание нельзя. -->
|
||||||
|
<property name="AnimWait" value="0.05"/>
|
||||||
|
<property name="SoundWarn" value="rabbitpain"/>
|
||||||
|
<property name="Entity" value="necroRatSpirit"/>
|
||||||
|
<property name="EntityOffset" value="0, -1, 2.5"/>
|
||||||
|
</property>
|
||||||
|
<property class="Action1">
|
||||||
|
<property name="Class" value="SpawnEntity"/>
|
||||||
|
<property name="AnimType" value="4"/>
|
||||||
|
<property name="AnimWait" value="0.1"/>
|
||||||
|
<property name="SoundWarn" value="swoosh"/>
|
||||||
|
<property name="Entity" value="necroRatSpirit"/>
|
||||||
|
<property name="EntityOffset" value="0, -1, 2.5"/>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
</append>
|
||||||
|
|
||||||
<!-- "Книга банши" (Banshee's Book): BACKLOG.md item 7 (dictated 2026-08-28, implemented
|
<!-- "Книга банши" (Banshee's Book): BACKLOG.md item 7 (dictated 2026-08-28, implemented
|
||||||
2026-08-29 "без вопросов" per user request). Consumed on use, plays a screamer's own
|
2026-08-29 "без вопросов" per user request). Consumed on use, plays a screamer's own
|
||||||
alert sound, spawns a small HOSTILE horde near the caster - see
|
alert sound, spawns a small HOSTILE horde near the caster - see
|
||||||
@@ -812,7 +1129,10 @@
|
|||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="bookBanshee">
|
<item name="bookBanshee">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||||
<property name="ItemTypeIcon" value="book"/>
|
<!-- ItemTypeIcon="book" УБРАН 2026-09-18 со ВСЕХ свитков и книг призыва разом,
|
||||||
|
прямое указание: "свитки призыва и книги призыва не должны содержать белую
|
||||||
|
иконку книги поверх. Они ПРЕДМЕТЫ, а не рецепты." Полный разбор виджета - у
|
||||||
|
bookSummonRatSpirit ниже и у Пространственного браслета (07.09). -->
|
||||||
<!-- ICON UPDATED 2026-08-29: real generated art (BansheesScroll.png), no tint. -->
|
<!-- ICON UPDATED 2026-08-29: real generated art (BansheesScroll.png), no tint. -->
|
||||||
<property name="CustomIcon" value="BansheesScroll"/>
|
<property name="CustomIcon" value="BansheesScroll"/>
|
||||||
<property name="DescriptionKey" value="bookBansheeDesc"/>
|
<property name="DescriptionKey" value="bookBansheeDesc"/>
|
||||||
@@ -893,8 +1213,61 @@
|
|||||||
whether melee weapons with ShowQuality="true" (inherited from the base knife)
|
whether melee weapons with ShowQuality="true" (inherited from the base knife)
|
||||||
render TintColor the same way. If it's still not black after this deploy, that's
|
render TintColor the same way. If it's still not black after this deploy, that's
|
||||||
the next thing to dig into (possibly needs ShowQuality or a cosmetic-slot
|
the next thing to dig into (possibly needs ShowQuality or a cosmetic-slot
|
||||||
workaround), not a guess to make blind right now. -->
|
workaround), not a guess to make blind right now.
|
||||||
<property name="TintColor" value="0, 0, 0"/>
|
|
||||||
|
СОМНЕНИЕ СНЯТО 2026-09-10 по самой ванили: meleeWpnBladeT0BoneKnife задаёт себе
|
||||||
|
TintColor 107, 107, 71 (Data/Config/items.xml:2419) на том же самом
|
||||||
|
boneShivPrefab, то есть слот тинта у этого меша рабочий и на оружии с
|
||||||
|
ShowQuality тоже применяется. Копать тут больше нечего; осталось только
|
||||||
|
посмотреть глазами, достаточно ли чёрный получается клинок. -->
|
||||||
|
<!-- TintColor ОТКЛЮЧЁН 2026-09-10 вместе с переходом на свою текстуру.
|
||||||
|
|
||||||
|
TintColor - это МНОЖИТЕЛЬ цвета меша, а 0,0,0 множит в чистый чёрный. Он был нужен,
|
||||||
|
пока нож носил ванильную бежевую кость: другого способа затемнить клинок не было.
|
||||||
|
Теперь цвет несёт своя текстура necroKnife_d.png, и тот же множитель погасил бы
|
||||||
|
всё нарисованное в ноль - вместе с пурпурным свечением в альбедо.
|
||||||
|
|
||||||
|
Иконка от этого не зависит: она отдельный арт (CustomIcon выше), CustomIconTint
|
||||||
|
здесь никогда не стоял. И на иконке клинок именно КОСТЯНОЙ, а не чёрный, так что
|
||||||
|
чёрный множитель ей вдобавок противоречил.
|
||||||
|
|
||||||
|
Побочно это снимает неоднозначность теста трубы: красный клинок мог быть виден
|
||||||
|
только за счёт эмиссии, если тинт всё-таки давил альбедо нашего меша. Без тинта
|
||||||
|
вопроса больше нет.
|
||||||
|
<property name="TintColor" value="0, 0, 0"/> -->
|
||||||
|
|
||||||
|
<!-- СВОЯ МОДЕЛЬ. ВКЛЮЧЕНА 2026-09-10 вместе с бандлом Resources/necroknife (2.19 МБ,
|
||||||
|
собран Unity 2022.3.62f2 в batch-режиме; внутри necroKnifePrefab.prefab, три меша
|
||||||
|
boneShiv_LOD0/1/2, текстуры boneShiv_d/_n и материал necroKnife.mat - бандл
|
||||||
|
самодостаточный, проверено по составу).
|
||||||
|
|
||||||
|
ВНИМАНИЕ: в бандле сейчас ТЕСТОВЫЙ вид - ярко-красный материал с эмиссией. Это
|
||||||
|
проверка трубы, а не финальная модель. Красный выбран потому, что ванильный нож
|
||||||
|
бежевый и по нему не отличить, загрузился наш бандл или подставилась ваниль;
|
||||||
|
эмиссия - потому что TintColor ниже стоит 0,0,0, и если игра множит тинт на
|
||||||
|
материал нашего меша, красный альбедо ушёл бы в чёрный и тест ничего бы не показал.
|
||||||
|
Настоящая текстура рисуется поверх _private/Extracted/boneShiv_d.png после того,
|
||||||
|
как труба подтвердится.
|
||||||
|
|
||||||
|
Синтаксис: "#" - грузить из бандла, "@modfolder(NecromancerTome):" - путь от
|
||||||
|
корня этого мода, "?" отделяет путь к префабу ВНУТРИ бандла. Форма собрана из
|
||||||
|
двух подтверждённых кусков: ваниль пишет "#Entities/Trees?SnakeweedPrefab.prefab"
|
||||||
|
(Data/Config/blocks.xml), а поддержка "@modfolder:" лежит в Assembly-CSharp.
|
||||||
|
ФОРМА ПОДТВЕРЖДЕНА ЖИВЫМ ПРИМЕРОМ 2026-09-10: с этой строкой тестовый красный
|
||||||
|
нож появился в руке. Значит и запись работает, и бандл из папки мода читается,
|
||||||
|
и Standard-шейдер из Unity 2022.3.62f2 игра отрисовывает. Больше не гипотеза -
|
||||||
|
этой же формой можно подключать любые следующие свои модели.
|
||||||
|
|
||||||
|
necroknife - имя AssetBundle, заданное префабу в Unity; necroKnifePrefab.prefab -
|
||||||
|
имя префаба ВНУТРИ бандла. Оба должны совпасть с тем, что реально собралось, иначе
|
||||||
|
предмет останется без модели молча. Пересборка - меню NecromancerTome -> Ctrl+Shift+B
|
||||||
|
либо batch: Unity.exe -batchmode -nographics -quit -projectPath <проект>
|
||||||
|
-executeMethod NecroKnifeSetup.SetupAndBuild. Игру после каждой пересборки
|
||||||
|
перезапускать: моды и их бандлы читаются только при старте.
|
||||||
|
|
||||||
|
Геометрия остаётся ванильной, меняются материал и текстура, поэтому HoldType,
|
||||||
|
посадка в руке и анимации от базового ножа продолжают подходить без правок. -->
|
||||||
|
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroknife?necroKnifePrefab.prefab"/>
|
||||||
|
|
||||||
<!-- MOD SLOTS ADDED 2026-09-07 (user request: "добавь в нож слоты для
|
<!-- MOD SLOTS ADDED 2026-09-07 (user request: "добавь в нож слоты для
|
||||||
модификаций... модификации там будут особые, именно для ножа
|
модификаций... модификации там будут особые, именно для ножа
|
||||||
@@ -1211,31 +1584,79 @@
|
|||||||
its mesh/material/hold/pickup-sound (reused wholesale, thematically it IS a bag of blood,
|
its mesh/material/hold/pickup-sound (reused wholesale, thematically it IS a bag of blood,
|
||||||
just a darker/necromantic one) - CustomIcon still set explicitly even though Extends is
|
just a darker/necromantic one) - CustomIcon still set explicitly even though Extends is
|
||||||
used, same lesson as every other item in this mod (Extends alone never gives a working
|
used, same lesson as every other item in this mod (Extends alone never gives a working
|
||||||
icon, confirmed originally on the Knife). Reuses the REAL vanilla sprite named
|
icon, confirmed originally on the Knife).
|
||||||
"medicalBloodBag" itself (that item has no CustomIcon of its own, so its sprite name
|
|
||||||
equals its item name) rather than a generated-art file, per direct instruction ("Иконка
|
ЭТОТ ПРЕДМЕТ НИКОГДА НЕ ДОЛЖЕН СТАТЬ <item_modifier>. ЭТО НЕ СТИЛЬ, ЭТО СЕЙВЫ.
|
||||||
такая же как и у обычной крови, но тинт затемнённый") - only TintColor differs (dark,
|
15.09.2026 он был перенесён в item_modifiers.xml, чтобы вставляться в Пространственный
|
||||||
near-black red vs. no tint on the vanilla bag).
|
браслет, и это уничтожило персонажа в тестовом мире - вместе с бэкапом. Разбор целиком в
|
||||||
|
BACKLOG.md, здесь суть, потому что соблазн повторить велик:
|
||||||
|
|
||||||
|
ItemValue.Read строка 1094: if ((version > 4 || HasQuality) && !(itemClass is ItemClassModifier))
|
||||||
|
ItemValue.Write строка 1228: if (!(ItemClass is ItemClassModifier))
|
||||||
|
|
||||||
|
Класс предмета решает БАЙТОВУЮ РАСКЛАДКУ каждого его стака в сейве: обычный предмет пишет
|
||||||
|
байт числа модификаций, ItemClassModifier - не пишет. Значит любой сейв, записанный до
|
||||||
|
переноса, после переноса читается со сдвигом: поток съезжает, ближайший ReadString()
|
||||||
|
получает мусор, PlayerDataFile.Load падает с "output char buffer is too small", и игра
|
||||||
|
откатывается на NewGame. Бэкап .ttp.bak умирает вместе с основным файлом - он старого
|
||||||
|
формата ровно так же. Кровь лежит в сейвах у всех, кто поставил 1.1.0 с Nexus.
|
||||||
|
|
||||||
|
Правило на будущее: предмет, который уже мог попасть в чужой инвентарь, нельзя переводить
|
||||||
|
между ItemClass и ItemClassModifier ни в какую сторону. Нужна модификация - это НОВЫЙ
|
||||||
|
предмет с новым именем, которого в старых сейвах нет. Проверка "айди не поедут"
|
||||||
|
(assignIdsFromMapping) к этому отношения не имеет и ничего тут не гарантирует - именно на
|
||||||
|
неё я и посмотрел вместо раскладки.
|
||||||
|
|
||||||
Crafting rules ("для создания нужна пустая банка и наличие любого ножа. При крафте нужно
|
Crafting rules ("для создания нужна пустая банка и наличие любого ножа. При крафте нужно
|
||||||
отнимать у персонажа 90% имеющегося ХП") - the jar is a normal recipe ingredient (see
|
отнимать у персонажа 90% имеющегося ХП") - the jar is a normal recipe ingredient (see
|
||||||
recipes.xml), but "any knife present, not consumed" and "cost 90% of current HP" have NO
|
recipes.xml), but "any knife present, not consumed" and "cost 90% of current HP" have NO
|
||||||
vanilla XML equivalent (recipes.xml has no per-ingredient "required but not consumed" flag,
|
vanilla XML equivalent (recipes.xml has no per-ingredient "required but not consumed" flag,
|
||||||
and crafting a resource has no HP-cost hook at all) - both enforced in
|
and crafting a resource has no HP-cost hook at all) - both enforced in
|
||||||
HarmonySrc/NecromancerBloodPatch.cs instead. See that file for the exact decompiled
|
HarmonySrc/NecromancerBloodPatch.cs instead. -->
|
||||||
mechanism and an important caveat about ingredient-refund timing that's flagged there, not
|
|
||||||
glossed over. -->
|
|
||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="resourceNecromancerBlood">
|
<item name="resourceNecromancerBlood">
|
||||||
<property name="Extends" value="medicalBloodBag"/>
|
<property name="Extends" value="medicalBloodBag"/>
|
||||||
<property name="DescriptionKey" value="resourceNecromancerBloodDesc"/>
|
<property name="DescriptionKey" value="resourceNecromancerBloodDesc"/>
|
||||||
<!-- Custom art delivered 2026-08-30 (exch/NecromantsBlood.png, 160x160, copied to
|
<!-- Своя рисованная иконка, 2026-08-30. CustomIcon задаётся явно даже при Extends. -->
|
||||||
UIAtlases/ItemIconAtlas/) - replaces the earlier placeholder that reused the
|
|
||||||
vanilla medicalBloodBag sprite with a darkened tint. No CustomIconTint here,
|
|
||||||
same reasoning as every other hand-drawn icon in this mod (Dog/Insect summon
|
|
||||||
books, etc.) - don't recolor finished art. -->
|
|
||||||
<property name="CustomIcon" value="NecromantsBlood"/>
|
<property name="CustomIcon" value="NecromantsBlood"/>
|
||||||
<property name="TintColor" value="60, 0, 10"/>
|
|
||||||
|
<!-- Своя банка с кровью, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое
|
||||||
|
заменяем на кровавый цвет, с фиолетовыми оттенками»). Заодно чинилось расхождение
|
||||||
|
текста и модели: описание говорит «Банка, наполненная кровью», а наследуемый
|
||||||
|
medicalBloodBag показывал sackPrefab - обычный мешок.
|
||||||
|
|
||||||
|
ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Ванильный префаб чая плюс TintColor:
|
||||||
|
банка осталась чаем из золотарника, тинт предмета на этот меш НЕ ПОДЕЙСТВОВАЛ
|
||||||
|
ВООБЩЕ - у шейдера Game_EntityTintMaskSSS выигрывает собственный _Color материала.
|
||||||
|
Поэтому TintColor здесь не задаётся совсем.
|
||||||
|
|
||||||
|
И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная
|
||||||
|
и густая, с плёнкой на стекле. Жидкость ПЕРЕРИСОВАНА по яркости, а не перекрашена
|
||||||
|
множителем - генератор _private/tools/make_necroblood_textures.py.
|
||||||
|
|
||||||
|
HoldType 3 - хват банки вместо 45 (мешок), Material Mglass - стекло вместо ткани. -->
|
||||||
|
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroBloodPrefab.prefab"/>
|
||||||
|
<property name="HoldType" value="3"/>
|
||||||
|
<property name="Material" value="Mglass"/>
|
||||||
|
|
||||||
|
<!-- НИ Stacknumber, НИ ПРОЧНОСТИ ЗДЕСЬ НЕТ, И ЭТО НАМЕРЕННО.
|
||||||
|
|
||||||
|
15.09.2026 крови на один заход выдали и то, и другое: стак по одной банке и
|
||||||
|
прочность 1000. Обе правки существовали ради одного - кровь должна была стать
|
||||||
|
расходником, вставляемым в Пространственный браслет. Эта затея откачена (она
|
||||||
|
ломала сейвы, см. предупреждение выше), расходником стала Кровавая сфера, и по
|
||||||
|
прямому указанию пользователя кровь возвращена к тому, чем была:
|
||||||
|
|
||||||
|
- стак снова 15 - наследуется от medicalBloodBag, своей строки Stacknumber
|
||||||
|
больше нет. Своя строка была нужна только чтобы перебить наследуемое 15 на 1;
|
||||||
|
- прочности нет вовсе: ни ShowQuality, ни DegradationBreaksAfter, ни
|
||||||
|
effect_group с DegradationMax. Тратил её браслет; тратить стало некому, а
|
||||||
|
полоска, которая никогда не двигается, хуже, чем её отсутствие.
|
||||||
|
|
||||||
|
Кровь снова то, чем была с самого начала: ингредиент рецептов, и только.
|
||||||
|
Если прочность когда-нибудь понадобится - разбор обеих ручек (пассивка
|
||||||
|
DegradationMax плюс отдельное свойство ShowQuality, и ловушка с полной полоской
|
||||||
|
при MaxUseTimes == 0) лежит в BACKLOG.md, повторно раскапывать не нужно. -->
|
||||||
<property name="EconomicValue" value="0"/>
|
<property name="EconomicValue" value="0"/>
|
||||||
</item>
|
</item>
|
||||||
</append>
|
</append>
|
||||||
@@ -1288,7 +1709,30 @@
|
|||||||
rest of the mod's hand-drawn icons). -->
|
rest of the mod's hand-drawn icons). -->
|
||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="braceletSpatialVault">
|
<item name="braceletSpatialVault">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<!-- MOD SLOTS ADDED 2026-09-13 ("добавь хранилищу 4 слота под модификации. Сами
|
||||||
|
модификации реализуем потом"), УБАВЛЕНЫ ДО ОДНОГО 2026-09-15 - число стоит в
|
||||||
|
effect_group в самом низу этого предмета, здесь только теги. Two tags, exactly the scheme necroWpnBladeNecroKnife
|
||||||
|
already proved on 2026-09-07 - see that item's own comment for the full
|
||||||
|
decompiled reasoning:
|
||||||
|
|
||||||
|
noMods - blocks every vanilla mod. All 87 vanilla item_modifiers that
|
||||||
|
declare blocked_tags at all list noMods among them; the
|
||||||
|
remaining 24 cannot reach this item anyway (10 dyes and 7 drone
|
||||||
|
mods need a cosmetic slot or the drone tag, 2 are quest items,
|
||||||
|
1 needs perkArchery, 3 are CreativeMode Test/Dev). "noMods"
|
||||||
|
means nothing in code - it is purely a naming convention used
|
||||||
|
inside other items' blocked_tags.
|
||||||
|
necroBracelet - the positive half, reserved for the mods that come later. Every
|
||||||
|
future bracelet mod MUST declare
|
||||||
|
installable_tags="necroBracelet": a modifier with no
|
||||||
|
installable_tags at all fits ANYTHING (XUiM_AssembleItem short-
|
||||||
|
circuits on InstallableTags.IsEmpty), so forgetting it produces
|
||||||
|
the exact opposite of what is wanted.
|
||||||
|
|
||||||
|
Deliberately NOT adding canHaveCosmetic: that tag alone is what creates the paint
|
||||||
|
slot (ItemValue's constructor sizes CosmeticMods by it), and the knife had to have
|
||||||
|
it removed for precisely this reason. No tag, no slot, no dyes. -->
|
||||||
|
<property name="Tags" value="T0,weapon,attPerception,noMods,necroBracelet"/>
|
||||||
<!-- ItemTypeIcon="melee" REMOVED 2026-09-07 (user report: "поверх пиктограмм некоторых
|
<!-- ItemTypeIcon="melee" REMOVED 2026-09-07 (user report: "поверх пиктограмм некоторых
|
||||||
рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the
|
рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the
|
||||||
small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe
|
small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe
|
||||||
@@ -1316,19 +1760,64 @@
|
|||||||
(bundle, computer, forge, explosion, campfire, gunsmithing, book). -->
|
(bundle, computer, forge, explosion, campfire, gunsmithing, book). -->
|
||||||
<property name="DescriptionKey" value="braceletSpatialVaultDesc"/>
|
<property name="DescriptionKey" value="braceletSpatialVaultDesc"/>
|
||||||
<property name="CustomIcon" value="ProstranstvennoeHranilische"/>
|
<property name="CustomIcon" value="ProstranstvennoeHranilische"/>
|
||||||
<!-- Same "seed"-style grip as braceletThiefLoop originally had (see that item's own
|
<!-- МЕШ И ХВАТ, 2026-09-13. Просьба в два захода: сперва "пусть будет камень, а
|
||||||
comment for the full history) - foodCropYuccaFruit's own HoldType="31" +
|
хват давай сделаем как когда пытаешься ставить какой-нибудь блок", затем
|
||||||
parcelGenericPrefab.prefab. Unlike Thief's Loop, this item never touches
|
уточнение - "браслет это браслет... в идеале меш камня вообще убрать". Было:
|
||||||
Class="Zoom" (both its actions are Class="Eat"), so it never hit the
|
свёрток-«семечко» parcelGenericPrefab.prefab (коробочка, перевязанная бечёвкой -
|
||||||
"Attachments" transform error that forced Thief's Loop onto a real weapon mesh -
|
для браслета нелепо) + HoldType="31", и то и другое унаследовано от Петли вора.
|
||||||
no reason to change this one's mesh too. TintColor changed to green 2026-08-30
|
|
||||||
per direct request. -->
|
ИТОГ: в руке НЕТ НИЧЕГО, только кулак. Пустой префаб собирать не пришлось - в
|
||||||
|
движке есть готовое свойство, и вся связка целиком списана с ванильного
|
||||||
|
vehicleMinibikePlaceable (items.xml:13385), у которого стоят ровно те же две
|
||||||
|
строки подряд: HoldType="7" + HoldingItemHidden="true".
|
||||||
|
|
||||||
|
HoldType="7" - это и есть блочный хват ("кулак вниз, как будто держишь руль"),
|
||||||
|
не угаданный номер. Декомпилировано Mono.Cecil'ом из Assembly-CSharp 3.2.0
|
||||||
|
(сам Mono.Cecil.dll лежит в Mods/0_TFP_Harmony, отдельный декомпилятор не нужен):
|
||||||
|
|
||||||
|
ItemClassBlock..ctor -> HoldType = new DataItem<int>(7)
|
||||||
|
AnimationDelayData.AnimationDelay[7] =
|
||||||
|
new AnimationDelays(0, 0f, 0f, .31f, .31f, true) <- последний флаг TwoHanded
|
||||||
|
|
||||||
|
В blocks.xml свойства HoldType нет ни разу (0 вхождений), то есть КАЖДЫЙ блок в
|
||||||
|
игре держится именно семёркой из этого конструктора. Костет
|
||||||
|
(meleeWpnKnucklesT0LeatherKnuckles) - это HoldType="70", запасной вариант не
|
||||||
|
понадобился.
|
||||||
|
|
||||||
|
HoldingItemHidden="true" - штатное свойство ItemClass, а не трюк:
|
||||||
|
ItemClass..cctor заводит PropHoldingItemHidden = "HoldingItemHidden",
|
||||||
|
ItemClass.Init читает его через StringParsers.ParseBool, а
|
||||||
|
Inventory.setHoldingItemTransform в самом конце делает
|
||||||
|
holdingItemTransform.gameObject.SetActive(!HoldingItemHidden). Гасится ТОЛЬКО
|
||||||
|
модель в руке: иконка в инвентаре (своя рисованная ProstranstvennoeHranilische)
|
||||||
|
и мешок на земле не трогаются, действия предмета живут в ItemActionEat и от
|
||||||
|
этого GameObject не зависят.
|
||||||
|
|
||||||
|
Пустой меш поставить было НЕЛЬЗЯ, и это проверено, а не предположено:
|
||||||
|
ItemClass.CloneModel, если имя меша пустое и ассет не загрузился, подставляет
|
||||||
|
заглушку "@:Other/Items/Crafting/leather.fbx" - в руке оказался бы кусок кожи.
|
||||||
|
Единственный ванильный предмет вообще без Meshfile - meleeHandMaster (голые
|
||||||
|
руки), и он выкручивается через Canhold="false", что нам не подходит: браслет
|
||||||
|
надо держать, чтобы им пользоваться.
|
||||||
|
|
||||||
|
Meshfile оставлен камнем как безобидная затычка (в руке он скрыт, а для
|
||||||
|
MeshPurpose World/Local/Preview что-то иметь надо), DropMeshfile - ванильный
|
||||||
|
мешок sack_droppedPrefab, ровно тем же приёмом и по той же причине, что у
|
||||||
|
vehicleMinibikePlaceable: выброшенный предмет должно быть видно на земле, а
|
||||||
|
своей модели у него нет. HandMeshfile убран за ненадобностью.
|
||||||
|
|
||||||
|
Про HoldType и действия: единственное место, где ItemActionEat вообще читает
|
||||||
|
HoldType, - AnimationDelay[HoldType].RayCast (в PercentDone и IsActionRunning),
|
||||||
|
и он равен 0f и у старого 31, и у нового 7 (InitStatic заполняет все 100 слотов
|
||||||
|
нулями, ItemClassBlock переписывает слот 7, оставляя RayCast нулём).
|
||||||
|
ExecuteAction, за которую держится SpatialVaultPatch.cs, HoldType не читает
|
||||||
|
вовсе - проверено сканом IL по всей сборке. -->
|
||||||
<property name="Material" value="Morganic"/>
|
<property name="Material" value="Morganic"/>
|
||||||
<property name="Meshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
<property name="HandMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
|
||||||
<property name="DropMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
|
||||||
<property name="TintColor" value="30, 200, 60"/>
|
<property name="TintColor" value="30, 200, 60"/>
|
||||||
<property name="HoldType" value="31"/>
|
<property name="HoldType" value="7"/>
|
||||||
|
<property name="HoldingItemHidden" value="true"/>
|
||||||
<property name="Weight" value="0"/>
|
<property name="Weight" value="0"/>
|
||||||
<property name="Stacknumber" value="1"/>
|
<property name="Stacknumber" value="1"/>
|
||||||
<property name="EconomicValue" value="0"/>
|
<property name="EconomicValue" value="0"/>
|
||||||
@@ -1341,6 +1830,49 @@
|
|||||||
<property name="Class" value="Eat"/>
|
<property name="Class" value="Eat"/>
|
||||||
<property name="Delay" value="0.3"/>
|
<property name="Delay" value="0.3"/>
|
||||||
</property>
|
</property>
|
||||||
|
|
||||||
|
<!-- FOUR MOD SLOTS. The count is a passive_effect, not an item property - same shape
|
||||||
|
the knife uses, and flat rather than a per-quality list because quality means
|
||||||
|
nothing on this item.
|
||||||
|
|
||||||
|
NOTE THE MISSING ATTRIBUTE: this effect_group has NO tiered="false", and that is
|
||||||
|
the entire point. ItemClass.HasQuality is literally Effects.IsOwnerTiered(), and
|
||||||
|
ItemValue.FireEvent bails out with `if (!HasQuality) return;` BEFORE it walks
|
||||||
|
Modifications[] - so on an untiered item the slots still appear and still accept
|
||||||
|
mods, and not one triggered_effect inside them ever fires. That silent failure
|
||||||
|
cost a whole debugging session on the knife on 2026-09-07; it is not repeated
|
||||||
|
here. The slots themselves would work either way (Modifications is allocated
|
||||||
|
unconditionally, earlier), which is exactly what makes the failure so quiet.
|
||||||
|
|
||||||
|
Quality is not SHOWN, though: ShowQuality is a separate property that defaults to
|
||||||
|
false (vanilla sets it to true explicitly on the ~80 items that want a quality
|
||||||
|
bar), and it is deliberately left unset here. The item behaves as tiered for the
|
||||||
|
mod system and still reads as a plain bracelet in the UI.
|
||||||
|
|
||||||
|
FOR THE MODS THEMSELVES: give each one its OWN modifier_tags.
|
||||||
|
XUiC_ItemPartStack.CanSwap counts already-installed mods whose modifier_tags
|
||||||
|
intersect the one being installed and refuses at num >= ItemClass.MaxModsAllowed,
|
||||||
|
which defaults to 1. With a single slot this no longer costs slots - but it still
|
||||||
|
matters, because a shared tag would ALSO make two different bracelet mods mutually
|
||||||
|
exclusive in ways nothing in the UI explains. Keep them distinct.
|
||||||
|
|
||||||
|
ОДИН СЛОТ, указание 2026-09-15 («убавь у пространственного браслета количество
|
||||||
|
слотов под модификации до одного»). Было 4, поставленные 2026-08-30 по прежнему
|
||||||
|
выбору пользователя («4 фиксированно»).
|
||||||
|
|
||||||
|
Что это меняет по сути: слот из «набора улучшений» превратился в ВЫБОР. Сейчас
|
||||||
|
единственный кандидат - флакон Крови некроманта (item_modifiers.xml, переехал туда
|
||||||
|
2026-09-15), так что выбирать пока не из чего; но каждая следующая модификация
|
||||||
|
браслета теперь конкурирует за один слот, а не добавляется к остальным. Это стоит
|
||||||
|
держать в голове при их придумывании - иначе получится набор, из которого всегда
|
||||||
|
берут одну и ту же.
|
||||||
|
|
||||||
|
Число только здесь. ModSlots - пассивка, а не свойство, и никакой другой файл на
|
||||||
|
него не смотрит; менять обратно - эта же строка. У Ножа некроманта свои 4 слота
|
||||||
|
(выше в этом файле, ~строка 1056) - их указание НЕ трогало. -->
|
||||||
|
<effect_group name="braceletSpatialVault">
|
||||||
|
<passive_effect name="ModSlots" operation="base_set" value="1"/>
|
||||||
|
</effect_group>
|
||||||
</item>
|
</item>
|
||||||
</append>
|
</append>
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<config>
|
||||||
|
<!-- Метки на карте и компасе для Духа крысы, 2026-09-18.
|
||||||
|
|
||||||
|
Две штуки, и это РАЗНЫЕ вещи: одна помечает саму крысу (чтобы игрок видел, где его
|
||||||
|
питомец), вторая - зомби, которого крыса укусила (метка духа).
|
||||||
|
|
||||||
|
Форма скопирована с ванильного nav_object_class "zombie" (Data/Config/nav_objects.xml):
|
||||||
|
это самый простой из существующих классов - у него НЕТ requirement_type, то есть метка
|
||||||
|
показывается безусловно, как только её кому-нибудь повесили. Классы с requirement_type
|
||||||
|
(IsPlayer, IsAlly, IsOwner, Tracking и ещё семь) сами решают, кому показываться, и нам
|
||||||
|
это только мешало бы: и питомец, и его добыча должны быть видны владельцу всегда.
|
||||||
|
|
||||||
|
Ванильные animaltracking_* сюда не годятся: они работают через систему отслеживания
|
||||||
|
животных (перк следопыта), а не через прямое назначение. -->
|
||||||
|
|
||||||
|
<append xpath="/nav_object_classes">
|
||||||
|
<!-- Сам питомец - ЖЁЛТЫМ, по прямому указанию.
|
||||||
|
|
||||||
|
СПРАЙТ: "воскрешение" (пробуем, указание 2026-09-18 - "для крысы попробуй иконку
|
||||||
|
воскрешения, проверим как смотрится"). Кроличий, стоявший тут сначала, не подошёл по
|
||||||
|
смыслу. Призрака, духа или души среди иконок игры НЕТ ВООБЩЕ - просмотрены все 359
|
||||||
|
имён вида ui_game_symbol_* из Data/Config и из самой сборки; ближайшее по названию
|
||||||
|
было specters_grace (бафф "Призрачная грация", перк Ловкости), но у воскрешения
|
||||||
|
смысл к некромантии ближе. Если в игре окажется невнятно, запасные - specters_grace
|
||||||
|
и skull (череп, он же значок самой Некромантии), меняется одна строка. -->
|
||||||
|
<nav_object_class name="necroRatSpiritPet">
|
||||||
|
<map_settings>
|
||||||
|
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/>
|
||||||
|
<property name="min_distance" value="0"/>
|
||||||
|
<property name="max_distance" value="-1"/>
|
||||||
|
<property name="color" value="255,220,0,255"/>
|
||||||
|
<property name="has_pulse" value="false"/>
|
||||||
|
</map_settings>
|
||||||
|
|
||||||
|
<compass_settings>
|
||||||
|
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/>
|
||||||
|
<property name="min_distance" value="0"/>
|
||||||
|
<property name="max_distance" value="1024"/>
|
||||||
|
<property name="max_scale_distance" value="32"/>
|
||||||
|
<property name="color" value="255,220,0,255"/>
|
||||||
|
<property name="has_pulse" value="false"/>
|
||||||
|
<property name="icon_clamped" value="false"/>
|
||||||
|
</compass_settings>
|
||||||
|
</nav_object_class>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<append xpath="/nav_object_classes">
|
||||||
|
<!-- Помеченная добыча. Спрайт зомбиный, как у ванильной метки, но цвет НЕ красный:
|
||||||
|
красным в игре уже отмечены зомби из Тёмного чутья (буфф necroModKnifeDarkSense
|
||||||
|
вешает ванильный nav_object "zombie"), и если бы метка крысы была такой же, две
|
||||||
|
разные механики стали бы неотличимы на карте.
|
||||||
|
|
||||||
|
ЦВЕТ ИСПРАВЛЕН 2026-09-18. Сначала здесь стоял зелёный (150,220,70) - ошибка, и
|
||||||
|
пользователь поймал её сразу: "игрок может решить, что цель дружелюбна". Зелёный в
|
||||||
|
интерфейсах значит "свой", а это помеченный ВРАГ.
|
||||||
|
|
||||||
|
Взят гнилостно-коричневый, а не ядовито-зелёный (предложены были оба). Причина
|
||||||
|
простая: сама крыса на карте жёлтая (255,220,0), а ядовито-зелёный - это
|
||||||
|
жёлто-зелёный, и на значке размером в десяток пикселей эти два цвета слились бы.
|
||||||
|
Коричневый не спорит ни с жёлтой крысой, ни с красными зомби Тёмного чутья и
|
||||||
|
читается как "гниёт" - ровно то, что метка и делает (см. GeneralDamageResist в
|
||||||
|
buffs.xml). Если в игре окажется тускло, ядовито-зелёный - это value="154,185,20",
|
||||||
|
одна строка.
|
||||||
|
|
||||||
|
has_pulse=true намеренно: метка живёт полминуты и должна быть заметна среди прочего,
|
||||||
|
это её единственная работа, а коричневый на карте сам по себе неяркий. -->
|
||||||
|
<nav_object_class name="necroRatSpiritMark">
|
||||||
|
<map_settings>
|
||||||
|
<property name="sprite_name" value="ui_game_symbol_zombie"/>
|
||||||
|
<property name="min_distance" value="0"/>
|
||||||
|
<property name="max_distance" value="-1"/>
|
||||||
|
<property name="color" value="165,100,35,255"/>
|
||||||
|
<property name="has_pulse" value="true"/>
|
||||||
|
</map_settings>
|
||||||
|
|
||||||
|
<compass_settings>
|
||||||
|
<property name="sprite_name" value="ui_game_symbol_zombie"/>
|
||||||
|
<property name="min_distance" value="0"/>
|
||||||
|
<property name="max_distance" value="1024"/>
|
||||||
|
<property name="max_scale_distance" value="32"/>
|
||||||
|
<property name="color" value="165,100,35,255"/>
|
||||||
|
<property name="has_pulse" value="true"/>
|
||||||
|
<property name="icon_clamped" value="false"/>
|
||||||
|
</compass_settings>
|
||||||
|
</nav_object_class>
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
+175
-59
@@ -18,16 +18,33 @@
|
|||||||
zombieTemplateMale that already drives necroZombieKillsCVar (see
|
zombieTemplateMale that already drives necroZombieKillsCVar (see
|
||||||
entityclasses.xml) - one kill, one level, capped at max_level.
|
entityclasses.xml) - one kill, one level, capped at max_level.
|
||||||
|
|
||||||
max_level=5000, with 5 recipe groups gated at fixed total-zombie-kills
|
ШКАЛА ПЕРЕДЕЛАНА 2026-09-17: 20 УБИЙСТВ = 1 УРОВЕНЬ, max_level=250.
|
||||||
thresholds (proportional to the original 1000-max version: 0.1% / 10% / 40%
|
Было "одно убийство - один уровень" при max_level=5000, и это молча ломалось на КАЖДОМ
|
||||||
/ 60% / 100%):
|
СОХРАНЕНИИ, потому что ванильный ProgressionValue хранит уровень ОДНИМ БАЙТОМ:
|
||||||
Group 1 "Адепт" - available from the start (level 1), but not all
|
_writer.Write((byte)level); // ProgressionValue.Write
|
||||||
of it - individual recipes within the group still
|
level = _reader.ReadByte(); // ProgressionValue.Read
|
||||||
unlock at their own level as usual.
|
Проверено не только декомпиляцией, но и на живом сейве (New Xisema Mountains/sezon8,
|
||||||
Group 2 "Подмастерье" - level 500
|
17.09.2026): necroZombieKillsCVar = 384, а уровень в файле игрока = 129, то есть 384-256.
|
||||||
Group 3 "Ученик" - level 2000
|
Всё выше 255 откатывалось по модулю 256: панель скилла заново закрывала уже открытые
|
||||||
Group 4 "Некромант" - level 3000
|
рецепты (именно это было видно на стриме - Слёзы мертвеца открыты, Пир падальщика под
|
||||||
Group 5 "Мастер" - level 5000
|
замком), а группы 500/2000/3000/5000 были недостижимы в принципе. В ванили этот предел
|
||||||
|
не всплывает: атрибуты идут до 10, перки до 5, крафтовые скиллы до 100.
|
||||||
|
|
||||||
|
5000 убийств / 20 = 250 уровней - влезает в байт с запасом, поэтому баг чинится самой
|
||||||
|
шкалой, а не костылём поверх сериализации. Счёт убийств по-прежнему живёт в
|
||||||
|
necroZombieKillsCVar (float, сохраняется честно) и остаётся ЕДИНСТВЕННЫМ источником
|
||||||
|
правды: уровень пересчитывается из него в HarmonySrc/NecromancyKillCreditPatch.cs и на
|
||||||
|
каждом убийстве, и при загрузке игрока - последнее заодно чинит сейвы, испорченные старой
|
||||||
|
шкалой (тот же sezon8: 384 убийства -> уровень 19 вместо сломанных 129).
|
||||||
|
|
||||||
|
ВСЕ ПОРОГИ НИЖЕ - В УРОВНЯХ. Умножь на 20, чтобы получить убийства:
|
||||||
|
Group 1 "Адепт" - с нулевого уровня, но не вся: рецепты внутри группы
|
||||||
|
открываются каждый на своей ступени: 0 / 1 / 3 / 5 / 15,
|
||||||
|
то есть 0 / 20 / 60 / 100 / 300 убийств.
|
||||||
|
Group 2 "Подмастерье" - уровень 25 (500 убийств)
|
||||||
|
Group 3 "Ученик" - уровень 100 (2000)
|
||||||
|
Group 4 "Некромант" - уровень 150 (3000)
|
||||||
|
Group 5 "Мастер" - уровень 250 (5000)
|
||||||
|
|
||||||
Recipe unlocks: give a recipe Tags="necroNecromancyAdept" / "necroNecromancyJourneyman"
|
Recipe unlocks: give a recipe Tags="necroNecromancyAdept" / "necroNecromancyJourneyman"
|
||||||
/ "necroNecromancyApprentice" / "necroNecromancyNecromancer" / "necroNecromancyMaster"
|
/ "necroNecromancyApprentice" / "necroNecromancyNecromancer" / "necroNecromancyMaster"
|
||||||
@@ -37,12 +54,14 @@
|
|||||||
"Адепт" is unlocked from level 1 anyway - a recipe with no unlock tag is just always
|
"Адепт" is unlocked from level 1 anyway - a recipe with no unlock tag is just always
|
||||||
available, same effect, no need to spend a tag on it.
|
available, same effect, no need to spend a tag on it.
|
||||||
|
|
||||||
One genuine one-off exception: necroNecromancyLvl20 (level 20) - the Пространственный
|
One genuine one-off exception: necroNecromancyLvl20 - the Пространственный браслет, per
|
||||||
браслет, per direct instruction 2026-08-30 ("нож, камень духов и хранилище - это база...
|
direct instruction 2026-08-30 ("нож, камень духов и хранилище - это база... хранилище,
|
||||||
хранилище, когда убито минимум 20 зомби") - it belongs in the Group 1 "Адепт" display
|
когда убито минимум 20 зомби") - it belongs in the Group 1 "Адепт" display bucket (see
|
||||||
bucket (see below) but needs its own slightly-later unlock level within that same group,
|
below) but needs its own slightly-later unlock level within that same group, which is
|
||||||
which is what unlock_tier is for (see display_entry below), not a reason to invent a
|
what unlock_tier is for (see display_entry below), not a reason to invent a whole
|
||||||
whole separate group.
|
separate group. Имя тега - в УБИЙСТВАХ (20), а RecipeTagUnlocked у него теперь стоит на
|
||||||
|
УРОВНЕ 1: это те же самые 20 убийств в новой шкале. С 2026-09-17 на этом же теге сидят
|
||||||
|
Кровавая сфера и мод ножа на воду (Слёзы мертвеца) - три рецепта одной ступени.
|
||||||
|
|
||||||
UPDATED 2026-08-29: display_entry blocks added below now that real necromancer items/
|
UPDATED 2026-08-29: display_entry blocks added below now that real necromancer items/
|
||||||
recipes exist to point them at - per the user's own direct report ("до сих пор нету ни
|
recipes exist to point them at - per the user's own direct report ("до сих пор нету ни
|
||||||
@@ -73,21 +92,25 @@
|
|||||||
range. Fixed by actually spreading the non-base items across all 5 real tiers
|
range. Fixed by actually spreading the non-base items across all 5 real tiers
|
||||||
instead of clustering them all near the bottom - see the effect_group tags below and
|
instead of clustering them all near the bottom - see the effect_group tags below and
|
||||||
recipes.xml for the final per-item distribution:
|
recipes.xml for the final per-item distribution:
|
||||||
Group 1 "Адепт" (level 1, +20 for the bracelet) - Spirit Stone, Knife,
|
Group 1 "Адепт" (ур. 0 = 0 убийств) - Spirit Stone, Knife, Blue Portal
|
||||||
Blue Portal Stone, Spatial Vault, Pyramid of Spirits,
|
Stone, Pyramid of Spirits; на ур. 1 (20 убийств) -
|
||||||
and (since 2026-09-09) the four survival-flavoured knife
|
Spatial Vault, Blood Sphere и мод на воду; дальше три
|
||||||
mods at +30 / +60 / +100 / +300
|
выживальческих мода ножа на ур. 3 / 5 / 15
|
||||||
Group 2 "Подмастерье" (level 500) - Grimoire of Deviation, plus the two combat
|
(60 / 100 / 300 убийств)
|
||||||
knife mods at 1400 / 1700
|
Group 2 "Подмастерье" (ур. 25 = 500) - Grimoire of Deviation, plus the two
|
||||||
Group 3 "Ученик" (level 2000) - Zombie Dog, Insect Swarm, Zombie Griffin
|
combat knife mods на ур. 70 / 85 (1400 / 1700 убийств)
|
||||||
Group 4 "Некромант" (level 3000) - Zombie Bear, Zombie Wolf, Banshee's Scroll
|
Group 3 "Ученик" (ур. 100 = 2000) - Zombie Dog, Insect Swarm, Zombie Griffin
|
||||||
Group 5 "Мастер" (level 5000) - Black Portal Stone
|
Group 4 "Некромант" (ур. 150 = 3000) - Zombie Bear, Zombie Wolf, Banshee Scroll
|
||||||
|
Group 5 "Мастер" (ур. 250 = 5000) - Black Portal Stone
|
||||||
necroNecromancyLvl50/Lvl200 tags removed (no longer used by anything - Grimoire and
|
necroNecromancyLvl50/Lvl200 tags removed (no longer used by anything - Grimoire and
|
||||||
the summon books now use the real necroNecromancyJourneyman/Apprentice/Necromancer
|
the summon books now use the real necroNecromancyJourneyman/Apprentice/Necromancer
|
||||||
tags instead). necroNecromancyLvl20 stays - still the one legitimate one-off (see
|
tags instead). necroNecromancyLvl30 убран 2026-09-17 вместе с переходом на шкалу
|
||||||
|
20-за-уровень: 30 убийств на сетке с шагом 20 не выражается, и мод на воду переехал
|
||||||
|
на necroNecromancyLvl20 - тот самый тег браслета, по прямому указанию ("20
|
||||||
|
убийств"). necroNecromancyLvl20 stays - still the one legitimate one-off (see
|
||||||
above). -->
|
above). -->
|
||||||
<append xpath="/progression/crafting_skills">
|
<append xpath="/progression/crafting_skills">
|
||||||
<crafting_skill name="craftingNecroNecromancy" max_level="5000" parent="attCrafting" name_key="craftingNecroNecromancyName" desc_key="craftingNecroNecromancyDesc" long_desc_key="craftingNecroNecromancyLongDesc" icon="ui_game_symbol_zombie">
|
<crafting_skill name="craftingNecroNecromancy" max_level="250" parent="attCrafting" name_key="craftingNecroNecromancyName" desc_key="craftingNecroNecromancyDesc" long_desc_key="craftingNecroNecromancyLongDesc" icon="ui_game_symbol_skull">
|
||||||
|
|
||||||
<!-- display_entry block kept in the SAME append as the crafting_skill itself (unlike
|
<!-- display_entry block kept in the SAME append as the crafting_skill itself (unlike
|
||||||
an earlier draft of this edit, which tried appending display_entry via a second,
|
an earlier draft of this edit, which tried appending display_entry via a second,
|
||||||
@@ -96,13 +119,29 @@
|
|||||||
loot.xml once, see that file's own load-order comment. Nesting it here avoids the
|
loot.xml once, see that file's own load-order comment. Nesting it here avoids the
|
||||||
ordering question entirely, and matches how vanilla itself writes display_entry -
|
ordering question entirely, and matches how vanilla itself writes display_entry -
|
||||||
directly inside the crafting_skill tag, not as a separate append). -->
|
directly inside the crafting_skill tag, not as a separate append). -->
|
||||||
<display_entry icon="SpiritStone" name_key="craftingNecroNecromancyTier1Name" has_quality="false" unlock_level="1,20,30,60,100,300">
|
<display_entry icon="SpiritStone" name_key="craftingNecroNecromancyTier1Name" has_quality="false" unlock_level="0,1,3,5,15">
|
||||||
<!-- necroHeresyPyramid added here 2026-08-31 (user request: "добавь рецепт блока в
|
<!-- necroHeresyPyramid added here 2026-08-31 (user request: "добавь рецепт блока в
|
||||||
скиллы") - unlock_tier="1" alongside the other always-available Tier-1 items,
|
скиллы") - unlock_tier="1" alongside the other always-available Tier-1 items,
|
||||||
matching its recipe's own necroNecromancyAdept tag in recipes.xml (see that
|
matching its recipe's own necroNecromancyAdept tag in recipes.xml (see that
|
||||||
file's comment - both express the same "available immediately" intent). -->
|
file's comment - both express the same "available immediately" intent). -->
|
||||||
<unlock_entry item="thrownStoneSpirit,necroWpnBladeNecroKnife,thrownStonePortalBlue,necroHeresyPyramid" unlock_tier="1"/>
|
<unlock_entry item="thrownStoneSpirit,necroWpnBladeNecroKnife,thrownStonePortalBlue,necroHeresyPyramid" unlock_tier="1"/>
|
||||||
<unlock_entry item="braceletSpatialVault" unlock_tier="2"/>
|
<!-- resourceBloodSphere ПЕРЕЕХАЛА СЮДА ИЗ tier 1, 2026-09-16.
|
||||||
|
Сначала (15.09) она стояла на tier 1 по указанию «доступна на первом грейде»,
|
||||||
|
то есть с уровня 1. Но вставляется она в Пространственный браслет, а тот
|
||||||
|
открывается на tier 2 (уровень 20) - ровно этой же строкой. Девятнадцать
|
||||||
|
уровней игрок мог крафтить расходник к предмету, которого у него нет.
|
||||||
|
По записанному правилу мода «пороги - по нужде, а не по силе» порог ставится
|
||||||
|
туда, где предмет впервые нужен, - здесь это момент появления браслета.
|
||||||
|
Парная правка в recipes.xml: рецепту добавлены теги learnable и
|
||||||
|
necroNecromancyLvl20. Оба уровня ОБЯЗАНЫ совпадать: RecipeTagUnlocked для
|
||||||
|
necroNecromancyLvl20 стоит на level="1,250" ниже, а tier 2 в
|
||||||
|
unlock_level="0,1,3,5,15" - это тот же уровень 1. Разойдись они, замок на
|
||||||
|
панели скилла разошёлся бы с реальной доступностью рецепта.
|
||||||
|
|
||||||
|
2026-09-17, СМЕНА ШКАЛЫ (20 убийств = 1 уровень, см. шапку файла): «уровень
|
||||||
|
20» превратился в «уровень 1», убийств за ним по-прежнему 20. Сюда же, на
|
||||||
|
эту ступень, переехал мод ножа на воду - см. следующий комментарий. -->
|
||||||
|
<unlock_entry item="braceletSpatialVault,resourceBloodSphere,necroModKnifeTearsOfTheDead" unlock_tier="2"/>
|
||||||
<!-- ЧЕТЫРЕ МОДА НОЖА ПЕРЕЕХАЛИ СЮДА ИЗ ГРУППЫ 2, 2026-09-09. Продиктовано:
|
<!-- ЧЕТЫРЕ МОДА НОЖА ПЕРЕЕХАЛИ СЮДА ИЗ ГРУППЫ 2, 2026-09-09. Продиктовано:
|
||||||
"Питьё важно в тот же день. Оно должно быть доступно после 30 убитых зомби.
|
"Питьё важно в тот же день. Оно должно быть доступно после 30 убитых зомби.
|
||||||
Еда - 60. Это самые важные для начала выживания модификации. Модификация на
|
Еда - 60. Это самые важные для начала выживания модификации. Модификация на
|
||||||
@@ -126,11 +165,23 @@
|
|||||||
так принципиально").
|
так принципиально").
|
||||||
|
|
||||||
Порядок вода -> еда не случаен и задан пользователем прямо: пить хочется в
|
Порядок вода -> еда не случаен и задан пользователем прямо: пить хочется в
|
||||||
тот же день, есть - позже. -->
|
тот же день, есть - позже.
|
||||||
<unlock_entry item="necroModKnifeTearsOfTheDead" unlock_tier="3"/>
|
|
||||||
<unlock_entry item="necroModKnifeScavengersFeast" unlock_tier="4"/>
|
ПЕРЕСЧИТАНО 2026-09-17 ПОД ШКАЛУ 20-ЗА-УРОВЕНЬ (см. шапку файла). Сами
|
||||||
<unlock_entry item="necroModKnifeGravesRepose" unlock_tier="5"/>
|
пороги в убийствах не тронуты, кроме воды: 30 убийств на сетке с шагом 20 не
|
||||||
<unlock_entry item="necroModKnifeDarkSense" unlock_tier="6"/>
|
выражается, и по прямому указанию вода уехала ВНИЗ, на 20 - то есть на одну
|
||||||
|
ступень с браслетом и Кровавой сферой, а не вверх на 40. Ступеней в группе
|
||||||
|
теперь пять, а не шесть, и мод на воду стоит в строке tier 2 выше:
|
||||||
|
ур. 0 (0 убийств) - Камень духов, Нож, Синий портал, Пирамида
|
||||||
|
ур. 1 (20 убийств) - Браслет, Кровавая сфера, ВОДА
|
||||||
|
ур. 3 (60 убийств) - еда
|
||||||
|
ур. 5 (100 убийств) - покой
|
||||||
|
ур. 15 (300 убийств) - чутьё
|
||||||
|
Тег necroNecromancyLvl30 вместе с этим удалён - им больше никто не
|
||||||
|
пользуется, вода сидит на necroNecromancyLvl20. -->
|
||||||
|
<unlock_entry item="necroModKnifeScavengersFeast,bookSummonRatSpirit" unlock_tier="3"/>
|
||||||
|
<unlock_entry item="necroModKnifeGravesRepose" unlock_tier="4"/>
|
||||||
|
<unlock_entry item="necroModKnifeDarkSense" unlock_tier="5"/>
|
||||||
</display_entry>
|
</display_entry>
|
||||||
<!-- Ступенчатая разблокировка внутри группы. ПЕРЕРАСПРЕДЕЛЕНО 2026-09-09: четыре из
|
<!-- Ступенчатая разблокировка внутри группы. ПЕРЕРАСПРЕДЕЛЕНО 2026-09-09: четыре из
|
||||||
шести модов ножа (вода/еда/покой/чутьё) уехали отсюда в группу 1 - см. большой
|
шести модов ножа (вода/еда/покой/чутьё) уехали отсюда в группу 1 - см. большой
|
||||||
@@ -144,50 +195,115 @@
|
|||||||
где GetQualityLevel возвращает индекс первого порога, который БОЛЬШЕ текущего
|
где GetQualityLevel возвращает индекс первого порога, который БОЛЬШЕ текущего
|
||||||
уровня. В сумме это даёт простое правило: запись с unlock_tier="N" выходит
|
уровня. В сумме это даёт простое правило: запись с unlock_tier="N" выходит
|
||||||
из-под замка ровно на N-м значении unlock_level, считая с единицы. Поэтому здесь
|
из-под замка ровно на N-м значении unlock_level, считая с единицы. Поэтому здесь
|
||||||
tier 1 -> 500, tier 2 -> 1400, tier 3 -> 1700, а в группе 1 выше -
|
tier 1 -> ур. 25, tier 2 -> ур. 70, tier 3 -> ур. 85 (500 / 1400 / 1700
|
||||||
tier 1 -> 1, tier 2 -> 20, tier 3 -> 30, tier 4 -> 60, tier 5 -> 100,
|
убийств), а в группе 1 выше - tier 1 -> ур. 0, tier 2 -> ур. 1, tier 3 -> ур. 3,
|
||||||
tier 6 -> 300.
|
tier 4 -> ур. 5, tier 5 -> ур. 15 (0 / 20 / 60 / 100 / 300 убийств).
|
||||||
|
|
||||||
|
Нижняя граница ноль - законна и проверена по коду: у группы 1 unlock_level
|
||||||
|
начинается с 0, и GetQualityLevel(0) возвращает 1 (первый порог БОЛЬШЕ нуля -
|
||||||
|
это единица), поэтому tier 1 (после -1 это 0) из-под замка выходит сразу. Точно
|
||||||
|
так же законен level="0,250" у RecipeTagUnlocked ниже: PassiveEffect.InLevelRange
|
||||||
|
это ровно "_level >= _min && _level <= _max".
|
||||||
|
|
||||||
Заблокированная запись рисуется греем из АТЛАСА ItemIconAtlasGreyscale плюс
|
Заблокированная запись рисуется греем из АТЛАСА ItemIconAtlasGreyscale плюс
|
||||||
спрайт-замок ui_game_symbol_unlock поверх (XUi_InGame/windows.xml ~2523-2524,
|
спрайт-замок ui_game_symbol_unlock поверх (XUi_InGame/windows.xml ~2523-2524,
|
||||||
привязки unlock_icon_atlasN / unlock_icon_lockedN). Именно поэтому у мода теперь
|
привязки unlock_icon_atlasN / unlock_icon_lockedN). Именно поэтому у мода теперь
|
||||||
есть вторая папка UIAtlases/ItemIconAtlasGreyscale - без неё под замком у иконки
|
есть вторая папка UIAtlases/ItemIconAtlasGreyscale - без неё под замком у иконки
|
||||||
не было бы картинки вообще. -->
|
не было бы картинки вообще. -->
|
||||||
<display_entry icon="ScrollOfDeviation" name_key="craftingNecroNecromancyTier2Name" has_quality="false" unlock_level="500,1400,1700">
|
<!-- ПИТОМЦЫ РАЗНЕСЕНЫ ПО ШКАЛЕ 2026-09-18, прямое указание: "давай разнесём получение
|
||||||
<unlock_entry item="thrownBookGrimoireDeviation" unlock_tier="1"/>
|
питомцев по шкале скилла. Крыса ок, она доступна почти сразу. Собака пусть
|
||||||
<unlock_entry item="necroModKnifeDeadMansGrip" unlock_tier="2"/>
|
открывается на Подмастерье. Медведь на Ученик. Лютоволк - на 4000 зомби
|
||||||
<unlock_entry item="necroModKnifeDeadStorm" unlock_tier="3"/>
|
(200 уровень некромантии)."
|
||||||
|
|
||||||
|
До этого трое из четверых лежали в одной куче: Пёс, Жуки и Гриф на Ученике (100),
|
||||||
|
Медведь, Волк и Банши на Некроманте (150). Стало так:
|
||||||
|
|
||||||
|
Дух крысы ур. 3 (60 убийств) - тег necroNecromancyLvl60, Адепт
|
||||||
|
Зомбопёс ур. 25 (500) - necroNecromancyJourneyman, Подмастерье
|
||||||
|
Зомбомедведь ур. 100 (2000) - necroNecromancyApprentice, Ученик
|
||||||
|
Зомбоволк ур. 200 (4000) - necroNecromancyLvl4000, Некромант
|
||||||
|
|
||||||
|
ГРИФ И ПЁС ПОМЕНЯНЫ МЕСТАМИ 2026-09-18, после игровой проверки: "пожалуй, стоит
|
||||||
|
его поменять местами с собакой по скиллу. Он проще собаки, и при этом сейчас
|
||||||
|
стал полезнее". Гриф встал на 500 убийств (ур. 25), Пёс на 1300 (ур. 65).
|
||||||
|
Странность, из-за которой это и поменяли: у Грифа урон 20, у Пса 35, но Гриф
|
||||||
|
летает, сам возвращается к хозяину и разведывает - на практике он оказался
|
||||||
|
полезнее. Жуки на Ученике и Свиток банши на Некроманте не трогались.
|
||||||
|
|
||||||
|
ПРАВИЛО, КОТОРОЕ ЛЕГКО НАРУШИТЬ (уже наступали, см. комментарий про браслет выше):
|
||||||
|
уровень в теге рецепта и unlock_level того display_entry, где предмет нарисован,
|
||||||
|
ОБЯЗАНЫ совпадать. display_entry рисует замок сам по себе, по unlock_tier, и про
|
||||||
|
теги не знает - разойдутся, и на панели будет замок на доступном рецепте. -->
|
||||||
|
<display_entry icon="ScrollOfDeviation" name_key="craftingNecroNecromancyTier2Name" has_quality="false" unlock_level="25,65,70,85">
|
||||||
|
<unlock_entry item="thrownBookGrimoireDeviation,bookSummonZombieGriffin" unlock_tier="1"/>
|
||||||
|
<!-- Зомбогриф, 18.09: "гриф пусть тоже будет на подмастерье, но 1300 (65 уровень
|
||||||
|
некромантии)". 65 вставлен в unlock_level МЕЖДУ 25 и 70, поэтому unlock_tier
|
||||||
|
у двух модов ножа ниже сдвинулись на единицу (были 2 и 3, стали 3 и 4).
|
||||||
|
Список обязан идти по возрастанию: unlock_tier - это номер позиции в нём. -->
|
||||||
|
<unlock_entry item="bookSummonZombieDog" unlock_tier="2"/>
|
||||||
|
<unlock_entry item="necroModKnifeDeadMansGrip" unlock_tier="3"/>
|
||||||
|
<unlock_entry item="necroModKnifeDeadStorm" unlock_tier="4"/>
|
||||||
</display_entry>
|
</display_entry>
|
||||||
<display_entry icon="SummonZombieDog" name_key="craftingNecroNecromancyTier3Name" has_quality="false" unlock_level="2000">
|
<!-- Иконка сменена с SummonZombieDog на SummonZombieBear 18.09: Пёс уехал на
|
||||||
<unlock_entry item="bookSummonZombieDog,bookSummonInsectSwarm,bookSummonZombieGriffin" unlock_tier="1"/>
|
Подмастерье, и группу теперь возглавляет Медведь. -->
|
||||||
|
<display_entry icon="SummonZombieBear" name_key="craftingNecroNecromancyTier3Name" has_quality="false" unlock_level="100">
|
||||||
|
<!-- resourceBloodStone добавлен 2026-09-16 (указание «доступен на третьем грейде
|
||||||
|
некромантии, в самом его начале»). Тир 3 - это и есть третий грейд, группа
|
||||||
|
«Ученик», а его единственный unlock_level равен 100 уровням (это те же 2000
|
||||||
|
убийств в шкале 20-за-уровень), то есть unlock_tier="1" здесь и означает «в
|
||||||
|
самом начале грейда, без своего смещения».
|
||||||
|
|
||||||
|
ЭТА СТРОКА - ТОЛЬКО ОТОБРАЖЕНИЕ. Открытие рецепта делает тег
|
||||||
|
necroNecromancyApprentice в recipes.xml вместе с RecipeTagUnlocked
|
||||||
|
level="100,250" ниже; display_entry о тегах не знает и рисует замок сам по
|
||||||
|
себе, по unlock_tier. Поэтому 100 здесь и 100 там ОБЯЗАНЫ совпадать -
|
||||||
|
иначе замок на панели разойдётся с реальной доступностью рецепта. Совпадают:
|
||||||
|
один порог, одно значение. -->
|
||||||
|
<unlock_entry item="bookSummonZombieBear,bookSummonInsectSwarm,resourceBloodStone" unlock_tier="1"/>
|
||||||
</display_entry>
|
</display_entry>
|
||||||
<display_entry icon="SummonZombieBear" name_key="craftingNecroNecromancyTier4Name" has_quality="false" unlock_level="3000">
|
<!-- Две ступени вместо одной: у группы появился второй уровень (200), потому что
|
||||||
<unlock_entry item="bookSummonZombieBear,bookSummonZombieWolf,bookBanshee" unlock_tier="1"/>
|
Лютоволк теперь открывается позже Свитка банши. Иконка сменена на волчью -
|
||||||
|
Медведь уехал на Ученика. -->
|
||||||
|
<display_entry icon="SummonZombieWolf" name_key="craftingNecroNecromancyTier4Name" has_quality="false" unlock_level="150,200">
|
||||||
|
<unlock_entry item="bookBanshee" unlock_tier="1"/>
|
||||||
|
<unlock_entry item="bookSummonZombieWolf" unlock_tier="2"/>
|
||||||
</display_entry>
|
</display_entry>
|
||||||
<display_entry icon="BlackPortalStone" name_key="craftingNecroNecromancyTier5Name" has_quality="false" unlock_level="5000">
|
<display_entry icon="BlackPortalStone" name_key="craftingNecroNecromancyTier5Name" has_quality="false" unlock_level="250">
|
||||||
<unlock_entry item="thrownStonePortalBlack" unlock_tier="1"/>
|
<unlock_entry item="thrownStonePortalBlack" unlock_tier="1"/>
|
||||||
</display_entry>
|
</display_entry>
|
||||||
|
|
||||||
<effect_group>
|
<effect_group>
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="1,5000" value="1" tags="necroNecromancyAdept"/>
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="0,250" value="1" tags="necroNecromancyAdept"/>
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="20,5000" value="1" tags="necroNecromancyLvl20"/>
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="1,250" value="1" tags="necroNecromancyLvl20"/>
|
||||||
<!-- Пороги модов ножа, 2026-09-09 (см. комментарий в recipes.xml и в группе 1
|
<!-- Пороги модов ножа, 2026-09-09 (см. комментарий в recipes.xml и в группе 1
|
||||||
выше). Каждый уровень тут ОБЯЗАН совпадать с соответствующим значением в
|
выше). Каждый уровень тут ОБЯЗАН совпадать с соответствующим значением в
|
||||||
unlock_level того display_entry, где лежит мод, иначе замок на панели скилла
|
unlock_level того display_entry, где лежит мод, иначе замок на панели скилла
|
||||||
разойдётся с реальной доступностью рецепта: display_entry рисует замок сам по
|
разойдётся с реальной доступностью рецепта: display_entry рисует замок сам по
|
||||||
себе, по unlock_tier, и о тегах не знает.
|
себе, по unlock_tier, и о тегах не знает.
|
||||||
Lvl800 и Lvl1100 удалены вместе с этой правкой - ими больше никто не
|
Lvl800 и Lvl1100 удалены вместе с этой правкой - ими больше никто не
|
||||||
пользуется (Тёмное чутьё уехало на 300, Могильный покой на 100). -->
|
пользуется (Тёмное чутьё уехало на 300, Могильный покой на 100).
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="30,5000" value="1" tags="necroNecromancyLvl30"/>
|
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="60,5000" value="1" tags="necroNecromancyLvl60"/>
|
ЗНАЧЕНИЯ level= ПЕРЕСЧИТАНЫ 2026-09-17 В УРОВНИ (шкала 20 убийств = 1
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="100,5000" value="1" tags="necroNecromancyLvl100"/>
|
уровень, см. шапку файла). ИМЕНА ТЕГОВ ОСТАЛИСЬ В УБИЙСТВАХ и менять их не
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="300,5000" value="1" tags="necroNecromancyLvl300"/>
|
надо: necroNecromancyLvl60 - это «60 убийств», а стоит он на level="3,250".
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="500,5000" value="1" tags="necroNecromancyJourneyman"/>
|
Верхняя граница у всех теперь 250, а не 5000 - это max_level скилла, выше
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="1400,5000" value="1" tags="necroNecromancyLvl1400"/>
|
него уровень не поднимется, и диапазон обязан его накрывать. -->
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="1700,5000" value="1" tags="necroNecromancyLvl1700"/>
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="3,250" value="1" tags="necroNecromancyLvl60"/>
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="2000,5000" value="1" tags="necroNecromancyApprentice"/>
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="5,250" value="1" tags="necroNecromancyLvl100"/>
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="3000,5000" value="1" tags="necroNecromancyNecromancer"/>
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="15,250" value="1" tags="necroNecromancyLvl300"/>
|
||||||
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="5000,5000" value="1" tags="necroNecromancyMaster"/>
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="25,250" value="1" tags="necroNecromancyJourneyman"/>
|
||||||
|
<!-- Зомбогриф, 2026-09-18. 1300 убийств = 65-й уровень. -->
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="65,250" value="1" tags="necroNecromancyLvl1300"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="70,250" value="1" tags="necroNecromancyLvl1400"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="85,250" value="1" tags="necroNecromancyLvl1700"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="100,250" value="1" tags="necroNecromancyApprentice"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="150,250" value="1" tags="necroNecromancyNecromancer"/>
|
||||||
|
<!-- Лютоволк, 2026-09-18. Имя тега В УБИЙСТВАХ (4000), уровень - в уровнях (200):
|
||||||
|
шкала 20 убийств = 1 уровень, см. шапку файла. Это одноразовый порог внутри
|
||||||
|
группы "Некромант", а не новая группа - ровно та же схема, что у
|
||||||
|
necroNecromancyLvl20 и Lvl60 выше. -->
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="200,250" value="1" tags="necroNecromancyLvl4000"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="250,250" value="1" tags="necroNecromancyMaster"/>
|
||||||
</effect_group>
|
</effect_group>
|
||||||
</crafting_skill>
|
</crafting_skill>
|
||||||
</append>
|
</append>
|
||||||
|
|||||||
+111
-14
@@ -151,7 +151,7 @@
|
|||||||
personal crafting). workbenchCrafting is just the same UI-categorization tag vanilla's
|
personal crafting). workbenchCrafting is just the same UI-categorization tag vanilla's
|
||||||
own workbench recipes carry alongside craft_area, not a separate gate. -->
|
own workbench recipes carry alongside craft_area, not a separate gate. -->
|
||||||
<append xpath="/recipes">
|
<append xpath="/recipes">
|
||||||
<recipe name="bookSummonZombieDog" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyApprentice">
|
<recipe name="bookSummonZombieDog" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyLvl1300">
|
||||||
<ingredient name="foodRottingFlesh" count="50"/>
|
<ingredient name="foodRottingFlesh" count="50"/>
|
||||||
<ingredient name="medicalBloodBag" count="3"/>
|
<ingredient name="medicalBloodBag" count="3"/>
|
||||||
<ingredient name="drinkJarBoiledWater" count="4"/>
|
<ingredient name="drinkJarBoiledWater" count="4"/>
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
resourceFeather/foodRawMeat both verified to exist in vanilla items.xml before use, same
|
resourceFeather/foodRawMeat both verified to exist in vanilla items.xml before use, same
|
||||||
lesson as the earlier wrong guesses. -->
|
lesson as the earlier wrong guesses. -->
|
||||||
<append xpath="/recipes">
|
<append xpath="/recipes">
|
||||||
<recipe name="bookSummonZombieGriffin" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyApprentice">
|
<recipe name="bookSummonZombieGriffin" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
|
||||||
<ingredient name="resourceFeather" count="30"/>
|
<ingredient name="resourceFeather" count="30"/>
|
||||||
<ingredient name="resourceBone" count="20"/>
|
<ingredient name="resourceBone" count="20"/>
|
||||||
<ingredient name="casinoCoin" count="5"/>
|
<ingredient name="casinoCoin" count="5"/>
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
</recipe>
|
</recipe>
|
||||||
</append>
|
</append>
|
||||||
<append xpath="/recipes">
|
<append xpath="/recipes">
|
||||||
<recipe name="bookSummonZombieBear" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyNecromancer">
|
<recipe name="bookSummonZombieBear" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyApprentice">
|
||||||
<ingredient name="resourceLeather" count="20"/>
|
<ingredient name="resourceLeather" count="20"/>
|
||||||
<ingredient name="foodRawMeat" count="10"/>
|
<ingredient name="foodRawMeat" count="10"/>
|
||||||
<ingredient name="resourceBone" count="20"/>
|
<ingredient name="resourceBone" count="20"/>
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
</recipe>
|
</recipe>
|
||||||
</append>
|
</append>
|
||||||
<append xpath="/recipes">
|
<append xpath="/recipes">
|
||||||
<recipe name="bookSummonZombieWolf" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyNecromancer">
|
<recipe name="bookSummonZombieWolf" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyLvl4000">
|
||||||
<ingredient name="resourceLeather" count="15"/>
|
<ingredient name="resourceLeather" count="15"/>
|
||||||
<ingredient name="foodRawMeat" count="10"/>
|
<ingredient name="foodRawMeat" count="10"/>
|
||||||
<ingredient name="resourceBone" count="15"/>
|
<ingredient name="resourceBone" count="15"/>
|
||||||
@@ -296,16 +296,23 @@
|
|||||||
трупа, к этому моменту бесполезен. Теперь порог у мода стоит там, где мод реально нужен,
|
трупа, к этому моменту бесполезен. Теперь порог у мода стоит там, где мод реально нужен,
|
||||||
а не там, где он "по силе" смотрится ровно.
|
а не там, где он "по силе" смотрится ровно.
|
||||||
|
|
||||||
Итоговая раскладка:
|
Итоговая раскладка (убийства; в скобках уровень скилла в шкале 20-за-уровень,
|
||||||
30 - Слёзы мертвеца (вода) группа 1 "Адепт"
|
введённой 2026-09-17 - см. шапку progression.xml):
|
||||||
60 - Пир падальщика (еда) группа 1
|
20 - Слёзы мертвеца (вода) группа 1 "Адепт" ур. 1
|
||||||
100 - Могильный покой (тепло/холод) группа 1
|
60 - Пир падальщика (еда) группа 1 ур. 3
|
||||||
300 - Тёмное чутьё (радар) группа 1
|
100 - Могильный покой (тепло/холод) группа 1 ур. 5
|
||||||
1400 - Хватка мертвеца (замедление) группа 2 "Подмастерье"
|
300 - Тёмное чутьё (радар) группа 1 ур. 15
|
||||||
1700 - Мёртвая буря (силовая) группа 2
|
1400 - Хватка мертвеца (замедление) группа 2 "Подмастерье" ур. 70
|
||||||
|
1700 - Мёртвая буря (силовая) группа 2 ур. 85
|
||||||
Первые четыре - выживание и информация, они переехали в группу 1 к самому ножу (он там и
|
Первые четыре - выживание и информация, они переехали в группу 1 к самому ножу (он там и
|
||||||
доступен с уровня 1). Последние два - чистый бой, остались в группе 2 на прежних порогах:
|
доступен с нулевого уровня). Последние два - чистый бой, остались в группе 2 на прежних
|
||||||
пользователь про них сказал "дальше уже не так принципиально".
|
порогах: пользователь про них сказал "дальше уже не так принципиально".
|
||||||
|
|
||||||
|
ВОДА ПЕРЕЕХАЛА С 30 УБИЙСТВ НА 20, 2026-09-17. Причина не балансовая, а арифметическая:
|
||||||
|
новая шкала идёт шагом в 20 убийств, и 30 на неё не ложится. Из двух соседних узлов (20
|
||||||
|
или 40) пользователь выбрал 20 - "мод на воду нужен на самом начальном этапе". Тега
|
||||||
|
necroNecromancyLvl30 больше нет, рецепт сидит на necroNecromancyLvl20 - том же теге, что
|
||||||
|
браслет и Кровавая сфера.
|
||||||
|
|
||||||
Двигать - тройка "тег в рецепте + RecipeTagUnlocked в progression.xml + unlock_tier в
|
Двигать - тройка "тег в рецепте + RecipeTagUnlocked в progression.xml + unlock_tier в
|
||||||
display_entry", все три должны совпадать, иначе замок на панели соврёт. -->
|
display_entry", все три должны совпадать, иначе замок на панели соврёт. -->
|
||||||
@@ -325,7 +332,7 @@
|
|||||||
призывов и не должны требовать верстак. Тег learnable, как у остальных гейтованных
|
призывов и не должны требовать верстак. Тег learnable, как у остальных гейтованных
|
||||||
рецептов мода, чтобы рецепт не светился в меню до открытия группы. -->
|
рецептов мода, чтобы рецепт не светился в меню до открытия группы. -->
|
||||||
<append xpath="/recipes">
|
<append xpath="/recipes">
|
||||||
<recipe name="necroModKnifeTearsOfTheDead" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl30">
|
<recipe name="necroModKnifeTearsOfTheDead" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl20">
|
||||||
<ingredient name="resourceZombieAsh" count="10"/>
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
<ingredient name="resourceVictimSkin" count="1"/>
|
<ingredient name="resourceVictimSkin" count="1"/>
|
||||||
<ingredient name="drinkJarEmpty" count="2"/>
|
<ingredient name="drinkJarEmpty" count="2"/>
|
||||||
@@ -337,6 +344,21 @@
|
|||||||
<ingredient name="foodRawMeat" count="5"/>
|
<ingredient name="foodRawMeat" count="5"/>
|
||||||
<ingredient name="resourceYuccaFibers" count="10"/>
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
</recipe>
|
</recipe>
|
||||||
|
<!-- "Свиток духа крысы" - питомец начального уровня, указание 2026-09-18. Состав задан
|
||||||
|
дословно. craft_area НЕТ намеренно: свиток Зомбоволка требует верстак, но это питомец
|
||||||
|
ТРЕТЬЕГО уровня, а верстак в первые дни игрок не находит - ровно эта претензия пришла с
|
||||||
|
Nexus (Derizor, 14.09, про Пространственный браслет). Порог ставится туда, где предмет
|
||||||
|
нужен, значит и крафт должен быть доступен там же - в личном верстаке.
|
||||||
|
Тег necroNecromancyLvl60 - это "60 убийств", и он стоит на уровне 3 (progression.xml). -->
|
||||||
|
<recipe name="bookSummonRatSpirit" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl60">
|
||||||
|
<ingredient name="resourceCoal" count="5"/>
|
||||||
|
<ingredient name="resourceScrapIron" count="2"/>
|
||||||
|
<ingredient name="resourceVictimSkin" count="1"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="2"/>
|
||||||
|
<ingredient name="resourceCropCottonPlant" count="10"/>
|
||||||
|
<ingredient name="resourceBone" count="3"/>
|
||||||
|
<ingredient name="resourceWood" count="1"/>
|
||||||
|
</recipe>
|
||||||
<recipe name="necroModKnifeDeadMansGrip" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1400">
|
<recipe name="necroModKnifeDeadMansGrip" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1400">
|
||||||
<ingredient name="resourceZombieAsh" count="10"/>
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
<ingredient name="resourceBone" count="15"/>
|
<ingredient name="resourceBone" count="15"/>
|
||||||
@@ -376,4 +398,79 @@
|
|||||||
<ingredient name="resourceYuccaFibers" count="10"/>
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
</recipe>
|
</recipe>
|
||||||
</append>
|
</append>
|
||||||
|
|
||||||
|
<!-- Кровавая сфера, продиктовано 2026-09-15: «Станки не нужны. Ингридиенты: Кровь некроманта,
|
||||||
|
5 праха зомби. По одному рецепту изготавливается две сферы.»
|
||||||
|
|
||||||
|
craft_area нет - крафт личный, как у Камня духов и самого ножа: сфера дешевле призывов и
|
||||||
|
верстака не требует.
|
||||||
|
|
||||||
|
ПОРОГ ПЕРЕНЕСЁН С УРОВНЯ 1 НА 20, 2026-09-16. Изначально (15.09) рецепт стоял вовсе без
|
||||||
|
тега разблокировки по указанию «доступна на первом грейде» - а рецепт без тега доступен
|
||||||
|
всегда. Но сфера вставляется в Пространственный браслет, который открывается только на 20
|
||||||
|
убийствах (necroNecromancyLvl20), то есть девятнадцать уровней её можно было крафтить в
|
||||||
|
пустоту. Теперь порог общий с браслетом, по записанному правилу мода «пороги - по нужде, а
|
||||||
|
не по силе».
|
||||||
|
|
||||||
|
Тег learnable добавлен вместе с necroNecromancyLvl20 - как у всех остальных гейтованных
|
||||||
|
рецептов мода, чтобы рецепт не светился в меню до открытия. Парная правка в
|
||||||
|
progression.xml: сфера переехала в unlock_entry с unlock_tier="2", там же, где браслет.
|
||||||
|
|
||||||
|
Цена реальная, а не по списку: одна кровь некроманта стоит ещё и 90% текущего ХП на её
|
||||||
|
собственный крафт (NecromancerBloodPatch.cs). Две сферы за один заход это и учитывают. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="resourceBloodSphere" count="2" tags="learnable,packMuleCrafting,necroNecromancyLvl20">
|
||||||
|
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="5"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- Кровавый камень, продиктовано 2026-09-15: «Рецепт: 50 праха зомби, 20 костей, 4 флакона
|
||||||
|
обычной крови, 1 кровь некроманта. Рецепт будет доступен на третьем грейде некромантии
|
||||||
|
(в самом его начале)... Рабочее место - химическая станция.»
|
||||||
|
|
||||||
|
Все четыре имени проверены по файлам, а не по памяти:
|
||||||
|
resourceZombieAsh - свой предмет мода (Config/items.xml)
|
||||||
|
resourceBone - ванильный (Data/Config/items.xml:23781)
|
||||||
|
medicalBloodBag - ванильный (Data/Config/items.xml:19196)
|
||||||
|
resourceNecromancerBlood - свой предмет мода (Config/items.xml:1321). После отката
|
||||||
|
15.09 он снова обычный <item> в items.xml, а не модификация.
|
||||||
|
|
||||||
|
ЦЕНА ВЫШЕ, ЧЕМ ЧИТАЕТСЯ ПО СПИСКУ: одна кровь некроманта стоит ещё и 90% текущего ХП на
|
||||||
|
её собственный крафт (NecromancerBloodPatch.cs).
|
||||||
|
|
||||||
|
«ТРЕТИЙ ГРЕЙД, В САМОМ ЕГО НАЧАЛЕ» = ровно порог группы 3 «Ученик», без собственного
|
||||||
|
смещения, то есть тег necroNecromancyApprentice и уровень 2000 (progression.xml,
|
||||||
|
RecipeTagUnlocked level="2000,5000"). Тег уже заведён и уже работает - свой
|
||||||
|
necroNecromancyLvl*-тег здесь не нужен, такие одноразовые уровни в моде заведены ровно
|
||||||
|
один раз (necroNecromancyLvl20 для браслета) и считаются исключением, а не приёмом.
|
||||||
|
В progression.xml камень добавлен в display_entry третьего тира (unlock_level="2000")
|
||||||
|
строкой скилл-панели - это чисто отображение, механику открытия делает тег.
|
||||||
|
|
||||||
|
ДВА ИМЕНИ, КОТОРЫЕ ЛЕГКО ПЕРЕПУТАТЬ, выписаны проверенными по ванили (образцы ammoGasCan,
|
||||||
|
carBattery в Data/Config/recipes.xml): craft_area="chemistryStation" - ПОЛНОЕ слово, а
|
||||||
|
тег - chemStationCrafting, СОКРАЩЁННОЕ "chem", не "chemistry".
|
||||||
|
|
||||||
|
Тег learnable - как у остальных гейтованных рецептов мода, чтобы рецепт не светился в
|
||||||
|
меню до открытия группы.
|
||||||
|
|
||||||
|
ПОБОЧНОЕ СЛЕДСТВИЕ, замеченное заранее: химстанция добавляет камню ВТОРОЙ порог поверх
|
||||||
|
уровня 2000 - она открывается своим путём (перк/схема), с Некромантией не связанным.
|
||||||
|
Практически к 2000 уровню она у игрока почти наверняка есть, но формально «третий грейд»
|
||||||
|
перестаёт быть единственным условием. Ручка одна: убрать craft_area и вернуть личный
|
||||||
|
крафт.
|
||||||
|
|
||||||
|
БАЛАНСОВАЯ ЗАМЕТКА: слот у браслета один, значит камень с бесконечной прочностью не
|
||||||
|
дополняет Кровавую сферу, а ВЫТЕСНЯЕТ её в тот момент, когда игрок его получает. Судя по
|
||||||
|
формулировке («тот же флакон, но с бесконечной прочностью») это и задумано - камень это
|
||||||
|
эндгейм-версия расходника, уровень 2000 как раз про это. Записано, чтобы потом не
|
||||||
|
удивляться, что сфера перестала расходоваться. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="resourceBloodStone" count="1" craft_area="chemistryStation" tags="learnable,chemStationCrafting,necroNecromancyApprentice">
|
||||||
|
<ingredient name="resourceZombieAsh" count="50"/>
|
||||||
|
<ingredient name="resourceBone" count="20"/>
|
||||||
|
<ingredient name="medicalBloodBag" count="4"/>
|
||||||
|
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DIAGNOSTIC - measures the game's opaque block texture atlas and logs what it finds. Adds
|
||||||
|
/// nothing and changes nothing.
|
||||||
|
///
|
||||||
|
/// KEPT ON PURPOSE, though it started as throwaway reconnaissance for CustomBlockPaintPatch.
|
||||||
|
/// That patch appends our paint to the end of the atlas, so the block's Texture number in
|
||||||
|
/// blocks.xml (608 today) is simply "however many entries vanilla had". A game update that
|
||||||
|
/// grows the atlas moves it. This probe prints the real numbers on every load, which is what
|
||||||
|
/// turns that drift from a silent wrong texture into one line in the log.
|
||||||
|
///
|
||||||
|
/// WHY THIS EXISTS. The Pyramid of Spirits should ship with its own surface, but vanilla has
|
||||||
|
/// no way to add one: a block's Texture property is an INDEX into a prebuilt atlas, and the
|
||||||
|
/// atlas itself lives in blocktextureatlases_assets_all.bundle. Confirmed by reading the
|
||||||
|
/// game's own strings - a paint entry carries only TextureId/PaintCost/Group/SortIndex, never
|
||||||
|
/// a path to an image. So the only way in is to extend the atlas at runtime from a Harmony
|
||||||
|
/// patch.
|
||||||
|
///
|
||||||
|
/// Extending it means building a bigger Texture2DArray, copying every existing slice across
|
||||||
|
/// and appending ours. That REQUIRES knowing the array's exact width, height, format and
|
||||||
|
/// mipmap count - a slice that disagrees on any of those cannot be copied in. None of it can
|
||||||
|
/// be known statically, hence this probe: measure first, write the real patch second.
|
||||||
|
///
|
||||||
|
/// TWO LESSONS FROM THE FIRST ATTEMPT, both paid for in a broken load:
|
||||||
|
///
|
||||||
|
/// 1. A THROWING POSTFIX BREAKS THE GAME'S LOADING. The first version dereferenced
|
||||||
|
/// BlockTextureData.list without checking it, threw, and the log answered with
|
||||||
|
/// "XML loader: Executing post load step on 'materials.xml' failed". A probe must be
|
||||||
|
/// incapable of harm, so everything here is wrapped and nothing is allowed to escape.
|
||||||
|
///
|
||||||
|
/// 2. THIS RUNS BEFORE THE PAINT TABLE EXISTS. ReloadTextureArrays fires during
|
||||||
|
/// MeshDescription.Init, and the log shows painting.xml loading well after it - so
|
||||||
|
/// BlockTextureData.list is still null at that point. Hence the probe reports several
|
||||||
|
/// times instead of once: the early call shows the atlas as loaded, later calls show it
|
||||||
|
/// once the rest of the game has caught up.
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(MeshDescription), "ReloadTextureArrays")]
|
||||||
|
public static class BlockAtlasProbePatch
|
||||||
|
{
|
||||||
|
const int MaxReports = 4;
|
||||||
|
static int reports;
|
||||||
|
|
||||||
|
static void Postfix()
|
||||||
|
{
|
||||||
|
if (reports >= MaxReports) return;
|
||||||
|
reports++;
|
||||||
|
// Never let a measurement break a load: the game calls this from inside its own
|
||||||
|
// XML post-load step, and an escaping exception aborts that step.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LogOpaqueAtlas(reports);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] BlockAtlasProbe: measurement #" + reports +
|
||||||
|
" failed harmlessly: " + e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LogOpaqueAtlas(int report)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("[NecromancerTome] BlockAtlasProbe #" + report + ": opaque block atlas");
|
||||||
|
|
||||||
|
if (MeshDescription.meshes == null)
|
||||||
|
{
|
||||||
|
sb.AppendLine(" MeshDescription.meshes is null - too early");
|
||||||
|
Debug.Log(sb.ToString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
|
||||||
|
if (mesh == null)
|
||||||
|
{
|
||||||
|
sb.AppendLine(" MESH_OPAQUE is null - too early");
|
||||||
|
Debug.Log(sb.ToString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var atlas = mesh.textureAtlas as TextureAtlasBlocks;
|
||||||
|
if (atlas == null)
|
||||||
|
{
|
||||||
|
sb.AppendLine(" textureAtlas is " + (mesh.textureAtlas == null
|
||||||
|
? "null" : mesh.textureAtlas.GetType().Name) + ", expected TextureAtlasBlocks");
|
||||||
|
Debug.Log(sb.ToString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(" uvMapping entries: " +
|
||||||
|
(atlas.uvMapping == null ? "null" : atlas.uvMapping.Length.ToString()));
|
||||||
|
Describe(sb, "diffuse ", atlas.diffuseTexture);
|
||||||
|
Describe(sb, "normal ", atlas.normalTexture);
|
||||||
|
Describe(sb, "specular", atlas.specularTexture);
|
||||||
|
|
||||||
|
// A new paint needs an unused index in BlockTextureData.list. The table is filled
|
||||||
|
// from painting.xml, which loads AFTER the textures - so on the early call this is
|
||||||
|
// still null, and that is expected rather than a fault.
|
||||||
|
if (BlockTextureData.list == null)
|
||||||
|
{
|
||||||
|
sb.AppendLine(" paint table: not built yet (painting.xml loads later)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int used = 0, free = 0;
|
||||||
|
for (int i = 0; i < BlockTextureData.list.Length; i++)
|
||||||
|
{
|
||||||
|
if (BlockTextureData.list[i] == null) free++; else used++;
|
||||||
|
}
|
||||||
|
sb.AppendLine(" paint slots: " + used + " used, " + free + " free, " +
|
||||||
|
BlockTextureData.list.Length + " total");
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Describe(StringBuilder sb, string label, Texture texture)
|
||||||
|
{
|
||||||
|
if (texture == null)
|
||||||
|
{
|
||||||
|
sb.AppendLine(" " + label + ": null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var arr = texture as Texture2DArray;
|
||||||
|
if (arr == null)
|
||||||
|
{
|
||||||
|
sb.AppendLine(" " + label + ": " + texture.GetType().Name +
|
||||||
|
" (expected Texture2DArray) " + texture.width + "x" + texture.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// depth = how many slices are already in the array; ours would become index `depth`,
|
||||||
|
// and every number below has to be matched exactly by our own texture.
|
||||||
|
sb.AppendLine(" " + label + ": " + arr.width + "x" + arr.height +
|
||||||
|
" slices=" + arr.depth +
|
||||||
|
" format=" + arr.format +
|
||||||
|
" graphicsFormat=" + arr.graphicsFormat +
|
||||||
|
" mips=" + arr.mipmapCount +
|
||||||
|
" readable=" + arr.isReadable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drains the colour out of the world while the necromancer is channelling something, and
|
||||||
|
/// lets it back in when he stops (user request 2026-09-14: "эффект, когда мир становится
|
||||||
|
/// тёмным и чёрнобелым... повесить его на момент ожидания применения порталов и на момент
|
||||||
|
/// ожидания утаскивания блока", with "желательно плавно... секунды за 3" in both directions).
|
||||||
|
/// Shared by both channels so the look, the timing and the name of the effect live in one
|
||||||
|
/// place rather than drifting apart in two files.
|
||||||
|
///
|
||||||
|
/// IT IS THE GAME'S OWN POST-PROCESS, not a reimplementation. EntityPlayerLocal carries a
|
||||||
|
/// ScreenEffects component - ScreenEffectManager - whose SetScreenEffect(name, intensity,
|
||||||
|
/// fadeTime) is what the engine itself calls for dying ("Dying"), for spawning in
|
||||||
|
/// ("VibrantDeSat") and for every buff in the game that tints the screen. THE FADE IS THAT
|
||||||
|
/// THIRD ARGUMENT: three seconds in and three seconds out cost nothing to implement, because
|
||||||
|
/// the ramp is the effect system's own.
|
||||||
|
///
|
||||||
|
/// THE EFFECT IS "Greyscale", AND THE CHOICE IS ABOUT WHO ELSE TOUCHES IT. These effects are
|
||||||
|
/// a flat namespace of materials loaded from Resources/ScreenEffects - anyone writing to a
|
||||||
|
/// name overwrites whatever was there, so picking one is mostly picking a fight to avoid:
|
||||||
|
///
|
||||||
|
/// - "Greyscale" is written by exactly two things in the whole game, twitch_buffMonochrome
|
||||||
|
/// and sandbox_blackandwhite - a Twitch-integration reward and a game-mode toggle. Neither
|
||||||
|
/// happens in an ordinary session, so the channel owns it in practice.
|
||||||
|
/// - "Dying" and "Dead" are the death visuals the user was describing, and they are exactly
|
||||||
|
/// the ones NOT to borrow: EntityPlayerLocal.Update writes "Dying" from the player's own
|
||||||
|
/// health every time it changes, so any damage mid-channel would take the effect over -
|
||||||
|
/// and being hit mid-channel is a thing that happens.
|
||||||
|
/// - "Dark" would have supplied the darkening half. It belongs to buffCrouching, which
|
||||||
|
/// fires on every crouch with a 0.2s fade and would stamp on this one.
|
||||||
|
///
|
||||||
|
/// SO THE DARKENING HALF IS DELIBERATELY NOT DONE. Both effects that dim the screen are owned
|
||||||
|
/// by something that fights for them - crouching, and dying - and losing that fight looks like
|
||||||
|
/// a bug in this mod rather than in the effect system. Greyscale alone reads as the world
|
||||||
|
/// going wrong, which is what was actually asked for; if it wants to be darker too, the list
|
||||||
|
/// below takes a second entry and nothing else changes.
|
||||||
|
///
|
||||||
|
/// NOTHING HERE TOUCHES INPUT. The effect is a camera post-process and outlives the timer
|
||||||
|
/// window on purpose: the three-second fade back keeps running while the player walks away,
|
||||||
|
/// which is the point of asking for a fade rather than a switch.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChannelVision
|
||||||
|
{
|
||||||
|
/// <summary>Seconds to fade in, and to fade back out.</summary>
|
||||||
|
public const float FadeSeconds = 3f;
|
||||||
|
|
||||||
|
/// <summary>What to fade, and how far. A list rather than a single name so a second layer
|
||||||
|
/// is one entry and not a rewrite - see the class comment on the darkening half.</summary>
|
||||||
|
public static readonly string[] EffectNames = { "Greyscale" };
|
||||||
|
|
||||||
|
/// <summary>Full strength per effect, in the same order as EffectNames.</summary>
|
||||||
|
public static readonly float[] EffectIntensities = { 1f };
|
||||||
|
|
||||||
|
/// <summary>Colour drains out over FadeSeconds.</summary>
|
||||||
|
public static void Begin(EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
Apply(_player, _fullStrength: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Colour comes back over FadeSeconds. Safe to call when nothing is running -
|
||||||
|
/// fading an effect that is already at zero to zero does nothing - which is what lets
|
||||||
|
/// every exit path call it without first working out whether it is the one that has to.
|
||||||
|
/// </summary>
|
||||||
|
public static void End(EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
Apply(_player, _fullStrength: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Apply(EntityPlayerLocal _player, bool _fullStrength)
|
||||||
|
{
|
||||||
|
if (_player == null || _player.ScreenEffectManager == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < EffectNames.Length; i++)
|
||||||
|
{
|
||||||
|
float intensity = _fullStrength ? EffectIntensities[i] : 0f;
|
||||||
|
_player.ScreenEffectManager.SetScreenEffect(EffectNames[i], intensity, FadeSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,4 +131,103 @@ namespace NecromancerTome
|
|||||||
Debug.Log("[NecromancerTome] CharmZombie: done, attack target cleared for " + zombie.EntityName);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the mod's own paint to the game's opaque block texture atlas, so the Pyramid of
|
||||||
|
/// Spirits can ship with a surface that does not exist in vanilla.
|
||||||
|
///
|
||||||
|
/// WHY A PATCH IS THE ONLY WAY. A block's Texture property is an INDEX into a prebuilt
|
||||||
|
/// atlas; a paint entry in painting.xml carries TextureId/PaintCost/Group/SortIndex and
|
||||||
|
/// never a path to an image, and the atlas itself is compiled into
|
||||||
|
/// blocktextureatlases_assets_all.bundle. Nothing in the XML layer can introduce new image
|
||||||
|
/// data, so the array has to be extended at runtime.
|
||||||
|
///
|
||||||
|
/// WHY THIS HOOK. CreateBlockTextures is the coroutine that reads painting.xml. Hooking its
|
||||||
|
/// completion is deliberate and was learned the hard way: an earlier probe ran from
|
||||||
|
/// MeshDescription.ReloadTextureArrays and found BlockTextureData.list still null, because
|
||||||
|
/// the texture arrays load BEFORE painting.xml. By the time this coroutine finishes, both the
|
||||||
|
/// arrays and the paint table exist.
|
||||||
|
///
|
||||||
|
/// THE NUMBERS THIS RELIES ON were measured in-game rather than assumed (BlockAtlasProbe,
|
||||||
|
/// 2026-09-10): the opaque atlas holds 407 slices of 512x512 with a full 10-level mip chain,
|
||||||
|
/// diffuse as DXT1 and normal/specular as DXT5, and all three arrays are non-readable. Two
|
||||||
|
/// consequences drive the code below - our textures must match those numbers exactly, and
|
||||||
|
/// every copy must go through the GPU, since a non-readable array cannot be read back.
|
||||||
|
///
|
||||||
|
/// SAFETY. Everything is wrapped: this runs inside the game's own XML loading, and an
|
||||||
|
/// escaping exception aborts that step - which is exactly how a careless earlier version
|
||||||
|
/// produced "XML loader: Executing post load step on 'materials.xml' failed". If anything
|
||||||
|
/// here fails, the mod logs it and leaves the game exactly as it was.
|
||||||
|
/// </summary>
|
||||||
|
public static class CustomBlockPaintPatch
|
||||||
|
{
|
||||||
|
/// <summary>Bundle we ship the paint textures in, relative to the mod folder.</summary>
|
||||||
|
const string BundlePath = "Resources/necroatlas";
|
||||||
|
|
||||||
|
const string DiffuseAsset = "Assets/NecroAtlas/atlas_necroPyramid_d.png";
|
||||||
|
const string NormalAsset = "Assets/NecroAtlas/atlas_necroPyramid_n.png";
|
||||||
|
const string SpecularAsset = "Assets/NecroAtlas/atlas_necroPyramid_m.png";
|
||||||
|
|
||||||
|
/// <summary>Name the paint is registered under; blocks.xml refers to the resulting id.</summary>
|
||||||
|
public const string PaintName = "txName_NecroAsh";
|
||||||
|
|
||||||
|
/// <summary>Paint id handed out by the game once registration succeeds, -1 while unset.
|
||||||
|
/// Logged on success so it can be written into blocks.xml.</summary>
|
||||||
|
public static int AssignedPaintId = -1;
|
||||||
|
|
||||||
|
static bool alreadyRan;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Patch the coroutine's MoveNext. A coroutine compiles into a hidden state-machine
|
||||||
|
/// class, so the method that actually runs is MoveNext, not CreateBlockTextures itself -
|
||||||
|
/// AccessTools.EnumeratorMoveNext resolves it for us.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch]
|
||||||
|
static class CreateBlockTexturesHook
|
||||||
|
{
|
||||||
|
static IEnumerable<MethodBase> TargetMethods()
|
||||||
|
{
|
||||||
|
MethodBase coroutine = AccessTools.Method(
|
||||||
|
typeof(BlockTexturesFromXML), "CreateBlockTextures");
|
||||||
|
if (coroutine == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
|
||||||
|
"BlockTexturesFromXML.CreateBlockTextures not found - paint not added");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
MethodBase moveNext = AccessTools.EnumeratorMoveNext(coroutine);
|
||||||
|
if (moveNext == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
|
||||||
|
"could not resolve the coroutine's MoveNext - paint not added");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
yield return moveNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// __result == false means the enumerator is done: the XML has been read in full.
|
||||||
|
static void Postfix(bool __result)
|
||||||
|
{
|
||||||
|
if (__result || alreadyRan) return;
|
||||||
|
alreadyRan = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AddPaint();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: failed, game left " +
|
||||||
|
"untouched: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AddPaint()
|
||||||
|
{
|
||||||
|
if (GameManager.IsDedicatedServer)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] CustomBlockPaint: dedicated server, textures skipped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
|
||||||
|
var atlas = mesh == null ? null : mesh.textureAtlas as TextureAtlasBlocks;
|
||||||
|
if (atlas == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: opaque atlas unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetBundle bundle = LoadBundle();
|
||||||
|
if (bundle == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var diffuse = bundle.LoadAsset<Texture2D>(DiffuseAsset);
|
||||||
|
var normal = bundle.LoadAsset<Texture2D>(NormalAsset);
|
||||||
|
var specular = bundle.LoadAsset<Texture2D>(SpecularAsset);
|
||||||
|
if (diffuse == null || normal == null || specular == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle is missing one " +
|
||||||
|
"of the three textures - nothing added");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe("our diffuse ", diffuse);
|
||||||
|
Describe("our normal ", normal);
|
||||||
|
Describe("our specular", specular);
|
||||||
|
|
||||||
|
int slice = Append(ref atlas.diffuseTexture, diffuse, "diffuse");
|
||||||
|
Append(ref atlas.normalTexture, normal, "normal");
|
||||||
|
Append(ref atlas.specularTexture, specular, "specular");
|
||||||
|
if (slice < 0) return;
|
||||||
|
|
||||||
|
mesh.TexDiffuse = atlas.diffuseTexture;
|
||||||
|
mesh.TexNormal = atlas.normalTexture;
|
||||||
|
mesh.TexSpecular = atlas.specularTexture;
|
||||||
|
mesh.ReloadTextureArrays(false);
|
||||||
|
|
||||||
|
int textureId = RegisterUvMapping(atlas, slice);
|
||||||
|
RegisterPaint(textureId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Keep the loaded textures alive: only the bundle wrapper is released.
|
||||||
|
bundle.Unload(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Give the new slice an entry in uvMapping and return its index - that index is what a
|
||||||
|
/// block's Texture property in blocks.xml actually refers to.
|
||||||
|
///
|
||||||
|
/// The entry is CLONED from an existing plain opaque paint rather than built field by
|
||||||
|
/// field. UVRectTiling carries more than a slice number (tiling, block size, material
|
||||||
|
/// flags), and copying a known-good neighbour keeps every one of those correct without
|
||||||
|
/// guessing at fields we have never inspected. Only the slice index is changed.
|
||||||
|
/// </summary>
|
||||||
|
static int RegisterUvMapping(TextureAtlasBlocks atlas, int slice)
|
||||||
|
{
|
||||||
|
// 356 is txName_Steel_wall - an ordinary full-block opaque paint, which is exactly
|
||||||
|
// the shape of entry we want.
|
||||||
|
const int TemplateTextureId = 356;
|
||||||
|
if (atlas.uvMapping == null || atlas.uvMapping.Length <= TemplateTextureId)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: uvMapping too small to " +
|
||||||
|
"clone a template from - paint not registered");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int textureId = atlas.uvMapping.Length;
|
||||||
|
Array.Resize(ref atlas.uvMapping, textureId + 1);
|
||||||
|
UVRectTiling tile = atlas.uvMapping[TemplateTextureId];
|
||||||
|
// Только индекс слоя: имени у UVRectTiling нет, оно живёт в BlockTextureData.
|
||||||
|
tile.index = slice;
|
||||||
|
atlas.uvMapping[textureId] = tile;
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] CustomBlockPaint: uvMapping entry " + textureId +
|
||||||
|
" points at slice " + slice + " (cloned from " + TemplateTextureId + ")");
|
||||||
|
return textureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the paint itself, so it has a name, shows up in the paint brush, and can be
|
||||||
|
/// referred to by name. The block only needs the texture id, but a nameless texture with
|
||||||
|
/// no paint entry would be invisible to the rest of the game.
|
||||||
|
/// </summary>
|
||||||
|
static void RegisterPaint(int textureId)
|
||||||
|
{
|
||||||
|
if (textureId < 0) return;
|
||||||
|
if (BlockTextureData.list == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: paint table missing - " +
|
||||||
|
"texture added but not named");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int free = -1;
|
||||||
|
for (int i = 0; i < BlockTextureData.list.Length; i++)
|
||||||
|
{
|
||||||
|
if (BlockTextureData.list[i] == null) { free = i; break; }
|
||||||
|
}
|
||||||
|
if (free < 0)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: no free paint slot - " +
|
||||||
|
"texture added but not named");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new BlockTextureData
|
||||||
|
{
|
||||||
|
ID = free,
|
||||||
|
Name = PaintName,
|
||||||
|
LocalizedName = Localization.Get(PaintName),
|
||||||
|
TextureID = (ushort)textureId,
|
||||||
|
Group = "txGroupMasonry",
|
||||||
|
PaintCost = 1,
|
||||||
|
SortIndex = 0,
|
||||||
|
Hidden = false,
|
||||||
|
};
|
||||||
|
data.Init();
|
||||||
|
|
||||||
|
AssignedPaintId = free;
|
||||||
|
Debug.Log("[NecromancerTome] CustomBlockPaint: paint registered, slot " + free +
|
||||||
|
", texture id " + textureId + " -> put Texture=\"" + textureId +
|
||||||
|
"\" on the block in blocks.xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
static AssetBundle LoadBundle()
|
||||||
|
{
|
||||||
|
if (ModEntry.Instance == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: mod path unknown");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string path = Path.Combine(ModEntry.Instance.Path, BundlePath);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle not found at " + path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetBundle bundle = AssetBundle.LoadFromFile(path);
|
||||||
|
if (bundle == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle failed to load: " + path);
|
||||||
|
}
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuild a texture array one slice larger and put our texture in the new last slot.
|
||||||
|
/// Returns the new slice index, or -1 if the arrays disagree on anything that makes a
|
||||||
|
/// copy impossible.
|
||||||
|
/// </summary>
|
||||||
|
static int Append(ref Texture target, Texture2D ours, string label)
|
||||||
|
{
|
||||||
|
var src = target as Texture2DArray;
|
||||||
|
if (src == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label +
|
||||||
|
" is not a Texture2DArray - skipped");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ours.width != src.width || ours.height != src.height)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " size mismatch, " +
|
||||||
|
"atlas is " + src.width + "x" + src.height + " but ours is " +
|
||||||
|
ours.width + "x" + ours.height + " - skipped");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (ours.graphicsFormat != src.graphicsFormat)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " format mismatch, " +
|
||||||
|
"atlas is " + src.graphicsFormat + " but ours is " + ours.graphicsFormat +
|
||||||
|
" - skipped");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (ours.mipmapCount != src.mipmapCount)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " mip mismatch, " +
|
||||||
|
"atlas has " + src.mipmapCount + " but ours has " + ours.mipmapCount +
|
||||||
|
" - skipped");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int slice = src.depth;
|
||||||
|
var grown = new Texture2DArray(src.width, src.height, slice + 1,
|
||||||
|
src.graphicsFormat, UnityEngine.Experimental.Rendering.TextureCreationFlags.MipChain,
|
||||||
|
src.mipmapCount);
|
||||||
|
grown.name = src.name + "+necro";
|
||||||
|
grown.wrapMode = src.wrapMode;
|
||||||
|
grown.filterMode = src.filterMode;
|
||||||
|
grown.anisoLevel = src.anisoLevel;
|
||||||
|
|
||||||
|
// GPU-side copy: the game's arrays are non-readable, so nothing can be pulled back
|
||||||
|
// to the CPU. CopyTexture moves whole slices with their mip chains.
|
||||||
|
for (int i = 0; i < slice; i++) Graphics.CopyTexture(src, i, grown, i);
|
||||||
|
Graphics.CopyTexture(ours, 0, grown, slice);
|
||||||
|
|
||||||
|
target = grown;
|
||||||
|
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " grown from " + slice +
|
||||||
|
" to " + (slice + 1) + " slices");
|
||||||
|
return slice;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Describe(string label, Texture2D tex)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " " +
|
||||||
|
tex.width + "x" + tex.height + " format=" + tex.format +
|
||||||
|
" graphicsFormat=" + tex.graphicsFormat + " mips=" + tex.mipmapCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ namespace NecromancerTome
|
|||||||
/// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
|
/// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
|
||||||
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
|
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
|
||||||
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
|
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
|
||||||
/// PortalStonePatch.ActivateBlackPortal). Как и вся остальная UI-часть этого мода, сцена
|
/// FinishEnding). Как и вся остальная UI-часть этого мода, сцена
|
||||||
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
|
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
|
||||||
/// ограничение самой ванили, а не мода.
|
/// ограничение самой ванили, а не мода.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -258,14 +258,12 @@ namespace NecromancerTome
|
|||||||
/// "Конец".
|
/// "Конец".
|
||||||
///
|
///
|
||||||
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
|
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
|
||||||
/// из финала"). Сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в
|
/// из финала"). Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm тогда же
|
||||||
/// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая
|
/// удалены из мода перед публикацией: все три были побайтовой копией ванильного
|
||||||
/// код по кускам.
|
|
||||||
///
|
|
||||||
/// Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm УДАЛЕНЫ ИЗ МОДА
|
|
||||||
/// 2026-09-09 перед публикацией: все три были побайтовой копией ванильного
|
|
||||||
/// TFP_Intro.webm (заглушка для тестов), а раздавать чужой ассет игры в релизе нельзя.
|
/// TFP_Intro.webm (заглушка для тестов), а раздавать чужой ассет игры в релизе нельзя.
|
||||||
/// Настоящее видео класть под тем же именем.
|
/// Мёртвый код проигрывания видео (PortalStonePatch.PlayBlackPortalVideoLegacy и
|
||||||
|
/// константа с путём) убран 2026-09-10 - живой пример того же вызова, если видео
|
||||||
|
/// понадобится вернуть, остался в NoteFlashbackPatch.cs.
|
||||||
///
|
///
|
||||||
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
|
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
|
||||||
/// текст показывается сразу.</summary>
|
/// текст показывается сразу.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine.Scripting;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// `necroghost [percent|reset]` - turns the traders' transparency live, without a rebuild
|
||||||
|
/// (user request 2026-09-14, right after the alpha went from 1% to 10%: "Сделай консольную
|
||||||
|
/// команду на альфу, чтобы крутить в игре"). The number being hunted - "a ghost, not a broken
|
||||||
|
/// model" - can only be judged by looking at him, and every step of that hunt otherwise costs
|
||||||
|
/// an edit, a `dotnet build`, a restart and the four-minute walk back to a trader, because
|
||||||
|
/// traders are streamed in on approach. This collapses the loop to one line in the console.
|
||||||
|
///
|
||||||
|
/// IT TAKES PERCENT OF TRANSPARENCY, NOT ALPHA, and that is deliberate: percent is the unit
|
||||||
|
/// the request has been made in twice ("буквально 1%", "пусть будет 10%"), while alpha is the
|
||||||
|
/// unit the renderer wants, and they run in opposite directions - 10% transparent is alpha
|
||||||
|
/// 0.9. Guessing which one a typed "10" meant would be a coin flip, so the command fixes the
|
||||||
|
/// unit and prints both back every time.
|
||||||
|
///
|
||||||
|
/// NOTHING IS PERSISTED. The value lives for the session; the one that turns out to be right
|
||||||
|
/// gets written into GhostTraderPatch.DefaultGhostAlpha, which is the line a release ships.
|
||||||
|
/// A settings file would just be a second place for the answer to hide.
|
||||||
|
///
|
||||||
|
/// WHY THE GAME FINDS THIS CLASS WITHOUT ANY REGISTRATION. SdtdConsole.RegisterCommands goes
|
||||||
|
/// through ReflectionHelpers.FindTypesImplementingBase(typeof(IConsoleCommand)), and that
|
||||||
|
/// walks ModManager.GetLoadedAssemblies() alongside the game's own - so a ConsoleCmdAbstract
|
||||||
|
/// in a mod DLL is picked up like any vanilla one. Ordering is not a gamble either:
|
||||||
|
/// GameManager calls ModManager.LoadMods() three lines before RegisterCommands().
|
||||||
|
///
|
||||||
|
/// IsExecuteOnClient IS true BECAUSE THIS CHANGES PIXELS. On a server the command would
|
||||||
|
/// otherwise run where there is nothing to look at; the flag makes the server bounce it back
|
||||||
|
/// to the client that typed it (ConnectionManager.ServerConsoleCommand), which is where the
|
||||||
|
/// materials and the eyes are. In single player it changes nothing.
|
||||||
|
/// </summary>
|
||||||
|
[Preserve]
|
||||||
|
public class ConsoleCmdNecroGhost : ConsoleCmdAbstract
|
||||||
|
{
|
||||||
|
public override bool IsExecuteOnClient => true;
|
||||||
|
|
||||||
|
public override bool AllowedInMainMenu => false;
|
||||||
|
|
||||||
|
public override string[] getCommands()
|
||||||
|
{
|
||||||
|
return new string[] { "necroghost", "necrotrader" };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string getDescription()
|
||||||
|
{
|
||||||
|
return "Necromancer's Tome: how transparent the ghost traders are, in percent.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string getHelp()
|
||||||
|
{
|
||||||
|
return "necroghost - show the current value and mode\n" +
|
||||||
|
"necroghost <0-100> - set transparency in percent (10 = the default, barely there;\n" +
|
||||||
|
" 30 = clearly a ghost; past ~30 he stops reading as a body)\n" +
|
||||||
|
"necroghost blend - fade the body by blending (re-shades it; smooth)\n" +
|
||||||
|
"necroghost dither - fade the body by dithering (the game's own _Fade; grainy)\n" +
|
||||||
|
"necroghost reset - back to the built-in default value and mode\n" +
|
||||||
|
"\n" +
|
||||||
|
"Applies to traders already in the world, immediately - walk up to one first and\n" +
|
||||||
|
"watch him while you type. Not saved: tell the mod author what you settled on.\n" +
|
||||||
|
"\n" +
|
||||||
|
"THE MODES ARE NOT DEGREES OF ONE THING. The body's own shader cannot blend, so the\n" +
|
||||||
|
"game fades it by throwing pixels away in a pattern - that is the fine grid. Blend\n" +
|
||||||
|
"re-shades the body onto the hair's shader, which has a transparent pass, at the\n" +
|
||||||
|
"cost of the character shader's own lighting. The hair fades the same way either\n" +
|
||||||
|
"way, so it is the body you compare.\n" +
|
||||||
|
"\n" +
|
||||||
|
"If he comes apart instead of fading - teeth through the cheek, an arm through the\n" +
|
||||||
|
"chest - that is not this number, that is depth writing, and no value here will fix it.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||||
|
{
|
||||||
|
if (_params.Count == 0)
|
||||||
|
{
|
||||||
|
Report("Ghost traders");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string argument = _params[0].Trim();
|
||||||
|
if (argument.EqualsCaseInsensitive("reset"))
|
||||||
|
{
|
||||||
|
GhostTraderPatch.GhostAlpha = GhostTraderPatch.DefaultGhostAlpha;
|
||||||
|
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Reset");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argument.EqualsCaseInsensitive("blend"))
|
||||||
|
{
|
||||||
|
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Body mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argument.EqualsCaseInsensitive("dither"))
|
||||||
|
{
|
||||||
|
SetMode(GhostTraderPatch.BodyOpacityMode.Dither, "Body mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParsePercent(argument, out float percent))
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
"necroghost: '" + argument + "' is neither a percentage nor blend/dither/reset. " +
|
||||||
|
"Try 'necroghost 10', or 'help necroghost'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percent < 0f || percent > 100f)
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
"necroghost: " + percent.ToString("0.#") + "% is outside 0-100. 0 = solid, 100 = invisible.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GhostTraderPatch.GhostAlpha = 1f - percent / 100f;
|
||||||
|
Report("Set");
|
||||||
|
|
||||||
|
// Said only when asked for, and only once the value is actually past the point where
|
||||||
|
// the two failure modes stop looking different - see GhostTraderPatch.GhostAlpha.
|
||||||
|
if (GhostTraderPatch.GhostAlpha < 0.7f)
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
" (past ~30% the silhouette stops reading as a solid body at all, which looks like " +
|
||||||
|
"a broken model for a different reason than depth writing does)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Switches how the body is faded and rebuilds the traders already standing, which
|
||||||
|
/// is the expensive path - the materials have to be built again from the originals, since
|
||||||
|
/// a re-shaded material cannot be un-re-shaded. Changing only the number never comes here.
|
||||||
|
/// Saying so out loud matters: this is the one thing in the command that is not free, and
|
||||||
|
/// flipping modes back and forth while hunting a value is the obvious way to use it.</summary>
|
||||||
|
public static void SetMode(GhostTraderPatch.BodyOpacityMode _mode, string _prefix)
|
||||||
|
{
|
||||||
|
bool changed = GhostTraderPatch.BodyMode != _mode;
|
||||||
|
GhostTraderPatch.BodyMode = _mode;
|
||||||
|
int rebuilt = changed ? GhostTraderPatch.Reapply() : 0;
|
||||||
|
Report(_prefix);
|
||||||
|
if (changed && rebuilt > 0)
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
" (" + rebuilt + " renderer(s) rebuilt from their original materials)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current value plus what it actually reached, in both units, and which way the
|
||||||
|
/// body is being faded. The count is the half that answers "did it do anything": 0
|
||||||
|
/// materials means no trader has been converted yet - they stream in on approach - not
|
||||||
|
/// that the number was refused.
|
||||||
|
///
|
||||||
|
/// The trader count is "held as a ghost RIGHT NOW", not "seen this session": since the
|
||||||
|
/// 2026-09-15 fix, Ghosted is keyed by entity id but re-entered when a trader is rebuilt,
|
||||||
|
/// and a trader whose chunk has unloaded keeps his entry only until the next sweep finds
|
||||||
|
/// his model gone. So the number falls as well as rises, and that is correct.</summary>
|
||||||
|
public static void Report(string _prefix)
|
||||||
|
{
|
||||||
|
float alpha = GhostTraderPatch.GhostAlpha;
|
||||||
|
int applied = GhostTraderPatch.Retint();
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
_prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " +
|
||||||
|
alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode +
|
||||||
|
", applied to " + applied + " live material(s) across " +
|
||||||
|
GhostTraderPatch.Ghosted.Count + " trader(s) currently held as ghosts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Percent out of what the user typed. StringParsers is the game's own parser and
|
||||||
|
/// is culture-independent, which matters here - but it reads ',' as a THOUSANDS separator,
|
||||||
|
/// so on a keyboard where the decimal key produces a comma "12,5" would silently parse as
|
||||||
|
/// 125 and the trader would vanish. The comma is turned into a point before it gets there.
|
||||||
|
/// A trailing '%' is accepted because it is the obvious thing to type.</summary>
|
||||||
|
public static bool TryParsePercent(string _argument, out float _percent)
|
||||||
|
{
|
||||||
|
string text = _argument.Replace(',', '.').TrimEnd('%').Trim();
|
||||||
|
return StringParsers.TryParseFloat(text, out _percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,26 @@ namespace NecromancerTome
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ModEntry : IModApi
|
public class ModEntry : IModApi
|
||||||
{
|
{
|
||||||
|
/// <summary>The mod's own folder, kept from InitMod so patches can find files we ship
|
||||||
|
/// (currently Resources/necroatlas for the custom block paint). Nothing else knows where
|
||||||
|
/// the mod lives - the game hands it over exactly once, right here.</summary>
|
||||||
|
public static Mod Instance;
|
||||||
|
|
||||||
public void InitMod(Mod _modInstance)
|
public void InitMod(Mod _modInstance)
|
||||||
{
|
{
|
||||||
|
Instance = _modInstance;
|
||||||
var harmony = new Harmony("necromancertome.harmony");
|
var harmony = new Harmony("necromancertome.harmony");
|
||||||
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||||
PetFollowPatch.Init();
|
PetFollowPatch.Init();
|
||||||
|
// SpatialVaultPersistence needs NO Init(): it is four Harmony postfixes that PatchAll
|
||||||
|
// above already attached. It used to register a WorldShuttingDown handler to clear its
|
||||||
|
// cache - that handler is exactly what wiped the vault on every clean exit, because
|
||||||
|
// that event fires BEFORE the final player save (GameManager.SaveAndCleanupWorld:
|
||||||
|
// event at IL_0026, SaveLocalPlayerData at IL_00c4). Freshness is decided by what was
|
||||||
|
// read instead; see that file.
|
||||||
|
// Traders rendered as washed-out ghosts (request 2026-09-13). Polls rather than
|
||||||
|
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
|
||||||
|
GhostTraderPatch.Init();
|
||||||
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||||
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||||
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||||
@@ -27,6 +42,31 @@ namespace NecromancerTome
|
|||||||
// another in-game death.
|
// another in-game death.
|
||||||
VerifyPrefixAttached(typeof(EntityAlive), "dropItemOnDeath");
|
VerifyPrefixAttached(typeof(EntityAlive), "dropItemOnDeath");
|
||||||
VerifyPrefixAttached(typeof(Entity), "DropBagServer");
|
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)
|
public static void VerifyPrefixAttached(System.Type type, string methodName)
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ namespace NecromancerTome
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml
|
/// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml
|
||||||
/// (resourceNecromancerBlood) for the item, recipes.xml for the base recipe (an empty jar,
|
/// (resourceNecromancerBlood) for the item and recipes.xml for the base recipe (an empty jar,
|
||||||
/// like any other resource conversion). Two rules the user asked for have NO vanilla XML
|
/// like any other resource conversion).
|
||||||
|
///
|
||||||
|
/// IT LIVES IN items.xml, AND THAT IS NOT AN ACCIDENT. On 2026-09-15 it was moved into
|
||||||
|
/// item_modifiers.xml so it could be installed in the Spatial Bracelet, and that move
|
||||||
|
/// DESTROYED a character in a save: ItemValue.Read/Write gate the modification block on
|
||||||
|
/// !(itemClass is ItemClassModifier), so the item's CLASS decides the byte layout of every
|
||||||
|
/// stack of it in the save, and an existing save read one byte off from the first blood stack
|
||||||
|
/// onward. It was rolled back the same day, the bracelet's charge became a brand-new item
|
||||||
|
/// (resourceBloodSphere, and later resourceBloodStone) instead, and the rule stands: an item
|
||||||
|
/// that could already be in someone's inventory must not change class in either direction.
|
||||||
|
/// The full account is in BACKLOG.md; the earlier wording of this comment claimed the modifier
|
||||||
|
/// home as current and outlived the code by a day. Two rules the user asked for have NO vanilla XML
|
||||||
/// equivalent at all, so both are enforced here instead:
|
/// equivalent at all, so both are enforced here instead:
|
||||||
/// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or
|
/// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or
|
||||||
/// backpack) to craft this, but is NOT consumed. recipes.xml has no "required but not
|
/// backpack) to craft this, but is NOT consumed. recipes.xml has no "required but not
|
||||||
@@ -50,6 +61,73 @@ namespace NecromancerTome
|
|||||||
public const string BloodItemName = "resourceNecromancerBlood";
|
public const string BloodItemName = "resourceNecromancerBlood";
|
||||||
public const float HealthCostFraction = 0.9f;
|
public const float HealthCostFraction = 0.9f;
|
||||||
|
|
||||||
|
/// <summary>Damage above which the player's BIG pain grunt is used instead of the small
|
||||||
|
/// one. Not invented: it is vanilla's own threshold, read out of
|
||||||
|
/// EntityPlayer.GetSoundHurt(DamageSource, int) - the override that every spike and every
|
||||||
|
/// strand of barbed wire goes through. Its IL is
|
||||||
|
///
|
||||||
|
/// ldarg.2 // _damageStrength
|
||||||
|
/// ldc.i4.s 15
|
||||||
|
/// bgt.s -> GetSoundHurt() // strictly MORE than 15 -> soundHurt (…painlg)
|
||||||
|
/// call GetSoundHurtSmall() // 15 or less -> soundHurtSmall (…painsm)
|
||||||
|
///
|
||||||
|
/// so the comparison is strictly greater-than, and 15 exactly still counts as small. The
|
||||||
|
/// same method has an earlier branch for damage type 16 that returns GetSoundDrownPain();
|
||||||
|
/// that one is the drowning case and has nothing to do with us.</summary>
|
||||||
|
public const int PainSoundBigDamage = 15;
|
||||||
|
|
||||||
|
/// <summary>Plays the player's own pain grunt, picking the big or the small one by the
|
||||||
|
/// same rule vanilla uses for spikes and barbed wire (user request 2026-09-16: "в игре
|
||||||
|
/// есть звук боли (когда персонаж напарывается на колья или на колючую проволоку). Пусть
|
||||||
|
/// этот звук воспроизводится при создании крови некроманта").
|
||||||
|
///
|
||||||
|
/// WHY THE SOUND NAME IS ASKED FOR AND NOT SPELLED OUT. The clip differs by gender -
|
||||||
|
/// playerMale carries SoundHurt="player1painlg"/SoundHurtSmall="player1painsm" and
|
||||||
|
/// playerFemale overrides both to player2pain* (Data/Config/entityclasses.xml). Hardcoding
|
||||||
|
/// "player1painlg" would have given every female character a male grunt. GetSoundHurt() and
|
||||||
|
/// GetSoundHurtSmall() are public on EntityAlive and are plain field reads (verified: each
|
||||||
|
/// one's whole body is "ldarg.0; ldfld soundHurt|soundHurtSmall; ret"), so they return
|
||||||
|
/// whatever this entity's own class declared and cost nothing.
|
||||||
|
///
|
||||||
|
/// WHY NOT GetSoundHurt(DamageSource, int), which would pick for us: it needs a
|
||||||
|
/// DamageSource, and this is not damage from a source - the HP here is spent by AddHealth,
|
||||||
|
/// deliberately (see the comment at the call site). Its selection rule is three lines, so
|
||||||
|
/// it is reproduced instead of faked with a synthetic DamageSource.
|
||||||
|
///
|
||||||
|
/// The null guard is vanilla's too: EntityAlive.OnUpdateEntity stores the result and skips
|
||||||
|
/// the call on null (brfalse right after the stloc) rather than handing PlayOneShot a null
|
||||||
|
/// clip name. The fallback to the big grunt covers an entity that declares SoundHurt but
|
||||||
|
/// not SoundHurtSmall - again exactly what vanilla's override does when
|
||||||
|
/// GetSoundHurtSmall() comes back empty.
|
||||||
|
///
|
||||||
|
/// PlayOneShot(name) with no further arguments is byte-for-byte what vanilla passes here:
|
||||||
|
/// its optional parameters default to sound_in_head:false, serverSignalOnly:false,
|
||||||
|
/// isUnique:false, _animEvent:null, volumeScale:1f, and OnUpdateEntity's own call pushes
|
||||||
|
/// exactly those five constants. So the grunt comes out of the character, not "in the
|
||||||
|
/// head", same as being spiked.</summary>
|
||||||
|
public static void PlayPainSound(EntityPlayerLocal _player, int _damage)
|
||||||
|
{
|
||||||
|
if (_player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string clip = _damage > PainSoundBigDamage
|
||||||
|
? _player.GetSoundHurt()
|
||||||
|
: _player.GetSoundHurtSmall();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(clip))
|
||||||
|
{
|
||||||
|
clip = _player.GetSoundHurt();
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(clip))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_player.PlayOneShot(clip);
|
||||||
|
}
|
||||||
|
|
||||||
public static bool HasAnyKnife(EntityPlayerLocal player)
|
public static bool HasAnyKnife(EntityPlayerLocal player)
|
||||||
{
|
{
|
||||||
return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots());
|
return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots());
|
||||||
@@ -131,6 +209,13 @@ namespace NecromancerTome
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
__state.AddHealth(-amount);
|
__state.AddHealth(-amount);
|
||||||
|
|
||||||
|
// The grunt goes AFTER the HP is actually gone, so a craft that somehow bailed out
|
||||||
|
// above never makes a sound the player cannot account for. At the 90% cost this is the
|
||||||
|
// big pain clip in every normal case (amount > 15 unless the player is already down to
|
||||||
|
// about 17 HP), and drops to the small one exactly where vanilla would drop it too.
|
||||||
|
NecromancerBloodPatch.PlayPainSound(__state, amount);
|
||||||
|
|
||||||
Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId);
|
Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
|
<!-- AssetBundle.LoadFromFile for CustomBlockPaintPatch.cs (2026-09-10) - the mod ships its
|
||||||
|
own block paint textures in Resources/necroatlas, and Unity keeps bundle loading in its
|
||||||
|
own module rather than CoreModule. -->
|
||||||
|
<Reference Include="UnityEngine.AssetBundleModule">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AssetBundleModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
<!-- PlayerActionsLocal.Secondary (PlayerAction) for PortalStonePatch.cs's power-attack
|
<!-- PlayerActionsLocal.Secondary (PlayerAction) for PortalStonePatch.cs's power-attack
|
||||||
channel-cancel (2026-08-29) - the game's own input layer, not something this mod
|
channel-cancel (2026-08-29) - the game's own input layer, not something this mod
|
||||||
previously needed to touch directly. -->
|
previously needed to touch directly. -->
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Счёт убийств для скилла "Некромантия" (user report 2026-09-16: "Почему-то наш скилл
|
||||||
|
/// некроманта не всегда засчитывает убийство зомби... если робомолот убьёт зомбака, то игрок
|
||||||
|
/// получает за это опыт. Если зомби умрёт от кровотечения, которое навесил игрок, то игрок
|
||||||
|
/// получит опыт. У нас скилл некроманта в этих случаях не прибавляется. Это баг.").
|
||||||
|
///
|
||||||
|
/// WHAT WAS BROKEN, AND IT WAS TWO SEPARATE THINGS.
|
||||||
|
///
|
||||||
|
/// Until this patch the whole count was four lines of XML appended to ONE entity class in
|
||||||
|
/// Config/entityclasses.xml:
|
||||||
|
///
|
||||||
|
/// <append xpath="/entity_classes/entity_class[@name='zombieTemplateMale']">
|
||||||
|
/// <requirement name="EntityTagCompare" target="other" tags="player"/>
|
||||||
|
/// <triggered_effect trigger="onOtherKilledSelf" action="ModifyCVar" target="other" .../>
|
||||||
|
/// <triggered_effect trigger="onOtherKilledSelf" action="AddProgressionLevel" target="other" .../>
|
||||||
|
///
|
||||||
|
/// 1. ONE CLASS IS NOT EVERY ZOMBIE. Humanoids were fine - effect_group DOES inherit through
|
||||||
|
/// extends on entity_class (unlike items.xml, see progression.xml's header), and every
|
||||||
|
/// zombie template chains back to zombieTemplateMale. But the five zombie ANIMALS inherit
|
||||||
|
/// the animal branch and never reach it:
|
||||||
|
/// animalZombieBear extends animalBear, animalZombieBoar extends animalBoar,
|
||||||
|
/// animalZombieDog extends animalWolf, animalZombieVulture extends animalTemplateHostile,
|
||||||
|
/// animalZombieVultureRadiated extends animalZombieVulture
|
||||||
|
/// Killing a zombie dog, bear, boar or vulture counted for nothing at all. Zombie vultures
|
||||||
|
/// are everywhere on roads, which is most of what "не всегда засчитывает" was.
|
||||||
|
///
|
||||||
|
/// 2. target="other" IS THE LITERAL KILLER, NOT THE PLAYER WHO EARNED IT. With
|
||||||
|
/// trigger="onOtherKilledSelf" plus a requirement that "other" be tagged player, anything
|
||||||
|
/// that kills on the player's behalf fails the requirement: a robotic sledge (the turret is
|
||||||
|
/// "other"), a bleed the player applied (no direct killer at the moment of death), a summoned
|
||||||
|
/// pet (the pet is "other"). Vanilla still awards XP in all of these because it does NOT use
|
||||||
|
/// the literal killer - it resolves the crediting player from the DamageSource, in
|
||||||
|
/// EntityAlive.AwardKillXPServer(DamageSource, EntityAlive), whose body reads BuffClass
|
||||||
|
/// (DoT damage) and a dedicated bTrapKillXP flag (trap kills) before calling AddKillXP.
|
||||||
|
///
|
||||||
|
/// BOTH EFFECTS SHARED ONE REQUIREMENT, so every missed kill also failed to raise
|
||||||
|
/// necroZombieKillsCVar - and that CVar is the Necromancer's Knife's damage (items.xml: "Damage
|
||||||
|
/// = necroZombieKillsCVar / 10", recomputed continuously in buffs.xml). The bug was quietly
|
||||||
|
/// underpowering the knife too, which is why the fix keeps both effects together.
|
||||||
|
///
|
||||||
|
/// WHY THIS HOOK AND NOT A WIDER XML PATCH. Adding the five animal classes by XML would have
|
||||||
|
/// fixed cause 1 and left cause 2 untouched. EntityPlayer.AddKillXP is the single point where
|
||||||
|
/// vanilla has ALREADY decided which player gets the kill - it is called from exactly one place
|
||||||
|
/// in the whole assembly, AwardKillXPServer, after all the DamageSource resolution is done.
|
||||||
|
/// Hooking it means our count agrees with the XP number the player sees on screen by
|
||||||
|
/// construction, for every case vanilla handles, including ones nobody has thought of yet.
|
||||||
|
/// Verified by metadata scan: AwardKillXPServer is the only caller of AddKillXP.
|
||||||
|
///
|
||||||
|
/// THE XML TRIGGERS ARE GONE, NOT LEFT ALONGSIDE. Config/entityclasses.xml no longer carries
|
||||||
|
/// the effect_group - if it stayed, a kill by the player's own hand would satisfy both it and
|
||||||
|
/// this patch and count TWICE. That was the one trap of moving the count into code, and it is
|
||||||
|
/// the first thing to check if levels ever start rising two at a time.
|
||||||
|
///
|
||||||
|
/// PETS ARE NOT GUARANTEED BY THIS PATCH. The user also asked that summoned creatures count.
|
||||||
|
/// They will count if and only if vanilla itself credits the owner for a pet kill - this patch
|
||||||
|
/// 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
|
||||||
|
{
|
||||||
|
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
|
||||||
|
/// "entity,animal,zombie,zombieAnimal,...". Note that entity Tags do NOT inherit through
|
||||||
|
/// extends (entityclasses.xml says so in a comment right on the property), which is exactly
|
||||||
|
/// why this works: every concrete, spawnable zombie spells its own tags out, and the bare
|
||||||
|
/// templates that do not are never spawned.
|
||||||
|
///
|
||||||
|
/// A tag test also ages better than the class list it replaces: any zombie added by a
|
||||||
|
/// future game version or another mod counts the moment it calls itself a zombie.</summary>
|
||||||
|
private static readonly FastTags<TagGroup.Global> ZombieTag =
|
||||||
|
FastTags<TagGroup.Global>.Parse("zombie");
|
||||||
|
|
||||||
|
public static void Postfix(EntityPlayer __instance, EntityAlive killedEntity)
|
||||||
|
{
|
||||||
|
if (__instance == null || killedEntity == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!killedEntity.HasAnyTags(ZombieTag))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 float AddKillsCVar(EntityPlayer _player)
|
||||||
|
{
|
||||||
|
float kills = _player.GetCVar(KillsCVarName) + 1f;
|
||||||
|
_player.SetCVar(KillsCVarName, kills);
|
||||||
|
return kills;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Приводит уровень Некромантии и оба индикатора в соответствие числу убийств.
|
||||||
|
/// Идемпотентна: вызывай сколько угодно раз, результат зависит только от _kills.
|
||||||
|
///
|
||||||
|
/// Тело повторяет MinEventActionAddProgressionLevel.Execute шаг в шаг (его IL для этого
|
||||||
|
/// читался): GetProgressionValue, новое значение, кламп по ProgressionClass.MaxLevel,
|
||||||
|
/// затем - для крафтового скилла - тост о повышении и HandleCheckCrafting, затем два
|
||||||
|
/// флага "изменилось".
|
||||||
|
///
|
||||||
|
/// HandleCheckCrafting - та часть, которую легко выкинуть и дорого не заметить: именно её
|
||||||
|
/// игра зовёт при смене уровня крафтового скилла, и без неё рецепты рискуют не заметить,
|
||||||
|
/// что стали доступны. И она, и AddCraftingSkillNotification публичные.</summary>
|
||||||
|
public static void SyncNecromancyLevel(EntityPlayer _player, float _kills)
|
||||||
|
{
|
||||||
|
Progression progression = _player.Progression;
|
||||||
|
if (progression == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressionValue pv = progression.GetProgressionValue(NecromancySkillName);
|
||||||
|
if (pv == null || pv.ProgressionClass == null)
|
||||||
|
{
|
||||||
|
// Not a crash, and not silent either: this means the skill did not load, which is a
|
||||||
|
// config problem worth seeing once in the log rather than a reason to throw inside
|
||||||
|
// a kill handler.
|
||||||
|
Debug.LogWarning("[NecromancerTome] NecromancyKillCredit: progression '" +
|
||||||
|
NecromancySkillName + "' not found - kill not counted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int kills = (int)_kills;
|
||||||
|
if (kills < 0)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pv.Level = newLevel;
|
||||||
|
|
||||||
|
EntityPlayerLocal local = _player as EntityPlayerLocal;
|
||||||
|
if (pv.ProgressionClass.IsCrafting && local != null)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEntityRemote guards these in vanilla too: a remote player's stats are the server's
|
||||||
|
// business, and marking them dirty here would be claiming an authority we do not have.
|
||||||
|
if (!_player.isEntityRemote)
|
||||||
|
{
|
||||||
|
progression.bProgressionStatsChanged = true;
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,21 @@ namespace NecromancerTome
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент
|
/// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент
|
||||||
/// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is
|
/// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is
|
||||||
/// opened, pause the game and play a flashback). Reuses the exact pause+video pipeline
|
/// opened, pause the game and play a flashback). Built on the pause+video pipeline first
|
||||||
/// already built and tested for the Black Portal Stone (see PortalStonePatch.cs's
|
/// written for the Black Portal Stone; since the finale switched to text slides
|
||||||
/// ActivateBlackPortal - GameManager.Instance.Pause/XUiC_VideoPlayer.PlayVideo, both APIs
|
/// (FinalSlides) and its dead video code was removed 2026-09-10, this patch is the only
|
||||||
/// decompiled there already, same reasoning applies unchanged here).
|
/// place in the mod that still calls either API, so both write-ups live here now:
|
||||||
|
/// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets
|
||||||
|
/// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check
|
||||||
|
/// baked into vanilla itself, not a limitation added by this mod) - a deliberate,
|
||||||
|
/// documented no-op in multiplayer rather than something silently broken.
|
||||||
|
/// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same
|
||||||
|
/// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use.
|
||||||
|
/// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it
|
||||||
|
/// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/
|
||||||
|
/// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived
|
||||||
|
/// already auto-closes on a bad file on its own, this is just a second, player-facing
|
||||||
|
/// way out.
|
||||||
///
|
///
|
||||||
/// FINDING THE RIGHT PATCH POINT: noteDuke01 has no custom C# class of its own - it's a
|
/// FINDING THE RIGHT PATCH POINT: noteDuke01 has no custom C# class of its own - it's a
|
||||||
/// plain Class="Eat" item (items.xml) whose entire "reading" experience is a vanilla trick:
|
/// plain Class="Eat" item (items.xml) whose entire "reading" experience is a vanilla trick:
|
||||||
@@ -42,8 +53,9 @@ namespace NecromancerTome
|
|||||||
/// flashback -> read text -> confirm", rather than overlapping the video with the text box.
|
/// flashback -> read text -> confirm", rather than overlapping the video with the text box.
|
||||||
///
|
///
|
||||||
/// VIDEO FILE: Video/DukeNoteFlashback.mp4 - the user's real flashback clip (delivered
|
/// VIDEO FILE: Video/DukeNoteFlashback.mp4 - the user's real flashback clip (delivered
|
||||||
/// 2026-08-30 as exch/flashbback.mp4), kept as .mp4 rather than renamed to .webm like the
|
/// 2026-08-30 as exch/flashbback.mp4), and the only video the mod still ships. Kept as
|
||||||
/// Black Portal placeholder: Unity's VideoPlayer component (confirmed by decompiling
|
/// .mp4 rather than renamed to .webm like the since-deleted Black Portal placeholder:
|
||||||
|
/// Unity's VideoPlayer component (confirmed by decompiling
|
||||||
/// XUiV_Video - it wraps a plain UnityEngine.Video.VideoPlayer) natively decodes MP4/H.264 on
|
/// XUiV_Video - it wraps a plain UnityEngine.Video.VideoPlayer) natively decodes MP4/H.264 on
|
||||||
/// Windows via Media Foundation, and re-labeling an actual MP4 container as .webm would just
|
/// Windows via Media Foundation, and re-labeling an actual MP4 container as .webm would just
|
||||||
/// make it fail to decode (VP8/VP9 container expected, not H.264) - not decompiled/proven
|
/// make it fail to decode (VP8/VP9 container expected, not H.264) - not decompiled/proven
|
||||||
@@ -54,6 +66,11 @@ namespace NecromancerTome
|
|||||||
[HarmonyPatch(typeof(XUiC_MessageBoxWindowGroup), "ShowOkCancel")]
|
[HarmonyPatch(typeof(XUiC_MessageBoxWindowGroup), "ShowOkCancel")]
|
||||||
public static class Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback
|
public static class Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback
|
||||||
{
|
{
|
||||||
|
/// <summary>"@modfolder(NecromancerTome):..." is the exact mod-relative path syntax
|
||||||
|
/// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to
|
||||||
|
/// confirm - looks for "@modfolder(<mod name>):" and substitutes the mod's real
|
||||||
|
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
|
||||||
|
/// DisplayName).</summary>
|
||||||
public const string NoteFlashbackVideoPath = "@modfolder(NecromancerTome):Video/DukeNoteFlashback.mp4";
|
public const string NoteFlashbackVideoPath = "@modfolder(NecromancerTome):Video/DukeNoteFlashback.mp4";
|
||||||
|
|
||||||
/// <summary>Guards the re-entrant call this patch makes to the very method it patches
|
/// <summary>Guards the re-entrant call this patch makes to the very method it patches
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// КОМАНДА "АТАКУЙ ТО, НА ЧТО Я СМОТРЮ" - второй режим свитка Духа крысы, указание
|
||||||
|
/// 2026-09-18: "у свитка призыва есть два режима (призыв и отзыв). Если крыса уже призвана,
|
||||||
|
/// то призыв становится атакой. И крыса берёт целью атаки того, на кого указал прицел
|
||||||
|
/// игрока. Если в прицеле нету целей для атаки, то пишется надпись "Нет цели для атаки"".
|
||||||
|
///
|
||||||
|
/// Это не Harmony-заплатка, а helper: точка входа одна и та же для всех питомцев -
|
||||||
|
/// префикс на ItemActionSpawnEntity.Spawn в SummonPatch.cs. Второй патч на тот же метод
|
||||||
|
/// только запутал бы порядок исполнения.
|
||||||
|
///
|
||||||
|
/// ОТКУДА БЕРЁТСЯ ЦЕЛЬ ПОД ПРИЦЕЛОМ. EntityPlayerLocal.HitInfo - публичное поле типа
|
||||||
|
/// WorldRayHitInfo, которое PlayerMoveController перезаписывает КАЖДЫЙ КАДР результатом луча
|
||||||
|
/// из прицела (Voxel.Raycast по GetLookRay, длина Utils.FastMax(cDigAndBuildDistance,
|
||||||
|
/// cCollectItemDistance, 30f), то есть не меньше 30 метров). Ничего своего лучить не надо -
|
||||||
|
/// игра уже посчитала это для интерфейса. Из попадания сущность достаётся собственным
|
||||||
|
/// публичным методом игры ItemActionAttack.GetEntityFromHit(hitInfo), который внутри зовёт
|
||||||
|
/// GameUtils.GetHitRootEntity(tag, transform) - то же самое, чем пользуется обычный удар
|
||||||
|
/// оружием, а значит и попадание по конечности/голове разрешится в саму сущность.
|
||||||
|
///
|
||||||
|
/// Луч упирается в блоки - и это правильно: натравить крысу сквозь стену на то, чего игрок
|
||||||
|
/// не видит, нельзя.
|
||||||
|
///
|
||||||
|
/// КОГО НЕЛЬЗЯ НАЗНАЧИТЬ ЦЕЛЬЮ (указание "пусть атакует всех существ кроме NPC торговцев"):
|
||||||
|
/// - EntityTrader - прямо по указанию;
|
||||||
|
/// - EntityPlayer - сам хозяин и вообще игроки: крыса не оружие против людей;
|
||||||
|
/// - собственный хозяин и она сама - на всякий случай, отдельной проверкой;
|
||||||
|
/// - мёртвые.
|
||||||
|
/// Отсев сделан ЗДЕСЬ, а не в entityclasses.xml, потому что targetClasses у
|
||||||
|
/// EAIApproachAndAttackTarget умеет только РАЗРЕШАТЬ тип, запретить им нельзя. В XML у крысы
|
||||||
|
/// стоит class=EntityAlive - это разрешение задаче подхватить любую цель, которую ей дали;
|
||||||
|
/// решение "кого дать" целиком принимается тут.
|
||||||
|
/// </summary>
|
||||||
|
public static class PetAttackCommand
|
||||||
|
{
|
||||||
|
/// <summary>Сколько тиков держится приказ. attackTargetTime уменьшается на 1 за тик ИИ и
|
||||||
|
/// на нуле сбрасывает цель (EntityAlive: "if (attackTargetTime > 0) { attackTargetTime--;
|
||||||
|
/// ... }"), так что это по сути "пока не убьёт". 6000 - заведомо больше любой драки;
|
||||||
|
/// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира.</summary>
|
||||||
|
public const int OrderTicks = 6000;
|
||||||
|
|
||||||
|
/// <summary>Отдаёт питомцу приказ атаковать то, на что смотрит игрок. false - приказа не
|
||||||
|
/// вышло, и вызывающая сторона показывает "Нет цели для атаки".</summary>
|
||||||
|
public static bool TryOrderAttack(EntityAlive _owner, EntityAlive _pet)
|
||||||
|
{
|
||||||
|
if (_owner == null || _pet == null || _pet.IsDead())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
EntityPlayerLocal local = _owner as EntityPlayerLocal;
|
||||||
|
if (local == null)
|
||||||
|
{
|
||||||
|
// Не локальный игрок - HitInfo просто неоткуда взять (у мода нет сетевого слоя
|
||||||
|
// нигде, см. SummonPatch.cs). Молча отказываем, как будто цели нет.
|
||||||
|
Debug.Log("[NecromancerTome] PetAttackCommand: owner is not the local player, no crosshair to read");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldRayHitInfo hit = local.HitInfo;
|
||||||
|
if (hit == null || !hit.bHitValid || hit.transform == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Entity hitEntity = ItemActionAttack.GetEntityFromHit(hit);
|
||||||
|
EntityAlive target = hitEntity as EntityAlive;
|
||||||
|
if (target == null || target.IsDead())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target == _pet || target == _owner)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target is EntityPlayer || target is EntityTrader)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Месть сбрасывается вместе с приказом: иначе задача SetAsTargetIfHurt могла бы на
|
||||||
|
// следующем тике перебить наш приказ тем, кто ударил крысу последним.
|
||||||
|
_pet.SetRevengeTarget(null);
|
||||||
|
_pet.SetAttackTarget(target, OrderTicks);
|
||||||
|
Debug.Log("[NecromancerTome] PetAttackCommand: pet " + _pet.entityId + " ordered to attack " +
|
||||||
|
target.entityId + " (" + target.EntityClass.entityClassName + ")");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// СВЕТЯЩИЕСЯ ГЛАЗА у призванных зомбоживотных - указание 2026-09-18: "псу и волку сделай,
|
||||||
|
/// глаза светлофиолетовые светящиеся".
|
||||||
|
///
|
||||||
|
/// ПОЧЕМУ ТОЛЬКО ДВОЕ, И ЭТО НЕ ВЫБОР, А ОГРАНИЧЕНИЕ МОДЕЛЕЙ. Покрасить глаза отдельно можно
|
||||||
|
/// ровно тогда, когда они лежат ОТДЕЛЬНЫМ материалом. Проба шейдеров, которую печатает
|
||||||
|
/// GhostTraderPatch, показала по питомцам такую картину:
|
||||||
|
///
|
||||||
|
/// necroZombieDog - 5 рендереров: FeralEye, NormalEye, LOD0..LOD2 - ГОДИТСЯ
|
||||||
|
/// necroZombieWolf - 2 рендерера: DireWolf + DireWolfHair - НЕ ГОДИТСЯ
|
||||||
|
/// necroZombieBear - 1 рендерер - НЕ ГОДИТСЯ
|
||||||
|
/// necroZombieGriffin - 1 рендерер - НЕ ГОДИТСЯ
|
||||||
|
///
|
||||||
|
/// ПО ВОЛКУ ОТВЕТ ОКОНЧАТЕЛЬНЫЙ И ОТРИЦАТЕЛЬНЫЙ, по дампу 18.09:
|
||||||
|
///
|
||||||
|
/// DireWolf[0] shader='Game/Animal/Fur' texture=dire_wolf_df _Color=False
|
||||||
|
/// DireWolfHair[0] shader='Game/SDCS/Hair' texture=Afro_normal _Color=False
|
||||||
|
///
|
||||||
|
/// Глазного рендерера у него НЕТ ВООБЩЕ - только шкура и шерсть, глаза запечены прямо в
|
||||||
|
/// dire_wolf_df. Ни отдельного материала, ни даже _Color, чтобы хоть что-то подкрасить.
|
||||||
|
/// Единственный оставшийся путь - править саму текстуру по пикселям, зная координаты глаз на
|
||||||
|
/// развёртке. Поэтому LitEyes у Волка снят: пусть флаг не обещает того, чего нет.
|
||||||
|
///
|
||||||
|
/// У Медведя и Грифа материал один на всю модель, и отдельно глаза там не достать ничем,
|
||||||
|
/// кроме правки самой текстуры по пикселям. Поэтому их в списке нет - см. PetInfo.LitEyes.
|
||||||
|
///
|
||||||
|
/// КРАСИТСЯ СВЕЧЕНИЕ, А НЕ ЦВЕТ. У зомби глаза светятся сами (_EmissionMap у этого материала
|
||||||
|
/// уже назначена), и именно свечение читается как "горящие глаза". Поэтому ставится
|
||||||
|
/// _EmissionColor, а _Color правится следом только чтобы радужка не спорила с ним при ярком
|
||||||
|
/// дневном свете. Ключевое слово _EMISSION включается явно: материал, собранный без эмиссии,
|
||||||
|
/// игнорирует _EmissionColor, пока оно выключено, - ровно тот же класс ловушки, что с
|
||||||
|
/// _SrcBlend/_Mode у прозрачности (см. ApplyTransparency в GhostTraderPatch.cs).
|
||||||
|
///
|
||||||
|
/// ПОЧЕМУ ЭТО ТИК, А НЕ ХУК НА ПРИЗЫВ. Материалы появляются вместе с моделью, а она
|
||||||
|
/// собирается ПОЗЖЕ создания сущности - это уже дважды стоило нам ошибок: сначала
|
||||||
|
/// GhostTraderPatch пришлось перевести на опрос из-за трейдеров, потом
|
||||||
|
/// TryIgnoreCollisionWithOwner из-за того, что коллайдеров в момент CreateEntity ещё нет.
|
||||||
|
/// Здесь та же природа, поэтому сразу попытка с повтором: раз в секунду, до первого успеха,
|
||||||
|
/// из общего тика в PetFollowPatch.
|
||||||
|
/// </summary>
|
||||||
|
public static class PetEyeGlow
|
||||||
|
{
|
||||||
|
/// <summary>Фиолетовый, НАСЫЩЕННЫЙ. Первая версия была светлой (0.72, 0.55, 1.0) и в игре
|
||||||
|
/// дала ровно то, о чём сказал пользователь: "у собаки глаза просто белые".
|
||||||
|
///
|
||||||
|
/// Причина арифметическая: _EmissionColor читается как HDR, цвет умножается на яркость.
|
||||||
|
/// При множителе 6 светлый фиолетовый превращался в (4.3, 3.3, 6.0) - все три канала
|
||||||
|
/// далеко за единицей, и тонемаппинг сводил их в белое пятно. Оттенок выживает только
|
||||||
|
/// когда каналы РАСХОДЯТСЯ: отсюда низкий зелёный и умеренная яркость ниже.</summary>
|
||||||
|
public static readonly Color EyeColour = new Color(0.55f, 0.18f, 1f);
|
||||||
|
|
||||||
|
/// <summary>ПОДНЯТО С 2.4 ДО 6 после первой проверки: "у собаки они вообще никак не
|
||||||
|
/// светятся". Цвет сменился (это видно), а свечения нет - значит либо _EmissionColor у
|
||||||
|
/// этого шейдера читается как HDR и 2.4 для него ничто, либо свечение вообще идёт не
|
||||||
|
/// отсюда. Шесть - это попытка проверить первое предположение, а чтобы проверить второе,
|
||||||
|
/// рядом добавлен разовый дамп всех материалов питомца (DumpMaterialsOnce).</summary>
|
||||||
|
/// <summary>1.6, а не 6: см. комментарий к EyeColour - на шести глаза выгорали в белый.
|
||||||
|
/// Итоговое свечение (0.88, 0.29, 1.6): синий вдвое выше красного и вшестеро выше
|
||||||
|
/// зелёного, так что фиолетовый читается, а не тонет в пересвете.</summary>
|
||||||
|
public const float EmissionIntensity = 1.6f;
|
||||||
|
|
||||||
|
/// <summary>Классы, по которым дамп уже печатался: он нужен один раз на вид, а не на
|
||||||
|
/// каждого призванного.</summary>
|
||||||
|
public static readonly System.Collections.Generic.HashSet<string> Dumped =
|
||||||
|
new System.Collections.Generic.HashSet<string>();
|
||||||
|
|
||||||
|
/// <summary>ДИАГНОСТИКА, добавлена 2026-09-18. Первая попытка зажечь глаза дала половину
|
||||||
|
/// результата: у Пса цвет сменился, но свечения нет, а у Волка не изменилось ничего -
|
||||||
|
/// значит материала с "eye" в имени текстуры у него просто не нашлось. Гадать дальше
|
||||||
|
/// дороже, чем один раз посмотреть: этот дамп печатает по каждому виду все рендереры, их
|
||||||
|
/// материалы, шейдеры, имена текстур и наличие свойств свечения. По нему и будет видно,
|
||||||
|
/// за что хвататься - и надо ли вообще.</summary>
|
||||||
|
public static void DumpMaterialsOnce(Entity _pet, string _className)
|
||||||
|
{
|
||||||
|
if (_className == null || !Dumped.Add(_className))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Renderer[] renderers = _pet.GetComponentsInChildren<Renderer>(true);
|
||||||
|
Debug.Log("[NecromancerTome] PetEyeGlow: ДАМП материалов " + _className + " - " +
|
||||||
|
(renderers != null ? renderers.Length : 0) + " рендерер(ов)");
|
||||||
|
if (renderers == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (Renderer renderer in renderers)
|
||||||
|
{
|
||||||
|
if (renderer == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Material[] mats = renderer.sharedMaterials;
|
||||||
|
for (int i = 0; mats != null && i < mats.Length; i++)
|
||||||
|
{
|
||||||
|
Material m = mats[i];
|
||||||
|
if (m == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string albedo = GhostTraderPatch.FindAlbedoProperty(m);
|
||||||
|
Texture tex = albedo != null ? m.GetTexture(albedo) : null;
|
||||||
|
Debug.Log("[NecromancerTome] PetEyeGlow: " + renderer.name + "[" + i + "] shader='" +
|
||||||
|
(m.shader != null ? m.shader.name : "null") + "' albedo=" + (albedo ?? "-") +
|
||||||
|
" texture=" + (tex != null ? tex.name : "-") +
|
||||||
|
" _EmissionColor=" + m.HasProperty("_EmissionColor") +
|
||||||
|
" _EmissionMap=" + (m.HasProperty("_EmissionMap") && m.GetTexture("_EmissionMap") != null) +
|
||||||
|
" _EmissionIntensity=" + m.HasProperty("_EmissionIntensity") +
|
||||||
|
" _Color=" + m.HasProperty("_Color"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ПЕРЕПИСАНО 2026-09-18 ПО ДАМПУ. Первая версия искала материал по имени
|
||||||
|
/// ТЕКСТУРЫ ("eye"), нашла у собаки ровно один и зажгла его - а глаза всё равно не
|
||||||
|
/// светились. Дамп показал, почему:
|
||||||
|
///
|
||||||
|
/// FeralEye[0] shader='Game/Autodesk' texture=feral_eye _EmissionMap=True
|
||||||
|
/// NormalEye[0] shader='Standard' texture=HD_ZombieDog _EmissionMap=False
|
||||||
|
/// LOD0..LOD2 shader='Standard' texture=HD_ZombieDog
|
||||||
|
///
|
||||||
|
/// Глаз у собаки ДВА РАЗНЫХ, отдельными рендерерами. Светящийся feral - тот, что горит у
|
||||||
|
/// разъярённой собаки, и в обычном состоянии он не показывается; именно его мы и зажгли,
|
||||||
|
/// не увидев ничего. А обычный глаз - NormalEye - берёт текстуру ТУШКИ (HD_ZombieDog),
|
||||||
|
/// поэтому поиск по имени текстуры его пропускал.
|
||||||
|
///
|
||||||
|
/// Поэтому теперь глаз опознаётся по имени РЕНДЕРЕРА, а имя текстуры оставлено как
|
||||||
|
/// запасной признак. Материал у NormalEye свой собственный, так что правка цвета и
|
||||||
|
/// свечения на нём затрагивает только глаз, хотя текстура и общая с телом.</summary>
|
||||||
|
public static bool IsEyeRenderer(Renderer _renderer)
|
||||||
|
{
|
||||||
|
if (_renderer == null || _renderer is ParticleSystemRenderer)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_renderer.name != null && _renderer.name.ToLower().Contains("eye"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Material[] shared = _renderer.sharedMaterials;
|
||||||
|
if (shared == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (Material m in shared)
|
||||||
|
{
|
||||||
|
if (IsEyeMaterial(m))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Запасной признак: текстура с "eye" в имени (у собаки это feral_eye).</summary>
|
||||||
|
public static bool IsEyeMaterial(Material _material)
|
||||||
|
{
|
||||||
|
if (_material == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
string albedo = GhostTraderPatch.FindAlbedoProperty(_material);
|
||||||
|
if (albedo == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Texture texture = _material.GetTexture(albedo);
|
||||||
|
return texture != null && texture.name != null &&
|
||||||
|
texture.name.ToLower().Contains("eye");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>true, только если глаза действительно нашлись и были подожжены. Пока false,
|
||||||
|
/// тик повторяет попытку - модель может быть ещё не собрана.</summary>
|
||||||
|
public static bool TryLightEyes(Entity _pet)
|
||||||
|
{
|
||||||
|
if (_pet == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Renderer[] renderers = _pet.GetComponentsInChildren<Renderer>(true);
|
||||||
|
if (renderers == null || renderers.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int lit = 0;
|
||||||
|
foreach (Renderer renderer in renderers)
|
||||||
|
{
|
||||||
|
if (!IsEyeRenderer(renderer))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Рендерер целиком глазной, поэтому красим ВСЕ его материалы: мешу глаза больше
|
||||||
|
// ничего не принадлежит, а вот у собаки их два (обычный и feral), и попасть надо
|
||||||
|
// в оба - какой из них покажется, решает сама игра.
|
||||||
|
Material[] instances = renderer.materials;
|
||||||
|
foreach (Material eye in instances)
|
||||||
|
{
|
||||||
|
if (eye == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (eye.HasProperty("_EmissionColor"))
|
||||||
|
{
|
||||||
|
eye.EnableKeyword("_EMISSION");
|
||||||
|
eye.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
|
||||||
|
eye.SetColor("_EmissionColor", EyeColour * EmissionIntensity);
|
||||||
|
if (eye.HasProperty("_EmissionIntensity"))
|
||||||
|
{
|
||||||
|
eye.SetFloat("_EmissionIntensity", EmissionIntensity);
|
||||||
|
}
|
||||||
|
lit++;
|
||||||
|
}
|
||||||
|
if (eye.HasProperty("_Color"))
|
||||||
|
{
|
||||||
|
Color tint = eye.GetColor("_Color");
|
||||||
|
eye.SetColor("_Color", new Color(EyeColour.r, EyeColour.g, EyeColour.b, tint.a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] PetEyeGlow: рендерер глаз '" + renderer.name + "', материалов " +
|
||||||
|
instances.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lit > 0)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PetEyeGlow: pet " + _pet.entityId + " - " + lit +
|
||||||
|
" eye material(s) lit");
|
||||||
|
}
|
||||||
|
return lit > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,27 @@ namespace NecromancerTome
|
|||||||
{
|
{
|
||||||
public int OwnerEntityId;
|
public int OwnerEntityId;
|
||||||
public int PetEntityId;
|
public int PetEntityId;
|
||||||
|
|
||||||
|
/// <summary>У питомца своя задача следования (NecroFollowOwnerTask, 2026-09-18) -
|
||||||
|
/// значит телепорт-поводок ниже его НЕ трогает. Уборка владения при этом остаётся:
|
||||||
|
/// она к способу передвижения отношения не имеет.</summary>
|
||||||
|
public bool OwnFollowTask;
|
||||||
|
|
||||||
|
/// <summary>Удалось ли развести коллайдеры питомца с хозяйскими. Пока false, попытка
|
||||||
|
/// повторяется каждый тик - см. TryIgnoreCollisionWithOwner.</summary>
|
||||||
|
public bool CollisionIgnored;
|
||||||
|
|
||||||
|
/// <summary>Зажжены ли глаза (PetEyeGlowPatch.cs). Как и коллайдеры, с первого раза
|
||||||
|
/// обычно не получается - модели ещё нет; попытка повторяется до успеха.</summary>
|
||||||
|
public bool EyesLit;
|
||||||
|
|
||||||
|
/// <summary>Нужно ли им вообще заниматься: копия PetInfo.LitEyes, чтобы тик не лазил
|
||||||
|
/// в словарь на каждом питомце каждую секунду.</summary>
|
||||||
|
public bool WantsLitEyes;
|
||||||
|
|
||||||
|
/// <summary>Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль -
|
||||||
|
/// питомец не летающий, дом ему не переставляем.</summary>
|
||||||
|
public int FlyingHomeRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
|
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
|
||||||
@@ -62,12 +83,98 @@ namespace NecromancerTome
|
|||||||
public static void Init()
|
public static void Init()
|
||||||
{
|
{
|
||||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||||
|
ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ДОБАВЛЕНО 2026-09-18. TrackedPets - статический список, он переживает выход в
|
||||||
|
/// меню, а вот мир, к которому относятся лежащие в нём entityId, - нет. Без этой уборки
|
||||||
|
/// на следующей загрузке первый же тик начинал разбирать чужие идентификаторы: в новом
|
||||||
|
/// мире тот же номер принадлежит совершенно другой сущности, и RemoveOwnedEntity уходил
|
||||||
|
/// бы неизвестно куда. GhostTraderPatch.cs подписан на то же событие ровно по той же
|
||||||
|
/// причине; здесь подписки не было - это был найденный, но не закрытый пробел из разбора
|
||||||
|
/// ИИ питомцев (BACKLOG.md, 2026-09-18, п. 6).</summary>
|
||||||
|
public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data)
|
||||||
|
{
|
||||||
|
if (TrackedPets.Count > 0)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: world shutting down, forgetting " +
|
||||||
|
TrackedPets.Count + " tracked pet(s)");
|
||||||
|
TrackedPets.Clear();
|
||||||
|
}
|
||||||
|
timer = 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
|
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
|
||||||
public static void Register(EntityAlive owner, Entity pet)
|
public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored,
|
||||||
|
bool wantsLitEyes, int flyingHomeRadius)
|
||||||
{
|
{
|
||||||
TrackedPets.Add(new TrackedPet { OwnerEntityId = owner.entityId, PetEntityId = pet.entityId });
|
TrackedPets.Add(new TrackedPet
|
||||||
|
{
|
||||||
|
OwnerEntityId = owner.entityId,
|
||||||
|
PetEntityId = pet.entityId,
|
||||||
|
OwnFollowTask = ownFollowTask,
|
||||||
|
CollisionIgnored = collisionIgnored,
|
||||||
|
WantsLitEyes = wantsLitEyes,
|
||||||
|
FlyingHomeRadius = flyingHomeRadius,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Разводит коллайдеры питомца и его хозяина, чтобы они проходили друг сквозь
|
||||||
|
/// друга. Возвращает true, только если РЕАЛЬНО что-то развела.
|
||||||
|
///
|
||||||
|
/// БАГ-РЕПОРТ 2026-09-18: "когда крыса оказывается под ногами, игрока подбрасывает".
|
||||||
|
/// Просьба сделать питомца нематериальным для игрока была ещё 28.08, и код для неё
|
||||||
|
/// написан тогда же (SummonPatch.IgnoreCollisionWithOwner) - но он вызывался ровно один
|
||||||
|
/// раз, из постфикса на EntityFactory.CreateEntity, а это СЛИШКОМ РАНО: там сущность
|
||||||
|
/// только создана и в мир ещё не добавлена (SpawnEntityInWorld идёт следующей строкой в
|
||||||
|
/// ItemActionSpawnEntity.Spawn), модель не собрана, и коллайдеров, привязанных к костям
|
||||||
|
/// через PhysicsBody, попросту ещё нет. GetComponentsInChildren возвращал пустой массив,
|
||||||
|
/// цикл не делал ни одной итерации, и никто этого не замечал - метод ничего не возвращал
|
||||||
|
/// и ничего не логировал. Ровно тот же урок про "модель собирается позже" уже записан в
|
||||||
|
/// GhostTraderPatch.cs, где из-за него пришлось опрашивать торговцев по таймеру.
|
||||||
|
///
|
||||||
|
/// Поэтому теперь попытка повторяется в тике раз в секунду, пока не удастся. Толкать
|
||||||
|
/// игрока питомец сможет в худшем случае одну секунду после призыва.
|
||||||
|
///
|
||||||
|
/// Разводятся ВСЕ пары коллайдеров, включая капсулы передвижения: CharacterController -
|
||||||
|
/// это тоже Collider, и именно на ней игрок и стоит, когда его подбрасывает.</summary>
|
||||||
|
public static bool TryIgnoreCollisionWithOwner(EntityAlive owner, Entity pet)
|
||||||
|
{
|
||||||
|
if (owner == null || pet == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>(true);
|
||||||
|
Collider[] petColliders = pet.GetComponentsInChildren<Collider>(true);
|
||||||
|
if (ownerColliders == null || petColliders == null ||
|
||||||
|
ownerColliders.Length == 0 || petColliders.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int pairs = 0;
|
||||||
|
foreach (Collider oc in ownerColliders)
|
||||||
|
{
|
||||||
|
if (oc == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (Collider pc in petColliders)
|
||||||
|
{
|
||||||
|
if (pc == null || pc == oc)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Physics.IgnoreCollision(oc, pc, true);
|
||||||
|
pairs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pairs > 0)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " passes through owner " +
|
||||||
|
owner.entityId + " now (" + ownerColliders.Length + "x" + petColliders.Length +
|
||||||
|
" collider pairs ignored)");
|
||||||
|
}
|
||||||
|
return pairs > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Called from SummonPatch.cs's manual recall path so a recalled pet stops being
|
/// <summary>Called from SummonPatch.cs's manual recall path so a recalled pet stops being
|
||||||
@@ -83,6 +190,130 @@ namespace NecromancerTome
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как
|
||||||
|
/// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье".
|
||||||
|
///
|
||||||
|
/// Поедание тут не отдельное поведение, а побочный эффект приказа: команда атаковать ставит
|
||||||
|
/// цель на 6000 тиков (PetAttackCommand.OrderTicks), а EAIApproachAndAttackTarget.CanExecute
|
||||||
|
/// сверяет только ТИП цели и ничего не знает про её смерть - так что, добив зомби, крыса
|
||||||
|
/// продолжает грызть труп, пока тот не исчезнет. Само по себе это выглядело хорошо, поэтому
|
||||||
|
/// не чинится, а используется.
|
||||||
|
///
|
||||||
|
/// Лечение идёт отсюда, а не из triggered_effect в items.xml, по простой причине: в XML
|
||||||
|
/// нельзя спросить "цель мертва". Полный список классов Requirement* в Assembly-CSharp
|
||||||
|
/// просмотрен - там есть RequirementFullHealth, RequirementHasEntityTag, RequirementNearbyEntities
|
||||||
|
/// и ещё сорок, но ни одного про смерть цели. Значит гейт всё равно оказался бы в коде, а
|
||||||
|
/// тик раз в секунду тут уже есть и обходит ровно тех же питомцев.
|
||||||
|
///
|
||||||
|
/// Условий три, и все три обязательны: цель есть, цель МЕРТВА (иначе это лечение в бою, а
|
||||||
|
/// не питание), и крыса рядом с ней (иначе она лечилась бы, стоя в другом конце улицы и
|
||||||
|
/// только СОБИРАЯСЬ дойти до трупа).</summary>
|
||||||
|
public const float FeedRangeSq = 2.5f * 2.5f;
|
||||||
|
|
||||||
|
/// <summary>Здоровья за секунду поедания. У Духа крысы всего 120 HP, так что 5 - это полное
|
||||||
|
/// восстановление примерно за полминуты над одним трупом: заметно, но не бесплатно.</summary>
|
||||||
|
public const int FeedHealthPerSecond = 5;
|
||||||
|
|
||||||
|
/// <summary>ДЫРА, ЗАКРЫТАЯ 2026-09-18. Защита от падения живёт в NecroFollowOwnerTask, а
|
||||||
|
/// та НЕ ИСПОЛНЯЕТСЯ, пока у питомца есть цель (CanExecute возвращает false) - то есть
|
||||||
|
/// именно в бою, когда питомец и бегает по незнакомым местам, ловить его было нечем.
|
||||||
|
/// Этот тик работает всегда, поэтому проверка переехала сюда.
|
||||||
|
///
|
||||||
|
/// ПРОВЕРКИ ДВЕ, И ОНИ ПРО РАЗНОЕ - это важно не путать.
|
||||||
|
///
|
||||||
|
/// 1. АБСОЛЮТНАЯ ВЫСОТА. Ниже отметки 5 не бывает законных причин находиться: мир
|
||||||
|
/// кончается на нуле, и всё, что туда опустилось, игра удаляет молча
|
||||||
|
/// (Entity.onUpdate: position.y < 0f -> MarkToUnload). Двусмысленности нет никакой,
|
||||||
|
/// поэтому спасаем всегда, хоть в бою, хоть нет. Это и есть настоящий детектор
|
||||||
|
/// падения, и он один закрывает тот случай, на котором 18.09 потерялась крыса.
|
||||||
|
///
|
||||||
|
/// 2. НИЖЕ ЦЕЛИ (предложено пользователем). Это детектор НЕ падения, а
|
||||||
|
/// НЕДОСТИЖИМОСТИ: питомец, который на десять метров ниже того, кого ему велели
|
||||||
|
/// грызть, скорее всего не упал, а не может добраться - зомби на крыше, на этаже
|
||||||
|
/// выше, за проломом. Без этой проверки он будет ломиться туда вечно: приказ игрока
|
||||||
|
/// держится 6000 тиков и сам не истечёт.
|
||||||
|
///
|
||||||
|
/// Поэтому здесь цель ещё и СБРАСЫВАЕТСЯ. Вернуть питомца к хозяину, не сняв
|
||||||
|
/// приказ, значило бы получить маятник: прыжок наверх - бег вниз - прыжок наверх.
|
||||||
|
///
|
||||||
|
/// Обратная сторона второй проверки названа честно: зомби, стоящий двумя этажами выше,
|
||||||
|
/// теперь отменяет приказ вместо бесконечной беготни. По-моему это лучше, но если
|
||||||
|
/// окажется, что питомец сдаётся слишком рано - крутить UnreachableBelowTarget.</summary>
|
||||||
|
public const float UnreachableBelowTarget = 10f;
|
||||||
|
|
||||||
|
public static void RescueFallen(EntityAlive pet, EntityAlive owner)
|
||||||
|
{
|
||||||
|
if (pet == null || owner == null || pet.world == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool outOfWorld = pet.position.y < NecroFollowOwnerTask.WorldFloorGuard;
|
||||||
|
EntityAlive target = pet.GetAttackTarget();
|
||||||
|
bool unreachable = !outOfWorld && target != null && !target.IsDead() &&
|
||||||
|
pet.position.y < target.position.y - UnreachableBelowTarget;
|
||||||
|
if (!outOfWorld && !unreachable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pet.IsStuck = false;
|
||||||
|
pet.motion = Vector3.zero;
|
||||||
|
if (pet.moveHelper != null)
|
||||||
|
{
|
||||||
|
pet.moveHelper.Stop();
|
||||||
|
}
|
||||||
|
if (unreachable)
|
||||||
|
{
|
||||||
|
pet.SetAttackTarget(null, 0);
|
||||||
|
}
|
||||||
|
pet.SetPosition(NecroFollowOwnerTask.SlotPosition(owner, 0, pet.world), true);
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " rescued to owner " +
|
||||||
|
owner.entityId + " - " + (outOfWorld
|
||||||
|
? "выпал из мира (высота " + pet.position.y.ToCultureInvariantString("0.0") + ")"
|
||||||
|
: "цель недостижима, она выше на " +
|
||||||
|
(target.position.y - pet.position.y).ToCultureInvariantString("0.0") + " м"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Кому принадлежит этот питомец. null - значит не наш или уже не отслеживается.
|
||||||
|
/// Нужна PetKillCreditPatch.cs, чтобы зачесть убийство владельцу.</summary>
|
||||||
|
public static EntityPlayer FindOwnerOfPet(int petEntityId)
|
||||||
|
{
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < TrackedPets.Count; i++)
|
||||||
|
{
|
||||||
|
if (TrackedPets[i].PetEntityId == petEntityId)
|
||||||
|
{
|
||||||
|
return world.GetEntity(TrackedPets[i].OwnerEntityId) as EntityPlayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void FeedOnCorpse(EntityAlive pet)
|
||||||
|
{
|
||||||
|
EntityAlive target = pet.GetAttackTarget();
|
||||||
|
if (target == null || !target.IsDead())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((target.position - pet.position).sqrMagnitude > FeedRangeSq)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int before = pet.Health;
|
||||||
|
pet.AddHealth(FeedHealthPerSecond);
|
||||||
|
if (pet.Health != before)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " fed on corpse " +
|
||||||
|
target.entityId + ", health " + before + " -> " + pet.Health);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||||
{
|
{
|
||||||
timer += Time.deltaTime;
|
timer += Time.deltaTime;
|
||||||
@@ -147,6 +378,72 @@ namespace NecromancerTome
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Повторная попытка развести коллайдеры - до первого успеха. Стоит ДО всех
|
||||||
|
// проверок про бой и расстояние: "питомец подбрасывает хозяина" не зависит ни от
|
||||||
|
// того, ни от другого.
|
||||||
|
if (!tracked.CollisionIgnored)
|
||||||
|
{
|
||||||
|
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую
|
||||||
|
// секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка
|
||||||
|
// "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни
|
||||||
|
// пафайндинга, ни своей задачи - см. PetInfo.FlyingHomeRadius.
|
||||||
|
//
|
||||||
|
// НА ВРЕМЯ АТАКИ ДОМ ОТВЯЗЫВАЕТСЯ СОВСЕМ (указание того же дня: "для атаки радиус
|
||||||
|
// нужно увеличить, пусть летит сколько надо... а вот после атаки пусть
|
||||||
|
// возвращается"). Иначе собственная проверка EntityVulture срывала бы его с цели
|
||||||
|
// на полпути: она прерывает атаку, как только питомец вышел за радиус.
|
||||||
|
//
|
||||||
|
// Отвязка - это detachHome(), то есть maximumHomeDistance = -1, и тогда
|
||||||
|
// isWithinHomeDistanceCurrentPosition() безусловно возвращает true. Ограничителем
|
||||||
|
// вместо радиуса остаётся сама дальность приказа: цель назначается только по
|
||||||
|
// прицелу, а луч прицела не длиннее 30 метров, так что отправить Грифа на другой
|
||||||
|
// край карты нельзя при всём желании.
|
||||||
|
//
|
||||||
|
// Как только цель пропала (убита, недостижима, приказ сброшен), дом на следующей
|
||||||
|
// же секунде встаёт обратно на хозяина - и питомец возвращается сам.
|
||||||
|
if (tracked.FlyingHomeRadius > 0)
|
||||||
|
{
|
||||||
|
if (pet.GetAttackTarget() != null)
|
||||||
|
{
|
||||||
|
if (pet.hasHome())
|
||||||
|
{
|
||||||
|
pet.detachHome();
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId +
|
||||||
|
" is on a target - home detached for the chase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!pet.hasHome())
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId +
|
||||||
|
" finished the chase - home re-anchored to owner " + owner.entityId);
|
||||||
|
}
|
||||||
|
pet.setHomeArea(new Vector3i(owner.position), tracked.FlyingHomeRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracked.WantsLitEyes && !tracked.EyesLit)
|
||||||
|
{
|
||||||
|
PetEyeGlow.DumpMaterialsOnce(pet, EntityClass.list[pet.entityClass]?.entityClassName);
|
||||||
|
tracked.EyesLit = PetEyeGlow.TryLightEyes(pet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracked.OwnFollowTask)
|
||||||
|
{
|
||||||
|
FeedOnCorpse(pet);
|
||||||
|
RescueFallen(pet, owner);
|
||||||
|
// У этого питомца есть настоящая задача следования (NecroFollowOwnerTask):
|
||||||
|
// она сама держит его при хозяине, сама решает, когда пройти сквозь стену, и
|
||||||
|
// сама сажает его на опорный блок. Телепорт-поводок здесь только мешал бы -
|
||||||
|
// две системы дёргали бы питомца в разные стороны. Уборка владения выше при
|
||||||
|
// этом уже отработала, и она остаётся общей для всех.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (pet.GetAttackTarget() != null)
|
if (pet.GetAttackTarget() != null)
|
||||||
{
|
{
|
||||||
// Mid-fight - let it finish rather than teleporting it away.
|
// Mid-fight - let it finish rather than teleporting it away.
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
using GamePath;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ПЕРВАЯ СВОЯ ЗАДАЧА ИИ В ЭТОМ МОДЕ. "Дух крысы" держится справа-сзади от хозяина, а не
|
||||||
|
/// бродит сам по себе - указание 2026-09-18 ("держится сбоку и позади хозяина на 2 блока...
|
||||||
|
/// если по циферблату часов, то на 3-6 часов (зависит от препятствий)").
|
||||||
|
///
|
||||||
|
/// ПОЧЕМУ ЭТО ПРИШЛОСЬ ПИСАТЬ, А НЕ НАСТРОИТЬ. Задачи "иди за сущностью" в игре НЕТ вообще -
|
||||||
|
/// проверено по списку типов в Assembly-CSharp: всего 32 класса EAI*, и ни одного
|
||||||
|
/// follow-подобного (EAIWander, EAITerritorial, EAIApproachSpot, EAIApproachAndAttackTarget,
|
||||||
|
/// EAISetNearestCorpseAsTarget и т.д.). Слежение дрона за хозяином - это захардкоженный C#
|
||||||
|
/// внутри EntityDrone, в XML его не выбрать. До сегодняшнего дня мод обходился телепорт-
|
||||||
|
/// поводком в PetFollowPatch.cs: раз в секунду, если питомец дальше 32 м, его просто
|
||||||
|
/// переставляли к игроку. Это и есть то, что игроки видели как "питомец не идёт следом".
|
||||||
|
///
|
||||||
|
/// ОБРАЗЕЦ ВЗЯТ С ВАНИЛЬНОГО EAIApproachSpot - он решает ровно ту же задачу "дойти до точки"
|
||||||
|
/// и показывает весь нужный обвес: theEntity.FindPath(...) -> theEntity.navigator.getPath()
|
||||||
|
/// -> moveHelper. Отличий от него три, и все три - требования пользователя:
|
||||||
|
///
|
||||||
|
/// 1. ТОЧКА ПЕРЕСЧИТЫВАЕТСЯ КАЖДЫЙ ТИК и привязана к хозяину, а не к месту на земле.
|
||||||
|
/// SlotAngles - сектор "3-6 часов" от направления взгляда хозяина, перебираемый от
|
||||||
|
/// середины к краям: сначала 135° (ровно между бортом и кормой), потом ближе к борту и к
|
||||||
|
/// корме. Первая точка, до которой нашёлся путь, и становится местом крысы - это и есть
|
||||||
|
/// "зависит от препятствий".
|
||||||
|
///
|
||||||
|
/// 2. canBreak: false в FindPath. Питомцы до сих пор носили ванильный AITask BreakBlock и
|
||||||
|
/// грызли всё на пути, включая базу игрока. Крыса не может грызть блоки НА УРОВНЕ
|
||||||
|
/// ПАФАЙНДЕРА: он просто не строит путей через разрушение. В entityclasses.xml у неё
|
||||||
|
/// заодно нет ни BreakBlock, ни BlockingTargetTask.
|
||||||
|
///
|
||||||
|
/// 3. ПРОХОД СКВОЗЬ БЛОКИ вместо разрушения - указание "технически, дух может проходить
|
||||||
|
/// сквозь блоки, если нужно пройти там где есть препятствие. Блоки ломать не нужно".
|
||||||
|
/// Для этого не понадобилось ничего изобретать: в Entity есть публичное поле IsStuck, и
|
||||||
|
/// единственное место, где игра его читает, - собственный шаг перемещения:
|
||||||
|
///
|
||||||
|
/// if (IsStuck) { PhysicsTransform.position += hitMove; } // мимо контроллера
|
||||||
|
/// else { collisionFlags = m_characterController.Move(hitMove); }
|
||||||
|
///
|
||||||
|
/// То есть это готовый выключатель столкновений, оставленный игрой себе на случай
|
||||||
|
/// застревания. Ни один из просмотренных классов (EntityMoveHelper, EntityZombie,
|
||||||
|
/// EntityEnemy, EntityAnimal, EntityAlive, EntityPlayerLocal) в него не ПИШЕТ - поле
|
||||||
|
/// фактически бесхозное, и мы можем владеть им единолично.
|
||||||
|
///
|
||||||
|
/// ОПАСНОСТЬ ЗДЕСЬ РЕАЛЬНАЯ, И ОНА УЖЕ СРАБОТАЛА: IsStuck отключает столкновения со
|
||||||
|
/// ВСЕМ, включая пол, и 18.09 крыса провалилась под мир ("fell off the world,
|
||||||
|
/// pos=(319.2, -0.4, 1367.2)"). Разбор и все пять страховок - в комментариях к
|
||||||
|
/// MaxDropBelowOwner и в UpdatePhase; коротко: гравитация гасится на каждом тике,
|
||||||
|
/// ниже цели питомец не опускается, проход не включается на дальней дистанции и
|
||||||
|
/// ограничен по времени, а падение ловится отдельной проверкой в начале Update.
|
||||||
|
///
|
||||||
|
/// ЗАДАЧА ДОБАВЛЯЕТСЯ В РАНТАЙМЕ (SummonPatch.cs), а не через XML. В XML это тоже возможно -
|
||||||
|
/// EAIManager.GetType() для незнакомого имени падает в Type.GetType("EAI" + имя), и
|
||||||
|
/// индексированные AITask-N берут значение свойства целиком, так что
|
||||||
|
/// AITask-N value="NecroFollowOwner,NecromancerHarmony" разрешилось бы в наш тип. Но
|
||||||
|
/// Type.GetType с именем сборки дёргает Assembly.Load, а как именно загрузчик модов кладёт
|
||||||
|
/// наши DLL - непроверено, и ставить работоспособность питомца в зависимость от этого нет
|
||||||
|
/// никакой нужды: aiManager.tasks.AddTask даёт тот же результат без единого допущения, да ещё
|
||||||
|
/// и позволяет передать владельца прямо в конструктор.
|
||||||
|
/// </summary>
|
||||||
|
public class NecroFollowOwnerTask : EAIBase
|
||||||
|
{
|
||||||
|
/// <summary>Сектор "3-6 часов" в градусах от направления взгляда хозяина: 90° - правый
|
||||||
|
/// борт, 180° - корма. Перебор идёт от середины сектора к его краям, чтобы обычное место
|
||||||
|
/// крысы было одно и то же, а края работали как запасные при препятствии.</summary>
|
||||||
|
public static readonly float[] SlotAngles = { 135f, 112f, 158f, 90f, 180f };
|
||||||
|
|
||||||
|
/// <summary>Расстояние по умолчанию - два блока от хозяина.</summary>
|
||||||
|
public const float DefaultSlotDistance = 2f;
|
||||||
|
|
||||||
|
/// <summary>Своё у каждого питомца: Медведю и Волку задан блок дальше остальных
|
||||||
|
/// (указание 2026-09-18 - "сделай место следования там же где и собаке, но на блок
|
||||||
|
/// дальше, они мощнее"). Сектор при этом общий, меняется только радиус, поэтому
|
||||||
|
/// крупные звери встают в тот же угол, но не наступают хозяину на пятки.
|
||||||
|
/// Значение приходит из SummonPatch.LimitedPets при создании задачи.</summary>
|
||||||
|
public float SlotDistance = DefaultSlotDistance;
|
||||||
|
|
||||||
|
/// <summary>Ближе этого крыса считает, что уже на месте, и останавливается. Квадрат
|
||||||
|
/// расстояния, как везде в этом коде - корень тут не нужен.</summary>
|
||||||
|
public const float ArrivedDistSq = 1.2f * 1.2f;
|
||||||
|
|
||||||
|
/// <summary>Столько секунд без сокращения расстояния - и точка считается недостижимой:
|
||||||
|
/// пробуем следующую, а когда кончатся все - проходим насквозь.
|
||||||
|
///
|
||||||
|
/// ПОДНЯТО С 1.5 ДО 3 СЕКУНД 2026-09-18, после того как крыса провалилась сквозь мир.
|
||||||
|
/// Полторы секунды - слишком мало: в помещении питомец столько обходит мебель, не
|
||||||
|
/// сокращая расстояния ПО ПРЯМОЙ, и проход включался там, где обычный обход дошёл бы
|
||||||
|
/// сам. Чем реже поднимается IsStuck, тем меньше поводов у всего, что с ним связано.</summary>
|
||||||
|
public const float NoProgressSeconds = 3f;
|
||||||
|
|
||||||
|
/// <summary>Метры в секунду на проходе сквозь препятствие. Медленнее бега: дух
|
||||||
|
/// просачивается, а не выстреливает.</summary>
|
||||||
|
public const float PhaseSpeed = 3.5f;
|
||||||
|
|
||||||
|
/// <summary>Предохранитель. Если за это время проход не закончился, он прекращается
|
||||||
|
/// принудительно - лучше крыса, стоящая не там, чем крыса, летящая сквозь мир.</summary>
|
||||||
|
public const float PhaseTimeout = 4f;
|
||||||
|
|
||||||
|
/// <summary>Дальше этого расстояния до хозяина ждать пафайндер бессмысленно - прыжок.
|
||||||
|
/// Та же цифра, что у старого поводка в PetFollowPatch.cs (и у ванильного DroneManager,
|
||||||
|
/// откуда она изначально и взята).
|
||||||
|
///
|
||||||
|
/// РАНЬШЕ ЗДЕСЬ БЫЛ ПРОХОД, И ЭТО БЫЛА ОШИБКА (исправлено 18.09, см. MaxDropBelowOwner):
|
||||||
|
/// проход задуман для препятствия в шаге, а не для перелёта через тридцать метров
|
||||||
|
/// незнакомой местности с отключёнными столкновениями.</summary>
|
||||||
|
public const float LeashDistance = 32f;
|
||||||
|
|
||||||
|
/// <summary>Аварийный предел по высоте: если питомец оказался ниже хозяина больше чем на
|
||||||
|
/// столько, разбираться уже некогда - прыжок к хозяину.
|
||||||
|
///
|
||||||
|
/// ЗАЧЕМ. 18.09 крыса пропала в игре, и лог сказал ровно что случилось:
|
||||||
|
/// WRN Entity [type=EntityZombieDog, name=necroRatSpirit, id=1459] fell off the world,
|
||||||
|
/// pos=(319.2, -0.4, 1367.2)
|
||||||
|
/// Высота -0.4: она провалилась под мир, а игра удаляет всё, что опустилось ниже нуля
|
||||||
|
/// (Entity.onUpdate: position.y < 0f -> MarkToUnload). Виноват IsStuck - он снимает
|
||||||
|
/// столкновения со ВСЕМ, включая пол, а гравитация никуда не девается: шаг перемещения
|
||||||
|
/// складывает hitMove с накопленным motion. Четыре секунды прохода над дыркой в полу -
|
||||||
|
/// и питомца нет.
|
||||||
|
///
|
||||||
|
/// Эта проверка работает независимо от прохода и ловит любое падение, а не только наше
|
||||||
|
/// собственное.</summary>
|
||||||
|
public const float MaxDropBelowOwner = 6f;
|
||||||
|
|
||||||
|
/// <summary>Ниже этой высоты вмешиваемся в любом случае: мир кончается на нуле, и пять
|
||||||
|
/// метров запаса нужны, чтобы успеть что-то сделать, а не констатировать.</summary>
|
||||||
|
public const float WorldFloorGuard = 5f;
|
||||||
|
|
||||||
|
public int OwnerEntityId = -1;
|
||||||
|
|
||||||
|
public Vector3 seekPos;
|
||||||
|
public int slotIndex;
|
||||||
|
public float noProgressTime;
|
||||||
|
public float lastDistSq;
|
||||||
|
public bool phasing;
|
||||||
|
public float phaseTime;
|
||||||
|
public int pathRecalculateTicks;
|
||||||
|
|
||||||
|
public override void Init(EntityAlive _theEntity)
|
||||||
|
{
|
||||||
|
base.Init(_theEntity);
|
||||||
|
// 3 - те же биты, что у EAIApproachSpot: это задача ПЕРЕМЕЩЕНИЯ, и она не должна
|
||||||
|
// исполняться одновременно с другой такой же (в первую очередь с погоней за целью).
|
||||||
|
MutexBits = 3;
|
||||||
|
executeDelay = 0.1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EntityAlive Owner()
|
||||||
|
{
|
||||||
|
if (OwnerEntityId < 0 || theEntity == null || theEntity.world == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return theEntity.world.GetEntity(OwnerEntityId) as EntityAlive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Точка для слота slotIndex: хозяин + поворот его собственного направления на
|
||||||
|
/// угол из SlotAngles, посаженный на опорный блок.</summary>
|
||||||
|
public Vector3 SlotPosition(EntityAlive _owner, int _slot)
|
||||||
|
{
|
||||||
|
return SlotPosition(_owner, _slot, theEntity.world, SlotDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>То же самое, но без экземпляра задачи - нужна PetFollowPatch.cs, который
|
||||||
|
/// спасает упавших питомцев во время боя, когда сама задача не исполняется. Там точный
|
||||||
|
/// радиус не важен (питомца просто возвращают к хозяину), поэтому берётся общий.</summary>
|
||||||
|
public static Vector3 SlotPosition(EntityAlive _owner, int _slot, World _world,
|
||||||
|
float _slotDistance = DefaultSlotDistance)
|
||||||
|
{
|
||||||
|
float angle = SlotAngles[_slot % SlotAngles.Length];
|
||||||
|
Vector3 dir = _owner.qrotation * (Quaternion.Euler(0f, angle, 0f) * Vector3.forward);
|
||||||
|
Vector3 raw = _owner.position + dir * _slotDistance;
|
||||||
|
return _world.FindSupportingBlockPos(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanExecute()
|
||||||
|
{
|
||||||
|
// Дерётся - не мешаем. Цель крысе даёт игрок (PetCommandPatch.cs), и пока она есть,
|
||||||
|
// место в строю подождёт.
|
||||||
|
if (theEntity.GetAttackTarget() != null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (theEntity.IsDead() || theEntity.IsSleeping)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
EntityAlive owner = Owner();
|
||||||
|
if (owner == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seekPos = SlotPosition(owner, slotIndex);
|
||||||
|
return (seekPos - theEntity.position).sqrMagnitude > ArrivedDistSq;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Start()
|
||||||
|
{
|
||||||
|
noProgressTime = 0f;
|
||||||
|
lastDistSq = float.MaxValue;
|
||||||
|
phasing = false;
|
||||||
|
phaseTime = 0f;
|
||||||
|
pathRecalculateTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Continue()
|
||||||
|
{
|
||||||
|
if (theEntity.GetAttackTarget() != null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
EntityAlive owner = Owner();
|
||||||
|
if (owner == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seekPos = SlotPosition(owner, slotIndex);
|
||||||
|
return (seekPos - theEntity.position).sqrMagnitude > ArrivedDistSq;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Update()
|
||||||
|
{
|
||||||
|
EntityAlive owner = Owner();
|
||||||
|
if (owner == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ПЕРВОЙ СТРОКОЙ, ДО ВСЕГО ОСТАЛЬНОГО: не падает ли питомец из мира. Проверка стоит
|
||||||
|
// здесь, а не внутри прохода, намеренно - провалиться можно и без нашего участия
|
||||||
|
// (дыра в полу POI, взорванный блок), а последствие одно и то же: сущность ниже нуля
|
||||||
|
// игра удаляет молча, и питомец просто исчезает.
|
||||||
|
if (theEntity.position.y < WorldFloorGuard ||
|
||||||
|
theEntity.position.y < owner.position.y - MaxDropBelowOwner)
|
||||||
|
{
|
||||||
|
SnapToOwner(owner, "падение (высота " + theEntity.position.y.ToCultureInvariantString("0.0") + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3 lookPosition = owner.position;
|
||||||
|
lookPosition.y += 0.8f;
|
||||||
|
theEntity.SetLookPosition(lookPosition);
|
||||||
|
|
||||||
|
if (phasing)
|
||||||
|
{
|
||||||
|
UpdatePhase();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float distSq = (seekPos - theEntity.position).sqrMagnitude;
|
||||||
|
bool ownerFarAway = (owner.position - theEntity.position).sqrMagnitude > LeashDistance * LeashDistance;
|
||||||
|
|
||||||
|
// Сокращаем расстояние - значит путь есть и он работает; счётчик простоя сбрасывается.
|
||||||
|
if (distSq < lastDistSq - 0.05f)
|
||||||
|
{
|
||||||
|
noProgressTime = 0f;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noProgressTime += executeDelay;
|
||||||
|
}
|
||||||
|
lastDistSq = distSq;
|
||||||
|
|
||||||
|
// ХОЗЯИН ДАЛЕКО - ЭТО НЕ ПОВОД ПРОХОДИТЬ СКВОЗЬ СТЕНЫ, и раньше было именно так.
|
||||||
|
// Проход задуман для препятствия В ШАГЕ, а не для перелёта через тридцать метров
|
||||||
|
// незнакомой местности с отключёнными столкновениями: ровно такой проход 18.09 и
|
||||||
|
// уронил крысу под мир. На дальней дистанции работает обычный прыжок поводком - тот
|
||||||
|
// самый приём, которым ванильный DroneManager возвращает дрона, и он безопасен,
|
||||||
|
// потому что точка назначения известна заранее и стоит на земле.
|
||||||
|
if (ownerFarAway)
|
||||||
|
{
|
||||||
|
SnapToOwner(owner, "хозяин дальше " + LeashDistance + " м");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noProgressTime >= NoProgressSeconds)
|
||||||
|
{
|
||||||
|
noProgressTime = 0f;
|
||||||
|
lastDistSq = float.MaxValue;
|
||||||
|
if (slotIndex >= SlotAngles.Length - 1)
|
||||||
|
{
|
||||||
|
// Перепробованы все точки сектора - значит дело в препятствии: сквозь.
|
||||||
|
slotIndex = 0;
|
||||||
|
BeginPhase();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Следующая точка сектора - "зависит от препятствий".
|
||||||
|
slotIndex++;
|
||||||
|
seekPos = SlotPosition(owner, slotIndex);
|
||||||
|
pathRecalculateTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theEntity.navigator.getPath() != null)
|
||||||
|
{
|
||||||
|
theEntity.moveHelper.CalcIfUnreachablePos();
|
||||||
|
}
|
||||||
|
if (--pathRecalculateTicks <= 0)
|
||||||
|
{
|
||||||
|
UpdatePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdatePath()
|
||||||
|
{
|
||||||
|
if (PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pathRecalculateTicks = 8 + GetRandom(6);
|
||||||
|
// canBreak: false - крыса не грызёт блоки, это её главное отличие от всех остальных
|
||||||
|
// питомцев мода (см. шапку файла).
|
||||||
|
theEntity.FindPath(seekPos, theEntity.GetMoveSpeedAggro(), false, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginPhase()
|
||||||
|
{
|
||||||
|
phasing = true;
|
||||||
|
phaseTime = 0f;
|
||||||
|
theEntity.moveHelper.Stop();
|
||||||
|
theEntity.IsStuck = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndPhase()
|
||||||
|
{
|
||||||
|
phasing = false;
|
||||||
|
phaseTime = 0f;
|
||||||
|
theEntity.IsStuck = false;
|
||||||
|
// ОБЯЗАТЕЛЬНО: пока IsStuck был поднят, пола для крысы не существовало. Сажаем её на
|
||||||
|
// опорный блок, иначе она останется висеть или продолжит падать.
|
||||||
|
theEntity.SetPosition(theEntity.world.FindSupportingBlockPos(theEntity.position), true);
|
||||||
|
lastDistSq = float.MaxValue;
|
||||||
|
noProgressTime = 0f;
|
||||||
|
pathRecalculateTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdatePhase()
|
||||||
|
{
|
||||||
|
phaseTime += executeDelay;
|
||||||
|
|
||||||
|
// ГРАВИТАЦИЯ ГАСИТСЯ КАЖДЫЙ ТИК, и это главная правка 18.09. Пока IsStuck поднят,
|
||||||
|
// шаг перемещения складывает накопленный motion прямо с позицией, минуя контроллер
|
||||||
|
// и пол, - то есть питомец продолжает "падать" между нашими SetPosition, причём
|
||||||
|
// ускоряясь. Обнуление motion означает: во время прохода питомца двигает ТОЛЬКО
|
||||||
|
// этот метод и ничто больше.
|
||||||
|
theEntity.motion = Vector3.zero;
|
||||||
|
|
||||||
|
Vector3 next = Vector3.MoveTowards(theEntity.position, seekPos, PhaseSpeed * executeDelay);
|
||||||
|
|
||||||
|
// И отдельно - высота. Даже с обнулённым motion спускаться ниже цели незачем:
|
||||||
|
// seekPos уже посажен на опорный блок (FindSupportingBlockPos), так что всё, что
|
||||||
|
// ниже него, - это пол, сквозь который мы проходить не собирались.
|
||||||
|
if (next.y < seekPos.y)
|
||||||
|
{
|
||||||
|
next.y = seekPos.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
theEntity.SetPosition(next, true);
|
||||||
|
if (phaseTime >= PhaseTimeout || (next - seekPos).sqrMagnitude <= ArrivedDistSq)
|
||||||
|
{
|
||||||
|
EndPhase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Прыжок к хозяину: аварийный выход и он же дальний поводок. Точка - тот же
|
||||||
|
/// слот в секторе, посаженный на опорный блок, а не позиция хозяина в упор (иначе
|
||||||
|
/// коллайдеры сложатся и питомец подбросит игрока - эти грабли уже разобраны в
|
||||||
|
/// PetFollowPatch.cs).</summary>
|
||||||
|
public void SnapToOwner(EntityAlive _owner, string _why)
|
||||||
|
{
|
||||||
|
if (phasing)
|
||||||
|
{
|
||||||
|
phasing = false;
|
||||||
|
phaseTime = 0f;
|
||||||
|
}
|
||||||
|
theEntity.IsStuck = false;
|
||||||
|
theEntity.motion = Vector3.zero;
|
||||||
|
theEntity.moveHelper.Stop();
|
||||||
|
slotIndex = 0;
|
||||||
|
lastDistSq = float.MaxValue;
|
||||||
|
noProgressTime = 0f;
|
||||||
|
pathRecalculateTicks = 0;
|
||||||
|
|
||||||
|
Vector3 dest = SlotPosition(_owner, 0);
|
||||||
|
theEntity.SetPosition(dest, true);
|
||||||
|
Debug.Log("[NecromancerTome] NecroFollowOwnerTask: pet " + theEntity.entityId +
|
||||||
|
" snapped back to owner " + _owner.entityId + " - " + _why);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Reset()
|
||||||
|
{
|
||||||
|
if (phasing)
|
||||||
|
{
|
||||||
|
EndPhase();
|
||||||
|
}
|
||||||
|
theEntity.IsStuck = false;
|
||||||
|
theEntity.moveHelper.Stop();
|
||||||
|
theEntity.SetLookPosition(Vector3.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return string.Format("{0}, slot{1}{2} dist{3}", base.ToString(), slotIndex,
|
||||||
|
phasing ? " PHASING" : "",
|
||||||
|
(theEntity.position - seekPos).magnitude.ToCultureInvariantString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// УБИЙСТВА ПИТОМЦЕМ ТЕПЕРЬ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ - 2026-09-18.
|
||||||
|
///
|
||||||
|
/// ПОЧЕМУ РАНЬШЕ НЕ ЗАСЧИТЫВАЛИСЬ. Ваниль решает, чей это фраг, ровно в одном месте:
|
||||||
|
///
|
||||||
|
/// public void AwardKillXPServer(DamageSource _source, EntityAlive _killingEntity)
|
||||||
|
/// {
|
||||||
|
/// if (_source == null || _source.BuffClass != null) return; // (2)
|
||||||
|
/// EntityPlayer entityPlayer = _killingEntity as EntityPlayer; // (1)
|
||||||
|
/// if ((bool)entityPlayer && !(entityPlayer == this) && ...)
|
||||||
|
/// entityPlayer.AddKillXP(this, _source.AttackingItem, num);
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// (1) Убийца обязан БЫТЬ игроком. При укусе питомца _killingEntity - это сам питомец
|
||||||
|
/// (берётся как world.GetEntity(_dmResponse.Source.getEntityId())), приведение к
|
||||||
|
/// EntityPlayer даёт null, и AddKillXP не вызывается вовсе. А счётчик Некромантии висит
|
||||||
|
/// постфиксом именно на AddKillXP (NecromancyKillCreditPatch.cs) - значит не растут ни
|
||||||
|
/// уровень, ни урон Ножа некроманта, который от этого счётчика и считается.
|
||||||
|
///
|
||||||
|
/// ЧТО ДЕЛАЕТ ЭТОТ ПРЕФИКС. Подменяет _killingEntity на владельца, если убил его питомец.
|
||||||
|
/// Дальше всё едет по ванильному пути само: и опыт, и наш счётчик, и урон Ножа, и испытания -
|
||||||
|
/// потому что мод по-прежнему СЛЕДУЕТ решению ванили, а не дублирует его. Это то же правило,
|
||||||
|
/// ради которого счёт убийств 16.09 переехал из XML в постфикс на AddKillXP.
|
||||||
|
///
|
||||||
|
/// ПОЧЕМУ ЭТО ВООБЩЕ ПОТРЕБОВАЛОСЬ ОБСУЖДАТЬ. 17.09 пользователь решил обратное для зомби под
|
||||||
|
/// Камнем духов: "если зомби под контролем камня духов убивает другого, то в скилл это не
|
||||||
|
/// идёт, что в целом логично. Пока оставляем так". Подчинённые зомби в TrackedPets не лежат,
|
||||||
|
/// поэтому то решение остаётся в силе - здесь меняется только судьба ПРИЗВАННЫХ питомцев,
|
||||||
|
/// которых игрок крафтит, кормит и водит за собой.
|
||||||
|
///
|
||||||
|
/// ВТОРАЯ ПОЛОВИНА: ДОБИВАНИЕ КРОВОТЕЧЕНИЕМ. Строка (2) отсекает любой урон от баффа ДО
|
||||||
|
/// всякой проверки убийцы - то есть зомби, истёкший кровью, не даёт опыта никому и никогда,
|
||||||
|
/// даже если рану нанёс сам игрок. Для Духа крысы это половина всего урона (buffNecroRatBleed
|
||||||
|
/// капает 5 в секунду при укусе в 5), и без этой части починка была бы половинчатой.
|
||||||
|
///
|
||||||
|
/// Мешало одно: в DamageSource от баффа НЕТ идентификатора того, кто бафф наложил. Значит
|
||||||
|
/// помнить приходится самим - что и делает RecentPetBites ниже: каждый удар нашего питомца
|
||||||
|
/// записывает "эту жертву кусал питомец такого-то" со сроком годности чуть больше, чем живёт
|
||||||
|
/// кровотечение. Когда жертва умирает от баффа, запись и отвечает, кому зачесть.
|
||||||
|
///
|
||||||
|
/// Ванильный путь при этом НЕ ПРАВИТСЯ: мы не обнуляем _source.BuffClass (это общий объект,
|
||||||
|
/// его читают и дальше - PartyShareKillServer, разбор смерти игрока), а сами зовём
|
||||||
|
/// AddKillXP и отменяем оригинал, который всё равно вышел бы ни с чем.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(EntityAlive), "AwardKillXPServer")]
|
||||||
|
public static class Patch_EntityAlive_AwardKillXPServer_PetCredit
|
||||||
|
{
|
||||||
|
public static bool Prefix(EntityAlive __instance, DamageSource _source, ref EntityAlive _killingEntity)
|
||||||
|
{
|
||||||
|
if (_source == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Смерть от баффа: ваниль выйдет по строке (2) ни с чем, поэтому зачисляем сами.
|
||||||
|
if (_source.BuffClass != null)
|
||||||
|
{
|
||||||
|
EntityPlayer bleedOwner = RecentPetBites.OwnerOf(__instance.entityId);
|
||||||
|
if (bleedOwner == null || bleedOwner == __instance || !EntityClass.list.ContainsKey(__instance.entityClass))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// KillXPScale у баффа обычно нулевой - это множитель "сколько опыта дать", и на
|
||||||
|
// нуле AddKillXP не дал бы ничего. Единица = обычное убийство.
|
||||||
|
float scale = _source.KillXPScale > 0f ? _source.KillXPScale : 1f;
|
||||||
|
bleedOwner.AddKillXP(__instance, _source.AttackingItem, scale);
|
||||||
|
RecentPetBites.Forget(__instance.entityId);
|
||||||
|
Debug.Log("[NecromancerTome] PetKillCredit: bleed kill of " + __instance.entityId +
|
||||||
|
" credited to owner " + bleedOwner.entityId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_killingEntity == null || _killingEntity is EntityPlayer)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
EntityPlayer owner = PetFollowPatch.FindOwnerOfPet(_killingEntity.entityId);
|
||||||
|
if (owner == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] PetKillCredit: kill by pet " + _killingEntity.entityId +
|
||||||
|
" credited to owner " + owner.entityId);
|
||||||
|
_killingEntity = owner;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Кто кого кусал из питомцев и когда. Запись живёт чуть дольше кровотечения
|
||||||
|
/// (buffNecroRatBleed - 15 секунд), чтобы добивание точно попало в окно, и при этом не
|
||||||
|
/// превращалась в вечную память: зомби, укушенный минуту назад и умерший от чужой ловушки,
|
||||||
|
/// не должен приносить очки владельцу питомца.</summary>
|
||||||
|
public static class RecentPetBites
|
||||||
|
{
|
||||||
|
public const float MemorySeconds = 18f;
|
||||||
|
|
||||||
|
public struct Bite
|
||||||
|
{
|
||||||
|
public int OwnerEntityId;
|
||||||
|
public float ExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly Dictionary<int, Bite> Bites = new Dictionary<int, Bite>();
|
||||||
|
|
||||||
|
public static void Remember(int _victimEntityId, int _ownerEntityId)
|
||||||
|
{
|
||||||
|
Bites[_victimEntityId] = new Bite
|
||||||
|
{
|
||||||
|
OwnerEntityId = _ownerEntityId,
|
||||||
|
ExpiresAt = Time.time + MemorySeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Forget(int _victimEntityId)
|
||||||
|
{
|
||||||
|
Bites.Remove(_victimEntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EntityPlayer OwnerOf(int _victimEntityId)
|
||||||
|
{
|
||||||
|
if (!Bites.TryGetValue(_victimEntityId, out Bite bite))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Time.time > bite.ExpiresAt)
|
||||||
|
{
|
||||||
|
Bites.Remove(_victimEntityId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
return world != null ? world.GetEntity(bite.OwnerEntityId) as EntityPlayer : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Здесь и запоминается укус. Точка выбрана самая общая: любой урон, нанесённый
|
||||||
|
/// нашим питомцем, метит жертву - неважно, укус это, кровотечение от прошлого укуса или
|
||||||
|
/// что-то ещё, что мы добавим потом.
|
||||||
|
///
|
||||||
|
/// Постфикс, а не префикс: смысл только в том, чтобы записать факт, а вмешиваться в сам урон
|
||||||
|
/// незачем. Заодно здесь же подчищается таблица - редко и дёшево, без своего таймера.</summary>
|
||||||
|
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
|
||||||
|
public static class Patch_EntityAlive_DamageEntity_RememberPetBite
|
||||||
|
{
|
||||||
|
public static int callsSincePrune;
|
||||||
|
|
||||||
|
public static void Postfix(EntityAlive __instance, DamageSource _damageSource)
|
||||||
|
{
|
||||||
|
if (_damageSource == null || __instance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int attackerId = _damageSource.getEntityId();
|
||||||
|
if (attackerId == -1 || attackerId == __instance.entityId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EntityPlayer owner = PetFollowPatch.FindOwnerOfPet(attackerId);
|
||||||
|
if (owner == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RecentPetBites.Remember(__instance.entityId, owner.entityId);
|
||||||
|
|
||||||
|
if (++callsSincePrune >= 200)
|
||||||
|
{
|
||||||
|
callsSincePrune = 0;
|
||||||
|
Prune();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Prune()
|
||||||
|
{
|
||||||
|
if (RecentPetBites.Bites.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<int> stale = new List<int>();
|
||||||
|
foreach (KeyValuePair<int, RecentPetBites.Bite> entry in RecentPetBites.Bites)
|
||||||
|
{
|
||||||
|
if (Time.time > entry.Value.ExpiresAt)
|
||||||
|
{
|
||||||
|
stale.Add(entry.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (int id in stale)
|
||||||
|
{
|
||||||
|
RecentPetBites.Bites.Remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,10 @@ namespace NecromancerTome
|
|||||||
// buff-trigger vocabulary, since there's no "for as long as this XUiC_Timer is open"
|
// buff-trigger vocabulary, since there's no "for as long as this XUiC_Timer is open"
|
||||||
// trigger to hang it off - this IS that lifecycle.
|
// trigger to hang it off - this IS that lifecycle.
|
||||||
player.Buffs.AddBuff(ChannelBuffName);
|
player.Buffs.AddBuff(ChannelBuffName);
|
||||||
|
// The world drains to black and white for the length of the channel - see
|
||||||
|
// ChannelVision.cs. Started here rather than from the buff so both of this mod's
|
||||||
|
// channels share one definition of what channelling looks like.
|
||||||
|
ChannelVision.Begin(player);
|
||||||
|
|
||||||
TimerEventData timerData = new TimerEventData
|
TimerEventData timerData = new TimerEventData
|
||||||
{
|
{
|
||||||
@@ -136,6 +140,7 @@ namespace NecromancerTome
|
|||||||
{
|
{
|
||||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
|
||||||
player.Buffs.RemoveBuff(ChannelBuffName);
|
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||||
|
ChannelVision.End(player);
|
||||||
};
|
};
|
||||||
|
|
||||||
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
|
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
|
||||||
@@ -151,6 +156,9 @@ namespace NecromancerTome
|
|||||||
{
|
{
|
||||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
|
||||||
player.Buffs.RemoveBuff(ChannelBuffName);
|
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||||
|
// Before the teleport rather than after: the colour is already on its way back while
|
||||||
|
// the player arrives, instead of starting to return only once he is standing there.
|
||||||
|
ChannelVision.End(player);
|
||||||
if (itemName == BlackStoneName)
|
if (itemName == BlackStoneName)
|
||||||
{
|
{
|
||||||
ShowBlackPortalConfirmation(player);
|
ShowBlackPortalConfirmation(player);
|
||||||
@@ -159,45 +167,23 @@ namespace NecromancerTome
|
|||||||
TeleportToBedroll(player);
|
TeleportToBedroll(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Black portal confirmation + fullscreen video, user request 2026-08-30
|
/// <summary>Black portal confirmation dialog, user request 2026-08-30
|
||||||
/// ("диалоговое окно... вы уверены... Если Да, то игра останавливается и проигрывается
|
/// ("диалоговое окно... вы уверены"). XUiC_MessageBoxWindowGroup.ShowCustom(xui, title,
|
||||||
/// видео"). Real APIs, both decompiled directly:
|
/// text, icon, setupCallback, ...) - the same generic Yes/No popup vanilla itself uses (its
|
||||||
/// - XUiC_MessageBoxWindowGroup.ShowCustom(xui, title, text, icon, setupCallback, ...) -
|
/// own delete-item/disconnect confirmations, etc), decompiled directly. ShowOkCancel/
|
||||||
/// the same generic Yes/No popup vanilla itself uses (its own delete-item/disconnect
|
/// ShowConfirmCancel exist too but hardcode their button caption keys ("xuiOk"/"xuiCancel"/
|
||||||
/// confirmations, etc). ShowOkCancel/ShowConfirmCancel exist too but hardcode their
|
/// "btnConfirm") - ShowCustom's _setupCallback is the only variant that lets the two buttons
|
||||||
/// button caption keys ("xuiOk"/"xuiCancel"/"btnConfirm") - ShowCustom's
|
/// be captioned "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys,
|
||||||
/// _setupCallback is the only variant that lets the two buttons be captioned
|
/// confirmed against Data/Config/Localization.csv), matching the user's literal "да/нет"
|
||||||
/// "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys, confirmed
|
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/ShowConfirmCancel
|
||||||
/// against Data/Config/Localization.csv), matching the user's literal "да/нет"
|
/// themselves use internally - Buttons[1] is left unused, same as vanilla's own 2-button
|
||||||
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/
|
/// dialogs.
|
||||||
/// ShowConfirmCancel themselves use internally - Buttons[1] is left unused, same as
|
|
||||||
/// vanilla's own 2-button dialogs.
|
|
||||||
/// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets
|
|
||||||
/// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check
|
|
||||||
/// baked into vanilla itself, not a limitation added by this mod) - a deliberate,
|
|
||||||
/// documented no-op in multiplayer rather than something silently broken.
|
|
||||||
/// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same
|
|
||||||
/// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use.
|
|
||||||
/// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it
|
|
||||||
/// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/
|
|
||||||
/// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived
|
|
||||||
/// already auto-closes on a bad file on its own, this is just a second, player-facing
|
|
||||||
/// way out.
|
|
||||||
///
|
///
|
||||||
/// VIDEO FILE: Video/BlackPortal.webm NO LONGER SHIPS WITH THE MOD. It used to be a
|
/// Второй половины прежнего сценария - паузы и полноэкранного видео - здесь больше нет:
|
||||||
/// byte-for-byte copy of vanilla's own TFP_Intro.webm (from
|
/// после "Да" управление уходит в FinalSlides (см. ActivateBlackPortal ниже), пауза живёт
|
||||||
/// 7DaysToDie_Data/StreamingAssets/Video/), placed there 2026-08-30 as a test stand-in
|
/// там, а видео из финала убрано 2026-09-09. Разбор GameManager.Instance.Pause и
|
||||||
/// ("Пока файл видео замени заглушкой") - deleted 2026-09-09 before the public release,
|
/// XUiC_VideoPlayer.PlayVideo переехал в NoteFlashbackPatch.cs - единственное место в моде,
|
||||||
/// since redistributing a game asset is not ours to do. This path is dead until a real
|
/// где обе эти ванильные API ещё вызываются.</summary>
|
||||||
/// video is dropped in under the same name; the method below is legacy anyway (the
|
|
||||||
/// finale plays text epilogues now, see FinalSlides).
|
|
||||||
/// "@modfolder(NecromancerTome):..." is the exact mod-relative path syntax
|
|
||||||
/// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to
|
|
||||||
/// confirm - looks for "@modfolder(<mod name>):" and substitutes the mod's real
|
|
||||||
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
|
|
||||||
/// DisplayName).</summary>
|
|
||||||
public const string BlackPortalVideoPath = "@modfolder(NecromancerTome):Video/BlackPortal.webm";
|
|
||||||
|
|
||||||
public static void ShowBlackPortalConfirmation(EntityPlayerLocal player)
|
public static void ShowBlackPortalConfirmation(EntityPlayerLocal player)
|
||||||
{
|
{
|
||||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
@@ -220,55 +206,18 @@ namespace NecromancerTome
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
|
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
|
||||||
/// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом
|
/// (Video/BlackPortal.webm), теперь запускается финальная сцена из шести слайдов с текстом
|
||||||
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
|
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
|
||||||
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
|
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
|
||||||
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
|
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
|
||||||
/// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора
|
/// теперь живёт внутри FinalSlides. Видео из концовки убрано целиком 2026-09-09 -
|
||||||
/// концовки на последнем слайде.
|
/// ни здесь, ни в FinalSlides его больше нет.</summary>
|
||||||
///
|
|
||||||
/// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и
|
|
||||||
/// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath /
|
|
||||||
/// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не
|
|
||||||
/// используется и оставлена только как документация к разбору "@modfolder(...)" и
|
|
||||||
/// XUiC_VideoPlayer.PlayVideo, на который FinalSlides ссылается.</summary>
|
|
||||||
public static void ActivateBlackPortal(EntityPlayerLocal player)
|
public static void ActivateBlackPortal(EntityPlayerLocal player)
|
||||||
{
|
{
|
||||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
|
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
|
||||||
FinalSlides.Begin(player);
|
FinalSlides.Begin(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Прежняя концовка "сразу видео, потом главное меню". Больше ниоткуда не
|
|
||||||
/// вызывается (см. ActivateBlackPortal выше) - оставлена целиком, потому что весь разбор
|
|
||||||
/// Pause/PlayVideo/Disconnect в её комментариях остаётся актуальным и на неё ссылается
|
|
||||||
/// FinalSlides. Удалять при следующей уборке, если так и не понадобится.</summary>
|
|
||||||
public static void PlayBlackPortalVideoLegacy(EntityPlayerLocal player)
|
|
||||||
{
|
|
||||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", pausing + playing video");
|
|
||||||
GameManager.Instance.Pause(true);
|
|
||||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
|
||||||
VideoData videoData = new VideoData { url = BlackPortalVideoPath };
|
|
||||||
XUiC_VideoPlayer.PlayVideo(playerUI.xui, videoData, true, delegate(bool skipped)
|
|
||||||
{
|
|
||||||
// EXIT TO MAIN MENU after the video, user request 2026-08-30 ("После видео нужно
|
|
||||||
// выходить из игры в главное меню") - fires whether the video played to the end
|
|
||||||
// or was skipped (Cancel key / a bad file), same as any other "the video is over"
|
|
||||||
// outcome. GameManager.Instance.Disconnect() is not a guess - it's the EXACT same
|
|
||||||
// call the real in-game ESC menu's own "Exit to Main Menu" button uses
|
|
||||||
// (decompiled XUiC_InGameMenuWindow.exitGame/BtnExit_OnPressed to confirm: it's a
|
|
||||||
// thin wrapper straight to this method). Handles everything a clean exit needs by
|
|
||||||
// itself - closes modal windows, un-pauses (calls Pause(false) internally, so no
|
|
||||||
// separate unpause call needed here), saves/shuts down the local server, and
|
|
||||||
// returns to XUiC_MainMenu - not reinventing any of that by hand. Replaces the
|
|
||||||
// earlier "thrownStonePortalBlackNotBound" tooltip placeholder entirely: with a
|
|
||||||
// real exit-to-menu ending, staying in-game and showing a tooltip no longer makes
|
|
||||||
// sense (BACKLOG.md item 6's "destination not decided" placeholder is now this
|
|
||||||
// exit itself, not a tooltip).
|
|
||||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal video finished (skipped=" + skipped + "), exiting to main menu");
|
|
||||||
GameManager.Instance.Disconnect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>BedrollPos comes from EntityPlayer.PersistentPlayerData (decompiled - reads
|
/// <summary>BedrollPos comes from EntityPlayer.PersistentPlayerData (decompiled - reads
|
||||||
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
|
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
|
||||||
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
|
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
|
||||||
|
|||||||
@@ -30,19 +30,15 @@ namespace NecromancerTome
|
|||||||
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
||||||
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
||||||
/// instead of silently opening a useless empty window.
|
/// instead of silently opening a useless empty window.
|
||||||
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently
|
/// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
|
||||||
/// assumed: the Bag backing each player's vault lives in a plain in-memory
|
/// when this item shipped, and that shortfall is exactly what became the mod's first
|
||||||
/// Dictionary<int, Bag> in this file (PlayerVaults below), keyed by entityId. This
|
/// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
|
||||||
/// is reliable for as long as the game process keeps running (survives death/respawn/
|
/// disappear after leaving the game and going online again"). PlayerVaults below is still
|
||||||
/// relogging within one play session, confirmed by how a static field behaves) but has
|
/// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
|
||||||
/// NOT been wired into any save/load system - closing the game entirely and reloading the
|
/// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
|
||||||
/// save later will NOT bring the vault's contents back (no persistence file, no hook into
|
/// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
|
||||||
/// PersistentPlayerData or a world-save event). Building real cross-session persistence
|
/// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
|
||||||
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned
|
/// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
|
||||||
/// world entity the way the summoned pets do - unconfirmed whether THOSE actually survive
|
|
||||||
/// a full restart either) is real, separate follow-up work, not attempted here. Treat
|
|
||||||
/// this like a session-scoped stash until that's built and confirmed - don't rely on it
|
|
||||||
/// across game restarts yet.
|
|
||||||
///
|
///
|
||||||
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
||||||
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
||||||
@@ -71,8 +67,9 @@ namespace NecromancerTome
|
|||||||
public const float MaxRange = 50f;
|
public const float MaxRange = 50f;
|
||||||
public const float ShoveDistance = 6f;
|
public const float ShoveDistance = 6f;
|
||||||
|
|
||||||
/// <summary>See the class-level comment above for exactly what this does and doesn't
|
/// <summary>Session cache only - the durable copy lives on disk, see
|
||||||
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary>
|
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
|
||||||
|
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
|
||||||
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||||||
|
|
||||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||||
@@ -93,13 +90,25 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
if (_actionData.indexInEntityOfAction == 1)
|
if (_actionData.indexInEntityOfAction == 1)
|
||||||
{
|
{
|
||||||
OpenVault(player);
|
// Not when this very press just cancelled a block pickup and opened the vault on
|
||||||
|
// the way - see SpatialVaultPickup.ConsumeCancelOpen. Down and up are one press.
|
||||||
|
if (!SpatialVaultPickup.ConsumeCancelOpen())
|
||||||
|
{
|
||||||
|
OpenVault(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// The regular attack takes the block under the crosshair into the vault from
|
||||||
|
// 2026-09-14 - see SpatialVaultPickupPatch.cs. Before that it did nothing at all,
|
||||||
|
// by direct user request of 2026-08-30 ("пусть тогда обычная атака у
|
||||||
|
// пространственного браслета не делает ничего"), after the knockback+slow version did not
|
||||||
|
// visibly do anything in testing. ShoveZombieAtCrosshair is kept below, unused,
|
||||||
|
// because that abandoned version was never shown to be WRONG - only invisible.
|
||||||
|
// The ItemValue goes with it: the pickup refuses outright when this particular
|
||||||
|
// bracelet has nothing in its mod slot, and mods live on the instance.
|
||||||
|
SpatialVaultPickup.Begin(player, _actionData.invData.itemValue);
|
||||||
}
|
}
|
||||||
// else: regular attack (index 0) deliberately does nothing, per direct user request
|
|
||||||
// 2026-08-30 ("пусть тогда обычная атака у пространственного браслета не делает
|
|
||||||
// ничего") after the knockback+slow version didn't visibly do anything in testing -
|
|
||||||
// rather than debug ShoveZombieAtCrosshair blind (kept below, unused, in case this
|
|
||||||
// gets revisited), just absorb the click silently.
|
|
||||||
|
|
||||||
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
|
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
|
||||||
return false;
|
return false;
|
||||||
@@ -118,7 +127,17 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
||||||
{
|
{
|
||||||
bag = new Bag(slotCount);
|
// Normally a restored vault is already here - the ToPlayer postfix puts it in
|
||||||
|
// when the game applies the save file to the spawning player. LastLoadedVault is
|
||||||
|
// the safety net for when that chain does not complete: opening the bracelet must
|
||||||
|
// never be what silently starts an empty vault over a saved one. Only then is a
|
||||||
|
// genuinely new bag created.
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||||
|
if (bag == SpatialVaultPersistence.LastLoadedVault)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" +
|
||||||
|
bag.SlotCount + " slots, " + bag.GetUsedSlotCount() + " used)");
|
||||||
|
}
|
||||||
PlayerVaults[player.entityId] = bag;
|
PlayerVaults[player.entityId] = bag;
|
||||||
}
|
}
|
||||||
else if (bag.SlotCount < slotCount)
|
else if (bag.SlotCount < slotCount)
|
||||||
@@ -134,7 +153,21 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
XUiC_BagStorageWindowGroup.Open(playerUI.xui, player, bag, LootContainer.GetLootContainer("roboticDrone"), Localization.Get("braceletSpatialVaultWindowTitle"));
|
// The trailing callbacks are vanilla's own optional parameters (_onModified, _onClose).
|
||||||
|
// _onModified is not needed: the vault lives in PlayerVaults, and PlayerDataFile's
|
||||||
|
// FromPlayer postfix reads it fresh every time the game saves the player, so there is
|
||||||
|
// nothing to flush per item move. _onClose asks for a player-data save right away, so
|
||||||
|
// closing the window is a commit point rather than waiting for the next autosave -
|
||||||
|
// SaveLocalPlayerData is the game's own routine call and no-ops when saving is not
|
||||||
|
// active (which is the correct behaviour on a client, where the server owns the file).
|
||||||
|
XUiC_BagStorageWindowGroup.Open(
|
||||||
|
playerUI.xui,
|
||||||
|
player,
|
||||||
|
bag,
|
||||||
|
LootContainer.GetLootContainer("roboticDrone"),
|
||||||
|
Localization.Get("braceletSpatialVaultWindowTitle"),
|
||||||
|
null,
|
||||||
|
() => GameManager.Instance.SaveLocalPlayerData());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-restart persistence for the Spatial Bracelet's vault - the fix for the first bug
|
||||||
|
/// report the mod ever got on Nexus (youkia96581, 11 Sep 2026: "Items stored in the space
|
||||||
|
/// bracelet will disappear after leaving the game and going online again").
|
||||||
|
///
|
||||||
|
/// WHY IT LIVES IN THE PLAYER'S SAVE FILE - "почему не сделать принцип как у ящика?" (user,
|
||||||
|
/// 13.09.2026). Right question, and it decided the design. A chest keeps its items because
|
||||||
|
/// they live in a TileEntity, and a TileEntity belongs to a CHUNK: decompiled, `TileEntity`
|
||||||
|
/// has chunkPos and chunk fields and its ONLY constructor is TileEntity(Chunk). The game saves
|
||||||
|
/// and syncs the chunk; the container rides along. That is the whole trick - not a "storage
|
||||||
|
/// system" one can call, but a home in something the engine already persists. The bracelet has
|
||||||
|
/// no position and no chunk, so it got the closest equivalent for something personal: the
|
||||||
|
/// player's own save data, written right after everything vanilla writes, in the same file and
|
||||||
|
/// the same moment as the backpack.
|
||||||
|
///
|
||||||
|
/// THAT ALSO ANSWERS THE ID QUESTION ("у браслета, как и у ящика, наверняка есть id"). A
|
||||||
|
/// chest's id IS its position. An item has no per-instance id by default - ItemValue.type is
|
||||||
|
/// the item CLASS, identical on every bracelet - but ItemValue.Metadata would hold one and
|
||||||
|
/// genuinely round-trips through saves (ItemValue.Write writes it, ItemValue.ReadData reads it
|
||||||
|
/// back; both checked). Per-bracelet vaults are therefore buildable and deliberately not built:
|
||||||
|
/// keying by the item means losing the bracelet locks the items away forever even though they
|
||||||
|
/// are still in the save file, and it would let ten bracelets be ten warehouses. Keying by the
|
||||||
|
/// player - which storing them IN the player's file does for free - has neither problem.
|
||||||
|
///
|
||||||
|
/// THE FOUR HOOKS:
|
||||||
|
/// FromPlayer - live player -> file object: attach that player's vault to the file.
|
||||||
|
/// Write - file object -> bytes (Save to disk, or WriteNetwork to the wire, which is
|
||||||
|
/// literally Write + PlayerMetaInfo): append the vault blob.
|
||||||
|
/// Read - bytes -> file object: pull the vault back off the stream.
|
||||||
|
/// ToPlayer - file object -> live player: hand the vault back.
|
||||||
|
/// FromPlayer always reads the CURRENT vault, so there is no dirty flag and no save scheduling
|
||||||
|
/// to get wrong: whenever the game saves the player, it saves the vault.
|
||||||
|
///
|
||||||
|
/// ================================================================================
|
||||||
|
/// THE BUG THAT COST TWO TEST RUNS, AND WHY IT IS WORTH A BIG COMMENT
|
||||||
|
/// ================================================================================
|
||||||
|
/// Earlier versions cleared the session cache from a ModEvents.WorldShuttingDown handler, to
|
||||||
|
/// stop one save's vault leaking into the next. The user reported the vault kept losing its
|
||||||
|
/// contents, and the diagnostics printed the murder weapon in order:
|
||||||
|
///
|
||||||
|
/// INF SaveAndCleanupWorld
|
||||||
|
/// [NecromancerTome] world shutting down, dropped 1 in-memory vault(s)
|
||||||
|
/// [NecromancerTome] FromPlayer entity 171 - vault NONE
|
||||||
|
/// [NecromancerTome] Write - no vault attached (writes an EMPTY marker)
|
||||||
|
///
|
||||||
|
/// **WorldShuttingDown fires BEFORE the final player save, not after.** Confirmed in
|
||||||
|
/// GameManager.SaveAndCleanupWorld by decompilation rather than inferred from the log: the
|
||||||
|
/// event is invoked at IL_0026 and SaveLocalPlayerData() is called at IL_00c4, a hundred-odd
|
||||||
|
/// instructions later. So the handler emptied the cache, and the save that followed
|
||||||
|
/// faithfully recorded "this player has no vault" over the real one. Every clean exit wiped
|
||||||
|
/// the vault - which is exactly the symptom the Nexus report described, reintroduced by the
|
||||||
|
/// fix for it.
|
||||||
|
///
|
||||||
|
/// There is no documentation to have checked first: the community consensus is that the
|
||||||
|
/// official ModAPI is barebones and has no reference for event ordering, so the decompiler is
|
||||||
|
/// the only authority. Treat every ModEvent's position in the shutdown sequence as unknown
|
||||||
|
/// until read out of the method that invokes it.
|
||||||
|
///
|
||||||
|
/// TWO RULES CAME OUT OF IT, and both are load-bearing here:
|
||||||
|
///
|
||||||
|
/// 1. A RESTORE PATH MAY FAIL; IT MAY NEVER DELETE. An empty session cache is not evidence
|
||||||
|
/// that the player has no vault - it is the absence of evidence. LastLoadedVault below is
|
||||||
|
/// the safety net, so a broken restore chain costs a restore, not the data.
|
||||||
|
/// 2. FRESHNESS IS DECIDED BY WHAT WAS READ, NOT BY A TIMER. Cross-save leaking is now
|
||||||
|
/// prevented by ToPlayer being authoritative: a player file that was read and explicitly
|
||||||
|
/// carried no vault CLEARS the cache. Nothing has to be cleared "at the right moment"
|
||||||
|
/// any more, which is what made the old approach fragile in the first place.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultPersistence
|
||||||
|
{
|
||||||
|
/// <summary>Payload layout version, independent of the blob framing in
|
||||||
|
/// SpatialVaultBlobIO. An unknown version is skipped, not guessed at - the framing's
|
||||||
|
/// explicit length means we can always step over a payload we do not understand.</summary>
|
||||||
|
public const byte PayloadVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>What a PlayerDataFile carries. A class rather than a bare Bag because its mere
|
||||||
|
/// PRESENCE is information: "this file has been read/filled, and the answer - including a
|
||||||
|
/// null Bag - is authoritative". ConditionalWeakTable cannot store null, so a null Bag
|
||||||
|
/// needs a wrapper to be expressible at all.</summary>
|
||||||
|
public class VaultSlot
|
||||||
|
{
|
||||||
|
public Bag Bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vault attached to a PlayerDataFile while it is being written, read or
|
||||||
|
/// converted. Weak, because PlayerDataFile objects are created fresh for every save and
|
||||||
|
/// every network packet and nothing here should keep one alive.</summary>
|
||||||
|
public static readonly ConditionalWeakTable<PlayerDataFile, VaultSlot> AttachedVaults =
|
||||||
|
new ConditionalWeakTable<PlayerDataFile, VaultSlot>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last vault seen this session, kept outside the weak table. This is rule 1 above made
|
||||||
|
/// concrete: if the Read -> ToPlayer -> PlayerVaults chain ever fails to complete, the bag
|
||||||
|
/// is still here, so the next save writes the real contents instead of an empty marker.
|
||||||
|
///
|
||||||
|
/// SINGLE LOCAL PLAYER ONLY. There is one of these per process, so on a dedicated server
|
||||||
|
/// it would be one player's vault handed to whoever asked next. Every use is gated on the
|
||||||
|
/// player being an EntityPlayerLocal - which a dedicated server does not have, and a host
|
||||||
|
/// or single-player game has exactly one of.
|
||||||
|
/// </summary>
|
||||||
|
public static Bag LastLoadedVault;
|
||||||
|
|
||||||
|
/// <summary>Last line printed by the save path, so an unchanged vault saved over and over
|
||||||
|
/// does not repeat itself in the log. Kept 2026-09-13 when the fix was confirmed: the
|
||||||
|
/// save pair fires on every autosave, and a player's log should not carry two lines of
|
||||||
|
/// inventory listing every few minutes - but the moment anything CHANGES it still says so,
|
||||||
|
/// which is the part that had diagnostic value.</summary>
|
||||||
|
public static string lastSaveLogged;
|
||||||
|
|
||||||
|
/// <summary>Builds the opaque payload SpatialVaultBlobIO wraps. Uses netstandard's own
|
||||||
|
/// BinaryWriter over a MemoryStream, which is why Bag serialization can stay in this
|
||||||
|
/// project instead of the satellite assembly.</summary>
|
||||||
|
public static byte[] BuildPayload(Bag _bag)
|
||||||
|
{
|
||||||
|
using (MemoryStream ms = new MemoryStream())
|
||||||
|
using (BinaryWriter bw = new BinaryWriter(ms))
|
||||||
|
{
|
||||||
|
bw.Write(PayloadVersion);
|
||||||
|
bool hasBag = _bag != null;
|
||||||
|
bw.Write(hasBag);
|
||||||
|
if (hasBag)
|
||||||
|
{
|
||||||
|
// Plain BinaryWriter is enough: Bag.Write only demands a PooledBinaryWriter
|
||||||
|
// when bag.preferences != null, and vault bags come from `new Bag(int)`, whose
|
||||||
|
// constructor sets nothing but the item array.
|
||||||
|
_bag.Write(bw);
|
||||||
|
}
|
||||||
|
bw.Flush();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Null when the payload holds no vault or is a version we do not know.</summary>
|
||||||
|
public static Bag ParsePayload(byte[] _payload)
|
||||||
|
{
|
||||||
|
if (_payload == null || _payload.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
using (MemoryStream ms = new MemoryStream(_payload, false))
|
||||||
|
using (BinaryReader br = new BinaryReader(ms))
|
||||||
|
{
|
||||||
|
byte version = br.ReadByte();
|
||||||
|
if (version != PayloadVersion)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: vault payload version " + version + ", expected " + PayloadVersion + " - skipped");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!br.ReadBoolean())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Bag.Read is the STATIC one and returns a new Bag; ReadInto is the instance
|
||||||
|
// version. Symmetric with BuildPayload: preferences were written as absent, so no
|
||||||
|
// PooledBinaryReader is needed here either.
|
||||||
|
return Bag.Read(br);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Attach(PlayerDataFile _file, Bag _bag)
|
||||||
|
{
|
||||||
|
AttachedVaults.Remove(_file);
|
||||||
|
AttachedVaults.Add(_file, new VaultSlot { Bag = _bag });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Contents of a bag, for the log. Item names rather than just a count, because
|
||||||
|
/// "2 slots, 0 used" was true and useless three test runs in a row - what was needed was
|
||||||
|
/// whether the items the user put in had actually reached this object.</summary>
|
||||||
|
public static string Describe(Bag _bag)
|
||||||
|
{
|
||||||
|
if (_bag == null)
|
||||||
|
{
|
||||||
|
return "NONE";
|
||||||
|
}
|
||||||
|
ItemStack[] slots = _bag.GetSlots();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.Append(_bag.SlotCount).Append(" slots, ").Append(_bag.GetUsedSlotCount()).Append(" used");
|
||||||
|
if (slots != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < slots.Length; i++)
|
||||||
|
{
|
||||||
|
ItemStack stack = slots[i];
|
||||||
|
if (stack == null || stack.IsEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string name = stack.itemValue != null && stack.itemValue.ItemClass != null
|
||||||
|
? stack.itemValue.ItemClass.GetItemName()
|
||||||
|
: "?";
|
||||||
|
sb.Append(" [").Append(i).Append("]=").Append(name).Append("x").Append(stack.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Live player -> save file: take the vault along.</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "FromPlayer")]
|
||||||
|
public static class Patch_PlayerDataFile_FromPlayer_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, EntityPlayer _player)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag);
|
||||||
|
string source = bag != null ? "session cache" : null;
|
||||||
|
if (bag == null && _player is EntityPlayerLocal && SpatialVaultPersistence.LastLoadedVault != null)
|
||||||
|
{
|
||||||
|
// Rule 1: never write "no vault" over a vault we know exists.
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault;
|
||||||
|
source = "last loaded (session cache was empty)";
|
||||||
|
}
|
||||||
|
SpatialVaultPersistence.Attach(__instance, bag);
|
||||||
|
string line = "FromPlayer entity " + _player.entityId + " - " + SpatialVaultPersistence.Describe(bag) +
|
||||||
|
(source != null ? ", from " + source : "");
|
||||||
|
if (line != SpatialVaultPersistence.lastSaveLogged)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.lastSaveLogged = line;
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: FromPlayer postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save file -> live player: hand the vault back. This is also where freshness is decided
|
||||||
|
/// (rule 2): a file that WAS read and explicitly carried no vault clears the cache, so loading
|
||||||
|
/// a different save cannot inherit the previous world's vault. Only a file that was never read
|
||||||
|
/// at all falls back to LastLoadedVault, which is the broken-chain safety net.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "ToPlayer")]
|
||||||
|
public static class Patch_PlayerDataFile_ToPlayer_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, EntityPlayer _player)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bool isLocal = _player is EntityPlayerLocal;
|
||||||
|
string note;
|
||||||
|
Bag bag;
|
||||||
|
|
||||||
|
if (SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot))
|
||||||
|
{
|
||||||
|
bag = slot.Bag;
|
||||||
|
note = bag != null ? "from this player file" : "this player file says there is no vault";
|
||||||
|
}
|
||||||
|
else if (isLocal && SpatialVaultPersistence.LastLoadedVault != null)
|
||||||
|
{
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault;
|
||||||
|
note = "nothing attached to this file - fell back to the last loaded vault";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bag = null;
|
||||||
|
note = "nothing attached and nothing loaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bag != null)
|
||||||
|
{
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.Remove(_player.entityId);
|
||||||
|
}
|
||||||
|
if (isLocal)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.LastLoadedVault = bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: ToPlayer entity " + _player.entityId +
|
||||||
|
" - " + SpatialVaultPersistence.Describe(bag) + " (" + note + ")");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: ToPlayer postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends the vault after everything vanilla wrote - to disk via Save, or to the
|
||||||
|
/// wire via WriteNetwork.</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "Write")]
|
||||||
|
public static class Patch_PlayerDataFile_Write_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, PooledBinaryWriter _bw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot);
|
||||||
|
Bag bag = slot != null ? slot.Bag : null;
|
||||||
|
SpatialVaultBlobIO.Write(_bw, SpatialVaultPersistence.BuildPayload(bag));
|
||||||
|
if (bag == null)
|
||||||
|
{
|
||||||
|
// Always shouted: writing an empty marker is how the vault got destroyed twice,
|
||||||
|
// so it must never again scroll past unnoticed.
|
||||||
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: Write - no vault attached (writes an EMPTY marker)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Write postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the vault back off the stream. Must never throw: PlayerDataFile.Load treats
|
||||||
|
/// any exception out of Read as "this save is broken, fall back to the .bak".</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "Read")]
|
||||||
|
public static class Patch_PlayerDataFile_Read_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, PooledBinaryReader _br)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] payload = SpatialVaultBlobIO.TryRead(_br);
|
||||||
|
if (payload == null)
|
||||||
|
{
|
||||||
|
// No vault block: a save from before this feature existed, or player data from
|
||||||
|
// somebody without the mod. Deliberately NOT recorded as an authoritative
|
||||||
|
// "no vault" - an absent block is silence, not a denial, and ToPlayer's
|
||||||
|
// fallback is what should handle it. SpatialVaultBlobIO has already put the
|
||||||
|
// stream position back.
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - no vault block on this stream");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bag bag = SpatialVaultPersistence.ParsePayload(payload);
|
||||||
|
// Attached even when null: a blob that says "no vault" IS an answer, and ToPlayer
|
||||||
|
// uses it to clear a stale cache when a different save is loaded.
|
||||||
|
SpatialVaultPersistence.Attach(__instance, bag);
|
||||||
|
if (bag != null)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.LastLoadedVault = bag;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - blob of " + payload.Length +
|
||||||
|
" byte(s), " + SpatialVaultPersistence.Describe(bag));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Read postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holding the Spatial Bracelet's REGULAR attack on a block takes that block into the vault
|
||||||
|
/// after a ten-second timer - the same circular indicator a workbench shows when you take it
|
||||||
|
/// (user request 2026-09-14: "при зажатии обычной атаки игрок видел индикатор как при
|
||||||
|
/// демонтаже верстака... блок должен исчезнуть и появиться в пространственном хранилище").
|
||||||
|
/// Entry point is SpatialVaultPatch's existing Prefix, index 0, which until now deliberately
|
||||||
|
/// swallowed that click and did nothing.
|
||||||
|
///
|
||||||
|
/// THE WHOLE RECIPE IS VANILLA'S, not an imitation of it. Block.TakeItemWithTimer and its
|
||||||
|
/// TakeItemWithTimerDone are short enough to read in one sitting, and they are the workbench
|
||||||
|
/// pickup; what follows is the same sequence with two substitutions - ten seconds instead of
|
||||||
|
/// the block's own TakeDelay, and the vault's Bag instead of the player's backpack. Even the
|
||||||
|
/// refusal messages are vanilla's own keys, which means they are already translated into every
|
||||||
|
/// language the game ships, and a player who has ever taken a workbench has already been
|
||||||
|
/// taught what they mean.
|
||||||
|
///
|
||||||
|
/// A DAMAGED BLOCK IS REFUSED BEFORE THE TIMER EVER OPENS. That is vanilla's first line:
|
||||||
|
///
|
||||||
|
/// if (_blockValue.damage > 0)
|
||||||
|
/// GameManager.ShowTooltip(_player, Localization.Get("ttRepairBeforePickup"), "", "ui_denied");
|
||||||
|
/// else if (canTake(...))
|
||||||
|
/// XUiC_Timer.OpenTimer(...);
|
||||||
|
///
|
||||||
|
/// - and it is exactly what the user asked for: a message, and no indicator at all.
|
||||||
|
///
|
||||||
|
/// EVERY GUARD IS CHECKED TWICE, ONCE TO OPEN THE TIMER AND ONCE TO FINISH IT, because ten
|
||||||
|
/// seconds is a long time in this game. Vanilla does the same for its own two seconds: the
|
||||||
|
/// block can be shot, mined, replaced, or opened by someone else while the circle fills, and
|
||||||
|
/// each of those has its own message rather than a silent failure or, worse, a block quietly
|
||||||
|
/// deleted from the world with nothing to show for it.
|
||||||
|
///
|
||||||
|
/// THE TARGET IS ANY BLOCK UNDER THE CROSSHAIR (user's choice of 2026-09-14, over the
|
||||||
|
/// narrower "only what vanilla already lets you take"). That is a wider promise than vanilla
|
||||||
|
/// ever makes, and two things follow from it that the narrow version would never have had to
|
||||||
|
/// face:
|
||||||
|
///
|
||||||
|
/// - MULTIBLOCKS. A door or a bed occupies several cells, and the crosshair usually lands on
|
||||||
|
/// a child rather than on the parent. Setting that one cell to air would leave the other
|
||||||
|
/// half standing as debris. The child is resolved to its parent first, with the engine's
|
||||||
|
/// own idiom - `isMultiBlock && ischild -> multiBlockPos.GetParentPos(...)` - which is
|
||||||
|
/// what Block's own methods do a dozen times over, and the parent is what gets removed.
|
||||||
|
/// - BLOCKS WITH NO ITEM FORM. Not everything placed in the world converts to something a
|
||||||
|
/// player can hold; ToItemValue comes back empty for those. They are refused up front,
|
||||||
|
/// because the alternative is deleting a block and handing back nothing.
|
||||||
|
///
|
||||||
|
/// THE CHANNEL GETS LONGER WITH REACH - ten seconds against the block, one more per full
|
||||||
|
/// block of distance. The measurement is not computed from the player's position and the
|
||||||
|
/// block's position, which would mean picking a point in the player (feet? eyes?) and a point
|
||||||
|
/// in the block (centre? face?) and being wrong about one of them: the engine already fills in
|
||||||
|
/// HitInfoDetails.distanceSq for the very ray that chose this block, so the number used is the
|
||||||
|
/// length of that ray. It is also the honest one - it measures to the surface being looked at,
|
||||||
|
/// which is what "вплотную" means to a player standing against a wall.
|
||||||
|
///
|
||||||
|
/// FLOOR, NOT ROUND, and that is what makes the two anchors in the request both come out
|
||||||
|
/// right: flush against a block the ray is well under a metre, floors to zero, and the channel
|
||||||
|
/// is the plain ten seconds; a block five away floors to five and costs fifteen.
|
||||||
|
///
|
||||||
|
/// THE POWER ATTACK CANCELS THE CHANNEL AND OPENS THE VAULT (user request 2026-09-14, after
|
||||||
|
/// the feature was confirmed working: "можно случайно нажать и не иметь возможности прервать").
|
||||||
|
/// Ten seconds of standing still after a misclick is a long time, and the vanilla escapes are
|
||||||
|
/// both poor here: getting hit is not something the player chooses, and the activate key is
|
||||||
|
/// not the button a hand is already on. The bracelet's other button is - and it lands on the
|
||||||
|
/// thing the player most likely wanted in the first place.
|
||||||
|
///
|
||||||
|
/// WHAT A PICKAXE CANNOT BREAK, THE BRACELET CANNOT TAKE (user report 2026-09-14: it would
|
||||||
|
/// happily take a trader's compound apart, and bedrock with it). TWO SEPARATE ENGINE RULES
|
||||||
|
/// stand behind that one sentence, and they are worth keeping apart because they look
|
||||||
|
/// identical from inside the game and are nothing alike in the code:
|
||||||
|
///
|
||||||
|
/// - A TRADER'S GROUND. The blocks there are ordinary; it is the AREA that is protected.
|
||||||
|
/// Vanilla simply skips DamageBlock inside it, which is why a pickaxe does nothing while
|
||||||
|
/// this bracelet - asking about the block rather than about the place - saw nothing wrong.
|
||||||
|
/// The test is the same predicate that suppression uses, with its condition copied whole:
|
||||||
|
///
|
||||||
|
/// World.SandboxUseTraderArea != TraderAreaStates.Default || !world.IsWithinTraderArea(pos)
|
||||||
|
///
|
||||||
|
/// The sandbox half is not padding. Trader protection is a server setting, and a server
|
||||||
|
/// that turned it off should not find this mod enforcing it anyway: where vanilla
|
||||||
|
/// protects, so does the bracelet; where it does not, neither does this.
|
||||||
|
/// - INDESTRUCTIBLE MATERIAL. The world's floor is the opposite case - nothing special about
|
||||||
|
/// the place, everything special about the block. Bedrock's material carries
|
||||||
|
/// CanDestroy=false (Data/Config/materials.xml, Mbedrock), and the engine reads exactly
|
||||||
|
/// `blockValue.Block.blockMaterial.CanDestroy` wherever it must not break something. Asked
|
||||||
|
/// as a material question rather than by block name, so it covers whatever else in this
|
||||||
|
/// game - or in another mod - is declared unbreakable.
|
||||||
|
///
|
||||||
|
/// Both say so out loud, where vanilla stays silent. Vanilla can afford silence because a
|
||||||
|
/// pickaxe that does nothing is its own explanation - the block visibly refuses to break. An
|
||||||
|
/// indicator that simply never appears looks like this mod is broken instead, so these
|
||||||
|
/// refusals get a message like every other one in this file.
|
||||||
|
///
|
||||||
|
/// CONTENTS CANNOT TRAVEL, AND THAT IS NOT A SHORTCUT. "In the state the original block was
|
||||||
|
/// in" holds for the block's identity and its integrity, but an ItemStack in this game has
|
||||||
|
/// nowhere to put another container's inventory - ToItemValue maps a block to an item and
|
||||||
|
/// stops there. Vanilla solves this by refusing: a workstation with anything in it cannot be
|
||||||
|
/// taken, and says so through ttWorkstationNotEmpty. The same refusal is used here, extended
|
||||||
|
/// to composite storage (chests) through ITileEntityLootable, which is how this version of the
|
||||||
|
/// game models a container's contents.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultPickup
|
||||||
|
{
|
||||||
|
/// <summary>The floor: what it costs to take a block you are standing against. Vanilla's
|
||||||
|
/// workbench is two; the Blue Portal Stone's channel in this mod is also ten, and this
|
||||||
|
/// reads as the same kind of deliberate act.</summary>
|
||||||
|
public const float BaseChannelSeconds = 10f;
|
||||||
|
|
||||||
|
/// <summary>Added per full block of reach (user request 2026-09-14: "вплотную 10 сек,
|
||||||
|
/// если объект от персонажа в пяти блоках то 15 сек"). Distance is a cost, so pulling
|
||||||
|
/// something out of a wall across the room is a commitment rather than a trick.</summary>
|
||||||
|
public const float SecondsPerBlock = 1f;
|
||||||
|
|
||||||
|
/// <summary>Vanilla's own refusal messages, already translated into every shipped
|
||||||
|
/// language. Reused rather than re-worded: a player who has taken a workbench has already
|
||||||
|
/// learned what these mean, and a second vocabulary for the same refusal would be worse
|
||||||
|
/// than no message.</summary>
|
||||||
|
public const string MsgRepairFirst = "ttRepairBeforePickup";
|
||||||
|
public const string MsgBlockMissing = "ttBlockMissingPickup";
|
||||||
|
public const string MsgInUse = "ttCantPickupInUse";
|
||||||
|
public const string MsgNotEmpty = "ttWorkstationNotEmpty";
|
||||||
|
|
||||||
|
/// <summary>This mod's own, added with this feature - see Config/Localization.csv.</summary>
|
||||||
|
public const string MsgNoBlock = "braceletSpatialVaultNoBlock";
|
||||||
|
public const string MsgNoItemForm = "braceletSpatialVaultNoItemForm";
|
||||||
|
public const string MsgVaultFull = "braceletSpatialVaultFull";
|
||||||
|
public const string MsgChanneling = "braceletSpatialVaultPickupChanneling";
|
||||||
|
public const string MsgTraderArea = "braceletSpatialVaultTraderArea";
|
||||||
|
public const string MsgIndestructible = "braceletSpatialVaultIndestructible";
|
||||||
|
public const string MsgNoMod = "braceletSpatialVaultNoMod";
|
||||||
|
|
||||||
|
/// <summary>The denial sound vanilla plays with these tooltips.</summary>
|
||||||
|
public const string DeniedSound = "ui_denied";
|
||||||
|
|
||||||
|
/// <summary>What the bracelet burns to pull a block: the Кровавая сфера, dictated
|
||||||
|
/// 2026-09-15. Its own definition is in Config/item_modifiers.xml.
|
||||||
|
///
|
||||||
|
/// WHY A SEPARATE ITEM AND NOT THE BLOOD ITSELF - this is the scar of the 15.09 accident
|
||||||
|
/// and the reason not to "simplify" it back. The blood was moved into item_modifiers.xml
|
||||||
|
/// so it could be installed here, and that destroyed a character's save along with its
|
||||||
|
/// backup: an item's CLASS decides the byte layout of every stack of it
|
||||||
|
/// (ItemValue.Read:1094 / Write:1228), so a save written before the move became
|
||||||
|
/// unreadable. The sphere is a NEW name that no old save contains, which is what makes it
|
||||||
|
/// safe. Full write-up in BACKLOG.md.
|
||||||
|
///
|
||||||
|
/// The Кровавый камень, when it exists, goes in the same slot and is NOT charged here -
|
||||||
|
/// it is the infinite one. Nothing to add for it: this check names the sphere, so
|
||||||
|
/// anything else in the slot simply pays nothing.</summary>
|
||||||
|
public const string ChargeItemName = "resourceBloodSphere";
|
||||||
|
|
||||||
|
/// <summary>Unscaled time at which a cancel last opened the vault, or -1. Exists to stop
|
||||||
|
/// ONE press from opening the vault TWICE: the cancel reacts to the button going down,
|
||||||
|
/// while the bracelet's ordinary power attack reacts to it coming back up, and those are
|
||||||
|
/// the same press. Whether the release even reaches the item action through the modal
|
||||||
|
/// window is unknown - it is exactly the input suppression that forced the raw mouse read
|
||||||
|
/// below - so this guards the case rather than assuming either answer.</summary>
|
||||||
|
public static float CancelOpenedVaultAt = -1f;
|
||||||
|
|
||||||
|
/// <summary>How long after a cancel a power-attack release is treated as the tail of that
|
||||||
|
/// same press. Long enough to cover a slow finger, far short of a deliberate second
|
||||||
|
/// click.</summary>
|
||||||
|
public const float CancelSwallowSeconds = 0.5f;
|
||||||
|
|
||||||
|
/// <summary>True once, if the vault was just opened by cancelling a channel. Consuming it
|
||||||
|
/// rather than only reading it means a genuine second press right afterwards still
|
||||||
|
/// works.</summary>
|
||||||
|
public static bool ConsumeCancelOpen()
|
||||||
|
{
|
||||||
|
if (CancelOpenedVaultAt < 0f || Time.unscaledTime - CancelOpenedVaultAt > CancelSwallowSeconds)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
CancelOpenedVaultAt = -1f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the timer is working on, handed through TimerEventData.Data - the same
|
||||||
|
/// use vanilla makes of that field (it packs a BlockValue, a position and the player into
|
||||||
|
/// an object[] there). A small class instead of an array because this one is read back in
|
||||||
|
/// a method that has to be right about which field is which.</summary>
|
||||||
|
public class PickupJob
|
||||||
|
{
|
||||||
|
public EntityPlayerLocal Player;
|
||||||
|
public Vector3i Position;
|
||||||
|
public BlockValue Expected;
|
||||||
|
|
||||||
|
/// <summary>The bracelet this pull was started with, so the blood that pays for it is
|
||||||
|
/// taken from the flask in THAT bracelet. Kept as the live ItemValue rather than
|
||||||
|
/// looked up again at the end: mods live on the instance, and ten seconds is long
|
||||||
|
/// enough for the player to have scrolled to another slot.</summary>
|
||||||
|
public ItemValue Bracelet;
|
||||||
|
|
||||||
|
/// <summary>What this particular pull costs, in seconds - computed once when the
|
||||||
|
/// channel starts (user request 2026-09-15: "пусть количество секунд требуемое для
|
||||||
|
/// поглощения блока записывается в отдельную переменную"). It is the same number the
|
||||||
|
/// timer counts down and the same number the flask pays, and that is the point of
|
||||||
|
/// storing it instead of recomputing: by the time the channel ends the player may
|
||||||
|
/// have turned away, the ray is gone, and a second call to ChannelSecondsFor would
|
||||||
|
/// quietly charge for a different block than the one that was taken.</summary>
|
||||||
|
public float ChannelSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Regular attack on the bracelet. Every refusal happens here, before the player
|
||||||
|
/// is asked to stand still for ten seconds.</summary>
|
||||||
|
public static void Begin(EntityPlayerLocal _player, ItemValue _bracelet)
|
||||||
|
{
|
||||||
|
// AN EMPTY MOD SLOT REFUSES THE WHOLE ACTION (user request 2026-09-15: "пусть обычная
|
||||||
|
// атака (поглощение блока) у пространственного хранилища не работает, если у хранилища
|
||||||
|
// в слоте модификаций пусто"). FIRST, deliberately, ahead of every other check in this
|
||||||
|
// method: the others are about the TARGET (no block, wrong kind of block, vault full),
|
||||||
|
// and telling the player "no block there" when the real problem is his empty bracelet
|
||||||
|
// would send him looking in the wrong place. This one is about the tool, so it is
|
||||||
|
// answered before the tool is even pointed at anything.
|
||||||
|
//
|
||||||
|
// ItemValue.HasMods() is the game's own test and the right one: it walks Modifications
|
||||||
|
// only, skipping both nulls and IsEmpty() slots, and does NOT count CosmeticMods - a
|
||||||
|
// dye would otherwise have read as "the bracelet is loaded". The bracelet has no
|
||||||
|
// cosmetic slot anyway (canHaveCosmetic is deliberately absent from its Tags, see
|
||||||
|
// items.xml), so this is belt and braces rather than a live case - but the next item
|
||||||
|
// that reuses this pattern may well have one.
|
||||||
|
//
|
||||||
|
// The ItemValue is handed in rather than read from the player, because the caller
|
||||||
|
// already holds the exact instance the click came from (_actionData.invData.itemValue)
|
||||||
|
// and mods live on the INSTANCE, not on the ItemClass. Two bracelets in the same
|
||||||
|
// inventory can legitimately disagree about whether they are loaded.
|
||||||
|
if (_bracelet == null || !_bracelet.HasMods())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoMod);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldRayHitInfo hitInfo = _player.HitInfo;
|
||||||
|
if (hitInfo == null || !hitInfo.bHitValid)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3i position = hitInfo.hit.blockPos;
|
||||||
|
BlockValue blockValue = world.GetBlock(position);
|
||||||
|
if (blockValue.isair || blockValue.Block == null)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A door or a bed is several cells and the crosshair lands on whichever one is
|
||||||
|
// nearest; removing that cell alone would leave the rest of the model standing.
|
||||||
|
if (blockValue.Block.isMultiBlock && blockValue.ischild)
|
||||||
|
{
|
||||||
|
position = blockValue.Block.multiBlockPos.GetParentPos(position, blockValue);
|
||||||
|
blockValue = world.GetBlock(position);
|
||||||
|
if (blockValue.isair || blockValue.Block == null)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before anything else about the block is considered: whether it may be touched at
|
||||||
|
// all outranks what state it happens to be in.
|
||||||
|
if (!CanTakeHere(world, position, blockValue, _player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vanilla's first line, and the user's explicit requirement: a damaged block gets the
|
||||||
|
// message and no indicator whatsoever.
|
||||||
|
if (blockValue.damage > 0)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgRepairFirst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemValue itemValue = blockValue.ToItemValue();
|
||||||
|
if (itemValue == null || itemValue.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoItemForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanTakeTileEntity(world, position, _player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked before the timer rather than after it, because ten seconds spent to be told
|
||||||
|
// the vault was full the whole time is the worst version of this feature.
|
||||||
|
Bag bag = GetVault(_player);
|
||||||
|
if (bag == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bag.CanTakeItem(new ItemStack(itemValue, 1)))
|
||||||
|
{
|
||||||
|
Deny(_player, MsgVaultFull);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed HERE, while the ray still exists, and carried in the job from this point
|
||||||
|
// on - see PickupJob.ChannelSeconds.
|
||||||
|
float channelSeconds = ChannelSecondsFor(hitInfo);
|
||||||
|
|
||||||
|
TimerEventData timerData = new TimerEventData
|
||||||
|
{
|
||||||
|
Data = new PickupJob
|
||||||
|
{
|
||||||
|
Player = _player,
|
||||||
|
Position = position,
|
||||||
|
Expected = blockValue,
|
||||||
|
Bracelet = _bracelet,
|
||||||
|
ChannelSeconds = channelSeconds
|
||||||
|
},
|
||||||
|
// Vanilla's own two escapes: taking a hit stops the channel, and so does the
|
||||||
|
// activate key. Neither is built here - both are fields XUiC_Timer.Update reads.
|
||||||
|
CloseOnHit = true,
|
||||||
|
CancelWithActivateButton = true
|
||||||
|
};
|
||||||
|
timerData.FullTimeFinishEvent += OnChannelComplete;
|
||||||
|
// Every way this ends that is NOT completion: damage, the activate key, the power
|
||||||
|
// attack. XUiC_Timer sets skipCloseEvent around the completion path specifically so
|
||||||
|
// the two are mutually exclusive, which is why the colour is restored in both places
|
||||||
|
// and not only here.
|
||||||
|
timerData.CloseEvent += delegate
|
||||||
|
{
|
||||||
|
ChannelVision.End(_player);
|
||||||
|
};
|
||||||
|
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
|
||||||
|
XUiC_Timer.OpenTimer(playerUI.xui, channelSeconds, timerData, -1f, Localization.Get(MsgChanneling));
|
||||||
|
// After the window is up, so a channel that somehow fails to open never leaves the
|
||||||
|
// world grey with nothing running.
|
||||||
|
ChannelVision.Begin(_player);
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + _player.entityId + " started taking " +
|
||||||
|
blockValue.Block.GetBlockName() + " at " + position + " - " +
|
||||||
|
Mathf.Sqrt(hitInfo.hit.distanceSq).ToString("0.##") + " blocks away, " +
|
||||||
|
channelSeconds.ToString("0.#") + "s channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ten seconds later. Everything is checked again from the live world rather than
|
||||||
|
/// trusted from the job, because the block that was there when the circle started filling
|
||||||
|
/// is not necessarily the block that is there now.</summary>
|
||||||
|
public static void OnChannelComplete(TimerEventData _timerData)
|
||||||
|
{
|
||||||
|
if (!(_timerData.Data is PickupJob job) || job.Player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FIRST, before any of the checks below can take an early exit: the ten seconds are
|
||||||
|
// over however this turns out, so the colour comes back whether the block is taken or
|
||||||
|
// refused.
|
||||||
|
ChannelVision.End(job.Player);
|
||||||
|
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockValue blockValue = world.GetBlock(job.Position);
|
||||||
|
if (!CanTakeHere(world, job.Position, blockValue, job.Player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blockValue.damage > 0)
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgRepairFirst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Shot out, mined, or replaced while the circle was filling.
|
||||||
|
if (blockValue.isair || blockValue.Block == null || blockValue.type != job.Expected.type)
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgBlockMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!CanTakeTileEntity(world, job.Position, job.Player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemValue itemValue = blockValue.ToItemValue();
|
||||||
|
if (itemValue == null || itemValue.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgNoItemForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bag bag = GetVault(job.Player);
|
||||||
|
if (bag == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER MATTERS: the item goes in first, and the block is only removed if it got
|
||||||
|
// there. The other way round is how a block gets deleted out of the world in exchange
|
||||||
|
// for nothing when the vault filled up during those ten seconds.
|
||||||
|
if (!bag.AddItem(new ItemStack(itemValue, 1)))
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgVaultFull);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.SetBlockRPC(job.Position, BlockValue.Air);
|
||||||
|
|
||||||
|
// AFTER the block is gone and the item is in the vault, never before: every refusal
|
||||||
|
// above returns early, and blood paid for a pull that was then refused would be blood
|
||||||
|
// charged for nothing. This is the only place the flask is spent.
|
||||||
|
float spent = SpendCharge(job);
|
||||||
|
|
||||||
|
// The vault lives in memory and is written out with the player's own save data; this
|
||||||
|
// is the same commit point closing the vault window uses, so a block taken and then
|
||||||
|
// left alone is not waiting on the next autosave to become real. The flask's UseTimes
|
||||||
|
// rides along in the same save - it lives on the bracelet in the player's inventory.
|
||||||
|
GameManager.Instance.SaveLocalPlayerData();
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " +
|
||||||
|
blockValue.Block.GetBlockName() + " at " + job.Position + " into the vault for " +
|
||||||
|
spent.ToString("0.#") + " of charge");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Charges this pull to the Кровавая сфера in the bracelet's mod slot, one point
|
||||||
|
/// of durability per second of channel (user request 2026-09-15). Returns what was
|
||||||
|
/// actually taken.
|
||||||
|
///
|
||||||
|
/// WHY THE NAME CHECK AND NOT "whatever is in the slot". The slot is meant to take the
|
||||||
|
/// Кровавый камень too, and that one is explicitly the infinite version - it must pay
|
||||||
|
/// nothing. Naming the sphere here gets that for free: anything else installed is simply
|
||||||
|
/// not charged, and the pull still happens.
|
||||||
|
///
|
||||||
|
/// NOT ENOUGH BLOOD IS NOT A REFUSAL (user request 2026-09-15: "пусть поглощение всё-равно
|
||||||
|
/// сработает, но флакон крови некроманта после этого пусть исчезнет из слота", clarified to
|
||||||
|
/// "если прочность 0 или меньше, пусть флакон исчезнет из слота"). So the last pull is
|
||||||
|
/// always free of charge in the sense that matters - it completes - and the flask simply
|
||||||
|
/// does not survive it. The charge is therefore NOT clamped: UseTimes is allowed to go past
|
||||||
|
/// MaxUseTimes, because the only thing that then reads it is the emptiness test right
|
||||||
|
/// below, and a clamp would have made "spent exactly to zero" and "overdrawn" look the
|
||||||
|
/// same at the moment the difference stopped mattering anyway.
|
||||||
|
///
|
||||||
|
/// The test is "durability 0 or less", not "could not cover the cost", and those are not
|
||||||
|
/// the same rule: a flask with exactly enough left is also gone afterwards. That is the
|
||||||
|
/// user's own correction and it closes the hole the first version would have left - a
|
||||||
|
/// flask sitting at 0/1000 in the slot, counting as "something is installed" for the empty-
|
||||||
|
/// slot gate in Begin, and pulling blocks for free forever.
|
||||||
|
///
|
||||||
|
/// WHY THE NAME CHECK AND NOT "whatever is in the slot" - see above; a mod that is not
|
||||||
|
/// blood pays nothing, is not emptied, and the pull still happens.</summary>
|
||||||
|
public static float SpendCharge(PickupJob _job)
|
||||||
|
{
|
||||||
|
ItemValue bracelet = _job.Bracelet;
|
||||||
|
if (bracelet == null || bracelet.Modifications == null || _job.ChannelSeconds <= 0f)
|
||||||
|
{
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float spent = 0f;
|
||||||
|
bool emptied = false;
|
||||||
|
for (int i = 0; i < bracelet.Modifications.Length; i++)
|
||||||
|
{
|
||||||
|
ItemValue mod = bracelet.Modifications[i];
|
||||||
|
if (mod == null || mod.IsEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mod.ItemClass == null || mod.ItemClass.Name != ChargeItemName)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int max = mod.MaxUseTimes;
|
||||||
|
mod.UseTimes += _job.ChannelSeconds;
|
||||||
|
spent += _job.ChannelSeconds;
|
||||||
|
|
||||||
|
// max <= 0 means this flask has no durability at all - a DegradationMax that did
|
||||||
|
// not resolve. Draining something with no capacity would delete it on the first
|
||||||
|
// pull, which is a config bug eating the player's item, so it is left alone and
|
||||||
|
// said out loud instead.
|
||||||
|
if (max <= 0)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i +
|
||||||
|
" has MaxUseTimes 0 - nothing to spend, flask kept. Check the " +
|
||||||
|
"DegradationMax passive_effect in Config/item_modifiers.xml");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mod.UseTimes >= max)
|
||||||
|
{
|
||||||
|
// ItemValue.None is what an empty mod slot holds - type 0, which is exactly
|
||||||
|
// what IsEmpty() tests for, so the slot reads as free to every other piece of
|
||||||
|
// code including the gate in Begin.
|
||||||
|
bracelet.Modifications[i] = ItemValue.None;
|
||||||
|
emptied = true;
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i +
|
||||||
|
" ran out (" + mod.UseTimes.ToString("0.#") + "/" + max +
|
||||||
|
") - sphere removed from the bracelet");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i + " now " +
|
||||||
|
mod.UseTimes.ToString("0.#") + "/" + max + " used");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptied)
|
||||||
|
{
|
||||||
|
// Vanilla's own answer to "the thing you were using is gone" - the same cue
|
||||||
|
// ItemAction.HandleItemBreak plays. An item vanishing out of a slot in silence is
|
||||||
|
// the one outcome here the player could miss entirely.
|
||||||
|
_job.Player.PlayOneShot("itembreak");
|
||||||
|
}
|
||||||
|
if (spent > 0f && _job.Player.inventory != null)
|
||||||
|
{
|
||||||
|
// Without this the number is right and the bar on the toolbelt icon is stale
|
||||||
|
// until something else happens to redraw it.
|
||||||
|
_job.Player.inventory.CallOnToolbeltChangedInternal();
|
||||||
|
}
|
||||||
|
return spent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How long this particular pull takes. See the class comment for why the ray's
|
||||||
|
/// own length is the measurement and why it is floored rather than rounded.</summary>
|
||||||
|
public static float ChannelSecondsFor(WorldRayHitInfo _hitInfo)
|
||||||
|
{
|
||||||
|
float distance = Mathf.Sqrt(_hitInfo.hit.distanceSq);
|
||||||
|
int blocks = Mathf.Max(0, Mathf.FloorToInt(distance));
|
||||||
|
return BaseChannelSeconds + blocks * SecondsPerBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>False (with the reason already shown) when this block is one the game itself
|
||||||
|
/// would not let a player break - because of where it stands, or because of what it is
|
||||||
|
/// made of. Split out because, like every other guard here, it is asked twice: once to
|
||||||
|
/// open the timer and once to finish it.</summary>
|
||||||
|
public static bool CanTakeHere(World _world, Vector3i _position, BlockValue _blockValue, EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
if (World.SandboxUseTraderArea == TraderAreaStates.Default && _world.IsWithinTraderArea(_position))
|
||||||
|
{
|
||||||
|
Deny(_player, MsgTraderArea);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_blockValue.Block != null && _blockValue.Block.blockMaterial != null &&
|
||||||
|
!_blockValue.Block.blockMaterial.CanDestroy)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgIndestructible);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>False (with the reason already shown) when a tile entity at this position
|
||||||
|
/// stands in the way: someone has it open, or it has something inside it. Contents cannot
|
||||||
|
/// travel inside an ItemStack, so a container has to be emptied first - vanilla's own rule
|
||||||
|
/// for its workstations, applied here to chests as well.</summary>
|
||||||
|
public static bool CanTakeTileEntity(World _world, Vector3i _position, EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
TileEntity tileEntity = _world.GetTileEntity(_position);
|
||||||
|
if (tileEntity == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tileEntity.IsUserAccessing())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgInUse);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tileEntity is TileEntityWorkstation workstation && !workstation.IsEmpty)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNotEmpty);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tileEntity is TileEntityCollector collector && !collector.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNotEmpty);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Chests and everything else that holds loot: this version of the game models them as
|
||||||
|
// a composite tile entity with a storage FEATURE rather than as their own class, so
|
||||||
|
// the question has to be asked of the feature - the same TryGetSelfOrFeature call the
|
||||||
|
// engine's own storage code uses.
|
||||||
|
if (tileEntity.TryGetSelfOrFeature(out ITileEntityLootable lootable) && !lootable.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNotEmpty);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The player's vault, or null with the reason already shown. Deliberately the
|
||||||
|
/// SAME bag the bracelet's power attack opens, reached through the same cache - a block
|
||||||
|
/// taken here has to be in the window that opens there, and the level gate has to answer
|
||||||
|
/// the same way in both places.</summary>
|
||||||
|
public static Bag GetVault(EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
ProgressionValue progressionValue = _player.Progression?.GetProgressionValue(
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.NecromancySkillName);
|
||||||
|
int level = progressionValue != null ? progressionValue.Level : 0;
|
||||||
|
int slotCount = Mathf.RoundToInt(level / 10f);
|
||||||
|
if (slotCount <= 0)
|
||||||
|
{
|
||||||
|
Deny(_player, "braceletSpatialVaultTooWeak");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag))
|
||||||
|
{
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
||||||
|
}
|
||||||
|
if (bag.SlotCount < slotCount)
|
||||||
|
{
|
||||||
|
ItemStack[] oldSlots = bag.GetSlots();
|
||||||
|
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
||||||
|
System.Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
||||||
|
bag.SetSlots(newSlots);
|
||||||
|
}
|
||||||
|
return bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A refusal, in vanilla's shape: the tooltip plus the denial sound. One method
|
||||||
|
/// so that no refusal in this file can accidentally go out silent.</summary>
|
||||||
|
public static void Deny(EntityPlayerLocal _player, string _localizationKey)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(_player, Localization.Get(_localizationKey), string.Empty, DeniedSound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets the power attack cancel a block pickup in progress and open the vault
|
||||||
|
/// instead. A separate patch class on XUiC_Timer.Update, not on the item action, because this
|
||||||
|
/// has to be asked every frame WHILE the timer is open rather than once at click time - the
|
||||||
|
/// same shape Patch_XUiC_Timer_Update_PortalStoneCancel already uses for the Blue Portal
|
||||||
|
/// Stone's channel.
|
||||||
|
///
|
||||||
|
/// BOTH INPUT CHECKS ARE DELIBERATE, AND THE RAW ONE IS THE ONE THAT WORKS. The portal stone
|
||||||
|
/// shipped with only the semantic PlayerActionsLocal.Secondary check and the user reported
|
||||||
|
/// that cancelling did not work at all: the modal timer window has input focus, and the press
|
||||||
|
/// never reached PlayerAction's polling layer. The fix there was a second, independent read of
|
||||||
|
/// Unity's raw Input.GetMouseButtonDown(1) - right mouse, confirmed as Secondary's real
|
||||||
|
/// default KBM binding by decompiling PlayerActionsLocal.CreateActions - which reads hardware
|
||||||
|
/// state directly and bypasses whatever swallows the other one. That lesson is reused here
|
||||||
|
/// rather than re-learned: the semantic check is kept because it costs nothing and would cover
|
||||||
|
/// a gamepad's Secondary if that one does get through, and the raw check is what is actually
|
||||||
|
/// expected to fire. A gamepad-only player still has no cancel - the same open gap the portal
|
||||||
|
/// stone has, and the same fix would close both.
|
||||||
|
///
|
||||||
|
/// THE TIMER IS CLOSED BEFORE THE VAULT IS OPENED, not after: closing runs OnClose, which is
|
||||||
|
/// what hands control back to the player and drops the event data. Opening a window on top of
|
||||||
|
/// one that is still closing is how two windows end up fighting over the same input.</summary>
|
||||||
|
[HarmonyPatch(typeof(XUiC_Timer), "Update")]
|
||||||
|
public static class Patch_XUiC_Timer_Update_VaultPickupCancel
|
||||||
|
{
|
||||||
|
public static void Postfix(XUiC_Timer __instance)
|
||||||
|
{
|
||||||
|
if (__instance == null || __instance.eventData == null ||
|
||||||
|
!(__instance.eventData.Data is SpatialVaultPickup.PickupJob job) || job.Player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerActionsLocal input = __instance.xui?.playerUI?.playerInput;
|
||||||
|
bool cancelPressed = (input != null && input.Secondary.WasPressed) || Input.GetMouseButtonDown(1);
|
||||||
|
if (!cancelPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityPlayerLocal player = job.Player;
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: pickup cancelled via power attack by owner=" + player.entityId);
|
||||||
|
__instance.xui.playerUI.windowManager.Close(__instance.windowGroup);
|
||||||
|
SpatialVaultPickup.CancelOpenedVaultAt = Time.unscaledTime;
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.OpenVault(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+349
-56
@@ -85,6 +85,54 @@ namespace NecromancerTome
|
|||||||
/// fix the earlier "dog died, no way to get the book back" complaint: with nothing
|
/// fix the earlier "dog died, no way to get the book back" complaint: with nothing
|
||||||
/// ever taken, there's nothing to lose when the dog dies off-screen.</summary>
|
/// ever taken, there's nothing to lose when the dog dies off-screen.</summary>
|
||||||
public bool ConsumesBook;
|
public bool ConsumesBook;
|
||||||
|
|
||||||
|
/// <summary>Указание 2026-09-18, пока только для Духа крысы: "если крыса уже
|
||||||
|
/// призвана, то призыв становится атакой". Для остальных питомцев Action0 при живом
|
||||||
|
/// питомце по-прежнему просто говорит "уже призван" (AlreadyActiveKey), как и было.
|
||||||
|
/// Разбор прицела - в PetCommandPatch.cs.</summary>
|
||||||
|
public bool CommandsAttack;
|
||||||
|
|
||||||
|
/// <summary>Что показать, когда команда атаки отдана, но под прицелом некого
|
||||||
|
/// атаковать. Читается только при CommandsAttack.</summary>
|
||||||
|
public string NoTargetKey;
|
||||||
|
|
||||||
|
/// <summary>Радиус "дома" для ЛЕТАЮЩИХ питомцев, в блоках. Ноль - выключено.
|
||||||
|
///
|
||||||
|
/// Указание 2026-09-18: "гриф улетает слишком далеко от игрока и срабатывает поводок.
|
||||||
|
/// Можем сделать ему следование, которое возвратит в радиус игрока, чтобы поводок
|
||||||
|
/// вообще не использовался?" Можем, и писать для этого ничего не пришлось: у
|
||||||
|
/// EntityVulture слежение за "домом" уже встроено. Каждые 60 тиков он проверяет
|
||||||
|
/// isWithinHomeDistanceCurrentPosition(), и если вышел за радиус - сам переходит в
|
||||||
|
/// State.Home и летит обратно СВОИМ полётом:
|
||||||
|
///
|
||||||
|
/// if (state != State.Home && --homeCheckDelay <= 0) {
|
||||||
|
/// homeCheckDelay = 60;
|
||||||
|
/// if (!isWithinHomeDistanceCurrentPosition()) SetState(State.AttackStop);
|
||||||
|
/// }
|
||||||
|
/// ... StartHome(getHomePosition().position.ToVector3());
|
||||||
|
///
|
||||||
|
/// Достаточно каждую секунду переставлять этот дом на позицию хозяина
|
||||||
|
/// (EntityAlive.setHomeArea), и получается настоящее следование за игроком, без
|
||||||
|
/// единого телепорта и без нашего пафайндинга.</summary>
|
||||||
|
public int FlyingHomeRadius;
|
||||||
|
|
||||||
|
/// <summary>Зажигать ли этому питомцу глаза (PetEyeGlowPatch.cs). Только у Пса и
|
||||||
|
/// Волка: у Медведя и Грифа модель - один материал на всё, и глаза там запечены в
|
||||||
|
/// текстуру шкуры, доставать их нечем.</summary>
|
||||||
|
public bool LitEyes;
|
||||||
|
|
||||||
|
/// <summary>За сколько блоков от хозяина держится этот питомец. Ноль означает "как
|
||||||
|
/// у всех" (NecroFollowOwnerTask.DefaultSlotDistance, два блока). Медведю и Волку
|
||||||
|
/// задан блок дальше - указание 2026-09-18, "они мощнее": сектор тот же, радиус
|
||||||
|
/// больше, чтобы туша не наступала хозяину на пятки.</summary>
|
||||||
|
public float FollowDistance;
|
||||||
|
|
||||||
|
/// <summary>Вешать ли этому питомцу собственную задачу следования
|
||||||
|
/// (NecroFollowOwnerTask): держаться справа-сзади, не бродить, не грызть блоки,
|
||||||
|
/// проходить сквозь препятствия. Старый телепорт-поводок из PetFollowPatch.cs для
|
||||||
|
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
|
||||||
|
/// разные стороны.</summary>
|
||||||
|
public bool UsesFollowTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
|
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
|
||||||
@@ -98,6 +146,14 @@ namespace NecromancerTome
|
|||||||
NothingToRecallKey = "necroZombieDogNothingToRecall",
|
NothingToRecallKey = "necroZombieDogNothingToRecall",
|
||||||
SummonItemName = "bookSummonZombieDog",
|
SummonItemName = "bookSummonZombieDog",
|
||||||
ConsumesBook = false,
|
ConsumesBook = false,
|
||||||
|
LitEyes = true,
|
||||||
|
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||||
|
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||||
|
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||||
|
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||||
|
CommandsAttack = true,
|
||||||
|
NoTargetKey = "necroPetNoTarget",
|
||||||
|
UsesFollowTask = true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,6 +187,18 @@ namespace NecromancerTome
|
|||||||
NothingToRecallKey = "necroZombieGriffinNothingToRecall",
|
NothingToRecallKey = "necroZombieGriffinNothingToRecall",
|
||||||
SummonItemName = "bookSummonZombieGriffin",
|
SummonItemName = "bookSummonZombieGriffin",
|
||||||
ConsumesBook = false,
|
ConsumesBook = false,
|
||||||
|
// 15 блоков: заметно меньше 32-метрового поводка, чтобы тот вообще не
|
||||||
|
// понадобился, и достаточно, чтобы Гриф не висел у игрока над головой.
|
||||||
|
FlyingHomeRadius = 15,
|
||||||
|
// UsesFollowTask у Грифа НЕТ с 18.09: он снова летающий (EntityVulture), а
|
||||||
|
// задача следования наземная - она гоняла бы его пафайндером по земле и
|
||||||
|
// дралась бы с его собственным полётом. Приказ атаковать оставлен.
|
||||||
|
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||||
|
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||||
|
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||||
|
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||||
|
CommandsAttack = true,
|
||||||
|
NoTargetKey = "necroPetNoTarget",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,6 +210,14 @@ namespace NecromancerTome
|
|||||||
NothingToRecallKey = "necroZombieBearNothingToRecall",
|
NothingToRecallKey = "necroZombieBearNothingToRecall",
|
||||||
SummonItemName = "bookSummonZombieBear",
|
SummonItemName = "bookSummonZombieBear",
|
||||||
ConsumesBook = false,
|
ConsumesBook = false,
|
||||||
|
FollowDistance = 3f,
|
||||||
|
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||||
|
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||||
|
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||||
|
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||||
|
CommandsAttack = true,
|
||||||
|
NoTargetKey = "necroPetNoTarget",
|
||||||
|
UsesFollowTask = true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,6 +229,34 @@ namespace NecromancerTome
|
|||||||
NothingToRecallKey = "necroZombieWolfNothingToRecall",
|
NothingToRecallKey = "necroZombieWolfNothingToRecall",
|
||||||
SummonItemName = "bookSummonZombieWolf",
|
SummonItemName = "bookSummonZombieWolf",
|
||||||
ConsumesBook = false,
|
ConsumesBook = false,
|
||||||
|
FollowDistance = 3f,
|
||||||
|
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||||
|
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||||
|
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||||
|
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||||
|
CommandsAttack = true,
|
||||||
|
NoTargetKey = "necroPetNoTarget",
|
||||||
|
UsesFollowTask = true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// "Дух крысы", указание 2026-09-18 - питомец начального уровня (Некромантия 3) и
|
||||||
|
// первый, у кого поведение задано намеренно, а не унаследовано от зомбопса. На нём
|
||||||
|
// отлаживается ИИ питомцев вообще, поэтому он единственный, у кого стоят оба новых
|
||||||
|
// флага. AlreadyActiveKey у него НЕ ЧИТАЕТСЯ НИКОГДА: повторный Action0 - это не
|
||||||
|
// отказ "уже призван", а команда атаковать (CommandsAttack), так что вместо него
|
||||||
|
// показывается либо ничего (приказ принят), либо NoTargetKey.
|
||||||
|
{
|
||||||
|
"necroRatSpirit",
|
||||||
|
new PetInfo
|
||||||
|
{
|
||||||
|
AlreadyActiveKey = null,
|
||||||
|
RecalledKey = "necroRatSpiritRecalled",
|
||||||
|
NothingToRecallKey = "necroRatSpiritNothingToRecall",
|
||||||
|
SummonItemName = "bookSummonRatSpirit",
|
||||||
|
ConsumesBook = false,
|
||||||
|
CommandsAttack = true,
|
||||||
|
NoTargetKey = "necroPetNoTarget",
|
||||||
|
UsesFollowTask = true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -161,8 +265,17 @@ namespace NecromancerTome
|
|||||||
/// summon books now declare Action1 with the same Class="SpawnEntity"/Entity as Action0.</summary>
|
/// summon books now declare Action1 with the same Class="SpawnEntity"/Entity as Action0.</summary>
|
||||||
public const int RecallActionIndex = 1;
|
public const int RecallActionIndex = 1;
|
||||||
|
|
||||||
|
/// <summary>Правда ровно между "префикс разрешил призыв" и концом этого же вызова Spawn.
|
||||||
|
/// Нужна постфиксу ниже, чтобы отличить НАСТОЯЩИЙ призыв от команды атаковать: обе идут
|
||||||
|
/// через один и тот же Action0, постфикс Harmony отрабатывает в обоих случаях (возврат
|
||||||
|
/// false из префикса его не отменяет), а делать им надо противоположное - в одном случае
|
||||||
|
/// снять цель со свежего питомца, в другом ни в коем случае её не трогать, её только что
|
||||||
|
/// поставил игрок.</summary>
|
||||||
|
public static bool summonAllowedThisCall;
|
||||||
|
|
||||||
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData)
|
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData)
|
||||||
{
|
{
|
||||||
|
summonAllowedThisCall = false;
|
||||||
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo tooltips))
|
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo tooltips))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -207,15 +320,73 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
if (owned.Count > 0)
|
if (owned.Count > 0)
|
||||||
{
|
{
|
||||||
|
// Action0 при уже живом питомце. Для всех, кроме Духа крысы, это тупик "уже
|
||||||
|
// призван". Для него - команда атаковать то, на что смотрит игрок: см.
|
||||||
|
// PetCommandPatch.cs, там же и весь отсев (торговцы, игроки, мёртвые).
|
||||||
|
if (tooltips.CommandsAttack)
|
||||||
|
{
|
||||||
|
EntityAlive pet = holdingEntity.world != null
|
||||||
|
? holdingEntity.world.GetEntity(owned[0].Id) as EntityAlive
|
||||||
|
: null;
|
||||||
|
if (!PetAttackCommand.TryOrderAttack(holdingEntity, pet) && holdingEntity.world != null)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NoTargetKey);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (holdingEntity.world != null)
|
if (holdingEntity.world != null)
|
||||||
{
|
{
|
||||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey);
|
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
summonAllowedThisCall = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
|
||||||
|
/// Последней строкой ваниль делает вот что:
|
||||||
|
///
|
||||||
|
/// entityAlive.SetAttackTarget(holdingEntity.GetAttackTarget(), 600);
|
||||||
|
///
|
||||||
|
/// то есть свежепризванный питомец НАСЛЕДУЕТ ЦЕЛЬ ХОЗЯИНА. Для зомбопса это задумано и
|
||||||
|
/// полезно, а для Духа крысы прямо противоречит его единственному правилу - "сама не
|
||||||
|
/// атакует никогда". Призови её в драке, и она бросилась бы в бой сама, без приказа.
|
||||||
|
///
|
||||||
|
/// Почистить это в постфиксе на EntityFactory.CreateEntity нельзя: тот отрабатывает
|
||||||
|
/// ВНУТРИ Spawn, ещё до этой строки. Поэтому чистка здесь, после всего.
|
||||||
|
///
|
||||||
|
/// Флаг summonAllowedThisCall обязателен: без него постфикс сбрасывал бы цель и после
|
||||||
|
/// команды атаковать - то есть приказ гасил бы сам себя.</summary>
|
||||||
|
public static void Postfix(ItemActionSpawnEntity __instance)
|
||||||
|
{
|
||||||
|
if (!summonAllowedThisCall)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
summonAllowedThisCall = false;
|
||||||
|
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo petInfo) || !petInfo.UsesFollowTask)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null || world.EntityAlives == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
|
||||||
|
for (int i = world.EntityAlives.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
EntityAlive candidate = world.EntityAlives[i];
|
||||||
|
if (candidate != null && candidate.entityClass == petClassId && candidate.GetAttackTarget() != null)
|
||||||
|
{
|
||||||
|
candidate.SetAttackTarget(null, 0);
|
||||||
|
Debug.Log("[NecromancerTome] SummonPatch: cleared the target vanilla Spawn handed to fresh pet " +
|
||||||
|
candidate.entityId + " (this species never attacks on its own)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>summonItemName is null when this species doesn't consume its book on summon
|
/// <summary>summonItemName is null when this species doesn't consume its book on summon
|
||||||
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
|
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
|
||||||
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
|
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
|
||||||
@@ -267,6 +438,140 @@ namespace NecromancerTome
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ОТЗЫВ СРАБАТЫВАЕТ СРАЗУ ПО НАЖАТИЮ - вторая правка того же бага, 2026-09-18. Первая
|
||||||
|
/// (Patch_..._IsActionRunning_FreeTheRecall ниже) сняла блокировку вторичного действия
|
||||||
|
/// основным, и этого оказалось мало: "отозвать собаку получилось далеко не с первого раза.
|
||||||
|
/// Это проблема всех питомцев".
|
||||||
|
///
|
||||||
|
/// ОСТАВШАЯСЯ ПРИЧИНА - ТА ЖЕ САМАЯ СЕМЬЯ, что и у призыва, только в другом слоте. Отзыв идёт
|
||||||
|
/// через ItemActionSpawnEntity.Spawn, а до него надо ДОЖИТЬ через автомат состояний:
|
||||||
|
///
|
||||||
|
/// OnHoldingUpdate: stateTime += 0.05
|
||||||
|
/// Anim: если stateTime >= animWait -> state = Spawn
|
||||||
|
/// Spawn: Spawn(data); state = End
|
||||||
|
/// ExecuteAction(_bReleased: true): state = None // отпустил - всё сбросилось
|
||||||
|
///
|
||||||
|
/// При animWait = 0.1 это ТРИ тика: 0.05 (мало), 0.10 (переход в Spawn), и только третий
|
||||||
|
/// вызывает сам Spawn. То есть кнопку надо продержать около 0.15 секунды. У призыва это
|
||||||
|
/// лечилось уменьшением animWait до 0.05, но там и оставалось два тика; меньше двух автомат
|
||||||
|
/// не умеет в принципе - один переход и одно исполнение.
|
||||||
|
///
|
||||||
|
/// Поэтому отзыв вынут из автомата совсем: он делается прямо на НАЖАТИИ, в префиксе
|
||||||
|
/// ExecuteAction, и оригинал не запускается вовсе. Никакого удержания, никакого ожидания.
|
||||||
|
///
|
||||||
|
/// ЗАЩЁЛКА ОБЯЗАТЕЛЬНА. ExecuteAction(_bReleased: false) зовётся не один раз за клик, а
|
||||||
|
/// КАЖДЫЙ КАДР, пока кнопка нажата (PlayerMoveController проверяет Secondary.IsPressed, а не
|
||||||
|
/// WasPressed), и вся защита от повторов у ванили держится на её же state == None, который мы
|
||||||
|
/// теперь не выставляем. Без задержки ниже одно удержание правой кнопки отозвало бы питомца и
|
||||||
|
/// следом залило экран надписью "не призван" по разу в кадр.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(ItemActionSpawnEntity), "ExecuteAction")]
|
||||||
|
public static class Patch_ItemActionSpawnEntity_ExecuteAction_InstantRecall
|
||||||
|
{
|
||||||
|
/// <summary>Секунды между двумя срабатываниями отзыва. Полсекунды заведомо больше любого
|
||||||
|
/// клика и заведомо меньше осмысленного повторного нажатия.</summary>
|
||||||
|
public const float RepeatGuardSeconds = 0.5f;
|
||||||
|
|
||||||
|
public static float lastRecallTime;
|
||||||
|
|
||||||
|
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData, bool _bReleased)
|
||||||
|
{
|
||||||
|
if (_bReleased || _actionData == null ||
|
||||||
|
_actionData.indexInEntityOfAction != Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallActionIndex)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(
|
||||||
|
__instance.entityToSpawn, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
EntityAlive holdingEntity = _actionData.invData != null ? _actionData.invData.holdingEntity : null;
|
||||||
|
if (holdingEntity == null || holdingEntity.world == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Time.time - lastRecallTime < RepeatGuardSeconds)
|
||||||
|
{
|
||||||
|
// Кнопка всё ещё зажата с прошлого кадра - молча проглатываем.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
|
||||||
|
if (petClassId == -1)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
lastRecallTime = Time.time;
|
||||||
|
|
||||||
|
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
|
||||||
|
if (owned.Count > 0)
|
||||||
|
{
|
||||||
|
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(holdingEntity, owned[0].Id,
|
||||||
|
petInfo.RecalledKey, petInfo.ConsumesBook ? petInfo.SummonItemName : null);
|
||||||
|
holdingEntity.PlayOneShot(__instance.soundWarn);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), petInfo.NothingToRecallKey);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "ОТЗЫВАЕТСЯ НЕ С ПЕРВОГО РАЗА" - баг-репорт пользователя 2026-09-18: "если прожал атаку
|
||||||
|
/// несколько раз, то при попытке отозвать крысу, она не отзывается с первого раза".
|
||||||
|
///
|
||||||
|
/// Причина не в нашем коде и не в питомце, а в гейте ввода. PlayerMoveController решает,
|
||||||
|
/// пускать ли вторичное действие, так:
|
||||||
|
///
|
||||||
|
/// bool flag16 = Actions[0].AllowConcurrentActions() || Actions[1].AllowConcurrentActions();
|
||||||
|
/// bool flag18 = Actions[0].IsActionRunning(actionData[0]); // основное ещё "идёт"
|
||||||
|
/// ...
|
||||||
|
/// if (flag12 && flag15 && (flag16 || !flag18)) // flag15 = Secondary.IsPressed
|
||||||
|
/// inventory.Execute(1, _bReleased: false, ...); // ВОТ ЭТО - отзыв
|
||||||
|
///
|
||||||
|
/// То есть пока основное действие считается идущим, вторичное просто не запускается.
|
||||||
|
/// А "идущим" оно считается вот по чему (ItemActionSpawnEntity):
|
||||||
|
///
|
||||||
|
/// public override bool IsActionRunning(ItemActionData _actionData)
|
||||||
|
/// => ((ItemActionDataSpawnEntity)_actionData).state != State.None;
|
||||||
|
///
|
||||||
|
/// Состояние проходит None -> Anim -> Spawn -> **End**, и в None возвращается ТОЛЬКО из
|
||||||
|
/// ExecuteAction(_bReleased: true), то есть по отпусканию кнопки. End - это уже
|
||||||
|
/// отработавшее, законченное действие, но формально всё ещё "не None". Чем чаще игрок
|
||||||
|
/// щёлкает приказом атаковать, тем выше шанс, что в момент нажатия правой кнопки Action0
|
||||||
|
/// висит именно в End, - и первый отзыв уходит в никуда.
|
||||||
|
///
|
||||||
|
/// ЧИНИМ РОВНО ЭТО И НИЧЕГО БОЛЬШЕ: для наших книг призыва End больше не считается "идёт".
|
||||||
|
/// Соблазн был переписывать сам state в None - так делать НЕЛЬЗЯ: ExecuteAction стартует
|
||||||
|
/// новое действие как раз по условию state == None, и при зажатой кнопке призыв/приказ пошёл
|
||||||
|
/// бы на повтор каждые два тика, вместе с воплем кролика на каждый. Здесь же меняется только
|
||||||
|
/// ОТВЕТ на вопрос "идёт ли действие": вторичное разблокировано, а повторный старт
|
||||||
|
/// основного по-прежнему заперт настоящим полем state, которое так и осталось End.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(ItemActionSpawnEntity), "IsActionRunning")]
|
||||||
|
public static class Patch_ItemActionSpawnEntity_IsActionRunning_FreeTheRecall
|
||||||
|
{
|
||||||
|
public static void Postfix(ItemActionSpawnEntity __instance, ItemActionData _actionData, ref bool __result)
|
||||||
|
{
|
||||||
|
if (!__result || _actionData == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(__instance.entityToSpawn))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_actionData is ItemActionSpawnEntity.ItemActionDataSpawnEntity data
|
||||||
|
&& data.state == ItemActionSpawnEntity.ItemActionDataSpawnEntity.State.End)
|
||||||
|
{
|
||||||
|
__result = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
|
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
|
||||||
public static class Patch_EntityFactory_CreateEntity_PetOwnership
|
public static class Patch_EntityFactory_CreateEntity_PetOwnership
|
||||||
{
|
{
|
||||||
@@ -323,69 +628,57 @@ namespace NecromancerTome
|
|||||||
{
|
{
|
||||||
owner.inventory.DecHoldingItem(1);
|
owner.inventory.DecHoldingItem(1);
|
||||||
}
|
}
|
||||||
PetFollowPatch.Register(owner, __result);
|
// Первая попытка развести коллайдеры - здесь же, чтобы в удачном случае не ждать
|
||||||
IgnoreCollisionWithOwner(owner, __result);
|
// секунду до тика. Удачной она, скорее всего, НЕ БУДЕТ: модель питомца на этом шаге
|
||||||
ApplyGhostlyTransparency(__result);
|
// ещё не собрана и коллайдеров у него нет (разбор - в TryIgnoreCollisionWithOwner).
|
||||||
|
// Поэтому результат передаётся в Register, и тик повторяет попытку до успеха.
|
||||||
|
bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result);
|
||||||
|
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
|
||||||
|
petInfo.FlyingHomeRadius);
|
||||||
|
AttachFollowTask(owner, __result, petInfo);
|
||||||
|
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
|
||||||
|
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
|
||||||
|
// поддерживают (на Рое это выяснилось ещё 28.08 - он только засорял лог). Призрачный
|
||||||
|
// вид всем питомцам теперь даёт GhostTraderPatch.cs - та же система, что у торговцев,
|
||||||
|
// с клонированием материала, обесцвечиванием и подбором blend-шейдера.
|
||||||
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId);
|
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>User request 2026-08-28 ("нематериальными") - makes the pet pass through its
|
/// <summary>Вешает питомцу собственную задачу следования - указание 2026-09-18, пока
|
||||||
/// own owner specifically, via Physics.IgnoreCollision on every collider pair between the
|
/// только Духу крысы (PetInfo.UsesFollowTask).
|
||||||
/// two, rather than stripping the pet's PhysicsBody entirely the way vanilla's own
|
|
||||||
/// animalInsectSwarm does for its "no physics body at all" look (confirmed by checking
|
|
||||||
/// entityclasses.xml). That works for a flying swarm; a ground-walking dog with no
|
|
||||||
/// collider at all would fall through the terrain. This keeps it solid against ground and
|
|
||||||
/// zombies - just not its owner - which also directly closes the last piece of the
|
|
||||||
/// spawn-launch bug documented in items.xml/PetFollowPatch.cs (nothing left to shove the
|
|
||||||
/// player if the two colliders can't touch in the first place).</summary>
|
|
||||||
public static void IgnoreCollisionWithOwner(EntityPlayer owner, Entity pet)
|
|
||||||
{
|
|
||||||
Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>();
|
|
||||||
Collider[] petColliders = pet.GetComponentsInChildren<Collider>();
|
|
||||||
foreach (Collider oc in ownerColliders)
|
|
||||||
{
|
|
||||||
if (oc == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach (Collider pc in petColliders)
|
|
||||||
{
|
|
||||||
if (pc == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Physics.IgnoreCollision(oc, pc, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>User request 2026-08-28 ("слегка прозрачными") - best effort only. Directly
|
|
||||||
/// sets renderer.material.color's alpha, the same technique ItemActionSpawnTurret uses for
|
|
||||||
/// its own placement-preview tint (confirmed by decompiling it), but that only visibly
|
|
||||||
/// shows up if the model's actual shader supports alpha blending - most opaque mob
|
|
||||||
/// shaders in this game don't, and there's no reliable XML/reflection-only way to swap a
|
|
||||||
/// live renderer's shader to a transparent variant without risking breaking how it's lit.
|
|
||||||
///
|
///
|
||||||
/// BUG FIXED 2026-08-28: the Insect Swarm's renderers use a particle shader
|
/// ЧЕРЕЗ КОД, А НЕ ЧЕРЕЗ XML - см. шапку PetFollowTask.cs: XML-путь существует, но упирается
|
||||||
/// ("Game Particles/surfaceShader_masked_particleEnhanced") that has no "_Color" property
|
/// в Type.GetType с именем сборки, а этот не упирается ни во что и заодно позволяет отдать
|
||||||
/// at all - setting .color on it doesn't throw, but Unity logs "doesn't have a color
|
/// владельца прямо в поле задачи, без поиска по ownedEntities на каждом тике.
|
||||||
/// property '_Color'" on every single access, once per renderer per spawn (confirmed in
|
///
|
||||||
/// output_log - this is what the user saw as "an error about colors"). HasProperty check
|
/// Приоритет 3: у крысы в entityclasses.xml объявлены AITask-1 (ApproachAndAttackTarget) и
|
||||||
/// added so this silently skips any renderer whose shader doesn't support it instead of
|
/// AITask-2 (Look), а AITask-3 пустой - то есть следование встаёт ровно туда, где в XML
|
||||||
/// spamming the log - the visual effect was never going to work on those anyway.</summary>
|
/// кончился список, и ниже погони за целью. Разводить их приоритетом при этом всё равно
|
||||||
public static void ApplyGhostlyTransparency(Entity pet)
|
/// недостаточно: задача следования сама отказывается работать, пока у питомца есть цель
|
||||||
|
/// (NecroFollowOwnerTask.CanExecute), и несёт те же MutexBits=3, что ванильный
|
||||||
|
/// EAIApproachSpot.</summary>
|
||||||
|
public static void AttachFollowTask(EntityPlayer owner, Entity pet, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo)
|
||||||
{
|
{
|
||||||
Renderer[] renderers = pet.GetComponentsInChildren<Renderer>();
|
if (!petInfo.UsesFollowTask)
|
||||||
foreach (Renderer renderer in renderers)
|
|
||||||
{
|
{
|
||||||
if (renderer == null || renderer.material == null || !renderer.material.HasProperty("_Color"))
|
return;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Color color = renderer.material.color;
|
|
||||||
color.a = 0.55f;
|
|
||||||
renderer.material.color = color;
|
|
||||||
}
|
}
|
||||||
|
EntityAlive alive = pet as EntityAlive;
|
||||||
|
if (alive == null || alive.aiManager == null || alive.aiManager.tasks == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SummonPatch: pet " + pet.entityId +
|
||||||
|
" has no aiManager - follow task NOT attached");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NecroFollowOwnerTask task = new NecroFollowOwnerTask { OwnerEntityId = owner.entityId };
|
||||||
|
if (petInfo.FollowDistance > 0f)
|
||||||
|
{
|
||||||
|
task.SlotDistance = petInfo.FollowDistance;
|
||||||
|
}
|
||||||
|
task.Init(alive);
|
||||||
|
alive.aiManager.tasks.AddTask(3, task);
|
||||||
|
Debug.Log("[NecromancerTome] SummonPatch: follow task attached to pet " + pet.entityId +
|
||||||
|
" (owner " + owner.entityId + ")");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ namespace NecromancerTome
|
|||||||
public static readonly Dictionary<string, VultureBasedPetInfo> SpeciesByName = new Dictionary<string, VultureBasedPetInfo>
|
public static readonly Dictionary<string, VultureBasedPetInfo> SpeciesByName = new Dictionary<string, VultureBasedPetInfo>
|
||||||
{
|
{
|
||||||
{ "necroInsectSwarm", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = true } },
|
{ "necroInsectSwarm", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = true } },
|
||||||
|
// Зомбогриф вернулся сюда 2026-09-18 вместе с откатом на летающую ветку. Он снова
|
||||||
|
// EntityVulture, то есть снова целится в игрока захардкоженным C#-кодом, и без этой
|
||||||
|
// строки кидался бы на хозяина. SkipAlreadyCharmedZombies=false: подчинять он не
|
||||||
|
// умеет, значит и обходить подчинённых ему незачем - пусть добивает.
|
||||||
|
//
|
||||||
|
// Честно: 29.08 этот же перехват Грифа НЕ СПАС - он просто летал и никого не
|
||||||
|
// трогал. Тогда причину искать не стали и ушли на наземную ветку. Сейчас мы вернулись
|
||||||
|
// к тому же месту, и если он снова будет безучастно кружить - копать надо здесь,
|
||||||
|
// в том, доходит ли до SetAttackTarget хоть что-нибудь.
|
||||||
|
{ "necroZombieGriffin", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = false } },
|
||||||
};
|
};
|
||||||
|
|
||||||
public static readonly Dictionary<int, VultureBasedPetInfo> cachedClassIds = new Dictionary<int, VultureBasedPetInfo>();
|
public static readonly Dictionary<int, VultureBasedPetInfo> cachedClassIds = new Dictionary<int, VultureBasedPetInfo>();
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
<xml>
|
<xml>
|
||||||
<Name value="NecromancerTome" />
|
<Name value="NecromancerTome" />
|
||||||
<DisplayName value="Necromancer's Tome" />
|
<DisplayName value="Necromancer's Tome" />
|
||||||
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, summonable undead pets, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, undead companions that keep station with you and attack what you point at, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
||||||
<Author value="Alex Cube" />
|
<Author value="Alex Cube" />
|
||||||
<Version value="1.0.0" />
|
<Version value="1.4.0" />
|
||||||
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
||||||
</xml>
|
</xml>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FIXED: the Necromancy level was rolling back on every save. The game stores a skill's level in a single byte, so anything above 255 was cut back by 256 - recipes you had already unlocked could lock themselves again, and the tiers at 500, 2000, 3000 and 5000 could never be reached at all.
|
||||||
|
Necromancy now levels once per 20 zombies, up to level 250 - the same 5000 zombies to the top, counted in a number the save can actually hold.
|
||||||
|
Old saves repair themselves on load. The count of zombies you put to rest was never damaged, and the level is now recomputed from it, so anything the rollback had stolen comes back the first time you load.
|
||||||
|
NEW: the skull in the status bar now shows your Necromancy level instead of the raw kill count.
|
||||||
|
NEW: a purple bar above the toolbelt shows the progress inside the level - it fills over 20 zombies, the level goes up, the bar resets.
|
||||||
|
FIXED: charmed zombies no longer fight each other. They treat each other as their own side and go for the next real enemy instead, and charming one of two zombies already fighting breaks the fight off. Ordinary zombies still attack the charmed ones - that is the point of them.
|
||||||
|
FIXED: kill credit now follows the game's own decision about who earned a kill. Bleeding, fire, traps, your own summoned undead and zombie animals all count. Thanks to Derizor and Savion for the reports - you were exactly right about the cause.
|
||||||
|
The Necromancer's Knife scales off that same counter, so it had been quietly weaker than intended too, and it is not any more.
|
||||||
|
Not fixed yet: the dagger's model still does not match its icon (thanks Scarlettdruid) - it needs a hand-made 3D prefab, which is the next thing to learn.
|
||||||
|
CHANGED: Tears of the Dead now unlocks at 20 zombies instead of 30, sharing that step with the Spatial Bracelet and the Blood Sphere. Thirst is a first-days problem, so the threshold moved down rather than up.
|
||||||
|
Every other threshold is unchanged in zombies: food at 60, Grave's Repose at 100, Dark Sense at 300, Dead Man's Grip at 1400, Dead Storm at 1700 - levels 3, 5, 15, 70 and 85.
|
||||||
|
The skill-up toast now arrives once per 20 zombies rather than on every kill.
|
||||||
|
COMING FROM 1.0.1: the Spatial Bracelet runs on a charge - the Blood Sphere is spent by pulling blocks and crumbles when empty, while the Blood Stone from the Apprentice tier takes the same slot and is never spent.
|
||||||
|
COMING FROM 1.0.1: the bracelet pulls blocks straight into the vault, with the world draining to black and white while you wait.
|
||||||
|
COMING FROM 1.0.1: a challenges tab of its own, traders rendered as ghosts, and an audible cry of pain when crafting Necromancer's Blood - it always cost 90% of your health, it was just silent about it.
|
||||||
|
Single-player. The mod ships Harmony libraries, so EasyAntiCheat must be off.
|
||||||
@@ -10,20 +10,20 @@ It starts with a note. Reading it, your character sees a blurred flashback - a f
|
|||||||
[*]Turn [b]EasyAntiCheat off[/b]. The mod ships Harmony DLLs and will not load with EAC enabled.
|
[*]Turn [b]EasyAntiCheat off[/b]. The mod ships Harmony DLLs and will not load with EAC enabled.
|
||||||
[*]Download the archive and unpack it.
|
[*]Download the archive and unpack it.
|
||||||
[*]Drop the [b]NecromancerTome[/b] folder into your [b]Mods[/b] folder, so that you end up with [i]Mods/NecromancerTome/ModInfo.xml[/i]. The Mods folder sits either next to the game executable or in [i]%APPDATA%/7DaysToDie/[/i] - either location works, create it if it is not there.
|
[*]Drop the [b]NecromancerTome[/b] folder into your [b]Mods[/b] folder, so that you end up with [i]Mods/NecromancerTome/ModInfo.xml[/i]. The Mods folder sits either next to the game executable or in [i]%APPDATA%/7DaysToDie/[/i] - either location works, create it if it is not there.
|
||||||
[*]Start the game. A new [b]Necromancy[/b] skill appears in the crafting skills panel from level one.
|
[*]Start the game. A new [b]Necromancy[/b] skill appears in the crafting skills panel right away, at level 0, and rises by one for every 20 zombies you put to rest.
|
||||||
[/list]
|
[/list]
|
||||||
|
|
||||||
To uninstall, delete the folder. The mod adds items and one block, so a save that used them will lose them - start a fresh world if you want a clean state.
|
To uninstall, delete the folder. The mod adds items and one block, so a save that used them will lose them - start a fresh world if you want a clean state.
|
||||||
|
|
||||||
[size=5][b]Main features[/b][/size]
|
[size=5][b]Main features[/b][/size]
|
||||||
|
|
||||||
[b]A skill that levels from kills, not perk points.[/b] Necromancy counts every zombie you put down. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal:
|
[b]A skill that levels from kills, not perk points.[/b] Necromancy counts every zombie you put down: twenty of them make one level, up to level 250 (5000 zombies). The skull in the status bar shows the level, the purple bar beside the experience bar shows the progress inside it. Five tiers, each threshold opening part of the arsenal (zombie counts, level in brackets):
|
||||||
|
|
||||||
[list]
|
[list]
|
||||||
[*][b]Adept[/b] (from the start) - Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits
|
[*][b]Adept[/b] (from the start, level 0) - Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits
|
||||||
[*][b]Adept[/b] (20 / 30 / 60 / 100 / 300) - Spatial Bracelet, then four knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense
|
[*][b]Adept[/b] (20 / 60 / 100 / 300, levels 1 / 3 / 5 / 15) - Spatial Bracelet and Blood Sphere, then four knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense
|
||||||
[*][b]Journeyman[/b] (500 / 1400 / 1700) - Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm
|
[*][b]Journeyman[/b] (500 / 1400 / 1700) - Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm
|
||||||
[*][b]Apprentice[/b] (2000) - Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin
|
[*][b]Apprentice[/b] (2000) - Summon Zombie Dog, Pharaoh’s Swarm, Summon Grave Vulture, Blood Stone
|
||||||
[*][b]Necromancer[/b] (3000) - Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll
|
[*][b]Necromancer[/b] (3000) - Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll
|
||||||
[*][b]Master[/b] (5000) - Black Portal Stone
|
[*][b]Master[/b] (5000) - Black Portal Stone
|
||||||
[/list]
|
[/list]
|
||||||
@@ -32,11 +32,13 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
|
|||||||
|
|
||||||
[b]A blade that grows with you.[/b] The [b]Necromancer's Knife[/b] scales its damage with your Necromancy level - nearly useless in unskilled hands, lethal once you are levelled. It heals you for half the damage dealt and marks the wounded zombie as a Victim, guaranteeing a bag of necromantic ingredients on its death. [b]Six mods fit this knife and nothing else[/b]: water and food drawn straight out of corpses, protection from heat and cold, slowed victims, an area power-attack storm bought with your own health, and every nearby zombie marked on your compass and map.
|
[b]A blade that grows with you.[/b] The [b]Necromancer's Knife[/b] scales its damage with your Necromancy level - nearly useless in unskilled hands, lethal once you are levelled. It heals you for half the damage dealt and marks the wounded zombie as a Victim, guaranteeing a bag of necromantic ingredients on its death. [b]Six mods fit this knife and nothing else[/b]: water and food drawn straight out of corpses, protection from heat and cold, slowed victims, an area power-attack storm bought with your own health, and every nearby zombie marked on your compass and map.
|
||||||
|
|
||||||
[b]Undead that fight for you.[/b] Summoning books raise a [b]zombie dog, bear, wolf or griffin[/b] - permanent companions, one of each kind at a time, recalled into the book with a power attack. [b]Beetles of the Lord[/b] release a swarm that scatters wide and stings zombies onto your side. The [b]Banshee's Scroll[/b] screams up a small horde that is [i]not[/i] on your side at all - use it on purpose.
|
[b]Undead that fight for you.[/b] Summoning books raise a [b]zombie dog, bear, wolf or griffin[/b] - permanent companions, one of each kind at a time, recalled into the book with a power attack. [b]Pharaoh’s Swarm[/b] release a swarm that scatters wide and stings zombies onto your side. The [b]Banshee's Scroll[/b] screams up a small horde that is [i]not[/i] on your side at all - use it on purpose.
|
||||||
|
|
||||||
[b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius.
|
[b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius.
|
||||||
|
|
||||||
[b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can.
|
[b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level - and holding its regular attack on a block pulls that block straight into the rift, ten seconds up close and one more per block of distance, while the world drains to black and white around you. That pull runs on a charge in the bracelet's mod slot: the [b]Blood Sphere[/b] is spent a point per second and crumbles when empty, while the [b]Blood Stone[/b] from the third grade is never spent at all. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can.
|
||||||
|
|
||||||
|
[b]The dead keep shop.[/b] Every trader in the world is rendered in black and white, half-transparent and matte. Nothing about the trade changes - but the necromancer deals with the dead, and the only people still doing business out here are not quite alive.
|
||||||
|
|
||||||
[b]A real ending.[/b] The [b]Black Portal Stone[/b] is the last thing the skill tree gives you, and it is the end of the mod's story - a full-screen finale that stops the game and closes on a choice of two. One of them ends the run and returns you to the main menu; the other lets you come back and keep playing. What waits on the far side is better seen than described.
|
[b]A real ending.[/b] The [b]Black Portal Stone[/b] is the last thing the skill tree gives you, and it is the end of the mod's story - a full-screen finale that stops the game and closes on a choice of two. One of them ends the run and returns you to the main menu; the other lets you come back and keep playing. What waits on the far side is better seen than described.
|
||||||
|
|
||||||
@@ -60,4 +62,4 @@ Thanks to [b]The Fun Pimps[/b] for a game that survives this much rewriting, and
|
|||||||
|
|
||||||
Source code, full change history and issue tracker: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
|
Source code, full change history and issue tracker: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
|
||||||
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
|
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
|
||||||
My YouTube channel: [url=https://www.youtube.com/@alexcube]@alexcube[/url]
|
My YouTube channel: [url=https://www.youtube.com/@alex_cube]@alex_cube[/url]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[size=5][b]Necromancer's Tome 1.0.1[/b][/size]
|
||||||
|
|
||||||
|
A bug-fix release on top of 1.0. Same content, one real fix and a few refinements. Drop-in replacement: delete the old [b]NecromancerTome[/b] folder, unpack this one in its place. Your save is fine.
|
||||||
|
|
||||||
|
[size=5][b]Fixed[/b][/size]
|
||||||
|
|
||||||
|
[b]The Spatial Bracelet no longer loses what you put in it.[/b] Reported by [b]youkia96581[/b] - thank you, this was a real hole and a fair catch. The storage rift was only ever held in memory: it survived death, respawn and relogging, but quitting the game threw it away.
|
||||||
|
|
||||||
|
It is now saved the way the game saves your backpack - inside your own player data, written and read in the same moment and the same file. Put things in, quit, come back days later: they are still there. Verified end to end, including a clean exit to the main menu, which was the exact moment things used to disappear.
|
||||||
|
|
||||||
|
[b]One honest caveat:[/b] this cannot bring back items lost in 1.0. There was no data on disk to recover - the vault simply was never written anywhere. Anything already lost is gone, and I am sorry about that.
|
||||||
|
|
||||||
|
[size=5][b]Changed[/b][/size]
|
||||||
|
|
||||||
|
[list]
|
||||||
|
[*][b]Traders are drawn in black and white.[/b] The necromancer deals with the dead, and the people still willing to trade with him have clearly seen too much. Their own shaders and lighting are untouched - only the colour is gone, plus a sliver of transparency.
|
||||||
|
[*][b]The Spatial Bracelet has four mod slots.[/b] Empty for now: the mods that fit them come in a later version. Ordinary weapon mods will not go in, and nothing else will take these.
|
||||||
|
[*][b]The Spatial Bracelet is no longer a parcel in your hand.[/b] It used to borrow a food-crop prefab and looked like a boxed lunch tied with string. Now nothing is drawn at all - just your fist, held the way you hold a block you are about to place.
|
||||||
|
[*][b]Grave's Repose is much stronger[/b] - cold and heat resistance raised from 5 to 50. With the knife in hand, weather stops being a problem rather than merely being survivable.
|
||||||
|
[/list]
|
||||||
|
|
||||||
|
[size=5][b]Note for existing saves[/b][/size]
|
||||||
|
|
||||||
|
A Spatial Bracelet you already own will not gain the four mod slots. The game fixes an item's slot count when the item is created, so an old one keeps the zero it was made with. [b]Craft a new bracelet[/b] and it will have them. Everything else in this release applies to your existing save immediately.
|
||||||
|
|
||||||
|
[size=5][b]Requirements[/b][/size]
|
||||||
|
|
||||||
|
Unchanged from 1.0: [b]7 Days to Die 3.2[/b], [b]EasyAntiCheat off[/b] (the mod uses Harmony patches), no other mods needed, built and tested single-player, installed per client.
|
||||||
|
|
||||||
|
[size=5][b]Shout outs[/b][/size]
|
||||||
|
|
||||||
|
[b]The Fun Pimps[/b] - for 7 Days to Die itself, and for the vanilla models, icons and UI templates this mod reuses (the thrown-stone prefab, the book icon behind the summoning tomes, the video player and confirmation dialog the finale is built on).
|
||||||
|
|
||||||
|
[b]Andreas Pardeike[/b] - for Harmony. Every runtime mechanic in this mod, this release's storage fix included, is a Harmony patch.
|
||||||
|
|
||||||
|
[b]The 7 Days to Die modding community[/b] - for forum posts and open-source mods that answer the questions the XML documentation does not. The official modding API has no reference for the order its events fire in; this release's fix came down to reading that order out of the game's own code, and knowing that was the only way to find out is community knowledge.
|
||||||
|
|
||||||
|
[b]youkia96581[/b] - for the bug report that made this version exist.
|
||||||
|
|
||||||
|
[b]AI disclosure:[/b] the item icons and the finale artwork are AI-generated. Everything else - the code, the design, the mechanics and the writing - is my own.
|
||||||
|
|
||||||
|
Source code and full change history: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
|
||||||
|
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
|
||||||
|
My YouTube channel: [url=https://www.youtube.com/@alex_cube]@alex_cube[/url]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Fixes the Necromancy level rolling back on every save: it now rises once per 20 zombies, up to 250, and old saves repair themselves on load. Bleed, fire, trap and pet kills count. Charmed zombies no longer fight each other. Drop-in; your save is fine.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,13 @@
|
|||||||
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
||||||
|
|
||||||
**Версия 1.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
|
*English version below — scroll past the Russian half.*
|
||||||
|
|
||||||
|
**Версия 1.3.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
|
||||||
|
|
||||||
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||||
|
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||||
- Репозиторий: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
|
- Репозиторий: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
|
||||||
- YouTube-канал автора: https://www.youtube.com/@alexcube
|
- YouTube-канал автора: https://www.youtube.com/@alex_cube
|
||||||
|
|
||||||
Мод для 7 Days to Die о пути от обычного выжившего до некроманта — с собственной веткой
|
Мод для 7 Days to Die о пути от обычного выжившего до некроманта — с собственной веткой
|
||||||
прогрессии, тёмным оружием, призываемыми существами и сюжетной концовкой.
|
прогрессии, тёмным оружием, призываемыми существами и сюжетной концовкой.
|
||||||
@@ -21,18 +24,29 @@
|
|||||||
## Прогрессия
|
## Прогрессия
|
||||||
|
|
||||||
Отдельный скилл **"Некромантия"** растёт не от опыта, а от счётчика упокоенных зомби — свой
|
Отдельный скилл **"Некромантия"** растёт не от опыта, а от счётчика упокоенных зомби — свой
|
||||||
счётчик, своя механика. Пять тиров, каждый открывает часть арсенала:
|
счётчик, своя механика. Засчитывается любое убийство, за которое игра начисляет вам опыт: добитые
|
||||||
|
ловушкой, сгоревшие, умершие от наложенного вами кровотечения, и зомби-звери наравне с
|
||||||
|
человекоподобными.
|
||||||
|
|
||||||
| Тир | Порог | Что открывается |
|
**Двадцать упокоенных зомби — один уровень Некромантии**, максимум 250-й уровень (5000 зомби). В
|
||||||
|---|---|---|
|
HUD за этим следят два индикатора: череп в статус-баре показывает текущий уровень, а фиолетовая
|
||||||
| Адепт | сразу | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов |
|
шкала рядом с полосой опыта — сколько зомби набрано внутри уровня; заполнилась — уровень вырос, и
|
||||||
| Адепт (доп.) | 20 зомби | Пространственный браслет |
|
шкала обнулилась.
|
||||||
| Адепт (доп.) | 30 / 60 / 100 / 300 зомби | Моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё |
|
|
||||||
| Подмастерье | 500 зомби | Свиток девиации |
|
Пять тиров, каждый открывает часть арсенала (в таблице и порог в зомби, и уровень скилла):
|
||||||
| Подмастерье (доп.) | 1400 / 1700 зомби | Моды ножа: Хватка мертвеца, Мёртвая буря |
|
|
||||||
| Ученик | 2000 зомби | Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа |
|
| Тир | Порог | Уровень | Что открывается |
|
||||||
| Некромант | 3000 зомби | Призыв зомбомедведя, Призыв зомбоволка, Свиток банши |
|
|---|---|---|---|
|
||||||
| Мастер | 5000 зомби | Чёрный портальный камень |
|
| Адепт | сразу | 0 | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов |
|
||||||
|
| Адепт (доп.) | 20 зомби | 1 | Пространственный браслет, Кровавая сфера, мод ножа «Слёзы мертвеца» |
|
||||||
|
| Адепт (доп.) | 60 / 100 / 300 зомби | 3 / 5 / 15 | Свиток духа крысы; моды ножа: Пир падальщика, Могильный покой, Тёмное чутьё |
|
||||||
|
| Подмастерье | 500 зомби | 25 | Свиток девиации, Призыв могильного стервятника |
|
||||||
|
| Подмастерье (доп.) | 1300 зомби | 65 | Призыв зомбособаки |
|
||||||
|
| Подмастерье (доп.) | 1400 / 1700 зомби | 70 / 85 | Моды ножа: Хватка мертвеца, Мёртвая буря |
|
||||||
|
| Ученик | 2000 зомби | 100 | Призыв зомбомедведя, Рой фараона, Кровавый камень |
|
||||||
|
| Некромант | 3000 зомби | 150 | Свиток банши |
|
||||||
|
| Некромант (доп.) | 4000 зомби | 200 | Призыв зомбоволка |
|
||||||
|
| Мастер | 5000 зомби | 250 | Чёрный портальный камень |
|
||||||
|
|
||||||
## Арсенал
|
## Арсенал
|
||||||
|
|
||||||
@@ -59,7 +73,19 @@
|
|||||||
своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не
|
своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не
|
||||||
расходуется.
|
расходуется.
|
||||||
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер
|
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер
|
||||||
растёт вместе с уровнем Некромантии. Обычная атака пока ничего не делает.
|
растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок
|
||||||
|
прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, с тем же
|
||||||
|
круглым индикатором, что и у разбора верстака. Мир на это время обесцвечивается. Не поддаются
|
||||||
|
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира —
|
||||||
|
каждый отказ со своим сообщением. Силовая атака прерывает утаскивание и открывает хранилище.
|
||||||
|
Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака
|
||||||
|
отказывает.
|
||||||
|
- **Кровавая сфера** — заряд браслета. 500 прочности, каждая секунда утаскивания блока тратит
|
||||||
|
единицу; опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две
|
||||||
|
за раз, из крови некроманта и праха зомби.
|
||||||
|
- **Кровавый камень** — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на
|
||||||
|
2000 упокоенных (уровень 100) и варится на химической станции из праха зомби, костей, обычной крови и крови
|
||||||
|
некроманта.
|
||||||
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
|
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
|
||||||
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
|
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
|
||||||
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
|
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
|
||||||
@@ -67,14 +93,27 @@
|
|||||||
|
|
||||||
## Питомцы
|
## Питомцы
|
||||||
|
|
||||||
Призывные книги поднимают союзных существ, которые дерутся с зомби, а не с игроком. Питомец не
|
Призывные книги поднимают союзных существ, которые дерутся с зомби, а не с игроком. Все призванные
|
||||||
«следует» за игроком в строгом смысле — просто бродит сам по себе, а если отойдёт дальше
|
существа выглядят выцветшими призраками — прозрачность настраивается консольной командой
|
||||||
32 блоков и в этот момент не занят боем, его телепортирует обратно к владельцу:
|
`necroghost`, той же, что и у призрачных торговцев.
|
||||||
|
|
||||||
- **Зомбособака**, **Зомбомедведь**, **Зомбоволк**, **Зомбогриф** — постоянные спутники. Можно
|
- **Дух крысы** — первый доступный питомец (Некромантия 3). Он ведёт себя не так, как остальные:
|
||||||
держать по одному экземпляру каждого вида одновременно; отзываются обратно в книгу силовой
|
сам не нападает никогда, держится справа-сзади от вас в двух блоках, а упёршись в препятствие,
|
||||||
атакой.
|
проходит сквозь него — блоки он не грызёт вовсе. Пока крыса призвана, обычное применение свитка
|
||||||
- **Жуки Властелина** — разовый свиток, выпускающий рой. Жуки сами разлетаются по широкому
|
становится приказом: она бросается на того, на кого наведён ваш прицел (кроме торговцев); если
|
||||||
|
цели под прицелом нет, появится надпись «Нет цели для атаки». Силовая атака отзывает её. Урон у
|
||||||
|
крысы почти никакой (5), но её укус делает три вещи: сильно замедляет, рвёт жилу (кровотечение,
|
||||||
|
которое не заживает, пока крыса грызёт) и оставляет **метку духа** — помеченный зомби полминуты
|
||||||
|
виден на вашем компасе и карте и получает на четверть больше урона от всего, чем бы вы его ни
|
||||||
|
били. Саму крысу видно на карте жёлтым. Обгрызая труп добитого зомби, она восстанавливает себе
|
||||||
|
здоровье. Если ударить саму крысу, она огрызнётся — но никогда на хозяина.
|
||||||
|
- **Зомбособака**, **Зомбомедведь**, **Зомбоволк**, **Могильный стервятник** — постоянные спутники, и ведут
|
||||||
|
себя они так же, как Дух крысы: идут за вами, сами ни на кого не нападают и ждут приказа. Разница
|
||||||
|
в силе: урон растёт от могильного стервятника к зомбоволку, и в отличие от крысы эти убивают быстро — пёс,
|
||||||
|
медведь и волк вдобавок могут отрывать зомби конечности. Их укус вызывает кровотечение, но метку
|
||||||
|
духа не ставит. Можно держать по одному экземпляру каждого вида
|
||||||
|
одновременно; отзываются обратно в книгу силовой атакой.
|
||||||
|
- **Рой фараона** — разовый свиток, выпускающий рой. Насекомые сами разлетаются по широкому
|
||||||
радиусу и жалят зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой нельзя
|
радиусу и жалят зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой нельзя
|
||||||
отозвать обратно, активен может быть только один. Расходуется при использовании.
|
отозвать обратно, активен может быть только один. Расходуется при использовании.
|
||||||
- **Свиток банши** — одноразовый: при открытии вопит голосом банши и поднимает рядом с игроком
|
- **Свиток банши** — одноразовый: при открытии вопит голосом банши и поднимает рядом с игроком
|
||||||
@@ -103,12 +142,14 @@
|
|||||||
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
|
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
|
||||||
- Некоторые декоративные блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием,
|
- Некоторые декоративные блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием,
|
||||||
как верстак.
|
как верстак.
|
||||||
|
- Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с
|
||||||
|
мёртвыми, и торгуют с ним те, кто уже не совсем жив.
|
||||||
|
|
||||||
## Локализация
|
## Локализация
|
||||||
|
|
||||||
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
|
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
|
||||||
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
|
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
|
||||||
традиционный. Все 123 ключа `Config/Localization.csv` заполнены, пустых ячеек нет.
|
традиционный. Все 149 ключей `Config/Localization.csv` заполнены, пустых ячеек нет.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -120,7 +161,283 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Версия 1.0 — весь заявленный контент реализован и проходит тесты в игре. Из запланированного не
|
Версия 1.3.0 — **шкала Некромантии переведена на 20 упокоенных зомби за уровень** (максимум 250-й
|
||||||
|
уровень, те же 5000 зомби). Это не косметика, а починка: игра хранит уровень любого навыка одним
|
||||||
|
байтом, поэтому старая шкала «одно убийство — один уровень» при каждом сохранении откатывала
|
||||||
|
уровень назад на 256. Внешне это выглядело как самопроизвольно закрывающиеся рецепты — на панели
|
||||||
|
скилла снова появлялся замок на том, что уже было открыто, — а тиры 500 / 2000 / 3000 / 5000 были
|
||||||
|
недостижимы вовсе. Число упокоенных зомби никогда не портилось, и уровень теперь считается из него,
|
||||||
|
в том числе при загрузке: **старые сейвы чинятся сами**, без команд и без новой игры.
|
||||||
|
|
||||||
|
Заодно в HUD появились два индикатора: череп в статус-баре показывает уровень Некромантии (раньше
|
||||||
|
он показывал общее число убийств), а новая фиолетовая шкала рядом с полосой опыта — продвижение
|
||||||
|
внутри уровня, от 0 до 20. Мод ножа на воду (Слёзы мертвеца) переехал с 30 упокоенных на 20 — на
|
||||||
|
новой шкале 30 не выражается, и порог опущен, а не поднят.
|
||||||
|
|
||||||
|
И третья правка этой версии: **подчинённые зомби больше не дерутся между собой**. Раньше зомби под
|
||||||
|
девиацией получал приказ «бей зомби» и видел законную цель в таком же подчинённом — чем больше вы
|
||||||
|
подчиняли, тем чаще ваша свита выясняла отношения вместо того, чтобы драться за вас. Теперь
|
||||||
|
подчинённый не считает подчинённого целью и выбирает следующего, настоящего врага; это же правило
|
||||||
|
гасит и месть, если драка началась до подчинения.
|
||||||
|
|
||||||
|
Версия 1.2.0 — у Пространственного браслета появился расходник. Забор блоков теперь питается
|
||||||
|
зарядом в слоте модификаций: **Кровавая сфера** тратит единицу прочности за секунду поглощения и,
|
||||||
|
опустев, рассыпается; **Кровавый камень** с третьего грейда занимает тот же слот и не тратится
|
||||||
|
вовсе. С пустым слотом обычная атака отказывает.
|
||||||
|
|
||||||
|
В этой же версии починен счёт убийств для Некромантии. Раньше скилл рос только от убийств своей
|
||||||
|
рукой и только от человекоподобных зомби — мимо проходили зомбопёс, зомбомедведь, зомбокабан и
|
||||||
|
зомбоворон, а также всё, что убивало за вас: ловушки, огонь, кровотечение. Теперь засчитывается
|
||||||
|
всё, за что игра начисляет вам опыт. Тот же счётчик задаёт урон Ножа некроманта, так что нож
|
||||||
|
заодно перестал недобирать. Плюс торговец больше не теряет призрачность после выгрузки чанка, а
|
||||||
|
создание Крови некроманта сопровождается криком боли — оно и раньше стоило 90% здоровья, просто
|
||||||
|
молча.
|
||||||
|
|
||||||
|
Версия 1.1.0 научила Пространственный браслет забирать блоки прямо в хранилище: зажатая на блоке
|
||||||
|
обычная атака утаскивает его туда через десять секунд плюс секунда за каждый блок расстояния, с
|
||||||
|
обесцвечиванием мира на время ожидания (оно же висит и на каналах обоих порталов). Не поддаются
|
||||||
|
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира.
|
||||||
|
Плюс все торговцы стали чёрно-белыми, полупрозрачными и матовыми.
|
||||||
|
|
||||||
|
Версия 1.0.1 закрывала первый баг-репорт с Nexus: содержимое браслета больше не пропадает после
|
||||||
|
выхода из игры (хранилище сохраняется в файле игрока, рядом с рюкзаком).
|
||||||
|
|
||||||
|
Весь заявленный контент реализован. Основное проверено в игре: заряд браслета и расход Кровавой
|
||||||
|
сферы, счёт убийств Некромантии (включая зомби-зверей, кровотечение и огонь), призрачность
|
||||||
|
торговцев, крик при создании Крови некроманта.
|
||||||
|
|
||||||
|
> **Эта сборка проверена не полностью.** Вкладка испытаний, оба первых испытания и счёт убийств
|
||||||
|
> в игре подтверждены. Ещё не проверены: **новая шкала уровней и оба индикатора** (череп с уровнем
|
||||||
|
> и фиолетовая шкала прогресса), **починка старых сейвов на загрузке**, **мир между подчинёнными
|
||||||
|
> зомби**, **Кровавый камень** (нужен
|
||||||
|
> уровень Некромантии 100, он же 2000 упокоенных, и химстанция), **новый порог Кровавой сферы**
|
||||||
|
> (переехала на уровень 1, туда же, где открывается Пространственный браслет), а также **награды
|
||||||
|
> испытаний и два новых испытания** — на Камень духов и Синий портальный камень. Если что-то
|
||||||
|
> поведёт себя не так, это ожидаемые места для сюрприза. Из запланированного не
|
||||||
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
|
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
|
||||||
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
|
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
|
||||||
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
|
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Necromancer's Tome (English)
|
||||||
|
|
||||||
|
**Version 1.3.0** - for 7 Days to Die 3.2. By Alex Cube.
|
||||||
|
|
||||||
|
- Mod page: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||||
|
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||||
|
- Repository: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
|
||||||
|
- The author's YouTube channel: https://www.youtube.com/@alex_cube
|
||||||
|
|
||||||
|
A 7 Days to Die mod about the road from ordinary survivor to necromancer - with its own
|
||||||
|
progression tree, cursed weapons, summonable creatures and a story ending.
|
||||||
|
|
||||||
|
## Premise
|
||||||
|
|
||||||
|
It all starts with a note, and reading it shows the character a blurred flashback.
|
||||||
|
|
||||||
|
Necromancy in this mod is an answer to a curse, not a side branch of crafting. Instead of falling
|
||||||
|
before the horde one day and joining it, the player learns to bend the dead to their will: to
|
||||||
|
infect zombies with madness, turn them on each other, raise their own creatures against them. Not
|
||||||
|
survival in spite of death, but power over it.
|
||||||
|
|
||||||
|
## Progression
|
||||||
|
|
||||||
|
A dedicated **"Necromancy"** skill grows not from experience but from a count of zombies put to
|
||||||
|
rest - its own counter, its own mechanic. Anything the game gives you XP for counts: kills finished
|
||||||
|
by a trap, by fire, by a bleed you applied, and zombie animals alongside the humanoids.
|
||||||
|
|
||||||
|
**Twenty zombies put to rest make one Necromancy level**, up to level 250 (5000 zombies). Two
|
||||||
|
indicators track it on the HUD: the skull in the status bar shows the current level, and the purple
|
||||||
|
bar next to the experience bar shows how many zombies you have gathered inside the level - it fills,
|
||||||
|
the level goes up, the bar resets.
|
||||||
|
|
||||||
|
Five tiers, each opening part of the arsenal (the table gives both the zombie count and the level):
|
||||||
|
|
||||||
|
| Tier | Threshold | Level | What unlocks |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Adept | from the start | 0 | Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits |
|
||||||
|
| Adept (extra) | 20 zombies | 1 | Spatial Bracelet, Blood Sphere, the Tears of the Dead knife mod |
|
||||||
|
| Adept (extra) | 60 / 100 / 300 zombies | 3 / 5 / 15 | Scroll of the Rat Spirit; knife mods: Scavenger's Feast, Grave's Repose, Dark Sense |
|
||||||
|
| Journeyman | 500 zombies | 25 | Scroll of Deviation, Summon Grave Vulture |
|
||||||
|
| Journeyman (extra) | 1300 zombies | 65 | Summon Zombie Dog |
|
||||||
|
| Journeyman (extra) | 1400 / 1700 zombies | 70 / 85 | Knife mods: Dead Man's Grip, Dead Storm |
|
||||||
|
| Apprentice | 2000 zombies | 100 | Summon Zombie Bear, Pharaoh’s Swarm, Blood Stone |
|
||||||
|
| Necromancer | 3000 zombies | 150 | Banshee's Scroll |
|
||||||
|
| Necromancer (extra) | 4000 zombies | 200 | Summon Zombie Wolf |
|
||||||
|
| Master | 5000 zombies | 250 | Black Portal Stone |
|
||||||
|
|
||||||
|
## Arsenal
|
||||||
|
|
||||||
|
- **Spirit Stone** - a thrown stone lit with necromantic energy. It catches a single zombie: that
|
||||||
|
one switches to your side and starts attacking other zombies instead of you.
|
||||||
|
- **Scroll of Deviation** - the same effect, but stronger: on impact it wins over every zombie in
|
||||||
|
the blast area at once rather than one. Consumed on use.
|
||||||
|
- **Necromancer's Knife** - a blackened bone blade. Its damage grows with the Necromancy skill:
|
||||||
|
nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for half the
|
||||||
|
damage dealt and marks the wounded zombie as a Victim - on death it is guaranteed to leave a
|
||||||
|
special bag of ingredients.
|
||||||
|
- **Six mods for the Necromancer's Knife only** - ordinary knife mods will not fit this weapon,
|
||||||
|
and these will not fit any other: **Tears of the Dead** (2 water from every zombie killed with
|
||||||
|
the blade), **Scavenger's Feast** (2 food per corpse), **Grave's Repose** (protection from heat
|
||||||
|
and cold while the knife is held), **Dead Man's Grip** (a zombie wounded by the blade is
|
||||||
|
slowed), **Dead Storm** (the power attack hits an area and causes bleeding, for 10 health
|
||||||
|
instead of 5 and double the stamina), **Dark Sense** (every nearby zombie is marked on the
|
||||||
|
compass and map while the knife is held).
|
||||||
|
- **Necromancer's Blood** - a ritual resource: a jar takes an empty jar, any knife in hand and 90%
|
||||||
|
of your current health per portion. An ingredient for the darkest recipes - the Black Portal and
|
||||||
|
the Knife itself.
|
||||||
|
- **Victim's Skin** and **Zombie Ash** - left by a zombie marked as a Victim by the Necromancer's
|
||||||
|
Knife. Ingredients for the summoning books and for most necromantic recipes respectively.
|
||||||
|
- **Blue Portal Stone** - hold the use button for 10 seconds to teleport to your bedroll.
|
||||||
|
Interrupted by any damage, or by a power attack before the time is up. Not consumed.
|
||||||
|
- **Spatial Bracelet** - a power attack opens a personal storage rift whose size grows with your
|
||||||
|
Necromancy level. Hold the regular attack on a block and that block is pulled straight into the
|
||||||
|
rift: ten seconds up close, one more per block of distance, behind the same circular indicator a
|
||||||
|
workbench pickup uses. The world drains to black and white while it runs. Damaged blocks,
|
||||||
|
containers with anything inside, a trader's ground and indestructible things like the world's
|
||||||
|
floor all refuse, each with its own message. The power attack interrupts the pull and opens the
|
||||||
|
rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the
|
||||||
|
regular attack refuses.
|
||||||
|
- **Blood Sphere** - the bracelet's charge. 500 points of durability, one spent per second of a
|
||||||
|
pull; emptied, the sphere crumbles. Available from the very start and hand-crafted without a
|
||||||
|
workbench, two at a time, from Necromancer's Blood and Zombie Ash.
|
||||||
|
- **Blood Stone** - the same slot, but never spent at all: the endgame replacement for the sphere.
|
||||||
|
Opens at 2000 zombies put to rest (level 100) and is brewed at a chemistry station from zombie ash, bones,
|
||||||
|
ordinary blood and Necromancer's Blood.
|
||||||
|
- **Tin cans** (empty / with river water / with boiled water) - a reusable cycle instead of
|
||||||
|
single-use jars: fill them with water, boil it right on a campfire without a pot, drink, and the
|
||||||
|
can comes back empty. River water from a can can cause dysentery, like any murky water; boiled
|
||||||
|
water is safe. They hold less water than glass jars.
|
||||||
|
|
||||||
|
## Pets
|
||||||
|
|
||||||
|
Summoning books raise allied creatures that fight zombies rather than the player. Every summoned
|
||||||
|
creature looks like a washed-out ghost - the transparency is tuned with the `necroghost` console
|
||||||
|
command, the same one the ghost traders use.
|
||||||
|
|
||||||
|
- **Rat Spirit** - the first pet available (Necromancy 3), and the only one that behaves
|
||||||
|
deliberately rather than by inheritance: it never attacks on its own, keeps two blocks to your
|
||||||
|
right and just behind you, and where an obstacle stands it passes straight through - it never
|
||||||
|
gnaws blocks. While it is out, an ordinary use of the scroll becomes an order: the rat goes for
|
||||||
|
whatever your crosshair rests on (traders excepted); with nothing to attack there, you get "No
|
||||||
|
target to attack". A power attack recalls it. Its damage is next to nothing (5), but its bite
|
||||||
|
does three things: it slows heavily, it tears a vein (a bleed that will not close while the rat
|
||||||
|
keeps at it), and it leaves a **spirit mark** - for half a minute the marked zombie shows on
|
||||||
|
your compass and map and takes a quarter more damage from everything, whatever you hit it with.
|
||||||
|
The rat itself shows on the map in yellow. Gnawing on the corpse of a zombie it finished off
|
||||||
|
heals it back up. Hitting the rat makes it bite back - never at its owner, though.
|
||||||
|
- **Zombie Dog**, **Zombie Bear**, **Zombie Wolf**, **Grave Vulture** - permanent companions that
|
||||||
|
behave exactly like the Rat Spirit: they follow you, attack nobody on their own and wait for an
|
||||||
|
order. What differs is force - the damage climbs from the griffin up to the wolf, and unlike the rat
|
||||||
|
these kill quickly; the dog, the bear and the wolf can also tear limbs off. Their bite draws blood
|
||||||
|
but leaves no spirit mark. One of each kind can be kept
|
||||||
|
at a time; a power attack recalls them into the book.
|
||||||
|
- **Pharaoh’s Swarm** - a one-shot scroll releasing a swarm. They scatter over a wide
|
||||||
|
radius on their own and sting zombies; a stung zombie switches to your side just like with the
|
||||||
|
Spirit Stone. The swarm cannot be recalled and only one can be active. Consumed on use.
|
||||||
|
- **Banshee's Scroll** - single use: on opening it screams with a banshee's voice and raises a
|
||||||
|
small hostile horde next to the player. These are not allies - they are as dangerous to you as
|
||||||
|
any other zombies.
|
||||||
|
|
||||||
|
## Base defence
|
||||||
|
|
||||||
|
- **Pyramid of Spirits** - a deployable block, not a held item. While you stand in its radius it
|
||||||
|
charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning
|
||||||
|
it against the other zombies instead of you or your base. Its block menu toggles the effect and
|
||||||
|
shows the edge of the radius.
|
||||||
|
|
||||||
|
## The Black Portal - the story's ending
|
||||||
|
|
||||||
|
**The Black Portal Stone unlocks at the top of the progression (5000 zombies) and is the mod's
|
||||||
|
ending.** Activating it opens a confirmation dialogue, stops the game and unfolds a full-screen
|
||||||
|
finale - it finishes the story the note started on day one. The scene closes on a choice of two:
|
||||||
|
one ends the story and returns to the main menu, the other puts the player back into the world to
|
||||||
|
keep playing.
|
||||||
|
|
||||||
|
The story texts live in `Config/Localization.csv` under the `necroFinal*` keys. They are
|
||||||
|
deliberately not retold here: a README gets read before the playthrough.
|
||||||
|
|
||||||
|
## Small things
|
||||||
|
|
||||||
|
- The opening note also pauses the game and plays a short flashback.
|
||||||
|
- Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the
|
||||||
|
key, like a workbench.
|
||||||
|
- Every trader looks different: black and white, half-transparent and matte. The necromancer deals
|
||||||
|
with the dead, and the only people still trading are not quite alive.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
**13 languages, complete:** Russian, English, German, Spanish, French, Italian, Japanese, Korean,
|
||||||
|
Polish, Brazilian Portuguese, Turkish, Simplified and Traditional Chinese. All 149 keys in
|
||||||
|
`Config/Localization.csv` are filled in, with no empty cells.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Unpack the `NecromancerTome` folder into `<game folder>/Mods/` (or into
|
||||||
|
`%APPDATA%/7DaysToDie/Mods/`) and launch the game. The mod ships Harmony libraries, so **EAC must
|
||||||
|
be turned off**.
|
||||||
|
|
||||||
|
Built for single-player: the vanilla pause only works in single-player, so in multiplayer the
|
||||||
|
story scenes play without stopping time.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Version 1.3.0 - **Necromancy now scales at 20 zombies put to rest per level** (up to level 250,
|
||||||
|
the same 5000 zombies). This is a fix, not a facelift: the game stores any skill's level in a
|
||||||
|
single byte, so the old "one kill, one level" scale rolled the level back by 256 on every save.
|
||||||
|
From the outside it looked like recipes closing on their own - the skill panel would put the lock
|
||||||
|
back on something already unlocked - and the 500 / 2000 / 3000 / 5000 tiers were unreachable
|
||||||
|
altogether. The count of zombies put to rest was never damaged, and the level is now derived from
|
||||||
|
it, including on load: **old saves repair themselves**, with no commands and no new game.
|
||||||
|
|
||||||
|
Two HUD indicators come with it: the skull in the status bar now shows the Necromancy level (it
|
||||||
|
used to show the total kill count), and a new purple bar next to the experience bar shows the
|
||||||
|
progress inside the level, from 0 to 20. The water knife mod (Tears of the Dead) moved from 30
|
||||||
|
zombies to 20 - 30 does not land on the new scale, and the threshold was lowered rather than
|
||||||
|
raised.
|
||||||
|
|
||||||
|
A third fix ships with this version: **charmed zombies no longer fight each other**. A zombie under
|
||||||
|
deviation used to be told "attack zombies" and saw a perfectly valid target in another charmed one -
|
||||||
|
the more of them you charmed, the more of your retinue settled scores among themselves instead of
|
||||||
|
fighting for you. Now a charmed zombie does not count another charmed zombie as a target and picks
|
||||||
|
the next, real enemy instead; the same rule cancels revenge if the fight started before the
|
||||||
|
charming.
|
||||||
|
|
||||||
|
Version 1.2.0 - the Spatial Bracelet now runs on a charge. Pulling blocks draws on whatever sits
|
||||||
|
in its mod slot: the **Blood Sphere** spends a point of durability per second of the pull and
|
||||||
|
crumbles once empty, while the **Blood Stone** from the third grade takes the same slot and is never
|
||||||
|
spent at all. With the slot empty, the regular attack refuses.
|
||||||
|
|
||||||
|
The same version fixes Necromancy's kill count. The skill used to rise only from kills by your own
|
||||||
|
hand, and only from humanoid zombies - zombie dogs, bears, boars and vultures never counted, and
|
||||||
|
neither did anything that killed on your behalf: traps, fire, bleeding. Now everything the game
|
||||||
|
gives you XP for counts. The same counter drives the Necromancer's Knife's damage, so the knife
|
||||||
|
stopped coming up short as well. On top of that, a trader no longer loses its ghostly look after
|
||||||
|
its chunk is unloaded, and crafting Necromancer's Blood now comes with a cry of pain - it always
|
||||||
|
cost 90% of your health, it was just silent about it.
|
||||||
|
|
||||||
|
Version 1.1.0 taught the Spatial Bracelet to take blocks straight into the vault: hold its regular
|
||||||
|
attack on a block and it is pulled in after ten seconds, plus one more per block of distance, with
|
||||||
|
the world draining to black and white for the wait (which also covers both portal channels).
|
||||||
|
Damaged blocks, containers with anything inside, a trader's ground and indestructible things like
|
||||||
|
the world's floor all refuse. Every trader is now rendered in black and white, half-transparent and
|
||||||
|
matte.
|
||||||
|
|
||||||
|
Version 1.0.1 closed the first bug report from Nexus: the bracelet's contents no longer disappear
|
||||||
|
after leaving the game (the storage is saved in the player's own file, next to the backpack).
|
||||||
|
|
||||||
|
All the announced content is implemented. The core of it is confirmed in game: the bracelet's
|
||||||
|
charge and the Blood Sphere's spending, Necromancy's kill count (zombie animals, bleeding and fire
|
||||||
|
included), the traders' ghostly look, and the cry when Necromancer's Blood is made.
|
||||||
|
|
||||||
|
> **This build has not been fully tested.** The challenges tab, its first two challenges and the
|
||||||
|
> kill count are confirmed in game. Not yet checked: the **new level scale and both indicators**
|
||||||
|
> (the skull showing the level and the purple progress bar), the **self-repair of old saves on
|
||||||
|
> load**, the **truce between charmed zombies**, the **Blood Stone** (it needs Necromancy level 100, i.e. 2000 zombies put to rest, and a
|
||||||
|
> chemistry station), the **Blood Sphere's new threshold** (it moved to level 1, where the Spatial
|
||||||
|
> Bracelet itself unlocks), and the **challenge rewards plus two new challenges** for the Spirit
|
||||||
|
> Stone and the Blue Portal Stone. If anything misbehaves, that is where to look. Of what was planned, only part of the mod's own sound effects is missing. The description text for the website
|
||||||
|
(RU + EN) is in `SITE_DESCRIPTION.html` (WordPress block markup). The full technical history of
|
||||||
|
development and the text of the finale sit next to the mod in `BACKLOG.md` and `FINAL_TEXT.md` -
|
||||||
|
they are not part of the repository (spoilers and back-of-house).
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
-15
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
<h2>Прогрессия, которая растёт от убийств</h2>
|
<h2>Прогрессия, которая растёт от убийств</h2>
|
||||||
|
|
||||||
Отдельный навык <strong>«Некромантия»</strong> качается не за очки перков и не за книги, а по счётчику упокоенных зомби. Пять тиров, 5000 убийств до вершины, каждый порог открывает часть арсенала:
|
Отдельный навык <strong>«Некромантия»</strong> качается не за очки перков и не за книги, а по счётчику упокоенных зомби: двадцать упокоенных — один уровень, до 250-го (5000 зомби). Уровень показывает череп в статус-баре, а фиолетовая шкала рядом с полосой опыта — сколько набрано внутри уровня. Пять тиров, каждый порог открывает часть арсенала (в таблице — зомби, в скобках уровень):
|
||||||
|
|
||||||
<table><thead><tr><th>Тир</th><th>Порог</th><th>Что открывается</th></tr></thead><tbody><tr><td>Адепт</td><td>сразу</td><td>Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов</td></tr><tr><td>Адепт</td><td>20 / 30 / 60 / 100 / 300</td><td>Пространственный браслет, затем моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё</td></tr><tr><td>Подмастерье</td><td>500 / 1400 / 1700</td><td>Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря</td></tr><tr><td>Ученик</td><td>2000</td><td>Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа</td></tr><tr><td>Некромант</td><td>3000</td><td>Призыв зомбомедведя, Призыв зомбоволка, Свиток банши</td></tr><tr><td>Мастер</td><td>5000</td><td>Чёрный портальный камень</td></tr></tbody></table>
|
<table><thead><tr><th>Тир</th><th>Порог</th><th>Что открывается</th></tr></thead><tbody><tr><td>Адепт</td><td>сразу (0)</td><td>Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов</td></tr><tr><td>Адепт</td><td>20 (1)</td><td>Пространственный браслет, Кровавая сфера, мод ножа «Слёзы мертвеца»</td></tr><tr><td>Адепт</td><td>60 / 100 / 300 (3 / 5 / 15)</td><td>Моды ножа: Пир падальщика, Могильный покой, Тёмное чутьё</td></tr><tr><td>Подмастерье</td><td>500 / 1400 / 1700 (25 / 70 / 85)</td><td>Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря</td></tr><tr><td>Ученик</td><td>2000 (100)</td><td>Призыв зомбособаки, Рой фараона, Призыв могильного стервятника, Кровавый камень</td></tr><tr><td>Некромант</td><td>3000 (150)</td><td>Призыв зомбомедведя, Призыв зомбоволка, Свиток банши</td></tr><tr><td>Мастер</td><td>5000 (250)</td><td>Чёрный портальный камень</td></tr></tbody></table>
|
||||||
|
|
||||||
<h2>Арсенал</h2>
|
<h2>Арсенал</h2>
|
||||||
|
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
|
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
|
||||||
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
|
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
|
||||||
<li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li>
|
<li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li>
|
||||||
<li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии.</li>
|
<li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, мир на это время обесцвечивается. Повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира не поддаются. Силовая атака прерывает утаскивание и открывает хранилище. Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака отказывает.</li>
|
||||||
|
<li><strong>Кровавая сфера</strong> — заряд браслета: 500 прочности, каждая секунда утаскивания блока тратит единицу, опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две за раз, из крови некроманта и праха зомби.</li>
|
||||||
|
<li><strong>Кровавый камень</strong> — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на 2000 упокоенных и варится на химической станции из праха зомби, костей, обычной крови и крови некроманта.</li>
|
||||||
<li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li>
|
<li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -29,8 +31,8 @@
|
|||||||
Призывные книги поднимают союзников, которые дерутся с зомби, а не с вами. Питомец бродит сам по себе, а если отойдёт дальше 32 блоков и не занят боем — телепортируется обратно к владельцу.
|
Призывные книги поднимают союзников, которые дерутся с зомби, а не с вами. Питомец бродит сам по себе, а если отойдёт дальше 32 блоков и не занят боем — телепортируется обратно к владельцу.
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Зомбособака, зомбомедведь, зомбоволк, зомбогриф</strong> — постоянные спутники, по одному экземпляру каждого вида одновременно. Отзываются обратно в книгу силовой атакой.</li>
|
<li><strong>Зомбособака, зомбомедведь, зомбоволк, могильный стервятник</strong> — постоянные спутники, по одному экземпляру каждого вида одновременно. Отзываются обратно в книгу силовой атакой.</li>
|
||||||
<li><strong>Жуки Властелина</strong> — разовый свиток с роем. Жуки разлетаются по широкому радиусу и жалят зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой не отзывается, активен может быть только один.</li>
|
<li><strong>Рой фараона</strong> — разовый свиток с роем. Жуки разлетаются по широкому радиусу и жалят зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой не отзывается, активен может быть только один.</li>
|
||||||
<li><strong>Свиток банши</strong> — одноразовый: вопит голосом банши и поднимает рядом небольшую орду. Это <strong>не</strong> союзники — эти зомби так же опасны для вас, как любые другие.</li>
|
<li><strong>Свиток банши</strong> — одноразовый: вопит голосом банши и поднимает рядом небольшую орду. Это <strong>не</strong> союзники — эти зомби так же опасны для вас, как любые другие.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -49,11 +51,12 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.</li>
|
<li>Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.</li>
|
||||||
<li>Часть декоративных блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как верстак.</li>
|
<li>Часть декоративных блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как верстак.</li>
|
||||||
|
<li>Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с мёртвыми, и торгуют с ним те, кто уже не совсем жив.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Локализация</h2>
|
<h2>Локализация</h2>
|
||||||
|
|
||||||
<strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 123 строки переведены, пустых ячеек нет.
|
<strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 149 строк переведены, пустых ячеек нет.
|
||||||
|
|
||||||
<h2>Установка</h2>
|
<h2>Установка</h2>
|
||||||
|
|
||||||
@@ -64,8 +67,9 @@
|
|||||||
<h2>Ссылки</h2>
|
<h2>Ссылки</h2>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>Исходники и загрузка: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
<li>Скачать на Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
|
||||||
<li>YouTube-канал автора: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
|
<li>Исходники: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
||||||
|
<li>YouTube-канал автора: <a href="https://www.youtube.com/@alex_cube" target="_blank" rel="noreferrer noopener">@alex_cube</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
@@ -80,9 +84,9 @@ Necromancy here is an answer to a curse, not a side branch of crafting. Instead
|
|||||||
|
|
||||||
<h3>Progression driven by kills</h3>
|
<h3>Progression driven by kills</h3>
|
||||||
|
|
||||||
A dedicated <strong>Necromancy</strong> skill levels not from perk points and not from books, but from your zombie kill count. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal:
|
A dedicated <strong>Necromancy</strong> skill levels not from perk points and not from books, but from your zombie kill count: twenty zombies put to rest make one level, up to level 250 (5000 zombies). The skull in the status bar shows the level, and the purple bar beside the experience bar shows how far you are into it. Five tiers, each threshold opening part of the arsenal (the table gives zombies, with the level in brackets):
|
||||||
|
|
||||||
<table><thead><tr><th>Tier</th><th>Threshold</th><th>What unlocks</th></tr></thead><tbody><tr><td>Adept</td><td>from the start</td><td>Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits</td></tr><tr><td>Adept</td><td>20 / 30 / 60 / 100 / 300</td><td>Spatial Bracelet, then the knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense</td></tr><tr><td>Journeyman</td><td>500 / 1400 / 1700</td><td>Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm</td></tr><tr><td>Apprentice</td><td>2000</td><td>Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin</td></tr><tr><td>Necromancer</td><td>3000</td><td>Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll</td></tr><tr><td>Master</td><td>5000</td><td>Black Portal Stone</td></tr></tbody></table>
|
<table><thead><tr><th>Tier</th><th>Threshold</th><th>What unlocks</th></tr></thead><tbody><tr><td>Adept</td><td>from the start (0)</td><td>Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits</td></tr><tr><td>Adept</td><td>20 (1)</td><td>Spatial Bracelet, Blood Sphere, the Tears of the Dead knife mod</td></tr><tr><td>Adept</td><td>60 / 100 / 300 (3 / 5 / 15)</td><td>Knife mods: Scavenger's Feast, Grave's Repose, Dark Sense</td></tr><tr><td>Journeyman</td><td>500 / 1400 / 1700 (25 / 70 / 85)</td><td>Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm</td></tr><tr><td>Apprentice</td><td>2000 (100)</td><td>Summon Zombie Dog, Pharaoh’s Swarm, Summon Grave Vulture, Blood Stone</td></tr><tr><td>Necromancer</td><td>3000 (150)</td><td>Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll</td></tr><tr><td>Master</td><td>5000 (250)</td><td>Black Portal Stone</td></tr></tbody></table>
|
||||||
|
|
||||||
<h3>Arsenal</h3>
|
<h3>Arsenal</h3>
|
||||||
|
|
||||||
@@ -94,7 +98,9 @@ A dedicated <strong>Necromancy</strong> skill levels not from perk points and no
|
|||||||
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
|
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
|
||||||
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
|
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
|
||||||
<li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li>
|
<li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li>
|
||||||
<li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level.</li>
|
<li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level. Hold the regular attack on a block and it is pulled straight into the rift: ten seconds up close, one more per block of distance, with the world draining to black and white while it runs. Damaged blocks, containers with anything inside, a trader's ground and indestructible things like the world's floor all refuse. The power attack interrupts the pull and opens the rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the regular attack refuses.</li>
|
||||||
|
<li><strong>Blood Sphere</strong> — the bracelet's charge: 500 points of durability, one spent per second of a pull, and the emptied sphere crumbles. Available from the very start and hand-crafted without a workbench, two at a time, from Necromancer's Blood and Zombie Ash.</li>
|
||||||
|
<li><strong>Blood Stone</strong> — the same slot, but never spent at all: the endgame replacement for the sphere. Opens at 2000 zombies put to rest and is brewed at a chemistry station from zombie ash, bones, ordinary blood and Necromancer's Blood.</li>
|
||||||
<li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li>
|
<li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -104,7 +110,7 @@ Summoning books raise allies that fight zombies, not you. A pet wanders on its o
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Zombie dog, bear, wolf and griffin</strong> — permanent companions, one of each kind at a time. A power attack recalls them into the book.</li>
|
<li><strong>Zombie dog, bear, wolf and griffin</strong> — permanent companions, one of each kind at a time. A power attack recalls them into the book.</li>
|
||||||
<li><strong>Beetles of the Lord</strong> — a one-shot scroll releasing a swarm. The beetles scatter over a wide radius and sting zombies; a stung zombie switches to your side just like with the Spirit Stone. The swarm cannot be recalled, and only one can be active.</li>
|
<li><strong>Pharaoh’s Swarm</strong> — a one-shot scroll releasing a swarm. They scatter over a wide radius and sting zombies; a stung zombie switches to your side just like with the Spirit Stone. The swarm cannot be recalled, and only one can be active.</li>
|
||||||
<li><strong>Banshee's Scroll</strong> — single use: it screams with a banshee's voice and raises a small horde next to you. These are <strong>not</strong> allies — they are as dangerous to you as any other zombies.</li>
|
<li><strong>Banshee's Scroll</strong> — single use: it screams with a banshee's voice and raises a small horde next to you. These are <strong>not</strong> allies — they are as dangerous to you as any other zombies.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -123,11 +129,12 @@ Activating it asks for confirmation, stops the game and unfolds a full-screen fi
|
|||||||
<ul>
|
<ul>
|
||||||
<li>The opening note also pauses the game and plays a short flashback.</li>
|
<li>The opening note also pauses the game and plays a short flashback.</li>
|
||||||
<li>Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the pick-up key, like a workbench.</li>
|
<li>Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the pick-up key, like a workbench.</li>
|
||||||
|
<li>Every trader looks different: black and white, half-transparent and matte. The necromancer deals with the dead, and the only people still trading are not quite alive.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Localization</h3>
|
<h3>Localization</h3>
|
||||||
|
|
||||||
<strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 123 strings are translated, with no empty cells.
|
<strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 149 strings are translated, with no empty cells.
|
||||||
|
|
||||||
<h3>Installation</h3>
|
<h3>Installation</h3>
|
||||||
|
|
||||||
@@ -138,6 +145,7 @@ Built for single-player. In multiplayer the vanilla pause does not apply, so the
|
|||||||
<h3>Links</h3>
|
<h3>Links</h3>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>Source and download: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
<li>Download on Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
|
||||||
<li>The author's YouTube channel: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
|
<li>Source: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
||||||
|
<li>The author's YouTube channel: <a href="https://www.youtube.com/@alex_cube" target="_blank" rel="noreferrer noopener">@alex_cube</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raw byte-level half of the Spatial Bracelet's vault persistence. Lives in this satellite
|
||||||
|
/// assembly for exactly the reason PyramidWardWriteHelper.cs documents: PooledBinaryWriter's
|
||||||
|
/// Write overload set cannot be resolved from the main project at all (CS7069), so anything
|
||||||
|
/// that actually touches a PooledBinaryWriter/PooledBinaryReader has to be compiled here,
|
||||||
|
/// against the game's own mscorlib.
|
||||||
|
///
|
||||||
|
/// The split is deliberately drawn so that ONLY primitives cross it: this file knows about
|
||||||
|
/// byte arrays and stream positions, nothing else. Bag/ItemStack serialization stays in the
|
||||||
|
/// main project, where `Bag.Write(BinaryWriter)` against netstandard's own BinaryWriter
|
||||||
|
/// already compiles fine (proven - that is how the vault blob is built). Keeping Bag out of
|
||||||
|
/// here also keeps UnityEngine out of here, which this project's reference setup (NoStdLib +
|
||||||
|
/// the game's mscorlib, no UnityEngine at all) cannot tolerate.
|
||||||
|
///
|
||||||
|
/// BLOB LAYOUT, appended after everything vanilla PlayerDataFile.Write produces:
|
||||||
|
///
|
||||||
|
/// int64 Magic "NECROVLT"
|
||||||
|
/// int32 payloadLength
|
||||||
|
/// byte[] payload (opaque here; the main project builds and parses it)
|
||||||
|
///
|
||||||
|
/// The magic plus the explicit length is what makes this safe to append to somebody else's
|
||||||
|
/// format. On read we remember the stream position first: if the magic is not there (an old
|
||||||
|
/// save written before this feature, or a player-data packet from a party that does not have
|
||||||
|
/// the mod) the position is put back exactly where it was and the caller is told "no vault" -
|
||||||
|
/// so whatever the game reads next still reads the right bytes. That matters concretely:
|
||||||
|
/// PlayerDataFile.ReadNetwork calls Read and then goes on to read PlayerMetaInfo from the
|
||||||
|
/// same stream, and PlayerDataFile.Load treats ANY exception out of Read as "file is broken,
|
||||||
|
/// roll back to the .bak". Neither may be disturbed, so nothing here throws.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultBlobIO
|
||||||
|
{
|
||||||
|
/// <summary>ASCII "NECROVLT" as one int64 - distinctive enough that stray bytes will not
|
||||||
|
/// be mistaken for our block.</summary>
|
||||||
|
public const long Magic = 0x4E4543524F564C54L;
|
||||||
|
|
||||||
|
/// <summary>Magic (8) + length (4).</summary>
|
||||||
|
public const int HeaderSize = 12;
|
||||||
|
|
||||||
|
public static void Write(PooledBinaryWriter _bw, byte[] _payload)
|
||||||
|
{
|
||||||
|
if (_bw == null || _payload == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_bw.Write(Magic);
|
||||||
|
_bw.Write(_payload.Length);
|
||||||
|
_bw.Write(_payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the payload, or null when this stream carries no vault block. Never
|
||||||
|
/// throws, and never leaves the stream anywhere the caller did not expect: either just
|
||||||
|
/// past our whole block, or exactly back where it started.</summary>
|
||||||
|
public static byte[] TryRead(PooledBinaryReader _br)
|
||||||
|
{
|
||||||
|
if (_br == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream stream = _br.BaseStream;
|
||||||
|
if (stream == null || !stream.CanSeek)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long startPosition = stream.Position;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stream.Length - startPosition < HeaderSize)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_br.ReadInt64() != Magic)
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = _br.ReadInt32();
|
||||||
|
if (length < 0 || stream.Length - stream.Position < length)
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _br.ReadBytes(length);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Reference in New Issue
Block a user