Книга некроманта 1.0 — первая публичная версия
Мод для 7 Days to Die 3.2: навык «Некромантия», растущий от счётчика убитых зомби, тёмное оружие с шестью собственными модами, призывная нежить, пирамида духов и сюжетный финал через Чёрный портал. Локализация на 13 языках. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaNro5hAGTzcQ7rJNN2tCX
@@ -0,0 +1,8 @@
|
|||||||
|
# Build intermediates - the shipped DLLs in the mod root are committed instead.
|
||||||
|
HarmonySrc/obj/
|
||||||
|
HarmonySrc/bin/
|
||||||
|
TEPersistenceSrc/obj/
|
||||||
|
TEPersistenceSrc/bin/
|
||||||
|
*.user
|
||||||
|
BACKLOG.md
|
||||||
|
FINAL_TEXT.md
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<windows>
|
||||||
|
<!-- Black portal video (BACKLOG.md, "чёрный портал" dialog/video feature, 2026-08-30 follow-up
|
||||||
|
bugfix). Root cause of "пауза встала, видео не вижу": XUiC_VideoPlayer.PlayVideo opens a
|
||||||
|
window/group named "videoPlayer" - real and fully functional (it's exactly what plays the
|
||||||
|
TFP studio intro and the main-menu background loop), but that window is declared ONLY in
|
||||||
|
Data/Config/XUi_Menu/windows.xml (the MAIN MENU's own UI layout) - it does not exist at
|
||||||
|
all in Data/Config/XUi_InGame/windows.xml (confirmed by grepping both files directly), so
|
||||||
|
GUIWindowManager.GetWindow/Open logged "Window \"videoPlayer\" unknown!" the moment the
|
||||||
|
in-game code tried to open it. This appends the EXACT SAME window block vanilla's own menu
|
||||||
|
already uses (copied verbatim from XUi_Menu/windows.xml, not reinvented) into the in-game
|
||||||
|
UI instead, so the exact same controller="VideoPlayer" (XUiC_VideoPlayer, already compiled
|
||||||
|
into the base game, no Harmony needed) and the <video> element (a generic engine-level UI
|
||||||
|
primitive, not menu-specific) can be found by the in-game windowManager too. Needs a
|
||||||
|
matching window_group entry - see Config/XUi_InGame/xui.xml.
|
||||||
|
|
||||||
|
THIS ONE WINDOW IS SHARED - both the Black Portal video AND Duke's note flashback
|
||||||
|
(NoteFlashbackPatch.cs) call the same XUiC_VideoPlayer.PlayVideo, which always opens this
|
||||||
|
same "videoPlayer" window/group by id - one fix here covers both.
|
||||||
|
|
||||||
|
"видео полупрозрачное" fix (user report 2026-08-30, after first successful in-game test):
|
||||||
|
added globalopacitymod="0" to videoBackground/videoTexture/skipPrompt below. Root cause,
|
||||||
|
decompiled, not guessed: every XUiV_ImageBased-derived view (sprite AND video both are -
|
||||||
|
XUiV_Sprite/XUiV_TextureBased both extend it) has a globalOpacityModifier defaulting to 1,
|
||||||
|
meaning by default its final alpha gets multiplied by xui.BackgroundGlobalOpacity (unless
|
||||||
|
foregroundlayer="true", which uses a separate ForegroundGlobalOpacity instead) - this is a
|
||||||
|
REAL, already-live game option, decompiled straight from EnumGamePrefs.Options:
|
||||||
|
OptionsBackgroundGlobalOpacity defaults to 0.95, not 1.0, and is user-adjustable (further
|
||||||
|
down is common - players lower it to see more of the game world through menu backgrounds).
|
||||||
|
This window's copy (like vanilla's own menu copy) never set globalopacitymod, so both the
|
||||||
|
"opaque" black background AND the video picture itself were always getting dimmed by
|
||||||
|
whatever fraction of full opacity the user's own Background Opacity option is set to -
|
||||||
|
in the menu this is invisible (nothing behind the menu to bleed through), but in-game the
|
||||||
|
actual 3D world sits right behind this window and shows through the gap. The vanilla menu
|
||||||
|
copy has this exact same latent issue, just never exposed because it's never used with a
|
||||||
|
3D scene behind it - not something to also go "fix" in Data/Config, just worth knowing this
|
||||||
|
mod's copy needed the explicit opt-out that context actually requires. -->
|
||||||
|
<append xpath="/windows">
|
||||||
|
<window name="videoPlayer" controller="VideoPlayer" pos="0,0" depth="900" cursor_area="true">
|
||||||
|
<sprite name="videoBackground" 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" style="press" gamepad_selectable="false" globalopacitymod="0" />
|
||||||
|
<video name="videoTexture" depth="1" anchor_left="#cam,0,0" anchor_right="#cam,1,0" anchor_bottom="#cam,0,0" anchor_top="#cam,1,0" globalopacitymod="0"/>
|
||||||
|
|
||||||
|
<sprite name="skipPrompt" depth="5" sprite="ui_game_panel_header" color="[black]" anchor_left="#cam,1,-200" anchor_right="#cam,1,0" anchor_bottom="#cam,0,-0" anchor_top="#cam,0,60" globalopacitymod="0">
|
||||||
|
<label name="lblSkip" depth="6" text_key="Skip" justify="left" pivot="left" font_size="32" width="180" height="100" pos="20,-30" parse_actions="true" actions_default_format="( ### )"/>
|
||||||
|
</sprite>
|
||||||
|
</window>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- ФИНАЛЬНЫЕ СЛАЙДЫ ЧЁРНОГО ПОРТАЛА, 2026-09-09 (BACKLOG.md "концовка серией диалоговых окон
|
||||||
|
вместо видео"). Шесть полноэкранных картинок, по одному окну на слайд; текст и кнопки
|
||||||
|
Назад/Дальше/выбор рисует поверх них ванильный messageBox - см. HarmonySrc/FinalSlides.cs,
|
||||||
|
там же вся логика листания.
|
||||||
|
|
||||||
|
ПОЧЕМУ ШЕСТЬ ОТДЕЛЬНЫХ ОКОН, А НЕ ОДНО С ПЕРЕКЛЮЧЕНИЕМ СПРАЙТА. Смена спрайта на лету
|
||||||
|
требует либо привязки ({binding}), либо своего XUiController - а в разведке (BACKLOG.md)
|
||||||
|
как раз осталось непроверенным, найдёт ли движок контроллер, объявленный в сборке мода.
|
||||||
|
Шесть окон с ЖЁСТКО прописанным sprite= обходят вопрос целиком: ни привязок, ни
|
||||||
|
контроллера, окно вообще без атрибута controller= (так объявлены и ванильные
|
||||||
|
HUDLeftStatBars/windowFuel, проверено в Data/Config/XUi_InGame/windows.xml). Листание -
|
||||||
|
это просто Close старого окна + Open нового из C#.
|
||||||
|
|
||||||
|
depth="900" - ниже messageBox (depth="1001" в шаблоне <messagebox>,
|
||||||
|
Data/Config/XUi_Common/templates.xml:143), так что текстовая коробка 1200x400 ложится
|
||||||
|
поверх картинки, а картинка видна вокруг неё. Открывать эти окна НЕ модально: в
|
||||||
|
GUIWindowManager.openInternal модальное открытие зовёт CloseAllOpenModalWindows(), то есть
|
||||||
|
модальный messageBox закрыл бы модальный слайд - а немодальный переживает это спокойно.
|
||||||
|
|
||||||
|
globalopacitymod="0" - та же правка, что у videoPlayer выше: без неё альфа умножается на
|
||||||
|
пользовательскую настройку Background Opacity (по умолчанию 0.95), и сквозь "финальную"
|
||||||
|
картинку просвечивал бы игровой мир.
|
||||||
|
|
||||||
|
ПРО ПРОПОРЦИИ: исходники 1536x1024 (3:2), а окно растягивается на весь экран (16:9). Чтобы
|
||||||
|
не плющить лица на крупных планах, картинки залиты чёрными полями по бокам до 1824x1024
|
||||||
|
(16:9) ЗАРАНЕЕ, при копировании в UIAtlases/NecroFinal - у XUiV_Sprite нет атрибута
|
||||||
|
сохранения пропорций (декомпилировано: он парсит только atlas/sprite/color/fill*/gradient*),
|
||||||
|
так что letterbox приходится запекать в сам PNG. Второй чёрный спрайт под картинкой -
|
||||||
|
страховка на неродных соотношениях сторон (ультраширокие мониторы).
|
||||||
|
|
||||||
|
Атлас NecroFinal - папка UIAtlases/NecroFinal, механизм тот же, что у ItemIconAtlas.
|
||||||
|
Шесть картинок 1824x1024 пакуются UIAtlasFromFolder.createUiAtlasFromTextures через
|
||||||
|
PackTextures(..., 2, 8192): суммарно ~11.2 Мпикс, влезает в один атлас 4096x4096. -->
|
||||||
|
<append xpath="/windows">
|
||||||
|
<window name="necroFinalSlide1" pos="0,0" depth="900">
|
||||||
|
<sprite name="slideLetterbox" 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="slideArt" depth="1" atlas="NecroFinal" sprite="NecroFinal1" type="simple" anchor_left="#cam,0,0" anchor_right="#cam,1,0" anchor_bottom="#cam,0,0" anchor_top="#cam,1,0" globalopacitymod="0"/>
|
||||||
|
</window>
|
||||||
|
<window name="necroFinalSlide2" pos="0,0" depth="900">
|
||||||
|
<sprite name="slideLetterbox" 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="slideArt" depth="1" atlas="NecroFinal" sprite="NecroFinal2" type="simple" anchor_left="#cam,0,0" anchor_right="#cam,1,0" anchor_bottom="#cam,0,0" anchor_top="#cam,1,0" globalopacitymod="0"/>
|
||||||
|
</window>
|
||||||
|
<window name="necroFinalSlide3" pos="0,0" depth="900">
|
||||||
|
<sprite name="slideLetterbox" 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="slideArt" depth="1" atlas="NecroFinal" sprite="NecroFinal3" type="simple" anchor_left="#cam,0,0" anchor_right="#cam,1,0" anchor_bottom="#cam,0,0" anchor_top="#cam,1,0" globalopacitymod="0"/>
|
||||||
|
</window>
|
||||||
|
<window name="necroFinalSlide4" pos="0,0" depth="900">
|
||||||
|
<sprite name="slideLetterbox" 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="slideArt" depth="1" atlas="NecroFinal" sprite="NecroFinal4" type="simple" anchor_left="#cam,0,0" anchor_right="#cam,1,0" anchor_bottom="#cam,0,0" anchor_top="#cam,1,0" globalopacitymod="0"/>
|
||||||
|
</window>
|
||||||
|
<window name="necroFinalSlide5" pos="0,0" depth="900">
|
||||||
|
<sprite name="slideLetterbox" 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="slideArt" depth="1" atlas="NecroFinal" sprite="NecroFinal5" type="simple" anchor_left="#cam,0,0" anchor_right="#cam,1,0" anchor_bottom="#cam,0,0" anchor_top="#cam,1,0" globalopacitymod="0"/>
|
||||||
|
</window>
|
||||||
|
<window name="necroFinalSlide6" pos="0,0" depth="900">
|
||||||
|
<sprite name="slideLetterbox" 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="slideArt" depth="1" atlas="NecroFinal" sprite="NecroFinal6" type="simple" anchor_left="#cam,0,0" anchor_right="#cam,1,0" anchor_bottom="#cam,0,0" anchor_top="#cam,1,0" globalopacitymod="0"/>
|
||||||
|
</window>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- ЭПИЛОГ, 2026-09-09. После выбора на шестом слайде картинки заканчиваются и текст идёт по
|
||||||
|
чёрному - отсюда отдельное окно без спрайта из атласа, только чёрная заливка. Тот же
|
||||||
|
depth="900", что у слайдов, так что текстовая коробка (depth 1001) снова ложится сверху.
|
||||||
|
Заливка копирует приём videoPlayer'а выше: menu_empty, растянутый по #cam с запасом в
|
||||||
|
10px по каждой стороне, плюс globalopacitymod="0" - без него сквозь "чёрный экран"
|
||||||
|
просвечивал бы игровой мир на величину пользовательской настройки Background Opacity. -->
|
||||||
|
<append xpath="/windows">
|
||||||
|
<window name="necroFinalBlack" pos="0,0" depth="900">
|
||||||
|
<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>
|
||||||
|
</append>
|
||||||
|
</windows>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<xui>
|
||||||
|
<!-- Pairs with Config/XUi_InGame/windows.xml's new "videoPlayer" window - see that file's
|
||||||
|
comment for the full root-cause explanation. Copied verbatim from
|
||||||
|
Data/Config/XUi_Menu/xui.xml's own "videoPlayer" window_group (no controller= on the
|
||||||
|
group itself there - the controller lives on the window element instead, same as this
|
||||||
|
mod's copy). Without this entry the window from windows.xml exists but has no group to be
|
||||||
|
opened by name through (XUiC_VideoPlayer.PlayVideo/GetInstance both resolve via the GROUP
|
||||||
|
id, not the raw window). -->
|
||||||
|
<append xpath="/xui">
|
||||||
|
<window_group name="videoPlayer">
|
||||||
|
<window name="videoPlayer" />
|
||||||
|
</window_group>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- Группы для шести финальных слайдов (см. XUi_InGame/windows.xml). Как и у videoPlayer,
|
||||||
|
контроллера на группе нет - окно чисто декоративное, открывается и закрывается по имени
|
||||||
|
из HarmonySrc/FinalSlides.cs. -->
|
||||||
|
<append xpath="/xui">
|
||||||
|
<window_group name="necroFinalSlide1">
|
||||||
|
<window name="necroFinalSlide1" />
|
||||||
|
</window_group>
|
||||||
|
<window_group name="necroFinalSlide2">
|
||||||
|
<window name="necroFinalSlide2" />
|
||||||
|
</window_group>
|
||||||
|
<window_group name="necroFinalSlide3">
|
||||||
|
<window name="necroFinalSlide3" />
|
||||||
|
</window_group>
|
||||||
|
<window_group name="necroFinalSlide4">
|
||||||
|
<window name="necroFinalSlide4" />
|
||||||
|
</window_group>
|
||||||
|
<window_group name="necroFinalSlide5">
|
||||||
|
<window name="necroFinalSlide5" />
|
||||||
|
</window_group>
|
||||||
|
<window_group name="necroFinalSlide6">
|
||||||
|
<window name="necroFinalSlide6" />
|
||||||
|
</window_group>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- Группа для чёрного экрана эпилога (см. windows.xml). -->
|
||||||
|
<append xpath="/xui">
|
||||||
|
<window_group name="necroFinalBlack">
|
||||||
|
<window name="necroFinalBlack" />
|
||||||
|
</window_group>
|
||||||
|
</append>
|
||||||
|
</xui>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<config>
|
||||||
|
<!-- "Пирамида Ереси" (Pyramid of Heresy) - user request 2026-08-31: a placeable ward that
|
||||||
|
periodically applies the existing Deviator charm (see HarmonySrc/PyramidWardPatch.cs,
|
||||||
|
reusing HarmonySrc/CharmPatch.cs's buffNecroDeviatorCharm wholesale - no new charm logic
|
||||||
|
was written, this just triggers the same buff on a timer) to any un-charmed zombie that
|
||||||
|
wanders into its radius, turning it against other zombies instead of the player/base - a
|
||||||
|
"barrier" in effect, not in physics: it never pushes or physically blocks anything, it
|
||||||
|
flips allegiance.
|
||||||
|
|
||||||
|
SHAPE: Shape="New" + Model="@:Shapes/pyramid.fbx" - the same standalone pyramid mesh
|
||||||
|
vanilla itself ships and uses for imposterPyramid's own LOD model (Data/Config/blocks.xml,
|
||||||
|
"*** blockNextGen imposters" section) - a real, solid, always-loaded asset, not invented
|
||||||
|
for this mod. Checked the "tentBiohazardRoofXxx" tent-roof pieces first (they're the
|
||||||
|
nearest thing to a "block with a pyramid top" already in vanilla) but rejected them - all
|
||||||
|
of them are 3x1x1/3x2x3 multi-blocks meant as building-sized tent roofs, wrong shape/scale
|
||||||
|
for a single placeable ward. Red per the user's request via TintColor="8A4142" - the exact
|
||||||
|
hex vanilla's own tentBiohazardRoofRed/tentBiohazardRoofCenterRed use for their red tint,
|
||||||
|
reused here rather than inventing a new one.
|
||||||
|
|
||||||
|
FIXED 2026-08-31 (user report: placed fine but genuinely invisible - "нету префаба"):
|
||||||
|
missing `Texture` property. EVERY vanilla block using this exact Shape="New" + a custom
|
||||||
|
standalone Model="@:Shapes/....fbx" (imposterPyramid itself, imposterQuarter,
|
||||||
|
imposterCTREighth, poiFillerBlock - checked all of them directly) always pairs it with an
|
||||||
|
explicit `Texture="N"` atlas index; ours never had one, which is the real, simple root
|
||||||
|
cause. Also switched Material from plain "Msteel" to "Msteel_shapes" + Texture="356" -
|
||||||
|
the exact Material/Texture pair vanilla's own `steelShapes` block (the real "shapes=All"
|
||||||
|
steel building family) uses for this same "New" shape renderer - plain "Msteel" is meant
|
||||||
|
for non-shape steel blocks (doors, hatches) and was never a matched pair with Shape="New"
|
||||||
|
to begin with. TintColor="8A4142" still multiplies on top as before.
|
||||||
|
|
||||||
|
User specifically asked to reuse the "Пирамида 1 м" ("Pyramid 1m") shape/prefab -
|
||||||
|
genuinely tried, but that specific shape turned out to be a NAMED VARIANT of the engine's
|
||||||
|
procedural multi-shape building system (`shapes="All"` blocks like `steelShapes`/
|
||||||
|
`concreteShapes` - see those in vanilla blocks.xml), not a standalone asset with its own
|
||||||
|
file path at all: decompiled `BlockShapeNew` (the C# class behind every Shape="New" block)
|
||||||
|
directly and it contains NO "Pyramid" string anywhere - the mapping from a shape's display
|
||||||
|
name to its actual geometry lives in compiled/binary shape-node data this decompile
|
||||||
|
couldn't reach, not in anything grep-able or copyable as a `Model=` path. Reproducing that
|
||||||
|
exact shape would mean reverse-engineering that binary shape data, real extra work with an
|
||||||
|
uncertain payoff - what shipped instead is the closest already-proven-working equivalent:
|
||||||
|
the same "New"-shape pyramid mesh `imposterPyramid` itself uses, now actually configured
|
||||||
|
correctly (see the fix above) so it renders for real. If the proportions look wrong next to
|
||||||
|
the in-game "Pyramid 1m" (e.g. too tall/full-block instead of a short 1m cap), that's a
|
||||||
|
scale tweak once it's visible, not a rendering bug - say so and it can be resized.
|
||||||
|
|
||||||
|
REWRITTEN 2026-09-01, Class changed from none to Class="CompositeTileEntity" +
|
||||||
|
CompositeFeatures/TEFeaturePyramidWard - two direct user findings drove this:
|
||||||
|
1. "Навожу прицел, но подсказка про E не появляется" - the original plain-Block +
|
||||||
|
Harmony-patches-on-Block approach never showed the E-prompt at all, because a
|
||||||
|
SEPARATE gate method (Block.HasBlockActivationCommands) was never patched and always
|
||||||
|
said "nothing to activate" for a plain decorative block.
|
||||||
|
2. "Сделай TileEntity" - direct request for the effect/zone toggle state to actually
|
||||||
|
survive a save/reload, which the old static-Dictionary-in-Harmony-patch version could
|
||||||
|
not do at all (documented as a known caveat before, now actually fixed).
|
||||||
|
Same syntax vanilla's own keystoneBlock (Land Claim) already uses for this exact
|
||||||
|
Class="CompositeTileEntity" + CompositeFeatures pattern - see HarmonySrc/
|
||||||
|
PyramidWardPatch.cs's own class doc comment on TEFeaturePyramidWard for the full decompiled
|
||||||
|
reasoning (why this is the sanctioned extension point, not a hardcoded-switch hack; the
|
||||||
|
E-menu/persistence/per-tick mechanics all live there now, not in this XML file). -->
|
||||||
|
<append xpath="/blocks">
|
||||||
|
<block name="necroHeresyPyramid">
|
||||||
|
<property name="CreativeMode" value="Player" />
|
||||||
|
<property name="DescriptionKey" value="necroHeresyPyramidDesc" />
|
||||||
|
<property name="Class" value="CompositeTileEntity" />
|
||||||
|
<property class="CompositeFeatures">
|
||||||
|
<property class="TEFeaturePyramidWard" />
|
||||||
|
</property>
|
||||||
|
<property name="Material" value="Msteel_shapes" />
|
||||||
|
<property name="Shape" value="New" />
|
||||||
|
<property name="Model" value="@:Shapes/pyramid.fbx" />
|
||||||
|
<property name="Texture" value="356" />
|
||||||
|
<property name="TintColor" value="8A4142" />
|
||||||
|
<!-- Real generated art (exch/pyramidOfSpirit.png, copied to
|
||||||
|
UIAtlases/ItemIconAtlas/) added 2026-09-02, per direct instruction - no
|
||||||
|
CustomIconTint alongside it (same lesson as every other real icon in this mod,
|
||||||
|
e.g. resourceZombieAsh: a real drawn icon already has its own correct color, tinting
|
||||||
|
it on top would recolor it unintentionally). TintColor above is unaffected - that's
|
||||||
|
the 3D block MESH's tint, a completely separate thing from the 2D inventory icon. -->
|
||||||
|
<property name="CustomIcon" value="pyramidOfSpirit" />
|
||||||
|
<property name="Group" value="Decoration" />
|
||||||
|
<property name="FilterTags" value="MC_building,SC_decor" />
|
||||||
|
<property name="Collide" value="movement,melee,bullet,arrow,rocket" />
|
||||||
|
<property name="StabilitySupport" value="false" />
|
||||||
|
<property name="MaxDamage" value="2000" />
|
||||||
|
<property name="EconomicValue" value="0" />
|
||||||
|
<property name="SellableToTrader" value="false" />
|
||||||
|
<drop event="Destroy" name="resourceScrapIron" count="10,20" />
|
||||||
|
</block>
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<config>
|
||||||
|
<!-- Step 1: lifetime zombie kill counter display.
|
||||||
|
buffStatusCheck01 is the vanilla hidden buff every player always has,
|
||||||
|
used here purely as a hook to (re)apply our display buff on game entry/respawn. -->
|
||||||
|
<append xpath="buffs/buff[@name='buffStatusCheck01']">
|
||||||
|
<effect_group name="necro zombie kill tracker">
|
||||||
|
<triggered_effect trigger="onSelfEnteredGame" action="AddBuff" buff="buffNecroZombieKillTrackerDisplay"/>
|
||||||
|
<triggered_effect trigger="onSelfRespawn" action="AddBuff" buff="buffNecroZombieKillTrackerDisplay"/>
|
||||||
|
</effect_group>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<append xpath="/buffs">
|
||||||
|
<!-- 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">
|
||||||
|
<stack_type value="ignore"/>
|
||||||
|
<duration value="0"/>
|
||||||
|
<update_rate value=".1"/>
|
||||||
|
<display_value value="necroZombieKillsCVar"/>
|
||||||
|
|
||||||
|
<!-- Нож некроманта (BACKLOG.md item 5, user request 2026-08-28): "урон умножается на
|
||||||
|
скилл некроманта ... и делится на 10". Computed here (this buff already ticks
|
||||||
|
10x/sec on every player via update_rate) rather than on the knife item itself,
|
||||||
|
since a held item has no update tick of its own - reading a live CVar from a
|
||||||
|
passive_effect (confirmed working: vanilla's own $PlayerLevelBonus, computed in
|
||||||
|
buffs.xml and read elsewhere via passive_effect value="@$PlayerLevelBonus") needs
|
||||||
|
something that keeps recomputing it. $necroKnifeDamageCVar = necroZombieKillsCVar
|
||||||
|
/ 10, read by necroWpnBladeNecroKnife's own EntityDamage passive_effect in
|
||||||
|
items.xml. $necroKnifeHealCVar = half of that, read by the same item's lifesteal
|
||||||
|
trigger. Both "@cvar" (write) and "@$cvar" (write) forms exist in vanilla data for
|
||||||
|
plain vs. "$"-prefixed cvars - $-prefix here just follows the naming convention
|
||||||
|
vanilla uses for these player-effect scratch values, not a different mechanism.
|
||||||
|
|
||||||
|
BUG FIXED 2026-08-28 (knife dealt no damage, no lifesteal, victim debuff never
|
||||||
|
applied): these four triggered_effect elements were direct children of <buff>,
|
||||||
|
with no <effect_group> wrapper - unlike buffNecroDeviatorCharm below or vanilla's
|
||||||
|
own buffStatusCheck01 (Data/Config/buffs.xml line ~334), which both wrap every
|
||||||
|
triggered_effect in one. triggered_effect isn't valid straight under <buff> (only
|
||||||
|
things like display_value are) - the loader didn't throw on this (no "buffs.xml
|
||||||
|
failed to load" the way loot.xml did for its own ordering bug), it just silently
|
||||||
|
never ran them, so $necroKnifeDamageCVar/$necroKnifeHealCVar never got computed
|
||||||
|
and the knife's damage passive_effect read an undefined (0) value the whole time. -->
|
||||||
|
<effect_group name="necro knife damage tracking">
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="ModifyCVar" cvar="$necroKnifeDamageCVar" operation="set" value="@necroZombieKillsCVar"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="ModifyCVar" cvar="$necroKnifeDamageCVar" operation="divide" value="10"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="ModifyCVar" cvar="$necroKnifeHealCVar" operation="set" value="@$necroKnifeDamageCVar"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="ModifyCVar" cvar="$necroKnifeHealCVar" operation="divide" value="2"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
|
||||||
|
<!-- "Жертва" (Victim) marker - applied by the Necromancer's Knife on hit (see
|
||||||
|
necroWpnBladeNecroKnife in items.xml). HarmonySrc/VictimPatch.cs reacts to its
|
||||||
|
presence at the moment the zombie actually dies (patches Entity.DropBagServer) to
|
||||||
|
force-spawn the guaranteed green "Жертва" bag instead of the normal loot roll -
|
||||||
|
nothing in XML alone can make Entity.DropBagServer's LootDropEntityClass choice
|
||||||
|
conditional on a live buff, confirmed by decompiling it (that property is a static
|
||||||
|
per-species entity_class value, not CVar-driven like EntityDamage above).
|
||||||
|
|
||||||
|
BUG FIXED 2026-08-28 (buff kept vanishing before death-loot checked it, despite being
|
||||||
|
permanent - duration="0" confirmed via decompiling BuffClass to genuinely mean "never
|
||||||
|
expires", not the bug): missing remove_on_death="false". EntityAlive.ClientKill()
|
||||||
|
calls Buffs.OnDeath() BEFORE OnEntityDeath()/dropItemOnDeath() ever run (confirmed by
|
||||||
|
decompiling both) - EntityBuffs.OnDeath() strips any buff whose BuffClass.RemoveOnDeath
|
||||||
|
is true, which is the default when remove_on_death isn't specified at all. So the buff
|
||||||
|
really was being added, really was permanent under normal circumstances, and really
|
||||||
|
was still getting wiped out from under VictimPatch.cs's HasBuff check by the death
|
||||||
|
sequence itself, one step before that check ever ran. remove_on_death="false" is a
|
||||||
|
real, documented buff attribute - vanilla's own buffStatusCheck01 uses it for exactly
|
||||||
|
this "must survive death" reason.
|
||||||
|
|
||||||
|
Visual (user request 2026-08-28, "подсвети бафнутого зомби... фиолетовым"): same
|
||||||
|
RadiatedParticlesOnMesh glow as buffNecroDeviatorCharm below, reused rather than a
|
||||||
|
different particle - HarmonySrc/ParticlePatch.cs recolors it purple specifically for
|
||||||
|
THIS buff (checks which buff is present before deciding tint), since XML's
|
||||||
|
AttachParticleEffectToEntity has no color attribute at all (confirmed by decompiling
|
||||||
|
it - same reason the charm's own glow can't be XML-recolored either). Removed on
|
||||||
|
death too (onSelfDied) even though the buff FLAG itself survives death on purpose
|
||||||
|
(remove_on_death="false" above) - the glow is cosmetic and shouldn't linger on a
|
||||||
|
corpse; VictimPatch.cs's own HasBuff check doesn't care whether the particle is gone. -->
|
||||||
|
<buff name="buffNecroVictim" hidden="true" remove_on_death="false">
|
||||||
|
<stack_type value="ignore"/>
|
||||||
|
<duration value="0"/>
|
||||||
|
<effect_group>
|
||||||
|
<triggered_effect trigger="onSelfBuffStart" action="AttachParticleEffectToEntity" particle="RadiatedParticlesOnMesh" local_offset="0,0.75,0"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffRemove" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||||
|
<triggered_effect trigger="onSelfDied" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
|
||||||
|
<!-- "Девиатор" charm marker. Purely a flag for HarmonySrc/CharmPatch.cs to react to -
|
||||||
|
the actual "fight for the player now" AI behavior happens in that Harmony patch, not
|
||||||
|
here. hidden/no duration: permanent for the charmed zombie's remaining lifetime.
|
||||||
|
The visible cue is the particle below - reuses the same mesh-hugging glow vanilla
|
||||||
|
radiated zombies wear (ParticleEffects/RadiatedParticlesOnMesh.prefab), the closest
|
||||||
|
thing to an actual recolor achievable from XML alone (no way to retint a live
|
||||||
|
entity's skin material via buff - AttachParticleEffectToEntity is as close as it gets).
|
||||||
|
It reads green/energy rather than "necromantic purple", but it's a real full-body
|
||||||
|
glow, not just a floating cloud; RemoveParticleEffectFromEntity on death/removal so it
|
||||||
|
doesn't linger on the corpse. -->
|
||||||
|
<buff name="buffNecroDeviatorCharm" hidden="true">
|
||||||
|
<stack_type value="ignore"/>
|
||||||
|
<duration value="0"/>
|
||||||
|
<effect_group>
|
||||||
|
<triggered_effect trigger="onSelfBuffStart" action="AttachParticleEffectToEntity" particle="RadiatedParticlesOnMesh" local_offset="0,0.75,0"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffRemove" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||||
|
<triggered_effect trigger="onSelfDied" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
|
||||||
|
<!-- "Портальный вихрь" - BACKLOG.md item 6, user request 2026-08-29 ("пусть вокруг
|
||||||
|
персонажа летают частицы, лучше чёрные, как дым"). Purely a marker/particle-carrier
|
||||||
|
buff, same shape as buffNecroVictim/buffNecroDeviatorCharm above - NOT added/removed
|
||||||
|
by any trigger here at all (no onSelf* triggers reference it), because there's
|
||||||
|
nothing in the buff-trigger vocabulary that means "for the duration of an open
|
||||||
|
XUiC_Timer window" - HarmonySrc/PortalStonePatch.cs adds it directly
|
||||||
|
(player.Buffs.AddBuff) the instant the 10s channel opens and removes it
|
||||||
|
(player.Buffs.RemoveBuff) the instant the channel ends, however it ends (completed,
|
||||||
|
cancelled by damage, cancelled by power attack) - see that file.
|
||||||
|
|
||||||
|
Reuses the same RadiatedParticlesOnMesh glow the two zombie-facing buffs above
|
||||||
|
already use (same reasoning: no XML color attribute exists on
|
||||||
|
AttachParticleEffectToEntity, HarmonySrc/ParticlePatch.cs has to recolor it in code
|
||||||
|
regardless of which buff triggers it) - generalized ParticlePatch.cs to handle
|
||||||
|
EntityAlive broadly instead of EntityZombie-only, since this buff's target is the
|
||||||
|
PLAYER, not a zombie. Tinted near-black/smoke grey, per the user's own request. -->
|
||||||
|
<buff name="buffNecroPortalChannel" hidden="true">
|
||||||
|
<stack_type value="ignore"/>
|
||||||
|
<duration value="0"/>
|
||||||
|
<effect_group>
|
||||||
|
<triggered_effect trigger="onSelfBuffStart" action="AttachParticleEffectToEntity" particle="RadiatedParticlesOnMesh" local_offset="0,0.9,0"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffRemove" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||||
|
<triggered_effect trigger="onSelfDied" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
<!-- "Тёмное чутьё" - постоянный радар зомби, пока Нож некроманта в руке и в нём стоит
|
||||||
|
модификатор necroModKnifeDarkSense. Продиктовано 2026-09-07: "В квестах на зачистку на
|
||||||
|
радаре показывает где находятся зомби. Нужен мод который включит подобный радар на
|
||||||
|
постоянку, пока мод в ноже, а нож в руке."
|
||||||
|
|
||||||
|
Да, реально, и целиком на XML - Harmony не нужен. Ключ в том, что метки на компасе и
|
||||||
|
карте ставятся через NavObject, а для них есть готовое XML-действие
|
||||||
|
MinEventActionSetNavObject (ваниль зовёт его в buffs.xml ~15281 для твич-эффектов):
|
||||||
|
entityAlive.AddNavObject(navObjectName, overrideSprite, overrideText)
|
||||||
|
и оно наследует MinEventActionTargetedBase, то есть умеет target="selfAOE" с range и
|
||||||
|
target_tags - ровно то, что нужно для "все зомби вокруг игрока".
|
||||||
|
|
||||||
|
ПОЧЕМУ nav_object="zombie", А НЕ "clear_sleeper". Квест на зачистку использует
|
||||||
|
clear_sleeper, но у того класса стоит <property name="requirement_type"
|
||||||
|
value="QuestBounds"/> - он показывается только внутри границ активного квеста, то есть
|
||||||
|
вне квеста не работал бы вообще. Класс "zombie" (nav_objects.xml ~431) - без такого
|
||||||
|
требования: красная пульсирующая иконка ui_game_symbol_zombie и на карте, и на компасе
|
||||||
|
(max_distance компаса 1024).
|
||||||
|
|
||||||
|
ПОЧЕМУ БАФФ, А НЕ ПРЯМО В МОДЕ. У модификатора нет своего тика. onSelfEquipUpdate
|
||||||
|
существует (Inventory.OnUpdate шлёт его на держимый предмет), но он идёт каждый
|
||||||
|
апдейт, а тут на каждом тике делается пространственная выборка сущностей - слишком
|
||||||
|
часто. Бафф даёт честный троттлинг через update_rate, поэтому мод только вешает и
|
||||||
|
снимает этот бафф по onSelfEquipStart/onSelfEquipStop (ванильный паттерн, так сделаны
|
||||||
|
эффекты кирок в items.xml ~158/175).
|
||||||
|
|
||||||
|
ПОЧЕМУ КАЖДЫЙ ТИК СНАЧАЛА СНИМАЕТ, ПОТОМ ВЕШАЕТ. Метка живёт на самой сущности
|
||||||
|
(RegisterNavObject(name, this) - она ездит за зомби), и сама собой не пропадает, когда
|
||||||
|
зомби отошёл. NavObjectManager.Update чистит только те, у которых TrackedEntity стал
|
||||||
|
null, то есть уже уничтоженный объект - мёртвый, но ещё не исчезнувший труп метку
|
||||||
|
сохраняет. Поэтому радиус снятия (60) заведомо больше радиуса добавления
|
||||||
|
(30): всё, что вышло из зоны показа, но ещё рядом, чистится само.
|
||||||
|
AddNavObjectClass дедуплицирует (NavObject.cs ~346: "if (!NavObjectClassList.Contains)"),
|
||||||
|
так что повторное добавление одному и тому же зомби безвредно.
|
||||||
|
|
||||||
|
ИЗВЕСТНЫЙ РИСК, ПРОВЕРИТЬ В ИГРЕ: снятие-и-добавление в одном тике для зомби, который
|
||||||
|
остаётся в зоне, технически пересоздаёт NavObject (RemoveNavObjectClass опустошает
|
||||||
|
список -> UnRegisterNavObject -> следующий Add регистрирует заново). Итоговое
|
||||||
|
состояние кадра верное, но пульсация иконки может подёргиваться раз в секунду. Если
|
||||||
|
будет заметно - убрать строку снятия из onSelfBuffUpdate и оставить чистку только на
|
||||||
|
onSelfBuffRemove, ценой залипших меток на ушедших зомби. -->
|
||||||
|
<buff name="buffNecroDarkSense" name_key="buffNecroDarkSenseName" description_key="buffNecroDarkSenseDesc" icon="ui_game_symbol_zombie" icon_color="150,0,255">
|
||||||
|
<stack_type value="replace"/>
|
||||||
|
<duration value="0"/>
|
||||||
|
<update_rate value="1"/>
|
||||||
|
|
||||||
|
<effect_group name="necro dark sense radar">
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="SetNavObject" target="selfAOE" range="60" target_tags="zombie" nav_object="zombie" add="false"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="SetNavObject" target="selfAOE" range="30" target_tags="zombie" nav_object="zombie" add="true"/>
|
||||||
|
<triggered_effect trigger="onSelfBuffRemove" action="SetNavObject" target="selfAOE" range="60" target_tags="zombie" nav_object="zombie" add="false"/>
|
||||||
|
</effect_group>
|
||||||
|
</buff>
|
||||||
|
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<config>
|
||||||
|
<!-- Step 1: lifetime zombie kill counter.
|
||||||
|
zombieTemplateMale is the root template every zombie entity_class extends
|
||||||
|
(directly, or indirectly via zombieTemplateShort), so patching it here covers
|
||||||
|
every zombie variant in the game without listing them individually. -->
|
||||||
|
<append xpath="/entity_classes/entity_class[@name='zombieTemplateMale']">
|
||||||
|
<effect_group>
|
||||||
|
<requirement name="EntityTagCompare" target="other" tags="player"/>
|
||||||
|
<triggered_effect trigger="onOtherKilledSelf" action="ModifyCVar" target="other" cvar="necroZombieKillsCVar" operation="add" value="1"/>
|
||||||
|
<!-- "Некромантия" skill: +1 level per zombie kill, capped by its own max_level.
|
||||||
|
See progression.xml for why this drives the skill instead of reading books. -->
|
||||||
|
<triggered_effect trigger="onOtherKilledSelf" action="AddProgressionLevel" target="other" progression_name="craftingNecroNecromancy" level="1"/>
|
||||||
|
</effect_group>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Зомбособака" (Zombie Dog pet): BACKLOG.md item 3. Extends the vanilla hostile
|
||||||
|
animalZombieDog (same prefab/physics/sounds - a real zombie dog model, not a reskinned
|
||||||
|
wolf) but flips it to fight FOR the player instead of against them:
|
||||||
|
|
||||||
|
- EntityFlags drops "zombie": EntityAlive.DamageEntity has a hardcoded rule that two
|
||||||
|
entities BOTH flagged EntityFlags.Zombie can never damage each other (see
|
||||||
|
HarmonySrc/DamagePatch.cs for the same rule affecting charmed zombies) - keeping our
|
||||||
|
pet flagged "zombie" would make it literally unable to hurt real zombies. Dropping the
|
||||||
|
flag sidesteps that block entirely; no Harmony damage patch needed here, unlike the
|
||||||
|
charmed-zombie case (which can't drop the flag without breaking its own zombie-side
|
||||||
|
kill-counter/quest logic - our pet has none of that to worry about).
|
||||||
|
- Tags drops "zombie"/"hostile" (kept "entity,animal,dog"), IsEnemyEntity="false" - same
|
||||||
|
shape vanilla's own player-owned entityJunkDrone uses for "friendly to the player".
|
||||||
|
- AITask-3/AITarget-1/AITarget-4 retarget from EntityPlayer/EntityBandit to EntityZombie -
|
||||||
|
purely data-driven, same AI task types animalZombieDog already used, just pointed at a
|
||||||
|
different class. This is also exactly how HarmonySrc/CharmPatch.cs flips a charmed
|
||||||
|
zombie's allegiance at runtime - confirmed here that the underlying AI system doesn't
|
||||||
|
care whether the retarget happens via live Harmony rewrite or via XML at spawn time.
|
||||||
|
|
||||||
|
Spawned by HarmonySrc/SummonPatch.cs via item bookSummonZombieDog (items.xml) - see that
|
||||||
|
file for the ownership/one-pet-limit logic (mirrors vanilla's own drone: EntityDrone.
|
||||||
|
isValidForPlayer() blocks summoning a second one while you already own one, rather than
|
||||||
|
replacing the old one - SummonPatch.cs does the same check via ownedEntities). -->
|
||||||
|
<append xpath="/entity_classes">
|
||||||
|
<entity_class name="necroZombieDog" extends="animalZombieDog">
|
||||||
|
<property name="EntityFlags" value="animal"/>
|
||||||
|
<property name="Tags" value="entity,animal,dog"/>
|
||||||
|
<property name="IsEnemyEntity" value="false"/>
|
||||||
|
<property name="Faction" value="none"/>
|
||||||
|
<!-- User request 2026-08-28: bite applies buffInjurySlow, same debuff the Insect Swarm
|
||||||
|
originally had before its own rework - see necroMeleeHandZombieDog in items.xml. -->
|
||||||
|
<property name="HandItem" value="necroMeleeHandZombieDog"/>
|
||||||
|
|
||||||
|
<property name="AITask-3" value="ApproachAndAttackTarget" data="class=EntityZombie,20"/>
|
||||||
|
<property name="AITarget-1" value="SetAsTargetIfHurt" data="class=EntityZombie"/>
|
||||||
|
<property name="AITarget-4" value="SetNearestEntityAsTarget" data="class=EntityZombie,22,20"/>
|
||||||
|
</entity_class>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Жуки Властелина" (insect swarm pet): user request 2026-08-28, alongside the Zombie Dog
|
||||||
|
bugfix (see items.xml). Extends vanilla's own animalInsectSwarm (same prefab/sounds/
|
||||||
|
tanky-vs-melee stats - PhysicalDamageResist 75% vs non-ranged inherits unchanged, a nice
|
||||||
|
free defensive fit for something meant to be swarmed by zombie melee).
|
||||||
|
|
||||||
|
BUG FIXED 2026-08-28 (swarm attacked the PLAYER, not zombies): the original version here
|
||||||
|
overrode AITask-1/AITarget-1, mirroring animalInsectSwarm's own choice to blank those two
|
||||||
|
indices - but animalInsectSwarm extends animalTemplateHostile, and re-reading THAT
|
||||||
|
template (Data/Config/entityclasses.xml) shows the actual player-attacking task/target
|
||||||
|
live at DIFFERENT indices: AITask-2 ("ApproachAndAttackTarget" class=EntityPlayer,...) and
|
||||||
|
AITarget-4 ("SetNearestEntityAsTarget" class=EntityPlayer,...). animalInsectSwarm's
|
||||||
|
AITask-1/AITarget-1 blanking only wipes animalTemplateHostile's harmless AITask-1
|
||||||
|
(BreakBlock) and AITarget-1 (SetAsTargetIfHurt, generic) - the real player-hunting
|
||||||
|
behavior at index 2/4 was never touched by that blanking, and my override at index 1
|
||||||
|
just sat next to it as a no-op extra task while the REAL one kept targeting players.
|
||||||
|
Fixed by overriding the actual indices (2 and 4) instead. AITask-1/AITarget-1 are left
|
||||||
|
alone now - they inherit animalInsectSwarm's own blank override, same as vanilla.
|
||||||
|
|
||||||
|
BEHAVIOR CHANGE 2026-08-28 (user request): no longer deals real damage + slows zombies -
|
||||||
|
it stings a zombie once and that zombie becomes a Deviator-charmed ally, same buff as the
|
||||||
|
Spirit Stone/Grimoire (buffNecroDeviatorCharm - see necroMeleeHandInsectSwarm in
|
||||||
|
items.xml). Search range widened (22/20 -> 40/30) per "ищут всех зомби в радиусе", though
|
||||||
|
that number turned out not to matter much either - see below.
|
||||||
|
|
||||||
|
TARGETING IS NOT ACTUALLY DRIVEN BY THESE AITask/AITarget PROPERTIES AT ALL, despite the
|
||||||
|
fix above (found out the hard way 2026-08-28, after that fix didn't stop the swarm from
|
||||||
|
attacking the player): animalInsectSwarm's own Class="EntitySwarm" extends EntityVulture,
|
||||||
|
which has entirely hardcoded C# target-finding (World.GetClosestPlayerSeen/GetClosestPlayer,
|
||||||
|
literally typed to EntityPlayer) - it never consults the declarative AITask/AITarget system
|
||||||
|
ground creatures like the Dog use. The AITask-2/AITarget-4 overrides above are harmless but
|
||||||
|
functionally dead for this entity_class. The real fix is HarmonySrc/SwarmTargetPatch.cs,
|
||||||
|
patching EntityAlive.SetAttackTarget to redirect player-targeting to the nearest zombie -
|
||||||
|
see that file. It also skips zombies that already carry buffNecroDeviatorCharm (added after
|
||||||
|
a second user report that the swarm would fly off in wide loops instead of moving straight
|
||||||
|
to an uncharmed zombie standing right next to the one it had just charmed - re-targeting
|
||||||
|
the same just-charmed zombie over and over, because it was still the nearest one, was
|
||||||
|
exactly what caused that).
|
||||||
|
|
||||||
|
EntityFlags/EntityType: parent sets EntityFlags="animal,zombie" and EntityType="Zombie" -
|
||||||
|
same DamageEntity zombie-vs-zombie block as the dog (see that entry above and
|
||||||
|
HarmonySrc/DamagePatch.cs) would otherwise stop it stinging real zombies at all, so both
|
||||||
|
get dropped to plain "animal". -->
|
||||||
|
<append xpath="/entity_classes">
|
||||||
|
<entity_class name="necroInsectSwarm" extends="animalInsectSwarm">
|
||||||
|
<property name="EntityFlags" value="animal"/>
|
||||||
|
<property name="EntityType" value="Animal"/>
|
||||||
|
<property name="Tags" value="entity,noHead"/>
|
||||||
|
<property name="IsEnemyEntity" value="false"/>
|
||||||
|
<property name="Faction" value="none"/>
|
||||||
|
<property name="HandItem" value="necroMeleeHandInsectSwarm"/>
|
||||||
|
|
||||||
|
<property name="AITask-2" value="ApproachAndAttackTarget" data="class=EntityZombie,20"/>
|
||||||
|
<property name="AITarget-4" value="SetNearestEntityAsTarget" data="class=EntityZombie,40,30"/>
|
||||||
|
</entity_class>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- Three more summon-pet zombies, BACKLOG.md item 4a. REPLACED AGAIN 2026-08-29, THIRD
|
||||||
|
APPROACH, per the user's own direct suggestion after the second approach's Bear/Wolf
|
||||||
|
fixes still didn't work ("почему нельзя взять зомбособаку, но добавить её ХП и силы
|
||||||
|
атаки, и заменить ей модельку на медведя?"). Correct call - stop re-deriving AI/targeting
|
||||||
|
data from each different vanilla template's own quirky format (full pipe-string vs
|
||||||
|
indexed properties, different index numbering, etc.) and instead extend the Dog's OWN
|
||||||
|
entity_class (necroZombieDog, defined above) directly. necroZombieDog already has
|
||||||
|
EntityFlags/Tags/IsEnemyEntity/Faction AND the AITask-3/AITarget-1/AITarget-4 zombie-
|
||||||
|
targeting overrides all confirmed working correctly in-game across multiple test rounds -
|
||||||
|
none of that has to be re-verified or re-derived per creature any more, it's inherited
|
||||||
|
unchanged. Bear/Wolf below only override what's actually cosmetic/stat-related: Prefab
|
||||||
|
(swap the visible model), PhysicsBody/Mass (match the new model's real size so
|
||||||
|
collision/knockback look right), HandItem (reuse the REAL vanilla hand-item for that
|
||||||
|
creature - meleeHandAnimalZombieBear/meleeHandAnimalDireWolf, both real items with their
|
||||||
|
own already-tuned damage - 60 each, vs. the Dog's own 8), and HealthMax (bumped up to
|
||||||
|
match, see each entity's own comment for the exact vanilla reference number used).
|
||||||
|
|
||||||
|
Griffin NOT converted this way (left on the previous animalZombieVulture-based approach,
|
||||||
|
unchanged) - flagged as a real, separate risk, not just left out by oversight: Bear/Wolf
|
||||||
|
share the Dog's own quadruped body plan/animation rig, so swapping just the Prefab is
|
||||||
|
low-risk (same skeleton shape, same generic AvatarAnimalController). A vulture is a
|
||||||
|
bird - almost certainly a structurally different skeleton/rig - forcing that Prefab onto
|
||||||
|
necroZombieDog's own ground-quadruped C# class (EntityZombieDog, no flight code at all)
|
||||||
|
risks either a broken/distorted-looking model (mismatched bone names) or, best case, a
|
||||||
|
"walking bird" that never actually flies. Worth a real decision, not a silent guess -
|
||||||
|
see BACKLOG.md for the open question. -->
|
||||||
|
<append xpath="/entity_classes">
|
||||||
|
<entity_class name="necroZombieBear" extends="necroZombieDog">
|
||||||
|
<!-- Real vanilla animalZombieBear's own Prefab/PhysicsBody/Mass (the same asset a
|
||||||
|
genuine zombie bear uses) - not re-deriving AI from it any more, just borrowing
|
||||||
|
its look and physical size so collision/knockback scale correctly for a
|
||||||
|
bear-sized model sitting on the Dog's skeleton/rig. -->
|
||||||
|
<property name="Prefab" value="@:Entities/Animals/Bear/animalBearZombiePrefab.prefab"/>
|
||||||
|
<property name="PrefabCombined" value="true"/>
|
||||||
|
<property name="PhysicsBody" value="bear"/>
|
||||||
|
<property name="Mass" value="600"/>
|
||||||
|
<property name="Tags" value="entity,animal,bear"/>
|
||||||
|
<!-- FIXED 2026-08-30 (user report: "зомбомедведь лает" - swapping the Prefab only
|
||||||
|
changes the visible model, sound properties are a totally separate set of
|
||||||
|
properties and stayed inherited from necroZombieDog/animalZombieDog (dog barks)
|
||||||
|
the whole time - same oversight needed fixing on Wolf/Griffin below too, not
|
||||||
|
just this one. Real animalBear's own sound set (animalZombieBear itself doesn't
|
||||||
|
override any of these, confirmed by reading it directly - it inherits animalBear's). -->
|
||||||
|
<property name="SoundRandom" value="bearroam"/>
|
||||||
|
<property name="SoundAlert" value="bearalert"/>
|
||||||
|
<property name="SoundHurt" value="bearpain"/>
|
||||||
|
<property name="SoundDeath" value="beardeath"/>
|
||||||
|
<property name="SoundAttack" value="bearattack"/>
|
||||||
|
<property name="SoundSense" value="bearsense"/>
|
||||||
|
<property name="SoundGiveUp" value="beargiveup"/>
|
||||||
|
<property name="SoundStepType" value="animalhvystep"/>
|
||||||
|
<!-- 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). -->
|
||||||
|
<property name="HandItem" value="meleeHandAnimalZombieBear"/>
|
||||||
|
<effect_group name="Base Effects">
|
||||||
|
<!-- 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. -->
|
||||||
|
<passive_effect name="HealthMax" operation="base_set" value="1500"/>
|
||||||
|
</effect_group>
|
||||||
|
</entity_class>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<append xpath="/entity_classes">
|
||||||
|
<entity_class name="necroZombieWolf" extends="necroZombieDog">
|
||||||
|
<!-- Real vanilla animalDireWolf's own Prefab/PhysicsBody/Mass/SizeScale. -->
|
||||||
|
<property name="Prefab" value="@:Entities/Animals/DireWolf/animalDireWolfPrefab.prefab"/>
|
||||||
|
<property name="PrefabCombined" value="true"/>
|
||||||
|
<property name="PhysicsBody" value="AWolf"/>
|
||||||
|
<property name="Mass" value="180"/>
|
||||||
|
<property name="SizeScale" value="1.4"/>
|
||||||
|
<property name="Tags" value="entity,animal,wolf"/>
|
||||||
|
<!-- FIXED 2026-08-30, same oversight as the Bear above (see its comment) - real
|
||||||
|
animalDireWolf's own sound set. -->
|
||||||
|
<property name="SoundRandom" value="wolfdireroam"/>
|
||||||
|
<property name="SoundAlert" value="wolfdirealert"/>
|
||||||
|
<property name="SoundHurt" value="wolfdirepain"/>
|
||||||
|
<property name="SoundDeath" value="wolfdiredeath"/>
|
||||||
|
<property name="SoundAttack" value="wolfdireattack"/>
|
||||||
|
<property name="SoundSense" value="wolfdiresense"/>
|
||||||
|
<property name="SoundGiveUp" value="wolfdiregiveup"/>
|
||||||
|
<property name="SoundStepType" value="animalpawstep"/>
|
||||||
|
<!-- Real vanilla hand item (bite damage 60, vs. the Dog's own 8). -->
|
||||||
|
<property name="HandItem" value="meleeHandAnimalDireWolf"/>
|
||||||
|
<effect_group name="Base Effects">
|
||||||
|
<!-- 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. -->
|
||||||
|
<passive_effect name="HealthMax" operation="base_set" value="1200"/>
|
||||||
|
</effect_group>
|
||||||
|
</entity_class>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- Griffin CONVERTED 2026-08-29 to the same Dog-reskin trick as Bear/Wolf, after the user
|
||||||
|
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
|
||||||
|
for reusing necroZombieDog's own quadruped rig (see the removed comment this replaces) -
|
||||||
|
picked animalMountainLion as the substitute model instead of a bird: it EXTENDS
|
||||||
|
animalWolf (Data/Config/entityclasses.xml, confirmed by reading it directly) - the SAME
|
||||||
|
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
|
||||||
|
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. -->
|
||||||
|
<append xpath="/entity_classes">
|
||||||
|
<entity_class name="necroZombieGriffin" extends="necroZombieDog">
|
||||||
|
<!-- Real vanilla animalMountainLion's own Prefab/PhysicsBody/Mass/SizeScale. -->
|
||||||
|
<property name="Prefab" value="@:Entities/Animals/Cat/animalMountainLion.prefab"/>
|
||||||
|
<property name="PrefabCombined" value="true"/>
|
||||||
|
<property name="PhysicsBody" value="MountainLion"/>
|
||||||
|
<property name="Mass" value="125"/>
|
||||||
|
<property name="SizeScale" value="1"/>
|
||||||
|
<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">
|
||||||
|
<!-- Real vanilla animalMountainLion's own HealthMax (750) - used as-is, not
|
||||||
|
scaled further, since this pet is meant to be the "fast/agile" one, not the
|
||||||
|
tankiest of the three. -->
|
||||||
|
<passive_effect name="HealthMax" operation="base_set" value="750"/>
|
||||||
|
</effect_group>
|
||||||
|
</entity_class>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Жертва" loot bag - BACKLOG.md item 5, Necromancer's Knife. Same shape as vanilla's own
|
||||||
|
EntityLootContainerStrong ("BLUE")/EntityLootContainerBoss ("RED") right above these in
|
||||||
|
Data/Config/entityclasses.xml - only the Mesh and LootList differ.
|
||||||
|
|
||||||
|
BUG FIXED 2026-08-28 ("зомби погиб с бафом, гарантированный дроп запустился, но лута
|
||||||
|
нет"): Mesh was a guess, zpackGreenPrefab.prefab - confirmed by the in-game test itself
|
||||||
|
that it doesn't exist as a real asset. VictimPatch.cs's own warning fired: "created entity
|
||||||
|
for 'EntityLootContainerVictim' wasn't an EntityLootContainer" - EntityFactory.CreateEntity
|
||||||
|
didn't produce a usable entity at all once its mesh failed to resolve, so nothing ever
|
||||||
|
got spawned, guaranteed roll or not. Reverted to zpackPrefab.prefab (the default/yellow
|
||||||
|
mesh, confirmed to exist - every other loot container variant in vanilla config either
|
||||||
|
uses this one directly or one of the three OTHER confirmed variants, blue/red/gold) per
|
||||||
|
the fallback plan agreed on before this was ever guessed at - not visually distinct from a
|
||||||
|
normal zombie drop, but a real, working bag with the right guaranteed contents.
|
||||||
|
Force-spawned by HarmonySrc/VictimPatch.cs when a zombie with buffNecroVictim dies -
|
||||||
|
never rolled by the normal LootDropProb/LootDropEntityClass system (not referenced from
|
||||||
|
any zombie's own entity_class), so its own IsEnemyEntity/Faction/etc. just mirror the
|
||||||
|
other loot containers for consistency, not because anything reads them via that path. -->
|
||||||
|
<append xpath="/entity_classes">
|
||||||
|
<entity_class name="EntityLootContainerVictim">
|
||||||
|
<property name="Class" value="EntityLootContainer"/>
|
||||||
|
<property name="UserSpawnType" value="Console"/>
|
||||||
|
<property name="Mesh" value="@:Entities/LootContainers/zpackPrefab.prefab"/>
|
||||||
|
<property name="ModelType" value="Custom"/>
|
||||||
|
<property name="Prefab" value="Backpack"/>
|
||||||
|
<property name="Parent" value="Backpack"/>
|
||||||
|
<property name="IsEnemyEntity" value="false"/>
|
||||||
|
<property name="TimeStayAfterDeath" value="3600"/>
|
||||||
|
<property name="LootList" value="zPackVictim"/>
|
||||||
|
<property name="Faction" value="none"/>
|
||||||
|
</entity_class>
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<config>
|
||||||
|
<!-- Модификации Ножа некроманта. Продиктовано 2026-09-07: "добавь в нож слоты для
|
||||||
|
модификаций. Но модификации там будут особые, именно для ножа некроманта, а не для
|
||||||
|
обычного ножа."
|
||||||
|
|
||||||
|
HOW THE EXCLUSIVITY WORKS (both halves verified against the decompiled assembly, not
|
||||||
|
guessed - see the long comment on the knife's Tags in items.xml for the full audit):
|
||||||
|
|
||||||
|
- installable_tags="necroKnife" is the positive half. The install UI checks
|
||||||
|
"InstallableTags.IsEmpty || itemClass.HasAnyTags(InstallableTags)"
|
||||||
|
(XUiC_ItemPartStack.CanSwap / XUiC_ItemStack / XUiM_AssembleItem all agree), matched
|
||||||
|
against the TARGET ITEM's Tags. necroWpnBladeNecroKnife is the only item anywhere -
|
||||||
|
vanilla or this mod - that carries "necroKnife", so these three fit it and nothing
|
||||||
|
else. Note the IsEmpty short-circuit above: a modifier with no installable_tags at all
|
||||||
|
goes into ANY item, so leaving it off would be the opposite of what was asked.
|
||||||
|
- The knife's own "noMods" tag is the negative half, blocking all 87 vanilla modifiers
|
||||||
|
from going the other way. That lives on the knife, not here.
|
||||||
|
|
||||||
|
WHY EACH ONE HAS ITS OWN modifier_tags: mods whose modifier_tags overlap are counted
|
||||||
|
against ItemClass.MaxModsAllowed, which defaults to 1 (ItemClass.cs line 376), and
|
||||||
|
CanSwap refuses once the count is reached. Giving all three a shared tag like
|
||||||
|
"necroKnifeMod" would therefore have let the player install exactly ONE of them at a time
|
||||||
|
- the four slots would be unusable. Distinct tags per mod is also what vanilla does
|
||||||
|
(damageBleed / barrelAttachment / droneArmor ...).
|
||||||
|
|
||||||
|
type="attachment" (not "mod") so they can be pulled back out and moved to another knife -
|
||||||
|
a type="mod" is permanent once installed.
|
||||||
|
|
||||||
|
ICONS UPDATED 2026-09-07: real generated art for all six, drawn by the user and dropped in
|
||||||
|
via c:\Exchange\exch. Each mod now points CustomIcon at its own sprite in
|
||||||
|
UIAtlases/ItemIconAtlas (TearsOfTheDead / ScavengersFeast / DeadMansGrip / GravesRepose /
|
||||||
|
DeadStorm / DarkSense, all 160x160 RGBA like the other 21). This replaced the old
|
||||||
|
reused-vanilla-sprite placeholders (drinkJarBoiledWater, foodShamSandwich,
|
||||||
|
modMeleeGraveDigger, modArmorInsulatedLinerT2, modMeleeStunBatonRepulsor,
|
||||||
|
modGunScopeSmall) - the same progression the Knife and Victim's Skin went through on
|
||||||
|
2026-08-29. CustomIconTint dropped from all six along with them: it existed only to stop
|
||||||
|
the borrowed vanilla sprites reading as the items they came from, and on purpose-drawn art
|
||||||
|
it would just darken the picture.
|
||||||
|
|
||||||
|
If a tint is ever needed again here, note the format trap that cost a round the first
|
||||||
|
time: CustomIconTint is HEX ("785AB4"), NOT the "R, G, B" triplet that TintColor takes on
|
||||||
|
the knife itself. They look interchangeable and are not - decompiled, ItemClass parses this
|
||||||
|
one with StringParsers.ParseHexColor while TintColor goes through the Color32 comma path,
|
||||||
|
and every vanilla CustomIconTint value is hex (FF00FF, 6441A5, C68C53...). -->
|
||||||
|
<append xpath="/item_modifiers">
|
||||||
|
|
||||||
|
<!-- "Слёзы мертвеца" - вода. Продиктовано 2026-09-07: "Если он вставлен в нож, то каждый
|
||||||
|
убитый зомби даёт 2 единицы воды."
|
||||||
|
|
||||||
|
IMPORTANT - this deliberately does NOT just add to $waterAmountAdd and stop there.
|
||||||
|
That CVar is only a QUEUE; on its own it hydrates nobody. Every vanilla drink
|
||||||
|
(drinkJarRiverWater, Data/Config/items.xml ~21744) pairs the add with
|
||||||
|
"AddBuff buffProcessConsumables", and it is buffProcessConsumables that adds
|
||||||
|
buffHealWaterMax (Data/Config/buffs.xml ~8527: AddBuff buffHealWaterMax requires
|
||||||
|
$waterAmountAdd GT 0), whose own onSelfBuffUpdate is what finally moves the number
|
||||||
|
into the water stat - .1 per .1s tick, so the 2 units land over about two seconds.
|
||||||
|
Missing that second line is exactly the bug that made the knife's lifesteal do
|
||||||
|
nothing at all for over a week (see items.xml), so it is spelled out here on purpose.
|
||||||
|
|
||||||
|
onSelfKilledOther is fired by ItemActionAttack (decompiled, lines ~798 and ~846) as
|
||||||
|
"entityAlive.FireEvent(MinEventTypes.onSelfKilledOther, flag4)", guarded by
|
||||||
|
"!wasAlreadyDead && entity.IsDead()" so it means a real kill, not a hit on a corpse.
|
||||||
|
flag4 is "the damaging item IS the held item", which is true for a melee swing - and
|
||||||
|
that same flag4 is what routes the event through Inventory -> ItemValue -> installed
|
||||||
|
Modifications. It is the identical argument the knife's already-working
|
||||||
|
onSelfAttackedOther effects ride on, so if those fire, this fires. (No vanilla
|
||||||
|
item_modifier happens to use onSelfKilledOther, but it is in item_modifiers.xml's own
|
||||||
|
documented TRIGGER LIST and the dispatch path above is unconditional.)
|
||||||
|
|
||||||
|
Gated to zombies via EntityTagCompare on "other" - killing a bear or a bird gives
|
||||||
|
nothing, matching how every other on-hit effect in this mod is gated. -->
|
||||||
|
<item_modifier name="necroModKnifeTearsOfTheDead" installable_tags="necroKnife" modifier_tags="necroKnifeWater" type="attachment">
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="CustomIcon" value="TearsOfTheDead"/>
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
|
<effect_group tiered="false">
|
||||||
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
|
<triggered_effect trigger="onSelfKilledOther" action="ModifyCVar" cvar="$waterAmountAdd" operation="add" value="2"/>
|
||||||
|
<triggered_effect trigger="onSelfKilledOther" action="AddBuff" buff="buffProcessConsumables"/>
|
||||||
|
</effect_group>
|
||||||
|
</item_modifier>
|
||||||
|
|
||||||
|
<!-- "Пир падальщика" - еда. Продиктовано 2026-09-07: "Следующий мод, на еду. Принцип тот
|
||||||
|
же." - same 2 units, same per-kill trigger, same zombie gate.
|
||||||
|
|
||||||
|
Food rides the mirror-image path of the water mod above: $foodAmountAdd is the queue,
|
||||||
|
buffProcessConsumables is what notices it (Data/Config/buffs.xml ~8525: AddBuff
|
||||||
|
buffHealFood requires $foodAmountAdd GT 0), buffHealFood is what actually feeds you.
|
||||||
|
One AddBuff covers both mods if they are installed together - buffProcessConsumables
|
||||||
|
checks water and food independently and hands out whichever buffs apply, so stacking
|
||||||
|
the two mods costs nothing extra and neither one cancels the other. -->
|
||||||
|
<item_modifier name="necroModKnifeScavengersFeast" installable_tags="necroKnife" modifier_tags="necroKnifeFood" type="attachment">
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="CustomIcon" value="ScavengersFeast"/>
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
|
<effect_group tiered="false">
|
||||||
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
|
<triggered_effect trigger="onSelfKilledOther" action="ModifyCVar" cvar="$foodAmountAdd" operation="add" value="2"/>
|
||||||
|
<triggered_effect trigger="onSelfKilledOther" action="AddBuff" buff="buffProcessConsumables"/>
|
||||||
|
</effect_group>
|
||||||
|
</item_modifier>
|
||||||
|
|
||||||
|
<!-- "Хватка мертвеца" - замедление. Chosen by the user from the proposed list 2026-09-07.
|
||||||
|
|
||||||
|
Reuses buffInjurySlow, the same vanilla debuff the mod's own Зомбособака already
|
||||||
|
applies on its bite (necroMeleeHandZombieDog in items.xml) - proven working in this
|
||||||
|
mod rather than a fresh guess, and the same EntityTagCompare zombie gate.
|
||||||
|
|
||||||
|
onSelfAttackedOther, not onSelfKilledOther: the point is to slow a zombie that is
|
||||||
|
still coming at you, so it has to land on the hit, not on the kill. -->
|
||||||
|
<item_modifier name="necroModKnifeDeadMansGrip" installable_tags="necroKnife" modifier_tags="necroKnifeSlow" type="attachment">
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="CustomIcon" value="DeadMansGrip"/>
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
|
<effect_group tiered="false">
|
||||||
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
|
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffInjurySlow"/>
|
||||||
|
</effect_group>
|
||||||
|
</item_modifier>
|
||||||
|
|
||||||
|
<!-- "Могильный покой" - защита от переохлаждения и перегрева. Продиктовано 2026-09-07:
|
||||||
|
"Пусть защищает от переохлаждения и перегрева... Есть параметр устойчивости к холоду и
|
||||||
|
жаре. Он бывает на предметах одежды и на некоторых модах на одежду."
|
||||||
|
|
||||||
|
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
|
||||||
|
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
|
||||||
|
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
|
||||||
|
T3 4.6->6 на ОДИН элемент брони, а элементов четыре. Взято 5 - примерно уровень
|
||||||
|
одной детали брони с T3-подкладкой, и ровно то число, которое ваниль использовала во
|
||||||
|
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh (там 5 на холод и 5 на
|
||||||
|
жару, но двумя РАЗНЫМИ модами; здесь оба в одном, что щедрее - но это стоит слота из
|
||||||
|
четырёх и работает только с ножом в руках, см. ниже). Крутить это число - одна правка.
|
||||||
|
|
||||||
|
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
|
||||||
|
комфортной, а не проценты (PlayerEntityStats, декомпиляция):
|
||||||
|
if (outsideTemperature < 70) { v = GetValue(HypothermalResist);
|
||||||
|
outsideTemperature = min(70, outsideTemperature + v); }
|
||||||
|
else { v = GetValue(HyperthermalResist);
|
||||||
|
outsideTemperature = max(70, outsideTemperature - v); }
|
||||||
|
|
||||||
|
ВАЖНО - РАБОТАЕТ ТОЛЬКО ПОКА НОЖ В РУКАХ. Это не оплошность, а то, как движок вообще
|
||||||
|
умеет учитывать не-броню, и проверено по всей цепочке, потому что термостойкость - это
|
||||||
|
обычно броневой стат, а нож не броня:
|
||||||
|
1. PlayerEntityStats зовёт EffectManager.GetValue(HypothermalResist, null, 0f, entity)
|
||||||
|
со всеми параметрами по умолчанию, а в сигнатуре GetValue значения по умолчанию -
|
||||||
|
calcHoldingItem: true и useMods: true. То есть предмет в руках и его моды учитываются.
|
||||||
|
2. Внутри GetValue ветка "else if (calcHoldingItem && ...)" зовёт Inventory.ModifyValue.
|
||||||
|
3. Inventory.ModifyValue пропускает предмет, если его теги попадают в
|
||||||
|
ignoreWhenHeld = FastTags.Parse("clothing,armor"). У ножа ни того, ни другого нет,
|
||||||
|
так что он проходит - а вот на реальной броне в руках это бы не сработало.
|
||||||
|
4. ItemValue.ModifyValue в конце обходит Modifications[j].ModifyValue под флагом
|
||||||
|
_useMods. Заметь: здесь, в отличие от FireEvent, НЕТ отсечки "if (!HasQuality)
|
||||||
|
return;" - пассивки модов считаются независимо от качества. Но нож всё равно уже
|
||||||
|
починен по tiered (см. items.xml), так что вопрос снят в обе стороны.
|
||||||
|
Убрал нож в рюкзак - защита пропала. Так и задумано, и так это описано в Localization.csv.
|
||||||
|
|
||||||
|
Пассивка, а не triggered_effect, поэтому ни onSelfKilledOther, ни гейта на зомби здесь
|
||||||
|
нет - эффект просто висит, пока нож в руке.
|
||||||
|
|
||||||
|
Иконка - спрайт настоящего ванильного мода-подкладки (у item_modifier без своего
|
||||||
|
CustomIcon спрайт называется как он сам), то есть по смыслу ровно та картинка. Тинт
|
||||||
|
тот же фиолетовый, что у остальных трёх, до появления собственной графики. -->
|
||||||
|
<item_modifier name="necroModKnifeGravesRepose" installable_tags="necroKnife" modifier_tags="necroKnifeThermal" type="attachment">
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="CustomIcon" value="GravesRepose"/>
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
|
<effect_group tiered="false">
|
||||||
|
<passive_effect name="HypothermalResist" operation="base_add" value="5"/>
|
||||||
|
<passive_effect name="HyperthermalResist" operation="base_add" value="5"/>
|
||||||
|
</effect_group>
|
||||||
|
</item_modifier>
|
||||||
|
|
||||||
|
<!-- "Мёртвая буря" - переделывает силовую атаку. Продиктовано 2026-09-07: "-5 HP повышаем
|
||||||
|
до -10 HP. Эффект делаем AOE в радиусе 20 блоков (если это много, то скажи). Расход
|
||||||
|
стамины на силовую атаку увеличиваем вдвое. Добавляем дебаф кровотечения и искрения."
|
||||||
|
|
||||||
|
ФОРМА И РАЗМЕР ОБЛАСТИ. Пользователь потом уточнил: "Нужна именно сфера. Иначе птиц не
|
||||||
|
заденет." Сфера не нужна и недоступна - движок умеет ровно одну форму, и она уже
|
||||||
|
объёмная. MinEventActionTargetedBase, ветка otherAOE:
|
||||||
|
entsInRange = World.GetLivingEntitiesInBounds(_params.Self,
|
||||||
|
new Bounds(_params.Other.position, Vector3.one * (maxRange * 2f)));
|
||||||
|
Unity-шный Bounds(center, SIZE) берёт РАЗМЕР, а не extents, здесь size = range*2 по
|
||||||
|
каждой оси - то есть это КУБ, расходящийся на range блоков во все шесть сторон,
|
||||||
|
ВКЛЮЧАЯ вверх и вниз. Дальше World.GetLivingEntitiesInBounds обходит чанки по X/Z и
|
||||||
|
сверяет коробки; никакого доп. фильтра по дистанции после выборки нет (см. цикл сразу
|
||||||
|
за вызовом - только isValidTarget и singleTargetCheck, оба про теги, не про радиус).
|
||||||
|
Поэтому вертикаль покрыта уже сейчас, и куб для летящей цели даже ЛУЧШЕ сферы: сфера
|
||||||
|
радиуса R целиком помещается внутрь куба с полу-размером R.
|
||||||
|
|
||||||
|
Заодно из этого следует, что 20 - это очень много: куб 40x40x40 вокруг цели, сквозь
|
||||||
|
стены и перекрытия (проверки линии взгляда тут нет вовсе). Весь ванильный диапазон
|
||||||
|
AOE для сравнения: 1.1, 1.3, 1.4, 2.7, 3, 6 и ровно один случай 10
|
||||||
|
(buffRingOfFireEffect). Поставлено 6 - это куб 12x12x12, то есть 6 блоков вверх, чего
|
||||||
|
хватает на пикирующего стервятника, и при этом не выкашивает соседний этаж POI. Если
|
||||||
|
нужно доставать высоко кружащих птиц - поднимать; помнить, что то же число уходит и
|
||||||
|
в горизонталь. Менять - четыре атрибута range ниже.
|
||||||
|
|
||||||
|
ЧТО ЭТОТ МОД ДОБАВЛЯЕТ К БАЗОВОЙ СИЛОВОЙ. Мод умеет только ДОБАВЛЯТЬ эффекты, снять
|
||||||
|
собственные эффекты предмета он не может, поэтому "-5 HP -> -10 HP" сделано вторым
|
||||||
|
списанием на 5, а не переписыванием первого. Порядок детерминирован: в
|
||||||
|
ItemValue.FireEvent собственные эффекты предмета (itemClass.FireEvent) идут ДО цикла
|
||||||
|
по Modifications, так что сперва снимается базовая пятёрка, затем эта. Оба списания
|
||||||
|
несут свой порог "Health GT 5", поэтому убить себя силовой атакой по-прежнему нельзя;
|
||||||
|
следствие - при здоровье между 5 и 10 спишется только часть.
|
||||||
|
|
||||||
|
ПРО СТАМИНУ. База берётся в ItemActionDynamicMelee.cs:384:
|
||||||
|
_actionData.StaminaUsage = EffectManager.GetValue(PassiveEffects.StaminaLoss,
|
||||||
|
itemValue, 2f, holdingEntity, null, _actionData.ActionTags) * StaminaUsageMultiplier;
|
||||||
|
- значение по умолчанию 2 (своего StaminaLoss у этого ножа нет), и читается оно с
|
||||||
|
ActionTags, то есть "secondary" для силовой. GetValue возвращает
|
||||||
|
_originalValue * _perc_value, где _perc_value стартует с 1, а perc_add к нему
|
||||||
|
прибавляет - поэтому value="1" это ровно "вдвое", а не "+1".
|
||||||
|
|
||||||
|
ПРО КРОВОТЕЧЕНИЕ - две строки, а не одна, и это не перестраховка. buffInjuryBleeding
|
||||||
|
снимает сам себя, если у цели bleedCounter == 0:
|
||||||
|
<triggered_effect trigger="onSelfBuffUpdate" action="RemoveBuff" buff="buffInjuryBleeding">
|
||||||
|
<requirement name="CVarCompare" cvar="bleedCounter" operation="Equals" value="0"/>
|
||||||
|
а урон берёт как HealthChangeOT base_subtract @$bleedAmount, где $bleedAmount
|
||||||
|
выставляется из bleedCounter на старте баффа. Просто повесить бафф - он мгновенно
|
||||||
|
снимется, не сделав ничего. Поэтому сначала счётчик, потом бафф - как и у ванильного
|
||||||
|
modMeleeSerratedBlade. Счётчик задан через "set 2", а не "add 1", чтобы не зависеть от
|
||||||
|
$maxBleedCounter (его выставляют перки игрока, buffs.xml ~530) и чтобы эффект не
|
||||||
|
накапливался бесконечно от серии ударов. 2 -> 2 HP/сек в течение 20 секунд.
|
||||||
|
|
||||||
|
ПРО ИСКРЕНИЕ - это ванильный buffShocked, тот самый, что вешают электродубинка и
|
||||||
|
электрозабор. Своя частица p_electric_shock и звук electric_fence_impact уже внутри
|
||||||
|
баффа, подключать отдельно нечего. По умолчанию 4 секунды и -5 HP/сек через
|
||||||
|
HealthChangeOT. Его замедление лежит в ОТДЕЛЬНЫХ effect_group, гейтованных на
|
||||||
|
$shockDurationMax >= 4 - это важно для птиц, см. ниже.
|
||||||
|
|
||||||
|
СРАБОТАЕТ ЛИ НА ПТИЦ (прямой вопрос пользователя) - да, в той части, которая наносит
|
||||||
|
урон:
|
||||||
|
+ Стервятник попадает в выборку: коробка трёхмерная, вертикаль включена.
|
||||||
|
+ Тег есть: animalZombieVulture несёт "entity,animal,zombie,zombieAnimal,hostile,
|
||||||
|
vulture,special", а target_tags="zombie" гейтит именно по нему.
|
||||||
|
+ Кровотечение работает: HealthChangeOT применяется в EntityStats.cs:142, это общий
|
||||||
|
путь для любого EntityAlive, полёт ему безразличен.
|
||||||
|
+ Урон от искрения работает по той же причине.
|
||||||
|
- Замедление от buffShocked по птице НЕ отработает: оно давит на RunSpeed/WalkSpeed,
|
||||||
|
а EntityVulture.cs (~563/567) умножает на сырые поля moveSpeed/moveSpeedAggro и
|
||||||
|
геттеры GetMoveSpeed()/GetMoveSpeedAggro() - единственные, кто эти пассивки
|
||||||
|
применяет - не зовёт вовсе.
|
||||||
|
? Ragdoll по летящей цели - НЕ ПРОВЕРЕНО. Ни в EntityVulture, ни в EntityFlying нет
|
||||||
|
ни строчки про ragdoll, полёт-специфичной поддержки точно нет. Проверять в игре.
|
||||||
|
Итого по птице: сбить с ног скорее всего не выйдет, но кровотечение и разряд догрызут.
|
||||||
|
|
||||||
|
Иконка-заглушка - спрайт ванильного мода-репульсора электродубинки, тематически
|
||||||
|
ближайшее, что есть готового. -->
|
||||||
|
<item_modifier name="necroModKnifeDeadStorm" installable_tags="necroKnife" modifier_tags="necroKnifePower" type="attachment">
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="CustomIcon" value="DeadStorm"/>
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
|
<effect_group tiered="false">
|
||||||
|
<!-- Вдвое дороже по стамине, только силовая. -->
|
||||||
|
<passive_effect name="StaminaLoss" operation="perc_add" value="1" tags="secondary"/>
|
||||||
|
|
||||||
|
<!-- Вторая половина платы: базовые 5 + эти 5 = 10 HP. -->
|
||||||
|
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="5">
|
||||||
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
|
<!-- "Health GT 0" на цели - тот же гейт на трупы, что у самого ножа, чтобы
|
||||||
|
силовой удар по трупу не брал 5 HP впустую; подробный разбор в items.xml
|
||||||
|
у лечения. AOE-строкам ниже он не нужен: GetLivingEntitiesInBounds сама
|
||||||
|
отсеивает мёртвых. -->
|
||||||
|
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||||
|
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="5"/>
|
||||||
|
</triggered_effect>
|
||||||
|
|
||||||
|
<!-- AOE вокруг задетой цели. Базовое сбивание с ног у ножа одиночное, это его
|
||||||
|
расширяет; двойное попадание по самой цели безвредно - Ragdoll это действие,
|
||||||
|
а не стакающийся бафф. -->
|
||||||
|
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="Ragdoll" target="otherAOE" range="6" target_tags="zombie" duration="1.5" force="150"/>
|
||||||
|
|
||||||
|
<!-- Кровотечение: сначала счётчик, затем бафф - см. большой комментарий выше. -->
|
||||||
|
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyCVar" target="otherAOE" range="6" target_tags="zombie" cvar="bleedCounter" operation="set" value="2"/>
|
||||||
|
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="AddBuff" target="otherAOE" range="6" target_tags="zombie" buff="buffInjuryBleeding"/>
|
||||||
|
|
||||||
|
<!-- Искрение. -->
|
||||||
|
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="AddBuff" target="otherAOE" range="6" target_tags="zombie" buff="buffShocked"/>
|
||||||
|
</effect_group>
|
||||||
|
</item_modifier>
|
||||||
|
|
||||||
|
<!-- "Тёмное чутьё" - радар зомби, пока нож в руке. Вся механика и разбор, почему это
|
||||||
|
вообще выполнимо на XML, лежат в комментарии к buffNecroDarkSense в Config/buffs.xml -
|
||||||
|
здесь только выключатель.
|
||||||
|
|
||||||
|
onSelfEquipStart / onSelfEquipStop - ванильный паттерн для "работает, только пока
|
||||||
|
предмет в руках" (так сделаны эффекты кирок, Data/Config/items.xml ~158 и ~175).
|
||||||
|
Оба события шлёт Inventory на ItemValue держимого предмета (строки ~1226 и ~1245), а
|
||||||
|
ItemValue.FireEvent прокидывает их в установленные Modifications - при условии, что у
|
||||||
|
предмета есть качество, что у этого ножа теперь так (см. правку tiered в items.xml).
|
||||||
|
|
||||||
|
Бафф снимается и при смерти игрока: remove_on_death у него не выставлен, а по
|
||||||
|
умолчанию это true - то есть залипнуть после респавна он не может. -->
|
||||||
|
<item_modifier name="necroModKnifeDarkSense" installable_tags="necroKnife" modifier_tags="necroKnifeSense" type="attachment">
|
||||||
|
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
|
||||||
|
<property name="CustomIcon" value="DarkSense"/>
|
||||||
|
<property name="EconomicValue" value="0"/>
|
||||||
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
|
<effect_group tiered="false">
|
||||||
|
<triggered_effect trigger="onSelfEquipStart" action="AddBuff" buff="buffNecroDarkSense"/>
|
||||||
|
<triggered_effect trigger="onSelfEquipStop" action="RemoveBuff" buff="buffNecroDarkSense"/>
|
||||||
|
</effect_group>
|
||||||
|
</item_modifier>
|
||||||
|
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<config>
|
||||||
|
<!-- "Жертва" (Victim) green loot bag - BACKLOG.md item 5, Necromancer's Knife. Guaranteed
|
||||||
|
drop off a zombie killed while carrying buffNecroVictim (see HarmonySrc/VictimPatch.cs,
|
||||||
|
which force-spawns EntityLootContainerVictim from entityclasses.xml on death, bypassing
|
||||||
|
the normal LootDropProb roll entirely).
|
||||||
|
|
||||||
|
Contents per user: random hides (usually one or none), rotten flesh, bones, and a chance
|
||||||
|
at "Кожа жертвы" (resourceVictimSkin, items.xml) - a crafting resource for summon books.
|
||||||
|
Exact counts/chance are a guess where the user didn't give numbers - count="0,1" for the
|
||||||
|
hide reads as "как правило одна или ноль" directly; flesh/bone ranges and the 15% skin
|
||||||
|
chance are otherwise reasonable defaults, not specified. Say if any of these should change.
|
||||||
|
|
||||||
|
UPDATED 2026-08-29 (BACKLOG.md item 8, dictated same day): foodRottingFlesh/resourceBone
|
||||||
|
removed per direct instruction - the mesh no longer drops the two generic zombie-corpse
|
||||||
|
staples. Replaced with a random chance at resourceZombieAsh ("Прах зомби") instead, a new
|
||||||
|
crafting resource fed into the other necromancy recipes (see recipes.xml - deliberately
|
||||||
|
NOT the Knife's own recipe, to avoid a craft-the-knife-to-get-ash-to-craft-the-knife loop).
|
||||||
|
Chance/count not specified by the user - guessed at the same shape as resourceVictimSkin's
|
||||||
|
own 15% (bumped the count up a little since ash is meant to actually get used as a
|
||||||
|
multi-unit crafting ingredient, unlike the skin). Say if this should change.
|
||||||
|
|
||||||
|
BUG FIXED 2026-08-28 (whole file failed to load, so NONE of this was active in-game -
|
||||||
|
"Loading and parsing 'loot.xml' failed" / "lootgroup 'groupZpackVictim' does not exist or
|
||||||
|
has not been defined before being referenced"): the lootgroup a lootcontainer's <item
|
||||||
|
group="..."/> points at must be defined EARLIER in load order, same file or not - vanilla
|
||||||
|
loot.xml itself always defines groupZpackReg etc. (~line 6589) well before the
|
||||||
|
lootcontainer that references it (~line 9971). Originally had the container's <append>
|
||||||
|
block first and the group's second; swapped so the group append comes first. Keep this
|
||||||
|
append-order intact when editing below - it's what makes the file load at all. -->
|
||||||
|
<append xpath="/lootcontainers">
|
||||||
|
<lootgroup name="groupZpackVictim" count="all">
|
||||||
|
<item name="resourceLeather" count="0,1"/>
|
||||||
|
<item name="resourceZombieAsh" count="1,4" prob="0.4"/>
|
||||||
|
<item name="resourceVictimSkin" count="1" prob="0.15"/>
|
||||||
|
</lootgroup>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<append xpath="/lootcontainers">
|
||||||
|
<lootcontainer name="zPackVictim" count="1" size="6,3" sound_open="UseActions/open_backpack" sound_close="UseActions/close_backpack" open_time="1" loot_quality_template="qualBaseTemplate">
|
||||||
|
<item group="groupZpackVictim"/>
|
||||||
|
</lootcontainer>
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<config>
|
||||||
|
<!-- "Некромантия" (Necromancy) skill.
|
||||||
|
Reuses the vanilla crafting_skill mechanism (same class that powers e.g.
|
||||||
|
craftingHarvestingTools) parented to the vanilla "attCrafting" virtual
|
||||||
|
attribute — this makes it auto-level from a game action (not spent perk
|
||||||
|
points) and show up for free in the game's existing crafting-skills panel.
|
||||||
|
|
||||||
|
Originally this leveled from reading books (see git history / earlier
|
||||||
|
comments), but items.xml effect_group does NOT inherit through Extends in
|
||||||
|
this game version (confirmed by decompiling ItemClassesFromXml.parseItem -
|
||||||
|
it always parses effect_group from the item's own XML node, passing null as
|
||||||
|
the "parent node" argument to MinEffectController.ParseXml regardless of
|
||||||
|
Extends; entity_class does the equivalent WITH the parent node passed in,
|
||||||
|
which is why the zombie kill counter below always worked and reading books
|
||||||
|
never did). Patching every single one of the ~150 skill magazine/schematic
|
||||||
|
items individually to work around that was judged not worth it - so this
|
||||||
|
levels from zombie kills instead, via the same entityclasses.xml patch on
|
||||||
|
zombieTemplateMale that already drives necroZombieKillsCVar (see
|
||||||
|
entityclasses.xml) - one kill, one level, capped at max_level.
|
||||||
|
|
||||||
|
max_level=5000, with 5 recipe groups gated at fixed total-zombie-kills
|
||||||
|
thresholds (proportional to the original 1000-max version: 0.1% / 10% / 40%
|
||||||
|
/ 60% / 100%):
|
||||||
|
Group 1 "Адепт" - available from the start (level 1), but not all
|
||||||
|
of it - individual recipes within the group still
|
||||||
|
unlock at their own level as usual.
|
||||||
|
Group 2 "Подмастерье" - level 500
|
||||||
|
Group 3 "Ученик" - level 2000
|
||||||
|
Group 4 "Некромант" - level 3000
|
||||||
|
Group 5 "Мастер" - level 5000
|
||||||
|
|
||||||
|
Recipe unlocks: give a recipe Tags="necroNecromancyAdept" / "necroNecromancyJourneyman"
|
||||||
|
/ "necroNecromancyApprentice" / "necroNecromancyNecromancer" / "necroNecromancyMaster"
|
||||||
|
(whichever group it belongs to) and it will unlock automatically once the
|
||||||
|
player crosses that group's threshold - no further change needed here.
|
||||||
|
thrownStoneSpirit (Камень духов) carries none of these tags at all, since Group 1
|
||||||
|
"Адепт" 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.
|
||||||
|
|
||||||
|
One genuine one-off exception: necroNecromancyLvl20 (level 20) - the Пространственный
|
||||||
|
браслет, per direct instruction 2026-08-30 ("нож, камень духов и хранилище - это база...
|
||||||
|
хранилище, когда убито минимум 20 зомби") - it belongs in the Group 1 "Адепт" display
|
||||||
|
bucket (see below) but needs its own slightly-later unlock level within that same group,
|
||||||
|
which is what unlock_tier is for (see display_entry below), not a reason to invent a
|
||||||
|
whole separate group.
|
||||||
|
|
||||||
|
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 ("до сих пор нету ни
|
||||||
|
одного рецепта" in the skill panel).
|
||||||
|
|
||||||
|
UPDATED 2026-08-30 (user report: "превью пустые" - the group icons in the skill panel
|
||||||
|
were blank). Root cause: `icon=` on display_entry needs a real ICON ATLAS SPRITE NAME
|
||||||
|
(the same string you'd give CustomIcon), not the item's own internal id - fixed by
|
||||||
|
pointing icon= at each group's actual CustomIcon sprite value.
|
||||||
|
|
||||||
|
REVERTED-AND-REDISTRIBUTED AGAIN 2026-08-30, two direct corrections in a row:
|
||||||
|
1. "Что ещё за 'уровень'? Была же система! А рецепту можно без группы задать уровень
|
||||||
|
скилла на котором он откроется." - an earlier edit had invented ad-hoc "Level
|
||||||
|
20/50/200" display groups, abandoning the real 5-tier flavor-name system this
|
||||||
|
comment already documents above (Адепт/Подмастерье/Ученик/Некромант/Мастер -
|
||||||
|
craftingNecroNecromancyTier1-5Name, all already localized, unused since). A recipe's
|
||||||
|
actual unlock level is controlled entirely by its own `tags=` + the
|
||||||
|
RecipeTagUnlocked effect_group below - display_entry is PURELY the skill-panel
|
||||||
|
preview widget, the two don't need to be 1:1. Confirmed against real vanilla
|
||||||
|
precedent (Data/Config/progression.xml's own explosives group): one display_entry
|
||||||
|
can list several items that reveal at different levels via a comma-list
|
||||||
|
unlock_level plus multiple unlock_entry elements at increasing unlock_tier.
|
||||||
|
2. "У тебя получается 5 групп, как и должно было быть. А вот уровень скилла ты
|
||||||
|
распределил идиотски." - the fix for #1 above had (wrongly) folded EVERYTHING except
|
||||||
|
the black stone into Group 1 alone (levels 1/20/50/200), leaving the three real
|
||||||
|
mid-game tiers (Подмастерье@500/Ученик@2000/Некромант@3000) completely empty and
|
||||||
|
unused - defeating the entire point of having 5 groups spread across the level
|
||||||
|
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
|
||||||
|
recipes.xml for the final per-item distribution:
|
||||||
|
Group 1 "Адепт" (level 1, +20 for the bracelet) - Spirit Stone, Knife,
|
||||||
|
Blue Portal Stone, Spatial Vault, Pyramid of Spirits,
|
||||||
|
and (since 2026-09-09) the four survival-flavoured knife
|
||||||
|
mods at +30 / +60 / +100 / +300
|
||||||
|
Group 2 "Подмастерье" (level 500) - Grimoire of Deviation, plus the two combat
|
||||||
|
knife mods at 1400 / 1700
|
||||||
|
Group 3 "Ученик" (level 2000) - Zombie Dog, Insect Swarm, Zombie Griffin
|
||||||
|
Group 4 "Некромант" (level 3000) - Zombie Bear, Zombie Wolf, Banshee's Scroll
|
||||||
|
Group 5 "Мастер" (level 5000) - Black Portal Stone
|
||||||
|
necroNecromancyLvl50/Lvl200 tags removed (no longer used by anything - Grimoire and
|
||||||
|
the summon books now use the real necroNecromancyJourneyman/Apprentice/Necromancer
|
||||||
|
tags instead). necroNecromancyLvl20 stays - still the one legitimate one-off (see
|
||||||
|
above). -->
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<!-- 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,
|
||||||
|
separate xpath targeting crafting_skill[@name='craftingNecroNecromancy'] BEFORE
|
||||||
|
this element even exists in load order - same class of bug that already broke
|
||||||
|
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 -
|
||||||
|
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">
|
||||||
|
<!-- necroHeresyPyramid added here 2026-08-31 (user request: "добавь рецепт блока в
|
||||||
|
скиллы") - unlock_tier="1" alongside the other always-available Tier-1 items,
|
||||||
|
matching its recipe's own necroNecromancyAdept tag in recipes.xml (see that
|
||||||
|
file's comment - both express the same "available immediately" intent). -->
|
||||||
|
<unlock_entry item="thrownStoneSpirit,necroWpnBladeNecroKnife,thrownStonePortalBlue,necroHeresyPyramid" unlock_tier="1"/>
|
||||||
|
<unlock_entry item="braceletSpatialVault" unlock_tier="2"/>
|
||||||
|
<!-- ЧЕТЫРЕ МОДА НОЖА ПЕРЕЕХАЛИ СЮДА ИЗ ГРУППЫ 2, 2026-09-09. Продиктовано:
|
||||||
|
"Питьё важно в тот же день. Оно должно быть доступно после 30 убитых зомби.
|
||||||
|
Еда - 60. Это самые важные для начала выживания модификации. Модификация на
|
||||||
|
покой - 100 зомби. Модификацию на тёмное чутьё я бы сделал доступной после
|
||||||
|
300 убитых зомби."
|
||||||
|
|
||||||
|
ЧТО БЫЛО СЛОМАНО. Правка 2026-09-07 разложила все шесть модов ножа внутри
|
||||||
|
группы 2, то есть в диапазоне 500-1700 убийств. Это ошибка баланса, а не
|
||||||
|
кода (сама тройка "тег рецепта + RecipeTagUnlocked + unlock_tier" сходилась
|
||||||
|
по всем шести): голод и жажда - проблема ПЕРВЫХ ДНЕЙ, а к 500 убийствам у
|
||||||
|
игрока давно есть ферма, костёр, банки и фильтр, и +2 воды с трупа ему уже
|
||||||
|
не нужны. Два самых "выживальческих" мода открывались ровно тогда, когда
|
||||||
|
переставали быть нужны - мёртвый контент. Сам нож лежит в группе 1 и
|
||||||
|
доступен с уровня 1, поэтому моды на воду, еду и тепло теперь идут сразу за
|
||||||
|
ним, в той же группе.
|
||||||
|
|
||||||
|
Ступени группы 1 после правки: 1 - база, 20 - браслет, 30 - вода, 60 - еда,
|
||||||
|
100 - покой, 300 - чутьё. Все шесть порогов ниже 500, то есть на группу 2
|
||||||
|
они не заезжают. Хватка мертвеца (1400) и Мёртвая буря (1700) остались в
|
||||||
|
группе 2 - это чисто боевые моды, ранний доступ им не нужен ("дальше уже не
|
||||||
|
так принципиально").
|
||||||
|
|
||||||
|
Порядок вода -> еда не случаен и задан пользователем прямо: пить хочется в
|
||||||
|
тот же день, есть - позже. -->
|
||||||
|
<unlock_entry item="necroModKnifeTearsOfTheDead" unlock_tier="3"/>
|
||||||
|
<unlock_entry item="necroModKnifeScavengersFeast" unlock_tier="4"/>
|
||||||
|
<unlock_entry item="necroModKnifeGravesRepose" unlock_tier="5"/>
|
||||||
|
<unlock_entry item="necroModKnifeDarkSense" unlock_tier="6"/>
|
||||||
|
</display_entry>
|
||||||
|
<!-- Ступенчатая разблокировка внутри группы. ПЕРЕРАСПРЕДЕЛЕНО 2026-09-09: четыре из
|
||||||
|
шести модов ножа (вода/еда/покой/чутьё) уехали отсюда в группу 1 - см. большой
|
||||||
|
комментарий там. Здесь остались Гримуар и два боевых мода ножа.
|
||||||
|
|
||||||
|
КАК ЭТО ЧИТАЕТСЯ (разобрано по декомпиляции, потому что семантика неочевидная):
|
||||||
|
unlock_level - это список порогов (ProgressionClass.QualityStarts), а unlock_tier
|
||||||
|
в XML 1-based, при разборе из него вычитается единица
|
||||||
|
(ProgressionFromXml.cs:396 - "ParseSInt32(...) - 1"). Дальше
|
||||||
|
GetUnlockItemLocked = GetQualityLevel(level) <= UnlockTier
|
||||||
|
где GetQualityLevel возвращает индекс первого порога, который БОЛЬШЕ текущего
|
||||||
|
уровня. В сумме это даёт простое правило: запись с unlock_tier="N" выходит
|
||||||
|
из-под замка ровно на N-м значении unlock_level, считая с единицы. Поэтому здесь
|
||||||
|
tier 1 -> 500, tier 2 -> 1400, tier 3 -> 1700, а в группе 1 выше -
|
||||||
|
tier 1 -> 1, tier 2 -> 20, tier 3 -> 30, tier 4 -> 60, tier 5 -> 100,
|
||||||
|
tier 6 -> 300.
|
||||||
|
|
||||||
|
Заблокированная запись рисуется греем из АТЛАСА ItemIconAtlasGreyscale плюс
|
||||||
|
спрайт-замок ui_game_symbol_unlock поверх (XUi_InGame/windows.xml ~2523-2524,
|
||||||
|
привязки unlock_icon_atlasN / unlock_icon_lockedN). Именно поэтому у мода теперь
|
||||||
|
есть вторая папка UIAtlases/ItemIconAtlasGreyscale - без неё под замком у иконки
|
||||||
|
не было бы картинки вообще. -->
|
||||||
|
<display_entry icon="ScrollOfDeviation" name_key="craftingNecroNecromancyTier2Name" has_quality="false" unlock_level="500,1400,1700">
|
||||||
|
<unlock_entry item="thrownBookGrimoireDeviation" unlock_tier="1"/>
|
||||||
|
<unlock_entry item="necroModKnifeDeadMansGrip" unlock_tier="2"/>
|
||||||
|
<unlock_entry item="necroModKnifeDeadStorm" unlock_tier="3"/>
|
||||||
|
</display_entry>
|
||||||
|
<display_entry icon="SummonZombieDog" name_key="craftingNecroNecromancyTier3Name" has_quality="false" unlock_level="2000">
|
||||||
|
<unlock_entry item="bookSummonZombieDog,bookSummonInsectSwarm,bookSummonZombieGriffin" unlock_tier="1"/>
|
||||||
|
</display_entry>
|
||||||
|
<display_entry icon="SummonZombieBear" name_key="craftingNecroNecromancyTier4Name" has_quality="false" unlock_level="3000">
|
||||||
|
<unlock_entry item="bookSummonZombieBear,bookSummonZombieWolf,bookBanshee" unlock_tier="1"/>
|
||||||
|
</display_entry>
|
||||||
|
<display_entry icon="BlackPortalStone" name_key="craftingNecroNecromancyTier5Name" has_quality="false" unlock_level="5000">
|
||||||
|
<unlock_entry item="thrownStonePortalBlack" unlock_tier="1"/>
|
||||||
|
</display_entry>
|
||||||
|
|
||||||
|
<effect_group>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="1,5000" value="1" tags="necroNecromancyAdept"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="20,5000" value="1" tags="necroNecromancyLvl20"/>
|
||||||
|
<!-- Пороги модов ножа, 2026-09-09 (см. комментарий в recipes.xml и в группе 1
|
||||||
|
выше). Каждый уровень тут ОБЯЗАН совпадать с соответствующим значением в
|
||||||
|
unlock_level того display_entry, где лежит мод, иначе замок на панели скилла
|
||||||
|
разойдётся с реальной доступностью рецепта: display_entry рисует замок сам по
|
||||||
|
себе, по unlock_tier, и о тегах не знает.
|
||||||
|
Lvl800 и Lvl1100 удалены вместе с этой правкой - ими больше никто не
|
||||||
|
пользуется (Тёмное чутьё уехало на 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"/>
|
||||||
|
<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"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="500,5000" value="1" tags="necroNecromancyJourneyman"/>
|
||||||
|
<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="2000,5000" value="1" tags="necroNecromancyApprentice"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="3000,5000" value="1" tags="necroNecromancyNecromancer"/>
|
||||||
|
<passive_effect name="RecipeTagUnlocked" operation="base_set" level="5000,5000" value="1" tags="necroNecromancyMaster"/>
|
||||||
|
</effect_group>
|
||||||
|
</crafting_skill>
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<config>
|
||||||
|
<!-- Tin can water boiling - user request 2026-08-30 ("Вода в консервных банках должна
|
||||||
|
кипятиться на костре даже если там нету кастрюли... так было сделано в другом моём моде
|
||||||
|
'Энерголук'" = AC-EnergyBow, the same __NoMods reference the tin can items themselves were
|
||||||
|
ported from - see items.xml's own comment on tinCanEmpty/tinCanRiverWater/
|
||||||
|
tinCanBoiledWater).
|
||||||
|
|
||||||
|
Root cause this was missing at all: `Extends` copies an item's PROPERTIES, never its
|
||||||
|
RECIPES - recipes are separate top-level <recipe> nodes matched by name, with no
|
||||||
|
inheritance mechanism of their own (confirmed by grepping this mod's own recipes.xml -
|
||||||
|
zero "tinCan" hits before this entry). tinCanRiverWater/tinCanBoiledWater extending
|
||||||
|
drinkJarRiverWater/drinkJarBoiledWater therefore got NONE of the vanilla mason-jar-boiling
|
||||||
|
recipe (`<recipe name="drinkJarBoiledWater" craft_area="campfire"
|
||||||
|
craft_tool="toolCookingPot">`, Data/Config/recipes.xml) - boiling a tin can was never
|
||||||
|
possible at all until this recipe existed, not merely pot-gated.
|
||||||
|
|
||||||
|
No `craft_tool="toolCookingPot"` here BY DESIGN, per the direct request - a metal can can
|
||||||
|
sit right in a campfire's coals on its own, unlike a mason jar. Confirmed real vanilla
|
||||||
|
precedent for a pot-less campfire recipe existing at all: `foodBakedPotato`/
|
||||||
|
`foodCharredMeat` both use craft_area="campfire" with no craft_tool attribute at all - a
|
||||||
|
campfire recipe is not implicitly pot-gated, craft_tool is an opt-in extra requirement,
|
||||||
|
not something `craft_area="campfire"` implies on its own. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="tinCanBoiledWater" count="1" craft_area="campfire">
|
||||||
|
<ingredient name="tinCanRiverWater" count="1"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Камень духов" (Spirit Stone): craftable from the start, no perk/skill gate -
|
||||||
|
packMuleCrafting is a weight-while-crafting tag, not a gate (see vanilla
|
||||||
|
campfire/candle recipes for the same pattern). Per BACKLOG.md item 1: 1 rock +
|
||||||
|
20 grass fiber (resourceRockSmall doubles as both the crafted item's own mesh/icon
|
||||||
|
source and its craft ingredient here, same as vanilla's own resourceRockSmallBundle). -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<!-- resourceZombieAsh added 2026-08-29 (BACKLOG.md item 8) - count is a guess, not
|
||||||
|
specified by the user, kept modest like the rest of this recipe's ingredients. -->
|
||||||
|
<!-- resourceZombieAsh REMOVED 2026-08-31, direct user correction: this is the starter
|
||||||
|
weapon, craftable before the player has the Knife at all - same circular-dependency
|
||||||
|
problem items.xml's own resourceZombieAsh comment already flags and deliberately avoids
|
||||||
|
for the Knife's own recipe ("the knife has to stay craftable from scratch, and ash only
|
||||||
|
exists because the knife already tagged a zombie as a Victim"). Ash got added here
|
||||||
|
anyway during the 2026-08-29 item-8 blanket rollout ("becomes a crafting ingredient
|
||||||
|
across the other necromancy recipes") without checking this one against that same rule
|
||||||
|
- missed it then, fixed now. Back to the original 2026-08-28 recipe (1 rock + 20
|
||||||
|
fibers, BACKLOG.md item 1), nothing else changed. -->
|
||||||
|
<recipe name="thrownStoneSpirit" count="1" tags="packMuleCrafting">
|
||||||
|
<ingredient name="resourceRockSmall" count="1"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="20"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- Two portal stones, BACKLOG.md item 6. Blue: no gate, simple early recipe by analogy with
|
||||||
|
the Spirit Stone (per that backlog note) - not specified exactly by the user. Black:
|
||||||
|
necroNecromancyMaster (level 5000, the existing "Мастер" tier, group 5) per direct
|
||||||
|
instruction. Both carry resourceZombieAsh like the other necromancy recipes
|
||||||
|
(BACKLOG.md item 8). -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<!-- resourceZombieAsh bumped 5 -> 150, direct user correction 2026-08-31: unlike the Spirit
|
||||||
|
Stone (removed entirely, see that fix above), the Blue Portal Stone is NOT a starter
|
||||||
|
weapon - "он не является начальным оружием... больше про комфорт" - so the same
|
||||||
|
circular-dependency concern doesn't apply, and the user wants it to actually cost a
|
||||||
|
real chunk of ash rather than a token amount. -->
|
||||||
|
<recipe name="thrownStonePortalBlue" count="1" tags="packMuleCrafting">
|
||||||
|
<ingredient name="resourceRockSmall" count="1"/>
|
||||||
|
<ingredient name="drinkJarPureMineralWater" count="3"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="15"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="150"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
<!-- resourceNecromancerBlood x10 added 2026-08-30, direct instruction ("10 крови некроманта
|
||||||
|
нужно будет для рецепта чёрного портала"). ADDITIVE to the existing medicalBloodBag
|
||||||
|
ingredient, not a replacement - the user said "также" (also/additionally), not "instead
|
||||||
|
of"; the plain blood bag stays as the mundane-blood component, Necromancer's Blood as the
|
||||||
|
new, harder-to-get one. Say if this should replace medicalBloodBag instead. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="thrownStonePortalBlack" count="1" tags="learnable,packMuleCrafting,necroNecromancyMaster">
|
||||||
|
<ingredient name="resourceRockSmall" count="1"/>
|
||||||
|
<ingredient name="medicalBloodBag" count="5"/>
|
||||||
|
<ingredient name="resourceNecromancerBlood" count="10"/>
|
||||||
|
<ingredient name="casinoCoin" count="20"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="15"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="20"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Кровь некроманта" (Necromancer's Blood): dictated 2026-08-30, see items.xml for the item
|
||||||
|
itself and HarmonySrc/NecromancerBloodPatch.cs for the knife-requirement/HP-cost mechanic
|
||||||
|
this recipe alone can't express. No level gate given by the user and none applied - same
|
||||||
|
"base" tier as the jar it's made from being a mundane always-available item; the real
|
||||||
|
gate on this ritual is the HP cost + knife requirement, not a skill level. drinkJarEmpty
|
||||||
|
confirmed as the real vanilla empty-jar item (Data/Config/items.xml) - not invented. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="resourceNecromancerBlood" count="1" tags="packMuleCrafting">
|
||||||
|
<ingredient name="drinkJarEmpty" count="1"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Пирамида Ереси" (Pyramid of Heresy): user request 2026-08-31, see blocks.xml/
|
||||||
|
HarmonySrc/PyramidWardPatch.cs for the block itself. "Рецепт доступен сразу" - no
|
||||||
|
"learnable" tag and no gate beyond necroNecromancyAdept (Group 1 "Адепт", unlocked from
|
||||||
|
level 1 - see progression.xml's own comment: a recipe with no unlock tag at all is already
|
||||||
|
always-available, the tag here is added anyway purely so the intent ("this is a Tier-1,
|
||||||
|
immediately-available necromancy recipe") is visible directly in this file, matching how
|
||||||
|
braceletSpatialVault documents itself with necroNecromancyLvl20 below rather than staying
|
||||||
|
unmarked). craft_area="workbench" (unlike the hand-craftable Knife/Spirit Stone) - this is
|
||||||
|
a heavy placeable structure, not a pocket item, matching braceletSpatialVault's own
|
||||||
|
workbench gate.
|
||||||
|
|
||||||
|
INGREDIENTS: resourceZombieAsh(1200)/resourceRockSmall/resourceYuccaFibers dictated
|
||||||
|
directly by the user ("прах зомби, камень, трава"). The rest chosen to fit a "necromantic
|
||||||
|
iron pylon" ("остальные ингредиенты добавь сам исходя из контекста"):
|
||||||
|
- resourceScrapIron: the block's own structural frame (real vanilla resource, matches
|
||||||
|
its own Destroy-drop in blocks.xml).
|
||||||
|
- resourceNecromancerBlood: the mod's existing magic-binding reagent (see above) - ties
|
||||||
|
the ward's charm magic to the same "blood" reagent already used elsewhere in this
|
||||||
|
mod's necromancy recipes, kept to a small count (3) since it costs the player 90% of
|
||||||
|
their current HP per unit to make (NecromancerBloodPatch.cs) - this recipe shouldn't
|
||||||
|
demand many. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="necroHeresyPyramid" count="1" craft_area="workbench" tags="workbenchCrafting,necroNecromancyAdept">
|
||||||
|
<ingredient name="resourceZombieAsh" count="1200"/>
|
||||||
|
<ingredient name="resourceRockSmall" count="300"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="200"/>
|
||||||
|
<ingredient name="resourceScrapIron" count="300"/>
|
||||||
|
<ingredient name="resourceNecromancerBlood" count="3"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Гримуар девиации" (Grimoire of Deviation): BACKLOG.md item 2. Gated at
|
||||||
|
necroNecromancyJourneyman - group 2 "Подмастерье" (level 500), one of the 5 real skill
|
||||||
|
tiers (see progression.xml; REDISTRIBUTED 2026-08-30, was a one-off level-50 tag before -
|
||||||
|
direct user correction, "уровень скилла ты распределил идиотски"). -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="thrownBookGrimoireDeviation" count="1" tags="learnable,packMuleCrafting,necroNecromancyJourneyman">
|
||||||
|
<ingredient name="resourcePaper" count="20"/>
|
||||||
|
<ingredient name="resourceGlue" count="2"/>
|
||||||
|
<ingredient name="resourceCoal" count="5"/>
|
||||||
|
<ingredient name="resourceWood" count="10"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="5"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Призыв зомбособаки" (Summon Zombie Dog): BACKLOG.md item 3. Gated at
|
||||||
|
necroNecromancyApprentice - group 3 "Ученик" (level 2000), one of the 5 real skill tiers
|
||||||
|
(see progression.xml; REDISTRIBUTED 2026-08-30, was a one-off level-200 tag before -
|
||||||
|
direct user correction, "уровень скилла ты распределил идиотски"). Per user request,
|
||||||
|
crafted at a workbench specifically (craft_area="workbench"), same as advanced-tier weapons
|
||||||
|
(e.g. vanilla gunHandgunT3SMG5) - NOT available from the player's own personal crafting
|
||||||
|
menu like the Spirit Stone/Grimoire above (those have no craft_area, so default to
|
||||||
|
personal crafting). workbenchCrafting is just the same UI-categorization tag vanilla's
|
||||||
|
own workbench recipes carry alongside craft_area, not a separate gate. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="bookSummonZombieDog" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyApprentice">
|
||||||
|
<ingredient name="foodRottingFlesh" count="50"/>
|
||||||
|
<ingredient name="medicalBloodBag" count="3"/>
|
||||||
|
<ingredient name="drinkJarBoiledWater" count="4"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="50"/>
|
||||||
|
<ingredient name="resourceCloth" count="3"/>
|
||||||
|
<ingredient name="resourceLeather" count="10"/>
|
||||||
|
<ingredient name="resourceBone" count="40"/>
|
||||||
|
<ingredient name="casinoCoin" count="5"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Жуки Властелина": user request 2026-08-28. Personal crafting (no craft_area) - unlike the
|
||||||
|
Dog, unconfirmed whether this should be workbench-gated too; see items.xml. Same
|
||||||
|
necroNecromancyApprentice gate as the Dog - group 3 "Ученик" (level 2000, see
|
||||||
|
progression.xml; REDISTRIBUTED 2026-08-30 like the others above). Ingredients per
|
||||||
|
explicit user instruction: 1 resourceQueenBee ("одна пчеломатка") is required; the rest
|
||||||
|
("сам додумай, но без фанатизма") is a modest hive/bait theme (honey to draw and bind the
|
||||||
|
swarm, wood for a hive, fiber to hold it together) - kept deliberately small, no rare
|
||||||
|
currency/high counts like the Dog's recipe. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="bookSummonInsectSwarm" count="1" tags="learnable,necroNecromancyApprentice">
|
||||||
|
<ingredient name="resourceQueenBee" count="1"/>
|
||||||
|
<ingredient name="foodHoney" count="20"/>
|
||||||
|
<ingredient name="resourceWood" count="10"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="15"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="5"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Книга банши" (Banshee's Book): BACKLOG.md item 7. Gated at necroNecromancyNecromancer -
|
||||||
|
group 4 "Некромант" (level 3000), alongside Bear/Wolf (see progression.xml;
|
||||||
|
REDISTRIBUTED 2026-08-30, was necroNecromancyLvl200 before) - not specified by the user,
|
||||||
|
guessed consistent with the other "advanced ritual" summon items. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="bookBanshee" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyNecromancer">
|
||||||
|
<ingredient name="resourcePaper" count="15"/>
|
||||||
|
<ingredient name="resourceGlue" count="3"/>
|
||||||
|
<ingredient name="foodRottingFlesh" count="10"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="15"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Нож некроманта" (Necromancer's Knife): BACKLOG.md item 5, user request 2026-08-28.
|
||||||
|
No recipe/gate specified beyond the item's own mechanics - guessed modest and available
|
||||||
|
from the start (personal crafting, no craft_area, no level tag), same tier as the Spirit
|
||||||
|
Stone, since its real power comes from playing the necromancer build over time (damage
|
||||||
|
scales with kill count), not an expensive unlock. Say if this should change.
|
||||||
|
|
||||||
|
resourceZombieAsh (BACKLOG.md item 8) deliberately NOT added here, unlike the other
|
||||||
|
necromancy recipes - per explicit user instruction 2026-08-29: ash only drops from
|
||||||
|
zombies the knife itself has already marked as "Жертва", so requiring ash to craft the
|
||||||
|
knife would make the knife uncraftable from a fresh start (circular dependency).
|
||||||
|
|
||||||
|
medicalBloodBag SWAPPED for resourceNecromancerBlood 2026-08-30, direct instruction
|
||||||
|
("для ножа некроманта пусть используется кровь некроманта"). NOT a circular dependency
|
||||||
|
like the ash case above, even though this item needs a knife to craft (see
|
||||||
|
NecromancerBloodPatch.cs) - resourceNecromancerBlood's own requirement is "ANY knife"
|
||||||
|
(ItemClass.DisplayType == "meleeKnife"), which vanilla's own starting/craftable knives
|
||||||
|
(e.g. the plain bone knife) already satisfy - it does NOT require this specific
|
||||||
|
necroWpnBladeNecroKnife to already exist. Count kept at 1, same as the medicalBloodBag it
|
||||||
|
replaces - not inflated further, since the blood itself is now a genuinely costly
|
||||||
|
ingredient (90% current HP + a knife + a jar per unit). -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="necroWpnBladeNecroKnife" count="1" tags="packMuleCrafting">
|
||||||
|
<ingredient name="resourceBone" count="5"/>
|
||||||
|
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- Three more summon-book recipes, BACKLOG.md item 4a. REPLACED 2026-08-29 - Griffin/Bear/
|
||||||
|
Wolf instead of Stripper/Cop/Soldier (see entityclasses.xml). Same workbench shape as the
|
||||||
|
Dog's own recipe; Griffin shares the Dog's gate (necroNecromancyApprentice, group 3
|
||||||
|
"Ученик", level 2000), Bear/Wolf are one tier higher (necroNecromancyNecromancer, group 4
|
||||||
|
"Некромант", level 3000, alongside the Banshee) - split REDISTRIBUTED 2026-08-30 across
|
||||||
|
the real 5 skill tiers instead of one shared one-off level-200 tag (see progression.xml).
|
||||||
|
Ingredient lists are a themed guess per creature (not specified by the user) -
|
||||||
|
resourceFeather/foodRawMeat both verified to exist in vanilla items.xml before use, same
|
||||||
|
lesson as the earlier wrong guesses. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="bookSummonZombieGriffin" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyApprentice">
|
||||||
|
<ingredient name="resourceFeather" count="30"/>
|
||||||
|
<ingredient name="resourceBone" count="20"/>
|
||||||
|
<ingredient name="casinoCoin" count="5"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="20"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="bookSummonZombieBear" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyNecromancer">
|
||||||
|
<ingredient name="resourceLeather" count="20"/>
|
||||||
|
<ingredient name="foodRawMeat" count="10"/>
|
||||||
|
<ingredient name="resourceBone" count="20"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="20"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="bookSummonZombieWolf" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyNecromancer">
|
||||||
|
<ingredient name="resourceLeather" count="15"/>
|
||||||
|
<ingredient name="foodRawMeat" count="10"/>
|
||||||
|
<ingredient name="resourceBone" count="15"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="20"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- "Петля вора" (Thief's Loop) recipe REMOVED 2026-08-30 along with the item itself - see
|
||||||
|
items.xml for why. -->
|
||||||
|
|
||||||
|
<!-- "Пространственный браслет" (Spatial Bracelet) - gate moved 2026-08-30 from
|
||||||
|
necroNecromancyLvl200 to necroNecromancyLvl20 per direct instruction ("нож, камень духов
|
||||||
|
и хранилище - это база... хранилище, когда убито минимум 20 зомби"). craft_area/workbench
|
||||||
|
left as-is (not specified either way) - still an "advanced" build, just unlocked much
|
||||||
|
earlier than before. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="braceletSpatialVault" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyLvl20">
|
||||||
|
<ingredient name="resourceLeather" count="10"/>
|
||||||
|
<ingredient name="resourceMechanicalParts" count="5"/>
|
||||||
|
<ingredient name="resourceScrapIron" count="10"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="15"/>
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
|
||||||
|
<!-- СТУПЕНЧАТАЯ РАЗБЛОКИРОВКА МОДОВ НОЖА. Изначально (2026-09-07) продиктовано: "механика
|
||||||
|
скиллов даёт возможность сделать их доступными не сразу, а по мере набора количества
|
||||||
|
убитых зомби. Т.е. грейд открылся, а рецепт ещё нет, на нём замочек. Еда/вода должны
|
||||||
|
открываться на грейде сразу. Чутьё чуть позже, хватка ещё позже. Но всё в рамках грейда."
|
||||||
|
|
||||||
|
ПЕРЕСТАВЛЕНО 2026-09-09 - прямая правка баланса от пользователя: "Питьё важно в тот же
|
||||||
|
день. Оно должно быть доступно после 30 убитых зомби. Еда - 60. Это самые важные для
|
||||||
|
начала выживания модификации. Модификация на покой - 100 зомби. Модификацию на тёмное
|
||||||
|
чутьё я бы сделал доступной после 300 убитых зомби. Дальше уже не так принципиально."
|
||||||
|
|
||||||
|
ЧТО БЫЛО НЕ ТАК. Первая раскладка держала все шесть модов внутри группы 2, то есть в
|
||||||
|
диапазоне 500-1700 убийств. Ошибка не в коде (тег рецепта, RecipeTagUnlocked и
|
||||||
|
unlock_tier сходились по всем шести), а в балансе: вода и еда нужны в ПЕРВЫЕ ДНИ, а к
|
||||||
|
500 убийствам у игрока уже есть ферма, костёр, банки и фильтр - мод, дающий +2 воды с
|
||||||
|
трупа, к этому моменту бесполезен. Теперь порог у мода стоит там, где мод реально нужен,
|
||||||
|
а не там, где он "по силе" смотрится ровно.
|
||||||
|
|
||||||
|
Итоговая раскладка:
|
||||||
|
30 - Слёзы мертвеца (вода) группа 1 "Адепт"
|
||||||
|
60 - Пир падальщика (еда) группа 1
|
||||||
|
100 - Могильный покой (тепло/холод) группа 1
|
||||||
|
300 - Тёмное чутьё (радар) группа 1
|
||||||
|
1400 - Хватка мертвеца (замедление) группа 2 "Подмастерье"
|
||||||
|
1700 - Мёртвая буря (силовая) группа 2
|
||||||
|
Первые четыре - выживание и информация, они переехали в группу 1 к самому ножу (он там и
|
||||||
|
доступен с уровня 1). Последние два - чистый бой, остались в группе 2 на прежних порогах:
|
||||||
|
пользователь про них сказал "дальше уже не так принципиально".
|
||||||
|
|
||||||
|
Двигать - тройка "тег в рецепте + RecipeTagUnlocked в progression.xml + unlock_tier в
|
||||||
|
display_entry", все три должны совпадать, иначе замок на панели соврёт. -->
|
||||||
|
<!-- Модификации Ножа некроманта (см. Config/item_modifiers.xml), 2026-09-07. Пользователь
|
||||||
|
выбрал получение через "Крафт по скиллу Некромантии", поэтому все три идут обычными
|
||||||
|
рецептами с тегом группы, а не через лут.
|
||||||
|
|
||||||
|
Пороги у каждого свои, см. комментарий выше - изначально все шесть сидели в группе 2
|
||||||
|
("Подмастерье", 500 убийств), но 2026-09-09 четыре из них уехали в группу 1 к самому
|
||||||
|
ножу. Распределение остальных тиров скилла эта правка не трогает - его пользователь уже
|
||||||
|
правил вручную (см. progression.xml).
|
||||||
|
|
||||||
|
Ингредиенты - только из уже существующих ресурсов мода плюс ванильная база, как и просил
|
||||||
|
("Ингредиенты из уже существующих ресурсов мода"). Прах зомби и Кожа жертвы обе падают
|
||||||
|
из мешка "Жертва", то есть добываются этим же ножом - моды для ножа делаются из того, что
|
||||||
|
нож добыл. Крафт личный (без craft_area), как у Камня духов и самого ножа: моды дешевле
|
||||||
|
призывов и не должны требовать верстак. Тег learnable, как у остальных гейтованных
|
||||||
|
рецептов мода, чтобы рецепт не светился в меню до открытия группы. -->
|
||||||
|
<append xpath="/recipes">
|
||||||
|
<recipe name="necroModKnifeTearsOfTheDead" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl30">
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
<ingredient name="resourceVictimSkin" count="1"/>
|
||||||
|
<ingredient name="drinkJarEmpty" count="2"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
<recipe name="necroModKnifeScavengersFeast" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl60">
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
<ingredient name="resourceVictimSkin" count="1"/>
|
||||||
|
<ingredient name="foodRawMeat" count="5"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
<recipe name="necroModKnifeDeadMansGrip" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1400">
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
<ingredient name="resourceBone" count="15"/>
|
||||||
|
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
<!-- "Могильный покой" (термозащита), добавлен 2026-09-07, порог 100 с 2026-09-09. Тот же
|
||||||
|
личный крафт, что у остальных модов ножа. Кожа жертвы тут не косметика: мод по смыслу - обмотка
|
||||||
|
рукояти, поэтому её взято 2 (больше всех), плюс перо как утеплитель - ванильный
|
||||||
|
resourceFeather, существование проверено. -->
|
||||||
|
<recipe name="necroModKnifeGravesRepose" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl100">
|
||||||
|
<ingredient name="resourceZombieAsh" count="10"/>
|
||||||
|
<ingredient name="resourceVictimSkin" count="2"/>
|
||||||
|
<ingredient name="resourceFeather" count="10"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
<!-- "Мёртвая буря" (переделка силовой атаки), 2026-09-07. Единственный мод ножа с
|
||||||
|
электрическими деталями в рецепте - искрение берётся из ванильного buffShocked, того
|
||||||
|
же, что у электродубинки, так что ингредиент тематически честный. Существование
|
||||||
|
resourceElectricParts проверено. -->
|
||||||
|
<!-- "Тёмное чутьё" (радар зомби), 2026-09-07. Глаз мертвеца как линза - отсюда кожа
|
||||||
|
жертвы и прах. Стекло взято resourceBrokenGlass (обычный лут), а НЕ resourceScopeLens:
|
||||||
|
та линза крафтится только в кузне и под перком perkAdvancedEngineering, то есть
|
||||||
|
утащила бы рецепт ножа в зависимость от чужой ветки прокачки. resourceGlass, которое
|
||||||
|
напрашивалось по названию, в игре не существует вовсе - проверено. -->
|
||||||
|
<recipe name="necroModKnifeDarkSense" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
|
||||||
|
<ingredient name="resourceZombieAsh" count="15"/>
|
||||||
|
<ingredient name="resourceVictimSkin" count="1"/>
|
||||||
|
<ingredient name="resourceBrokenGlass" count="10"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
|
||||||
|
<recipe name="necroModKnifeDeadStorm" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1700">
|
||||||
|
<ingredient name="resourceZombieAsh" count="15"/>
|
||||||
|
<ingredient name="resourceElectricParts" count="5"/>
|
||||||
|
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||||
|
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||||
|
</recipe>
|
||||||
|
</append>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// "Книга банши" (Banshee's Book) - BACKLOG.md item 7 (dictated 2026-08-28, implemented
|
||||||
|
/// 2026-08-29 "без вопросов" per user request). On use, the book is consumed, plays a
|
||||||
|
/// screamer's own scream sound, and spawns a small HOSTILE horde near the player - hostile to
|
||||||
|
/// the player, unlike the Dog/Swarm/three new zombie pets, which are all summoned allies.
|
||||||
|
///
|
||||||
|
/// DELIBERATE SIMPLIFICATION, flagged rather than guessed past - read before touching this
|
||||||
|
/// file again: the backlog's own research pointed at AIScoutHordeSpawner (decompiled here,
|
||||||
|
/// see the class itself in Assembly-CSharp) as "the" mechanism a real zombieScreamer's scream
|
||||||
|
/// uses. Decompiling it in full shows it is NOT a simple "spawn N zombies now" call - it's a
|
||||||
|
/// whole per-tick simulation object (constructed with an EntitySpawner, driven by
|
||||||
|
/// AIDirector.CanSpawn()/EntitySpawner.CurrentWave/SpawnManually, tracking a scout zombie
|
||||||
|
/// that has to physically wander off, spot a player, and only THEN calls its own
|
||||||
|
/// spawnHordeNear near that scout) - built for the existing "distant scout triggers a
|
||||||
|
/// blood-moon-style horde" system, not for "an item makes a horde appear right now". Wiring a
|
||||||
|
/// real AIScoutHordeSpawner up from a one-shot item click would mean also owning a per-tick
|
||||||
|
/// driver for it (another ModEvents.UnityUpdate loop, same shape as PetFollowPatch.cs) and an
|
||||||
|
/// EntitySpawner instance to hand it, for a payoff (a scout that has to run off and get
|
||||||
|
/// spotted first) the user's own description doesn't ask for - they asked for the scream and
|
||||||
|
/// the horde appearing "poblizosti" (nearby), not a scout-fetch quest.
|
||||||
|
///
|
||||||
|
/// So instead: this directly builds a small set of ordinary hostile zombies near the player
|
||||||
|
/// using the exact same EntityFactory.CreateEntity -> SetSpawnerSource -> SpawnEntityInWorld
|
||||||
|
/// sequence ItemActionSpawnEntity.Spawn itself uses (decompiled to confirm, not guessed) -
|
||||||
|
/// the same primitive this mod's own pet summons are already built on
|
||||||
|
/// (HarmonySrc/SummonPatch.cs), just spawning several real vanilla zombie classes instead of
|
||||||
|
/// one tamed pet, and never touching their AI/flags at all (they're meant to be hostile,
|
||||||
|
/// which is what an untouched vanilla zombie already is by default - the OPPOSITE of the
|
||||||
|
/// pet-taming work the rest of this mod does).
|
||||||
|
///
|
||||||
|
/// Zombie pool (zombieArlene/zombieBoe/zombieYo/zombieJoe) is a hardcoded guess at "generic
|
||||||
|
/// early/mid walker" flavor, picked because all four are confirmed real vanilla entity_class
|
||||||
|
/// names (checked directly against Data/Config/entityclasses.xml) with no special
|
||||||
|
/// gimmick (no explosion, no ranged attack) - NOT gamestage-scaled or otherwise tied to the
|
||||||
|
/// real difficulty-scaling horde-selection system real hordes use. Spawn positions come from
|
||||||
|
/// World.GetMobRandomSpawnPosWithWater (decompiled from AIScoutHordeSpawner's own use of it) -
|
||||||
|
/// the same "find a valid ground spot near here" helper the real scout-horde system itself
|
||||||
|
/// calls, just pointed at the player directly instead of a scout zombie's position.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(ItemActionEat))]
|
||||||
|
[HarmonyPatch("consume")]
|
||||||
|
public static class Patch_ItemActionEat_Consume_Banshee
|
||||||
|
{
|
||||||
|
public const string ItemName = "bookBanshee";
|
||||||
|
|
||||||
|
/// <summary>Real, confirmed vanilla entity_class names - not invented. Deliberately no
|
||||||
|
/// exploders/spitters/screamers-of-their-own in the pool (would either be anticlimactic -
|
||||||
|
/// a screamer summoning more screamers - or risk chain-reaction explosions on the caster).</summary>
|
||||||
|
public static readonly string[] ZombiePool = { "zombieArlene", "zombieBoe", "zombieYo", "zombieJoe" };
|
||||||
|
|
||||||
|
/// <summary>"Небольшая орда" - not specified by the user as an exact number, guessed
|
||||||
|
/// modest (comparable to a small blood-moon wave, not a screen-filling swarm).</summary>
|
||||||
|
public const int HordeSize = 5;
|
||||||
|
|
||||||
|
public static readonly System.Random Rand = new System.Random();
|
||||||
|
|
||||||
|
public static void Postfix(ItemActionData _actionData)
|
||||||
|
{
|
||||||
|
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
|
||||||
|
if (itemName != ItemName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EntityAlive holdingEntity = _actionData.invData.holdingEntity;
|
||||||
|
World world = holdingEntity?.world;
|
||||||
|
if (holdingEntity == null || world == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] BansheePatch: casting near owner=" + holdingEntity.entityId);
|
||||||
|
|
||||||
|
// Reuses the real screamer's own alert sound (zombiefemalescoutalert, see
|
||||||
|
// zombieScreamer in Data/Config/entityclasses.xml's SoundAlert) rather than inventing
|
||||||
|
// a new one - PlayOneShot(string) confirmed via ItemActionEat's own
|
||||||
|
// ExecuteInstantAction, same call shape used there.
|
||||||
|
holdingEntity.PlayOneShot("zombiefemalescoutalert");
|
||||||
|
|
||||||
|
Vector3 casterPos = holdingEntity.GetPosition();
|
||||||
|
for (int i = 0; i < HordeSize; i++)
|
||||||
|
{
|
||||||
|
string zombieClassName = ZombiePool[Rand.Next(ZombiePool.Length)];
|
||||||
|
int classId = EntityClass.GetId(zombieClassName);
|
||||||
|
if (classId == -1)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] BansheePatch: entity class '" + zombieClassName + "' not found");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 15-30m out, never closer than 15m to the caster - same shape of call
|
||||||
|
// AIScoutHordeSpawner itself makes to place a zombie near a point without
|
||||||
|
// dropping it inside terrain/right on top of a player.
|
||||||
|
if (!world.GetMobRandomSpawnPosWithWater(casterPos, 15, 30, 15, true, out Vector3 spawnPos))
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] BansheePatch: no valid spawn position found for " + zombieClassName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Entity entity = EntityFactory.CreateEntity(classId, spawnPos, new Vector3(0f, holdingEntity.rotation.y, 0f));
|
||||||
|
entity.SetSpawnerSource(EnumSpawnerSource.StaticSpawner);
|
||||||
|
world.SpawnEntityInWorld(entity);
|
||||||
|
Debug.Log("[NecromancerTome] BansheePatch: spawned " + zombieClassName + " (" + entity.entityId + ") at " + spawnPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// BACKLOG.md item 9 (dictated 2026-08-29, implemented same day "без вопросов" per user
|
||||||
|
/// request, clarified same day to drop the land-claim requirement entirely). Lets specific
|
||||||
|
/// decorative/prop blocks be picked back up as an item via hold-E + a hand icon + a progress
|
||||||
|
/// timer, the same VISUAL mechanic vanilla workstations (workbench/forge/etc.) already use
|
||||||
|
/// when placed inside your own land claim - but WITHOUT the land-claim requirement, works
|
||||||
|
/// anywhere on the map, per the user's explicit clarification.
|
||||||
|
///
|
||||||
|
/// RESEARCH FIRST, not guessed (decompiled Assembly-CSharp's Block/BlockWorkstation/
|
||||||
|
/// BlockCompositeTileEntity/BlockTrunkTip classes directly):
|
||||||
|
///
|
||||||
|
/// - The actual "hold E, see hand icon + timer, get the item back" mechanic is a GENERIC
|
||||||
|
/// pair of methods already on the base `Block` class itself, not something
|
||||||
|
/// BlockWorkstation invented: `Block.takeItemWithTimer(...)` (instance) calls the static
|
||||||
|
/// `Block.TakeItemWithTimer(pos, blockValue, player, delaySeconds, canTakeCallback)`, which
|
||||||
|
/// opens the real timer UI (XUiC_Timer.OpenTimer) and, on completion, converts the block to
|
||||||
|
/// an item, adds it to inventory (or drops it if full), and clears the block - all engine-
|
||||||
|
/// native, nothing reimplemented here. BlockWorkstation's own "take" activation command is
|
||||||
|
/// just ONE caller of this generic method, gated behind
|
||||||
|
/// `_world.IsMyLandProtectedBlock(...) && tileEntityWorkstation.IsPlayerPlaced` (that IS
|
||||||
|
/// real land-claim gating in vanilla, confirmed - the backlog's original worry about
|
||||||
|
/// needing Harmony either way was right) - this patch calls the SAME generic
|
||||||
|
/// TakeItemWithTimer directly, deliberately WITHOUT that land-claim check, per the user's
|
||||||
|
/// own clarification.
|
||||||
|
/// - Which "take" appears on a block at all is decided by `Block.GetBlockActivationCommands`/
|
||||||
|
/// `HasBlockActivationCommands`/`OnBlockActivated(string,...)` - all three are `virtual` on
|
||||||
|
/// the base `Block` class, so a plain undecorated block (no Class= override in XML) runs the
|
||||||
|
/// base implementation and can be patched there directly. But several candidate blocks use a
|
||||||
|
/// DIFFERENT C# class that overrides all three (confirmed by decompiling it) -
|
||||||
|
/// `BlockCompositeTileEntity` (used by the water cooler/cardboard box below) - so those need
|
||||||
|
/// their own separate patches on that type; a patch on the base `Block` type alone would
|
||||||
|
/// never run for them (Harmony patches the actual method that executes via virtual dispatch,
|
||||||
|
/// not every subclass "logically implementing the same slot").
|
||||||
|
///
|
||||||
|
/// TARGET BLOCKS - best-guess mapping from the user's four Russian category names to real
|
||||||
|
/// Data/Config/blocks.xml block names (checked directly, matched by name PREFIX since most
|
||||||
|
/// categories have many color/variant blocks) - tell me if any of these aren't what was
|
||||||
|
/// meant, this is an interpretation, not a spec:
|
||||||
|
/// - "Кровати" (beds) -> bedMadeNoFrame*/bedMessyNoFrame* ONLY. Deliberately excludes
|
||||||
|
/// bed02*/bunkBedMade*/bunkBedMessy* even though they look like beds too - decompiling
|
||||||
|
/// showed those all use Class="SleepingBag" (they're actually functional sleeping-bag/
|
||||||
|
/// respawn-anchor blocks, not pure decoration - same family as the player's own bedroll,
|
||||||
|
/// which the user explicitly said NOT to touch). "NoFrame" variants have no Class=
|
||||||
|
/// override at all (plain decorative furniture), a clean match for "decorative bed".
|
||||||
|
/// - "Колья" (stakes) -> NOT IMPLEMENTED. The real spike-trap blocks
|
||||||
|
/// (trapSpikesWood*/trapSpikesIron*) use Class="TrunkTip" (BlockTrunkTip : BlockDamage),
|
||||||
|
/// which does NOT override GetBlockActivationCommands/OnBlockActivated at all - it isn't
|
||||||
|
/// built on the activation-command system this "take" mechanic depends on (harvest-node-
|
||||||
|
/// style blocks are typically hit-to-harvest instead). Making these pickable would need a
|
||||||
|
/// genuinely different mechanism, not a variant of this one - left out rather than forced
|
||||||
|
/// in broken. Say if a different "колья" block was meant.
|
||||||
|
/// - "Кулеры с водой" (water coolers) -> cntWaterCooler* (Class="CompositeTileEntity").
|
||||||
|
/// - "Коробки" (boxes) -> cntCardboardBox (Class="CompositeTileEntity") - the one
|
||||||
|
/// unambiguous plain-cardboard-box block; there are dozens of OTHER "*box*" blocks in
|
||||||
|
/// vanilla (mailboxes, breaker boxes, truck cargo) not included here since they don't
|
||||||
|
/// read as "coробки, расставленные на карте" the way a cardboard box does.
|
||||||
|
///
|
||||||
|
/// TakeDelay (8s) is a guess, not specified by the user - shorter than the workstation
|
||||||
|
/// default (15s) since these are simpler props, not a full crafting station.
|
||||||
|
/// NOT VERIFIED IN-GAME - same caution as everything else added 2026-08-29.
|
||||||
|
/// </summary>
|
||||||
|
public static class BlockPickupPatch
|
||||||
|
{
|
||||||
|
public const float TakeDelay = 8f;
|
||||||
|
|
||||||
|
public static readonly string[] TargetPrefixes = new string[]
|
||||||
|
{
|
||||||
|
"bedMadeNoFrame",
|
||||||
|
"bedMessyNoFrame",
|
||||||
|
"cntWaterCooler",
|
||||||
|
"cntCardboardBox",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsTargetBlock(BlockValue _blockValue)
|
||||||
|
{
|
||||||
|
Block block = _blockValue.Block;
|
||||||
|
if (block == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
string name = block.GetBlockName();
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (string prefix in TargetPrefixes)
|
||||||
|
{
|
||||||
|
if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AppendTakeCommand(BlockValue _blockValue, ref BlockActivationCommand[] __result)
|
||||||
|
{
|
||||||
|
if (!IsTargetBlock(_blockValue))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<BlockActivationCommand> commands = new List<BlockActivationCommand>(__result ?? Array.Empty<BlockActivationCommand>());
|
||||||
|
commands.Add(new BlockActivationCommand("take", "hand", true));
|
||||||
|
__result = commands.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HandleTakeActivation(string _commandName, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player, ref bool __result)
|
||||||
|
{
|
||||||
|
if (_commandName != "take" || !IsTargetBlock(_blockValue))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] BlockPickupPatch: take activated on " + _blockValue.Block.GetBlockName() + " at " + _blockPos);
|
||||||
|
// Deliberately calls the static TakeItemWithTimer directly (no canTakeCallback -
|
||||||
|
// null means "always takeable", same default as the base Block.takeItemWithTimer
|
||||||
|
// virtual's own unconditional `return true`) rather than going through
|
||||||
|
// BlockWorkstation's land-claim-gated instance wrapper.
|
||||||
|
Block.TakeItemWithTimer(_blockPos, _blockValue, _player, TakeDelay);
|
||||||
|
__result = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plain Block-class targets (bedMadeNoFrame*/bedMessyNoFrame*) ---
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(Block), "HasBlockActivationCommands")]
|
||||||
|
public static class Patch_Block_HasBlockActivationCommands
|
||||||
|
{
|
||||||
|
public static void Postfix(BlockValue _blockValue, ref bool __result)
|
||||||
|
{
|
||||||
|
if (IsTargetBlock(_blockValue))
|
||||||
|
{
|
||||||
|
__result = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(Block), "GetBlockActivationCommands")]
|
||||||
|
public static class Patch_Block_GetBlockActivationCommands
|
||||||
|
{
|
||||||
|
public static void Postfix(BlockValue _blockValue, ref BlockActivationCommand[] __result)
|
||||||
|
{
|
||||||
|
AppendTakeCommand(_blockValue, ref __result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(Block), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })]
|
||||||
|
public static class Patch_Block_OnBlockActivated
|
||||||
|
{
|
||||||
|
public static bool Prefix(string _commandName, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player, ref bool __result)
|
||||||
|
{
|
||||||
|
return HandleTakeActivation(_commandName, _blockPos, _blockValue, _player, ref __result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BlockCompositeTileEntity targets (cntWaterCooler*/cntCardboardBox) - a DIFFERENT
|
||||||
|
// C# class that overrides the same three methods, so needs its own separate patches;
|
||||||
|
// see the class-level comment above for why patching Block alone wouldn't reach these. ---
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(BlockCompositeTileEntity), "HasBlockActivationCommands")]
|
||||||
|
public static class Patch_Composite_HasBlockActivationCommands
|
||||||
|
{
|
||||||
|
public static void Postfix(BlockValue _blockValue, ref bool __result)
|
||||||
|
{
|
||||||
|
if (IsTargetBlock(_blockValue))
|
||||||
|
{
|
||||||
|
__result = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(BlockCompositeTileEntity), "GetBlockActivationCommands")]
|
||||||
|
public static class Patch_Composite_GetBlockActivationCommands
|
||||||
|
{
|
||||||
|
public static void Postfix(BlockValue _blockValue, ref BlockActivationCommand[] __result)
|
||||||
|
{
|
||||||
|
AppendTakeCommand(_blockValue, ref __result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(BlockCompositeTileEntity), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })]
|
||||||
|
public static class Patch_Composite_OnBlockActivated
|
||||||
|
{
|
||||||
|
public static bool Prefix(string _commandName, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player, ref bool __result)
|
||||||
|
{
|
||||||
|
return HandleTakeActivation(_commandName, _blockPos, _blockValue, _player, ref __result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The core "Deviator" effect: whichever zombie the buff named in CharmBuffName lands on
|
||||||
|
/// (via the item's own XML - see Config/items.xml/buffs.xml) stops targeting the player
|
||||||
|
/// and starts targeting other zombies instead.
|
||||||
|
///
|
||||||
|
/// Why this needs Harmony at all: a zombie's valid-target class list (who it's even allowed
|
||||||
|
/// to look for/attack) is parsed once from its entity_class XML into per-instance AI task
|
||||||
|
/// objects at spawn time (EAIApproachAndAttackTarget / EAISetNearestEntityAsTarget). There is
|
||||||
|
/// no XML-level action to change it afterwards - the buff system alone cannot flip a live
|
||||||
|
/// zombie's allegiance. This patch is the smallest hook that can: it reacts to our specific
|
||||||
|
/// buff being added and then rewrites those same per-instance task objects directly, using
|
||||||
|
/// the exact same fields the game's own EAIManager.SetTargetOnlyPlayers() helper uses for the
|
||||||
|
/// mirror-image trick (restricting a task to players only). We do the same thing in reverse:
|
||||||
|
/// restrict to zombies only.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(EntityBuffs), "AddBuff", new System.Type[] { typeof(string), typeof(Vector3i), typeof(int), typeof(bool), typeof(bool), typeof(float) })]
|
||||||
|
public static class Patch_EntityBuffs_AddBuff_DeviatorCharm
|
||||||
|
{
|
||||||
|
public const string CharmBuffName = "buffNecroDeviatorCharm";
|
||||||
|
|
||||||
|
/// <summary>Diagnostic-only, added 2026-08-28 while chasing "the Knife's Victim debuff
|
||||||
|
/// never sticks" - this patch already logs every AddBuff call for our other marker buff,
|
||||||
|
/// so logging buffNecroVictim's calls here too (no CharmZombie() side effects for it,
|
||||||
|
/// just visibility) answers the actual open question directly: does
|
||||||
|
/// necroWpnBladeNecroKnife's onSelfAttackedOther trigger ever even call AddBuff at all,
|
||||||
|
/// or does it call it but something downstream (target class mismatch, requirement gate,
|
||||||
|
/// stacking) rejects it. No log line at all next time means the XML trigger itself isn't
|
||||||
|
/// firing (likely because EntityDamage computes to 0 and a 0-damage swing doesn't count
|
||||||
|
/// as a landed hit); a log line with a non-Added result narrows it further.</summary>
|
||||||
|
public const string VictimBuffName = "buffNecroVictim";
|
||||||
|
|
||||||
|
public static void Postfix(EntityBuffs __instance, string _name, EntityBuffs.BuffStatus __result)
|
||||||
|
{
|
||||||
|
if (_name == CharmBuffName || _name == VictimBuffName)
|
||||||
|
{
|
||||||
|
// entityId added 2026-08-28 while chasing "the zombie that died didn't have the
|
||||||
|
// buff even though AddBuff Added fired repeatedly" - logging just the TYPE name
|
||||||
|
// couldn't tell whether it landed on the same zombie that later died or a
|
||||||
|
// different one nearby (the knife swings a 90-degree arc, plausible it's hitting
|
||||||
|
// more than one zombie in a group). This settles it directly against
|
||||||
|
// VictimPatch's own "dropItemOnDeath Prefix entered for <id>" line.
|
||||||
|
string parentId = __instance.parent != null ? __instance.parent.entityId.ToString() : "?";
|
||||||
|
Debug.Log("[NecromancerTome] AddBuff(" + _name + ") result=" + __result + " parent=" + (__instance.parent != null ? __instance.parent.GetType().Name : "null") + " id=" + parentId);
|
||||||
|
}
|
||||||
|
if (__result != EntityBuffs.BuffStatus.Added)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_name != CharmBuffName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// HUMANOID-ONLY IS A DELIBERATE DESIGN DECISION, NOT A GAP TO CLOSE (user, 2026-09-07:
|
||||||
|
// "Камень духов не годится, он для зомбо-гуманоидов, и пусть так и остаётся").
|
||||||
|
// Do not "fix" this by widening the type check.
|
||||||
|
//
|
||||||
|
// The user had noticed the behaviour in play first ("камень душ на зомбособаках не
|
||||||
|
// работает, это я проверил однажды") and was right. The reason is this line: zombie
|
||||||
|
// ANIMALS sit on a completely different branch of the C# hierarchy, so the pattern
|
||||||
|
// match below is false for every one of them -
|
||||||
|
// EntityZombie : EntityHuman <- the only charmable one
|
||||||
|
// EntityZombieDog : EntityEnemyAnimal : EntityEnemy
|
||||||
|
// EntityEnemyAnimal : EntityEnemy (bear, boar)
|
||||||
|
// EntityVulture : EntityFlying (not even EntityEnemy)
|
||||||
|
// - while the XML side still lets the buff land on them, because they DO carry the
|
||||||
|
// "zombie" tag ("entity,animal,zombie,zombieAnimal,hostile,..."), which is what
|
||||||
|
// EntityTagCompare gates on. So the buff is added, this check rejects it, and the log
|
||||||
|
// line below fires. That log line is expected on zombie animals and is not an error.
|
||||||
|
//
|
||||||
|
// Note the same decision applies to the other half of this patch: CharmZombie() below
|
||||||
|
// restricts targetClasses to typeof(EntityZombie), so a charmed humanoid also ignores
|
||||||
|
// zombie animals as targets. Consistent with "the Spirit Stone is a humanoid tool".
|
||||||
|
// Countering zombie animals/birds is meant to be a separate mechanic - open topic,
|
||||||
|
// see BACKLOG.md 2026-09-07.
|
||||||
|
if (!(__instance.parent is EntityZombie zombie) || zombie.aiManager == null)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] charm buff added but parent is not a charmable EntityZombie (aiManager null or wrong type)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CharmZombie(zombie);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CharmZombie(EntityZombie zombie)
|
||||||
|
{
|
||||||
|
// AITask: whichever task actually chases/melees the current attack target.
|
||||||
|
// Restrict it to EntityZombie only, same shape as SetTargetOnlyPlayers() but reversed.
|
||||||
|
List<EAIApproachAndAttackTarget> approachTasks = zombie.aiManager.GetTasks<EAIApproachAndAttackTarget>();
|
||||||
|
Debug.Log("[NecromancerTome] CharmZombie: approachTasks=" + (approachTasks != null ? approachTasks.Count.ToString() : "null"));
|
||||||
|
if (approachTasks != null)
|
||||||
|
{
|
||||||
|
foreach (EAIApproachAndAttackTarget task in approachTasks)
|
||||||
|
{
|
||||||
|
task.targetClasses.Clear();
|
||||||
|
task.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass
|
||||||
|
{
|
||||||
|
type = typeof(EntityZombie),
|
||||||
|
chaseTimeMax = 0f
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AITarget: whichever task picks the nearest valid entity to go attack in the first
|
||||||
|
// place. Same restriction here, or the zombie would never even pick a target to hand
|
||||||
|
// off to the approach task above.
|
||||||
|
List<EAISetNearestEntityAsTarget> targetTasks = zombie.aiManager.GetTargetTasks<EAISetNearestEntityAsTarget>();
|
||||||
|
Debug.Log("[NecromancerTome] CharmZombie: targetTasks=" + (targetTasks != null ? targetTasks.Count.ToString() : "null"));
|
||||||
|
if (targetTasks != null)
|
||||||
|
{
|
||||||
|
foreach (EAISetNearestEntityAsTarget task in targetTasks)
|
||||||
|
{
|
||||||
|
task.targetClasses.Clear();
|
||||||
|
task.targetClasses.Add(new EAISetNearestEntityAsTarget.TargetClass
|
||||||
|
{
|
||||||
|
type = typeof(EntityZombie),
|
||||||
|
hearDistMax = 50f,
|
||||||
|
seeDistMax = 0f
|
||||||
|
});
|
||||||
|
task.playerTargetClassIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop whatever it was mid-attack on (almost certainly the player who just threw the
|
||||||
|
// book at it) so the switch takes effect immediately instead of after its current target dies/despawns.
|
||||||
|
zombie.SetAttackTarget(null, 0);
|
||||||
|
Debug.Log("[NecromancerTome] CharmZombie: done, attack target cleared for " + zombie.EntityName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lets a charmed ("Deviator"-hit) zombie actually hurt other zombies.
|
||||||
|
///
|
||||||
|
/// EntityAlive.DamageEntity has a hardcoded block, independent of anything AI/targeting
|
||||||
|
/// related: `if (!isHeatDamage && (entityFlags & attacker.entityFlags & EntityFlags.Zombie) != None)
|
||||||
|
/// return -1;` - i.e. any two entities that are BOTH flagged EntityFlags.Zombie (which is
|
||||||
|
/// every zombie, always) simply cannot damage each other, full stop, no matter what
|
||||||
|
/// CharmPatch.cs did to the attacker's AI targeting. Without this, a charmed zombie would
|
||||||
|
/// walk up to another zombie and "attack" it forever with every hit landing as a no-op.
|
||||||
|
///
|
||||||
|
/// Fix: for the duration of a single DamageEntity call where the attacker currently has
|
||||||
|
/// buffNecroDeviatorCharm and the target is also a zombie, temporarily clear
|
||||||
|
/// EntityFlags.Zombie on the ATTACKER only (never the target, never persisted) so the AND
|
||||||
|
/// check in the original method comes up empty and damage proceeds normally. Restored
|
||||||
|
/// immediately afterward, so nothing else about that zombie (kill-counting via its Tags
|
||||||
|
/// property, quest tracking, anything else keyed off EntityFlags) is ever affected outside
|
||||||
|
/// this one call. A Stack (not a single field) survives re-entrant DamageEntity calls
|
||||||
|
/// correctly (e.g. explosions/knockback triggering further damage inside the same callstack).
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
|
||||||
|
public static class Patch_EntityAlive_DamageEntity_CharmedZombieVsZombie
|
||||||
|
{
|
||||||
|
public static readonly Stack<EntityZombie> ToggledAttackers = new Stack<EntityZombie>();
|
||||||
|
|
||||||
|
public static void Prefix(EntityAlive __instance, DamageSource _damageSource)
|
||||||
|
{
|
||||||
|
EntityZombie toggled = null;
|
||||||
|
if (__instance is EntityZombie && __instance.world != null)
|
||||||
|
{
|
||||||
|
EntityAlive attacker = __instance.world.GetEntity(_damageSource.getEntityId()) as EntityAlive;
|
||||||
|
if (attacker is EntityZombie attackerZombie
|
||||||
|
&& attackerZombie.Buffs != null
|
||||||
|
&& attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
|
||||||
|
&& (attackerZombie.entityFlags & EntityFlags.Zombie) != EntityFlags.None)
|
||||||
|
{
|
||||||
|
attackerZombie.entityFlags &= ~EntityFlags.Zombie;
|
||||||
|
toggled = attackerZombie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToggledAttackers.Push(toggled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Postfix()
|
||||||
|
{
|
||||||
|
EntityZombie toggled = ToggledAttackers.Pop();
|
||||||
|
if (toggled != null)
|
||||||
|
{
|
||||||
|
toggled.entityFlags |= EntityFlags.Zombie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Финальная сцена Чёрного портального камня: шесть полноэкранных слайдов с текстом и
|
||||||
|
/// кнопками Назад/Дальше, а на последнем - выбор концовки. Заменяет собой прежнюю связку
|
||||||
|
/// "подтверждение -> сразу видео" (см. PortalStonePatch.ActivateBlackPortal, которая теперь
|
||||||
|
/// зовёт Begin вместо PlayVideo).
|
||||||
|
///
|
||||||
|
/// ЗАЧЕМ ЭТО ВООБЩЕ (BACKLOG.md, "концовка серией диалоговых окон вместо видео", запись
|
||||||
|
/// 2026-09-07): видео не локализуется - под английскую версию пришлось бы держать второй
|
||||||
|
/// файл. Текст слайдов идёт через Localization.Get + Localization.csv, то есть переводится
|
||||||
|
/// строкой, как всё остальное в моде.
|
||||||
|
///
|
||||||
|
/// НИ ОДНОГО НОВОГО UI-ПРИМИТИВА ЗДЕСЬ НЕ ИЗОБРЕТЕНО - вся сцена собрана из двух уже
|
||||||
|
/// работающих в этом моде кусков:
|
||||||
|
/// - Картинка: своё окно на слайд (Config/XUi_InGame/windows.xml, шесть штук
|
||||||
|
/// necroFinalSlide1..6) плюс свой атлас UIAtlases/NecroFinal. Механизм атласа тот же,
|
||||||
|
/// что у ItemIconAtlas/ItemIconAtlasGreyscale. Окна БЕЗ контроллера и без привязок -
|
||||||
|
/// спрайт в каждом прописан жёстко, поэтому листание это просто Close одного окна и
|
||||||
|
/// Open другого, см. ShowSlideWindow. Открываются НЕмодально: в
|
||||||
|
/// GUIWindowManager.openInternal модальное открытие зовёт CloseAllOpenModalWindows(), и
|
||||||
|
/// модальный messageBox снёс бы модальный слайд.
|
||||||
|
/// - Текст и кнопки: ванильный XUiC_MessageBoxWindowGroup.ShowCustom - ровно тот же вызов,
|
||||||
|
/// которым PortalStonePatch уже показывает подтверждение "да/нет". Шаблон
|
||||||
|
/// <messagebox> (Data/Config/XUi_Common/templates.xml:142) объявляет таблицу кнопок с
|
||||||
|
/// repeat_count="3", то есть три слота - их хватает и на Назад/Дальше, и на выбор из
|
||||||
|
/// двух вариантов с Назад в придачу.
|
||||||
|
///
|
||||||
|
/// ПОЧЕМУ ПЕРЕЛИСТЫВАНИЕ НЕ МОРГАЕТ: showMessage (декомпилировано) делает
|
||||||
|
/// "if (windowGroup.isShowing) OnOpen(); else windowManager.Open(...)" - то есть повторный
|
||||||
|
/// ShowCustom на уже открытой коробке просто обновляет её содержимое на месте, не закрывая
|
||||||
|
/// и не открывая окно заново.
|
||||||
|
///
|
||||||
|
/// ESC. _buttonOnExternalClose говорит, какую кнопку "нажать", если окно закрыли снаружи
|
||||||
|
/// (Esc). Ставим 2 ("Дальше") на слайдах 1-5 и 0 ("Назад") на шестом. Первое - чтобы Esc не
|
||||||
|
/// оставлял игрока в замершей паузе с картинкой на весь экран и без единого элемента
|
||||||
|
/// управления; второе - чтобы случайным Esc нельзя было ВЫБРАТЬ концовку. При -1 окно
|
||||||
|
/// закрылось бы, не нажав ничего, и сцена повисла бы намертво.
|
||||||
|
///
|
||||||
|
/// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
|
||||||
|
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
|
||||||
|
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
|
||||||
|
/// PortalStonePatch.ActivateBlackPortal). Как и вся остальная UI-часть этого мода, сцена
|
||||||
|
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
|
||||||
|
/// ограничение самой ванили, а не мода.
|
||||||
|
/// </summary>
|
||||||
|
public static class FinalSlides
|
||||||
|
{
|
||||||
|
public const int PageCount = 6;
|
||||||
|
|
||||||
|
/// <summary>Имена окон и групп в Config/XUi_InGame/windows.xml + xui.xml: к префиксу
|
||||||
|
/// приписывается номер страницы, 1..PageCount.</summary>
|
||||||
|
public const string SlideWindowPrefix = "necroFinalSlide";
|
||||||
|
|
||||||
|
/// <summary>Окно-заливка под текст эпилога: картинки к этому моменту кончились, текст
|
||||||
|
/// идёт по чёрному. Объявлено в Config/XUi_InGame/windows.xml + xui.xml.</summary>
|
||||||
|
public const string BlackWindow = "necroFinalBlack";
|
||||||
|
|
||||||
|
/// <summary>Имя Чёрного портального камня - его отбирают у игрока на концовке
|
||||||
|
/// "Вернуться" (см. ConsumeBlackStone). Совпадает с
|
||||||
|
/// Patch_ItemActionEat_ExecuteAction_PortalStones.BlackStoneName, продублировано здесь
|
||||||
|
/// строкой, чтобы этот класс не зависел от патча в другом файле.</summary>
|
||||||
|
public const string BlackStoneName = "thrownStonePortalBlack";
|
||||||
|
|
||||||
|
/// <summary>Имя спрайта с картинкой внутри окна слайда. ОБЯЗАНО совпадать с
|
||||||
|
/// name="slideArt" в Config/XUi_InGame/windows.xml - по нему ищется Transform, который
|
||||||
|
/// потом масштабируется наездом (см. RevealPage).</summary>
|
||||||
|
public const string SlideArtId = "slideArt";
|
||||||
|
|
||||||
|
/// <summary>Пауза между появлением картинки и появлением текстовой коробки поверх неё,
|
||||||
|
/// в секундах. Продиктовано 2026-09-09: "можно ли выводить диалоговое окно с задержкой в
|
||||||
|
/// 5 секунд, чтобы пользователь успевал увидеть картинку".
|
||||||
|
///
|
||||||
|
/// ВРЕМЯ СЧИТАЕТСЯ НЕМАСШТАБИРУЕМОЕ, И ЭТО НЕ ПРИДИРКА. Сцена стартует из Begin сразу
|
||||||
|
/// после GameManager.Instance.Pause(true), а тот выставляет Time.timeScale = 0. Обычный
|
||||||
|
/// WaitForSeconds и Time.deltaTime считают как раз по масштабированному времени, то есть
|
||||||
|
/// при timeScale = 0 не досчитали бы НИКОГДА: текст не появился бы вообще, и игрок
|
||||||
|
/// остался бы с картинкой и без единой кнопки. Поэтому везде ниже - unscaledDeltaTime.
|
||||||
|
/// (Ровно на этом уже обжигались в PyramidWardPatch, см. BACKLOG.md.)
|
||||||
|
///
|
||||||
|
/// Поставить 0, чтобы вернуть прежнее поведение "текст сразу, без наезда".</summary>
|
||||||
|
public const float SlideRevealSeconds = 5f;
|
||||||
|
|
||||||
|
/// <summary>Насколько картинка увеличивается за эти секунды: 0.10 = медленный наезд на
|
||||||
|
/// 10%. Продиктовано 2026-09-09. Ноль - наезда нет, картинка просто стоит.
|
||||||
|
///
|
||||||
|
/// Делается через localScale спрайта, а не через пересчёт якорей. Спрайт растянут по
|
||||||
|
/// #cam на все четыре стороны, то есть его РАЗМЕР каждый кадр пересчитывает сама NGUI по
|
||||||
|
/// якорям - трогать размер бессмысленно, его тут же перезапишут. А localScale к этому
|
||||||
|
/// отношения не имеет: XUiView выставляет его один раз при создании (Vector3.one) и
|
||||||
|
/// больше не трогает, так что наше значение держится. Масштабирование идёт от центра
|
||||||
|
/// виджета, поэтому кадр наезжает симметрично и ничего не перекашивает.
|
||||||
|
///
|
||||||
|
/// Пропорции при этом не плывут: картинка уже приведена к 16:9 чёрными полями по бокам
|
||||||
|
/// (см. UIAtlases/NecroFinal и комментарий в windows.xml), а наезд на 10% срезает по 4.5%
|
||||||
|
/// с каждой стороны - поля становятся уже, но не исчезают и не растягиваются.</summary>
|
||||||
|
public const float SlideZoomAmount = 0.10f;
|
||||||
|
|
||||||
|
/// <summary>Номер сейчас открытого слайда, 0 = ни одного. Статика, а не поле игрока:
|
||||||
|
/// сцена по построению одна на весь клиент и заканчивается выходом в главное меню.</summary>
|
||||||
|
private static int openSlide;
|
||||||
|
|
||||||
|
/// <summary>Слайды, которые игрок уже видел. Задержка и наезд нужны только при ПЕРВОМ
|
||||||
|
/// показе: на кнопку "Назад" картинка уже знакома, и повторное пятисекундное ожидание
|
||||||
|
/// читалось бы как зависание, а не как пауза на разглядывание.</summary>
|
||||||
|
private static readonly HashSet<int> revealedPages = new HashSet<int>();
|
||||||
|
|
||||||
|
public static void Begin(EntityPlayerLocal player)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] FinalSlides: starting finale for owner=" + player.entityId);
|
||||||
|
GameManager.Instance.Pause(true);
|
||||||
|
openSlide = 0;
|
||||||
|
revealedPages.Clear();
|
||||||
|
ShowPage(player, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowPage(EntityPlayerLocal player, int page)
|
||||||
|
{
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
|
ShowSlideWindow(playerUI, page);
|
||||||
|
|
||||||
|
// Первый показ слайда - сначала голая картинка с медленным наездом, текстовая коробка
|
||||||
|
// приезжает через SlideRevealSeconds. Возврат на уже виденный слайд - сразу с текстом
|
||||||
|
// и без повторного наезда.
|
||||||
|
if (SlideRevealSeconds > 0f && revealedPages.Add(page))
|
||||||
|
{
|
||||||
|
GameManager.Instance.StartCoroutine(RevealPage(player, page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShowText(player, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Пять секунд наезда, потом текст. Крутится покадрово, а не через
|
||||||
|
/// WaitForSecondsRealtime, потому что наезд всё равно надо обновлять каждый кадр - так
|
||||||
|
/// одна корутина делает обе вещи.
|
||||||
|
///
|
||||||
|
/// GameManager - обычный MonoBehaviour, и корутины на нём тикают из Update, то есть при
|
||||||
|
/// timeScale = 0 продолжают идти; замирает только само ожидание, если считать его
|
||||||
|
/// масштабированным временем (см. SlideRevealSeconds). Тот же способ запуска корутины
|
||||||
|
/// использует и ваниль - MinEventActionModifyStats.executeDelayed для Delay= у
|
||||||
|
/// triggered_effect.</summary>
|
||||||
|
private static IEnumerator RevealPage(EntityPlayerLocal player, int page)
|
||||||
|
{
|
||||||
|
Transform art = FindSlideArt(player, page);
|
||||||
|
if (art != null)
|
||||||
|
{
|
||||||
|
art.localScale = Vector3.one;
|
||||||
|
}
|
||||||
|
|
||||||
|
float elapsed = 0f;
|
||||||
|
while (elapsed < SlideRevealSeconds)
|
||||||
|
{
|
||||||
|
// Сцену успели закрыть (например, игрок вышел через Esc-меню, пока коробки на
|
||||||
|
// экране не было) - бросаем и наезд, и показ текста.
|
||||||
|
if (openSlide != page)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
elapsed += Time.unscaledDeltaTime;
|
||||||
|
if (art != null)
|
||||||
|
{
|
||||||
|
float k = 1f + SlideZoomAmount * Mathf.Clamp01(elapsed / SlideRevealSeconds);
|
||||||
|
art.localScale = new Vector3(k, k, 1f);
|
||||||
|
}
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openSlide == page)
|
||||||
|
{
|
||||||
|
ShowText(player, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ищет Transform картинки внутри окна слайда: группа по имени -> потомок по
|
||||||
|
/// id -> его view. GetChildById рекурсивный (декомпилировано), так что вложенность
|
||||||
|
/// значения не имеет. Возвращает null, если что-то из цепочки не нашлось - тогда наезда
|
||||||
|
/// просто не будет, а текст всё равно покажется: анимация не должна уметь сломать
|
||||||
|
/// сцену.</summary>
|
||||||
|
private static Transform FindSlideArt(EntityPlayerLocal player, int page)
|
||||||
|
{
|
||||||
|
XUiController group = LocalPlayerUI.GetUIForPlayer(player).xui.FindWindowGroupByName(SlideWindowPrefix + page);
|
||||||
|
XUiController art = (group != null) ? group.GetChildById(SlideArtId) : null;
|
||||||
|
if (art == null || art.ViewComponent == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] FinalSlides: no \"" + SlideArtId + "\" view on slide " + page + ", zoom skipped");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return art.ViewComponent.UiTransform;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowText(EntityPlayerLocal player, int page)
|
||||||
|
{
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
|
bool lastPage = page >= PageCount;
|
||||||
|
XUiC_MessageBoxWindowGroup.ShowCustom(
|
||||||
|
playerUI.xui,
|
||||||
|
Localization.Get("necroFinalPage" + page + "Title"),
|
||||||
|
Localization.Get("necroFinalPage" + page + "Text"),
|
||||||
|
"",
|
||||||
|
delegate(XUiC_MessageBoxWindowGroup mb)
|
||||||
|
{
|
||||||
|
// Слот 0 - "Назад", кроме самого первого слайда, где назад некуда.
|
||||||
|
// Хоткея нарочно нет (null): Esc уже разобран через
|
||||||
|
// _buttonOnExternalClose ниже, и вешать его сюда же значило бы обработать
|
||||||
|
// одно нажатие дважды.
|
||||||
|
if (page > 1)
|
||||||
|
{
|
||||||
|
mb.Buttons[0].Set("necroFinalBtnBack", null, delegate { ShowPage(player, page - 1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastPage)
|
||||||
|
{
|
||||||
|
// DefaultConfirm вешает на кнопку хоткей Submit (Enter) - листать можно
|
||||||
|
// и с клавиатуры, не целясь мышью.
|
||||||
|
mb.Buttons[2].DefaultConfirm("necroFinalBtnNext", delegate { ShowPage(player, page + 1); });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mb.Buttons[1].Set("necroFinalBtnStay", null, delegate { ShowEpilogue(player, _stay: true); });
|
||||||
|
mb.Buttons[2].Set("necroFinalBtnReturn", null, delegate { ShowEpilogue(player, _stay: false); });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_openMainMenuOnClose: false,
|
||||||
|
_modal: true,
|
||||||
|
_buttonOnOutsideClick: -1,
|
||||||
|
// См. блок "ESC" в комментарии к классу.
|
||||||
|
_buttonOnExternalClose: lastPage ? 0 : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Закрывает предыдущее окно-слайд и открывает нужное. Открытие немодальное -
|
||||||
|
/// иначе следующий же модальный ShowCustom закрыл бы картинку (GUIWindowManager
|
||||||
|
/// .openInternal -> CloseAllOpenModalWindows).</summary>
|
||||||
|
private static void ShowSlideWindow(LocalPlayerUI playerUI, int page)
|
||||||
|
{
|
||||||
|
if (openSlide == page)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CloseSlideWindow(playerUI);
|
||||||
|
playerUI.windowManager.Open(SlideWindowPrefix + page, false);
|
||||||
|
openSlide = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CloseSlideWindow(LocalPlayerUI playerUI)
|
||||||
|
{
|
||||||
|
if (openSlide > 0)
|
||||||
|
{
|
||||||
|
playerUI.windowManager.Close(SlideWindowPrefix + openSlide);
|
||||||
|
openSlide = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Седьмой экран: чёрный фон и текст выбранной концовки, единственная кнопка -
|
||||||
|
/// "Конец".
|
||||||
|
///
|
||||||
|
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
|
||||||
|
/// из финала"). Файлы Video/FinalStay.webm и FinalReturn.webm остались лежать на месте, а
|
||||||
|
/// сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в
|
||||||
|
/// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая
|
||||||
|
/// код по кускам.
|
||||||
|
///
|
||||||
|
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
|
||||||
|
/// текст показывается сразу.</summary>
|
||||||
|
private static void ShowEpilogue(EntityPlayerLocal player, bool _stay)
|
||||||
|
{
|
||||||
|
string choice = _stay ? "stay" : "return";
|
||||||
|
Debug.Log("[NecromancerTome] FinalSlides: ending chosen (" + choice + ") by owner=" + player.entityId);
|
||||||
|
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
|
CloseSlideWindow(playerUI);
|
||||||
|
playerUI.windowManager.Open(BlackWindow, false);
|
||||||
|
|
||||||
|
string prefix = _stay ? "necroFinalStay" : "necroFinalReturn";
|
||||||
|
XUiC_MessageBoxWindowGroup.ShowCustom(
|
||||||
|
playerUI.xui,
|
||||||
|
Localization.Get(prefix + "Title"),
|
||||||
|
Localization.Get(prefix + "Text"),
|
||||||
|
"",
|
||||||
|
delegate(XUiC_MessageBoxWindowGroup mb)
|
||||||
|
{
|
||||||
|
mb.Buttons[0].DefaultConfirm("necroFinalBtnTheEnd", delegate { FinishEnding(player, _stay); });
|
||||||
|
},
|
||||||
|
_openMainMenuOnClose: false,
|
||||||
|
_modal: true,
|
||||||
|
_buttonOnOutsideClick: -1,
|
||||||
|
// Esc здесь равносилен "Конец": выбор уже сделан, отменять нечего, а оставить
|
||||||
|
// игрока на чёрном экране в замершей паузе нельзя.
|
||||||
|
_buttonOnExternalClose: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Две концовки расходятся именно здесь, и только здесь.</summary>
|
||||||
|
private static void FinishEnding(EntityPlayerLocal player, bool _stay)
|
||||||
|
{
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
|
playerUI.windowManager.Close(BlackWindow);
|
||||||
|
|
||||||
|
if (_stay)
|
||||||
|
{
|
||||||
|
// "Остаться" - игра действительно кончилась. Disconnect() это ровно то, что зовёт
|
||||||
|
// кнопка "Выйти в главное меню" из игрового Esc-меню: она закрывает окна, сама
|
||||||
|
// снимает паузу (Pause(false) внутри), сохраняет и гасит локальный сервер.
|
||||||
|
Debug.Log("[NecromancerTome] FinalSlides: staying - exiting to main menu");
|
||||||
|
GameManager.Instance.Disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Вернуться" - игрок продолжает играть в том же мире. Паузу здесь снимаем сами:
|
||||||
|
// Disconnect(), который делал это за нас, не вызывается.
|
||||||
|
Debug.Log("[NecromancerTome] FinalSlides: returning to the game");
|
||||||
|
ConsumeBlackStone(player);
|
||||||
|
GameManager.Instance.Pause(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Забирает у игрока один Чёрный портальный камень. Продиктовано 2026-09-09:
|
||||||
|
/// "портальный камень исчезает из инвентаря. Разумеется, его потом можно скрафтить
|
||||||
|
/// заново" - рецепт не трогаем, только предмет.
|
||||||
|
///
|
||||||
|
/// Ищем и в поясе (inventory), и в рюкзаке (bag): камень применяется из руки, то есть
|
||||||
|
/// лежит в поясе, но игрок мог за время сцены... вообще-то не мог - игра на паузе, а
|
||||||
|
/// сцена модальная. Проверяем оба всё равно, это дешевле, чем полагаться на догадку о
|
||||||
|
/// том, где предмет обязан оказаться.
|
||||||
|
///
|
||||||
|
/// DecItem у Bag и Inventory имеет одинаковую сигнатуру и возвращает, сколько СНЯТЬ НЕ
|
||||||
|
/// УДАЛОСЬ (декомпилировано) - поэтому остаток от первого вызова передаётся во второй.
|
||||||
|
/// Если камня не нашлось нигде, пишем варнинг и молча продолжаем: концовка не должна
|
||||||
|
/// падать из-за инвентаря.</summary>
|
||||||
|
private static void ConsumeBlackStone(EntityPlayerLocal player)
|
||||||
|
{
|
||||||
|
ItemValue stone = ItemClass.GetItem(BlackStoneName);
|
||||||
|
if (stone == null || stone.IsEmpty())
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] FinalSlides: item \"" + BlackStoneName + "\" not found, nothing consumed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int left = 1;
|
||||||
|
if (player.inventory != null)
|
||||||
|
{
|
||||||
|
left = player.inventory.DecItem(stone, left);
|
||||||
|
}
|
||||||
|
if (left > 0 && player.bag != null)
|
||||||
|
{
|
||||||
|
left = player.bag.DecItem(stone, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left > 0)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] FinalSlides: no " + BlackStoneName + " found on owner=" + player.entityId + " to consume");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] FinalSlides: consumed one " + BlackStoneName + " from owner=" + player.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Mod entry point. The game finds this by scanning every assembly dropped in a
|
||||||
|
/// Mods/<ModFolder>/ directory for a type implementing IModApi.
|
||||||
|
/// </summary>
|
||||||
|
public class ModEntry : IModApi
|
||||||
|
{
|
||||||
|
public void InitMod(Mod _modInstance)
|
||||||
|
{
|
||||||
|
var harmony = new Harmony("necromancertome.harmony");
|
||||||
|
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||||
|
PetFollowPatch.Init();
|
||||||
|
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||||
|
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||||
|
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||||
|
|
||||||
|
// Diagnostic-only, added 2026-08-28 while chasing "VictimPatch never logs anything at
|
||||||
|
// all for dropItemOnDeath, even though decompiling EntityAlive.OnEntityDeath()
|
||||||
|
// confirms it's called unconditionally right after the 'killed by' line seen in the
|
||||||
|
// log". This checks, at load time, whether Harmony actually attached our Prefix to
|
||||||
|
// that method at all - rules "patch never applied" in or out without waiting on
|
||||||
|
// another in-game death.
|
||||||
|
VerifyPrefixAttached(typeof(EntityAlive), "dropItemOnDeath");
|
||||||
|
VerifyPrefixAttached(typeof(Entity), "DropBagServer");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void VerifyPrefixAttached(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;
|
||||||
|
Debug.Log("[NecromancerTome] ModEntry: " + type.Name + "." + methodName + " resolved, has " + prefixCount + " prefix patch(es) attached after PatchAll");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml
|
||||||
|
/// (resourceNecromancerBlood) for the item, recipes.xml for the base recipe (an empty jar,
|
||||||
|
/// like any other resource conversion). Two rules the user asked for have NO vanilla XML
|
||||||
|
/// equivalent at all, so both are enforced here instead:
|
||||||
|
/// 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
|
||||||
|
/// consumed" ingredient flag (confirmed - only `craft_tool="itemName"` exists for
|
||||||
|
/// something adjacent, but it takes exactly ONE item name, not "any item of a category",
|
||||||
|
/// and its actual runtime enforcement point wasn't confirmed by decompilation either -
|
||||||
|
/// not risking an untested mechanism for this).
|
||||||
|
/// 2. "При крафте нужно отнимать у персонажа 90% имеющегося ХП" - crafting this recipe
|
||||||
|
/// costs 90% of the player's CURRENT health. Recipes have no cost hook beyond their
|
||||||
|
/// ingredient list at all.
|
||||||
|
///
|
||||||
|
/// "любого ножа" (ANY knife) is detected via ItemClass.DisplayType == "meleeKnife" - decompiled
|
||||||
|
/// Data/Config/items.xml directly: every real vanilla knife (meleeWpnBladeT0BoneKnife,
|
||||||
|
/// meleeWpnBladeT1HuntingKnife, even meleeWpnBladeT3Machete) shares this exact DisplayType,
|
||||||
|
/// which is how the game itself categorizes "the knife family" in its own UI - a single,
|
||||||
|
/// reliable check instead of hand-maintaining a list of item names. necroWpnBladeNecroKnife
|
||||||
|
/// (Extends meleeWpnBladeT0BoneKnife, never overrides DisplayType) is covered by the same
|
||||||
|
/// check automatically.
|
||||||
|
///
|
||||||
|
/// PATCH POINTS - both on XUiC_RecipeStack, decompiled directly (not guessed):
|
||||||
|
/// - SetRecipe(...) Prefix: the earliest confirmed point a "craft this recipe" click reaches
|
||||||
|
/// (XUiC_CraftingQueue.AddRecipeToCraftAtIndex calls straight into this). Blocking here
|
||||||
|
/// (return false) stops the craft from ever starting - recipe/recipeCount never get set,
|
||||||
|
/// isCrafting never becomes true.
|
||||||
|
/// - outputStack() Prefix+Postfix (via __state): outputStack() is where the output item is
|
||||||
|
/// actually granted, once per queued unit - HP is only deducted when __result is true,
|
||||||
|
/// i.e. the item genuinely was produced this call, not on a failed/blocked attempt.
|
||||||
|
///
|
||||||
|
/// CAVEAT - not glossed over: decompiling XUiC_RecipeStack/XUiC_CraftingQueue/XUiM_Recipes did
|
||||||
|
/// NOT turn up the exact line that removes ingredients from the player's inventory (it happens
|
||||||
|
/// somewhere upstream of SetRecipe, in whatever UI code handles the actual "Craft" button click
|
||||||
|
/// - not found within reasonable search). XUiC_RecipeStack.HandleOnPress (the CANCEL button)
|
||||||
|
/// refunds ingredients, which proves they're already gone by the time SetRecipe runs - so if
|
||||||
|
/// SetRecipe's Prefix blocks a no-knife attempt, the jar may already be spent with nothing
|
||||||
|
/// granted back. Blocking at the earliest CONFIRMED point was judged better than not blocking
|
||||||
|
/// at all; a lost jar on a rare misclick is a minor rough edge, not a correctness bug. Revisit
|
||||||
|
/// if this turns out to happen often in practice.
|
||||||
|
/// </summary>
|
||||||
|
public static class NecromancerBloodPatch
|
||||||
|
{
|
||||||
|
public const string BloodItemName = "resourceNecromancerBlood";
|
||||||
|
public const float HealthCostFraction = 0.9f;
|
||||||
|
|
||||||
|
public static bool HasAnyKnife(EntityPlayerLocal player)
|
||||||
|
{
|
||||||
|
return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsKnife(ItemStack[] slots)
|
||||||
|
{
|
||||||
|
if (slots == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (ItemStack stack in slots)
|
||||||
|
{
|
||||||
|
if (stack == null || stack.IsEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stack.itemValue?.ItemClass?.DisplayType == "meleeKnife")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(XUiC_RecipeStack), "SetRecipe")]
|
||||||
|
public static class Patch_XUiC_RecipeStack_SetRecipe_NecromancerBlood
|
||||||
|
{
|
||||||
|
public static bool Prefix(XUiC_RecipeStack __instance, Recipe _recipe, bool recipeModification)
|
||||||
|
{
|
||||||
|
// recipeModification covers the "clear this slot" calls (ClearQueue/RefreshQueue/
|
||||||
|
// cancel) - never block those, only an actual attempt to start crafting our recipe.
|
||||||
|
if (recipeModification || _recipe == null || _recipe.GetName() != NecromancerBloodPatch.BloodItemName)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
EntityPlayerLocal player = __instance.xui?.playerUI?.entityPlayer;
|
||||||
|
if (player == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!NecromancerBloodPatch.HasAnyKnife(player))
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(player, "resourceNecromancerBloodNeedsKnife");
|
||||||
|
Debug.Log("[NecromancerTome] NecromancerBloodPatch: blocked craft (no knife present) for owner=" + player.entityId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(XUiC_RecipeStack), "outputStack")]
|
||||||
|
public static class Patch_XUiC_RecipeStack_outputStack_NecromancerBlood
|
||||||
|
{
|
||||||
|
public static void Prefix(XUiC_RecipeStack __instance, out EntityPlayerLocal __state)
|
||||||
|
{
|
||||||
|
__state = null;
|
||||||
|
if (__instance.recipe != null && __instance.recipe.GetName() == NecromancerBloodPatch.BloodItemName)
|
||||||
|
{
|
||||||
|
__state = __instance.xui?.playerUI?.entityPlayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Postfix(bool __result, EntityPlayerLocal __state)
|
||||||
|
{
|
||||||
|
if (!__result || __state == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deliberately not clamped to leave the player at least 1 HP - the user asked for a
|
||||||
|
// straight 90% cost, and a blood ritual that can genuinely kill you if you're already
|
||||||
|
// badly hurt fits the theme. AddHealth is the same safe, non-combat HP-modification
|
||||||
|
// API vanilla itself uses (decompiled EntityAlive.AddHealth) - not DamageEntity/
|
||||||
|
// DamageResponse, since this isn't damage from a source, it's a direct self-cost.
|
||||||
|
int amount = Mathf.RoundToInt(__state.Health * NecromancerBloodPatch.HealthCostFraction);
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
__state.AddHealth(-amount);
|
||||||
|
Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<AssemblyName>NecromancerHarmony</AssemblyName>
|
||||||
|
<RootNamespace>NecromancerTome</RootNamespace>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<!-- The DLL is loaded straight out of the mod folder by the game, not via NuGet/deps.json. -->
|
||||||
|
<GenerateDependencyFile>false</GenerateDependencyFile>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<OutputPath>bin\</OutputPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Game/Harmony assemblies: compile-time only, never copied into our output (they already
|
||||||
|
exist where the game loads them from). -->
|
||||||
|
<Reference Include="0Harmony">
|
||||||
|
<HintPath>..\..\0_TFP_Harmony\0Harmony.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Assembly-CSharp">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\Assembly-CSharp.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.CoreModule">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.ParticleSystemModule">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.ParticleSystemModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<!-- Collider/Physics.IgnoreCollision for SummonPatch.cs's "pet passes through its owner"
|
||||||
|
(user request 2026-08-28) - lives in its own module, not CoreModule. -->
|
||||||
|
<Reference Include="UnityEngine.PhysicsModule">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.PhysicsModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<!-- EntityAlive.PlayOneShot(string)'s own overload set touches AnimationEvent (2026-08-29,
|
||||||
|
BansheePatch.cs) - needed even though this mod never uses AnimationEvent directly, just
|
||||||
|
to satisfy the compiler's reference-resolution for that overload. -->
|
||||||
|
<Reference Include="UnityEngine.AnimationModule">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<!-- PlayerActionsLocal.Secondary (PlayerAction) for PortalStonePatch.cs's power-attack
|
||||||
|
channel-cancel (2026-08-29) - the game's own input layer, not something this mod
|
||||||
|
previously needed to touch directly. -->
|
||||||
|
<Reference Include="InControl">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\InControl.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<!-- UnityEngine.Input (raw mouse polling) for the same channel-cancel fix - Unity split
|
||||||
|
Input into its own module, not part of CoreModule. -->
|
||||||
|
<Reference Include="UnityEngine.InputLegacyModule">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.InputLegacyModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<!-- PyramidWardPatch.cs's TEFeaturePyramidWard.Write() calls into this instead of
|
||||||
|
PooledBinaryWriter.Write directly - see TEPersistenceSrc/NecromancerTEPersistence.csproj's
|
||||||
|
own comment for why that call can't compile in THIS project at all. Private=false: it's
|
||||||
|
built and deployed separately (its own dotnet build + copy to the mod root), same as
|
||||||
|
0Harmony above - not something this project's own build should try to copy/rebuild. -->
|
||||||
|
<Reference Include="NecromancerTEPersistence">
|
||||||
|
<HintPath>..\TEPersistenceSrc\bin\NecromancerTEPersistence.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент
|
||||||
|
/// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is
|
||||||
|
/// opened, pause the game and play a flashback). Reuses the exact pause+video pipeline
|
||||||
|
/// already built and tested for the Black Portal Stone (see PortalStonePatch.cs's
|
||||||
|
/// ActivateBlackPortal - GameManager.Instance.Pause/XUiC_VideoPlayer.PlayVideo, both APIs
|
||||||
|
/// decompiled there already, same reasoning applies unchanged here).
|
||||||
|
///
|
||||||
|
/// 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:
|
||||||
|
/// PromptTitle="noteDuke01"/PromptDescription="noteDuke01Desc" make the ENGINE ITSELF show
|
||||||
|
/// the note as a XUiC_MessageBoxWindowGroup.ShowOkCancel(...) confirm box - decompiled
|
||||||
|
/// ItemActionEat directly and confirmed it has no UI-showing code of its own at all
|
||||||
|
/// (NeedPrompt/PromptTitle/PromptDescription/bPromptChecked are all read, never acted on,
|
||||||
|
/// inside that class); the actual ShowOkCancel call lives in the CALLERS instead - two
|
||||||
|
/// separate, decompiled call sites:
|
||||||
|
/// 1. ItemClass.ExecuteAction(int, ItemInventoryData, bool, PlayerActionsLocal) - the
|
||||||
|
/// holding-the-item-and-clicking path.
|
||||||
|
/// 2. XUiC_ItemStack's inventory "Use" (double-click / context-menu) path.
|
||||||
|
/// Both funnel through the exact same static XUiC_MessageBoxWindowGroup.ShowOkCancel call -
|
||||||
|
/// patching THAT one method, instead of either call site separately, covers both input paths
|
||||||
|
/// with a single patch.
|
||||||
|
///
|
||||||
|
/// IDENTIFYING OUR NOTE: ShowOkCancel receives only already-localized strings, not an item
|
||||||
|
/// reference - decompilation confirms this overload has no ItemValue/ItemClass parameter at
|
||||||
|
/// all. Matched by comparing the incoming title against Localization.Get("noteDuke01") (the
|
||||||
|
/// exact PromptTitle key from items.xml) - unique to this one item in the whole game, not a
|
||||||
|
/// generic vanilla dialog string, so this is a safe match, not a guess.
|
||||||
|
///
|
||||||
|
/// FLOW: on match, suppress the real dialog for now (Prefix returns false), pause the game,
|
||||||
|
/// and play the flashback; only once the video finishes (or is skipped/errors - PlayVideo's
|
||||||
|
/// own onFinished callback fires in every case, confirmed by decompiling
|
||||||
|
/// XUiC_VideoPlayer.OnClose/FinishAndClose, so this can never soft-lock the pause) does it
|
||||||
|
/// unpause and open the REAL note-text dialog (a re-entrant call to ShowOkCancel itself, via
|
||||||
|
/// a bypass flag so the Prefix doesn't intercept its own follow-up call) - "open note ->
|
||||||
|
/// 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
|
||||||
|
/// 2026-08-30 as exch/flashbback.mp4), kept as .mp4 rather than renamed to .webm like the
|
||||||
|
/// 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
|
||||||
|
/// 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
|
||||||
|
/// that MP4 plays correctly in THIS build, but there is no reason implied by the decompiled
|
||||||
|
/// code to expect otherwise, and even a decode failure only degrades to a skipped video (see
|
||||||
|
/// FLOW above), never a stuck pause. **Not confirmed in game.**
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(XUiC_MessageBoxWindowGroup), "ShowOkCancel")]
|
||||||
|
public static class Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback
|
||||||
|
{
|
||||||
|
public const string NoteFlashbackVideoPath = "@modfolder(NecromancerTome):Video/DukeNoteFlashback.mp4";
|
||||||
|
|
||||||
|
/// <summary>Guards the re-entrant call this patch makes to the very method it patches
|
||||||
|
/// (to actually show the note text once the flashback is done) - without this, that
|
||||||
|
/// second call would just trigger the Prefix again and loop back into another flashback
|
||||||
|
/// instead of showing the dialog. Not [ThreadStatic]: XUi/UI code in this game only ever
|
||||||
|
/// runs on the main thread (every other UI-touching patch in this mod makes the same
|
||||||
|
/// assumption, e.g. PortalStonePatch.cs's local-player-only UI calls), so a plain static
|
||||||
|
/// bool is enough here.</summary>
|
||||||
|
public static bool bypass;
|
||||||
|
|
||||||
|
public static bool Prefix(XUi _xuiInstance, string _title, string _text, string _icon, Action _onOk, Action _onCancel, bool _openMainMenuOnClose, bool _modal, bool _cancelOnOutsideClick)
|
||||||
|
{
|
||||||
|
if (bypass)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_xuiInstance == null || _title != Localization.Get("noteDuke01"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] NoteFlashbackPatch: Duke's note opened, pausing + playing flashback");
|
||||||
|
GameManager.Instance.Pause(true);
|
||||||
|
VideoData videoData = new VideoData { url = NoteFlashbackVideoPath };
|
||||||
|
XUiC_VideoPlayer.PlayVideo(_xuiInstance, videoData, true, delegate(bool skipped)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] NoteFlashbackPatch: flashback finished (skipped=" + skipped + "), unpausing and showing note text");
|
||||||
|
GameManager.Instance.Pause(false);
|
||||||
|
bypass = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
XUiC_MessageBoxWindowGroup.ShowOkCancel(_xuiInstance, _title, _text, _icon, _onOk, _onCancel, _openMainMenuOnClose, _modal, _cancelOnOutsideClick);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
bypass = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shrinks and recolors the "RadiatedParticlesOnMesh" glow that both buffNecroDeviatorCharm
|
||||||
|
/// and buffNecroVictim attach to a zombie (see buffs.xml, action="AttachParticleEffectToEntity"
|
||||||
|
/// - both buffs reuse the same particle prefab rather than needing two different ones).
|
||||||
|
///
|
||||||
|
/// Why this needs Harmony: AttachParticleEffectToEntity's XML attributes are limited to
|
||||||
|
/// particle/parent_transform/local_offset/local_rotation/oneshot/shape_mesh/sound - there is
|
||||||
|
/// no scale or color/alpha attribute (confirmed by decompiling
|
||||||
|
/// MinEventActionAttachParticleEffectToEntity.ParseXmlAttribute - that's the exhaustive list).
|
||||||
|
/// The prefab always instantiates at its own authored size/color; nothing in XML can change
|
||||||
|
/// that. So instead we let the vanilla action run as normal (Postfix, not Prefix - the
|
||||||
|
/// particle GameObject has to already exist), then find the same child object it just created
|
||||||
|
/// and adjust it directly - same lookup the engine itself uses internally: a child transform
|
||||||
|
/// named "Ptl_" + the particle prefab's name, parented under the entity's mesh transform.
|
||||||
|
///
|
||||||
|
/// SizeFactor dropped 0.5 -> 0.2 2026-08-28 (user: "выглядят как шар вне зомби" - even the
|
||||||
|
/// original half-size shrink still read as a floating ball rather than a mesh-hugging glow).
|
||||||
|
///
|
||||||
|
/// Color (2026-08-28): Deviator green and Victim purple are both explicit now (Deviator used
|
||||||
|
/// to just be whatever RadiatedParticlesOnMesh's own baked-in color happens to be - reads
|
||||||
|
/// "green/energy" on its own, never actually set). Explicit per user request: "если оба бафа,
|
||||||
|
/// то пусть свечения смешиваются" - a zombie carrying both gets Color.Lerp(charm, victim, .5),
|
||||||
|
/// not one color just overriding the other.
|
||||||
|
///
|
||||||
|
/// Gated to only affect entities carrying at least one of OUR buffs (checked per-buff, not
|
||||||
|
/// just "is this the right particle name") - not vanilla naturally-irradiated zombies that
|
||||||
|
/// happen to reuse the same particle prefab elsewhere.
|
||||||
|
///
|
||||||
|
/// GENERALIZED 2026-08-29 for buffNecroPortalChannel (BACKLOG.md item 6, user request:
|
||||||
|
/// dense green-blue particles while a portal stone channels, thick enough to partially
|
||||||
|
/// obscure the player) - was hard-gated to `EntityZombie` specifically (`_params.Self is
|
||||||
|
/// EntityZombie`) since the two original buffs are both zombie-facing; this new one targets
|
||||||
|
/// the PLAYER, so the check is now against the common `EntityAlive` base (where
|
||||||
|
/// `.Buffs`/`.emodel` actually live) instead. Also needed its own size/alpha/DENSITY numbers
|
||||||
|
/// separate from the zombie glow's - the first version reused the same SizeFactor/AlphaFactor
|
||||||
|
/// constants for all three buffs, which the user confirmed reads as "редкие-редкие" (way too
|
||||||
|
/// sparse) for a "should partly cover you" effect - see GlowConfig below, one per buff now
|
||||||
|
/// instead of two shared constants.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(MinEventActionAttachParticleEffectToEntity), "Execute")]
|
||||||
|
public static class Patch_AttachParticleEffectToEntity_ShrinkCharmGlow
|
||||||
|
{
|
||||||
|
public const string ParticleName = "RadiatedParticlesOnMesh";
|
||||||
|
|
||||||
|
public class GlowConfig
|
||||||
|
{
|
||||||
|
public Color Tint;
|
||||||
|
public float SizeFactor;
|
||||||
|
public float AlphaFactor;
|
||||||
|
/// <summary>Multiplies both the emission rate (particles/second) AND maxParticles by
|
||||||
|
/// this factor together - raising rate alone caps out silently once the system hits
|
||||||
|
/// its authored maxParticles ceiling, so both have to move together to actually get a
|
||||||
|
/// visibly denser cloud instead of the same particle count arriving faster.</summary>
|
||||||
|
public float DensityFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unchanged from the original 2026-08-28 tuning - the zombie-facing glow was
|
||||||
|
/// never asked to get denser/bigger, only the new portal-channel one was.</summary>
|
||||||
|
public static readonly GlowConfig CharmGlow = new GlowConfig { Tint = new Color(0.2f, 1f, 0.3f), SizeFactor = 0.2f, AlphaFactor = 0.5f, DensityFactor = 1f };
|
||||||
|
/// <summary>Purple, per user request 2026-08-28 ("подсвети бафнутого зомби... фиолетовым").</summary>
|
||||||
|
public static readonly GlowConfig VictimGlow = new GlowConfig { Tint = new Color(0.55f, 0.05f, 0.85f), SizeFactor = 0.2f, AlphaFactor = 0.5f, DensityFactor = 1f };
|
||||||
|
/// <summary>RE-TUNED 2026-08-29 (user: "частицы есть, но они редкие-редкие. А надо чтобы
|
||||||
|
/// прямо густо располагались... чтобы частично перекрывали внешний вид" + colour changed
|
||||||
|
/// from the first version's near-black to green-blue/teal, "зелёноголубые"). SizeFactor
|
||||||
|
/// bumped from a shrink (0.2, matching the mesh-hugging zombie glow) to just under full
|
||||||
|
/// size (0.9) - a swirl meant to partly obscure the player needs to actually be
|
||||||
|
/// body-sized, not a tight skin-hugging glow. AlphaFactor raised to near-opaque (0.9) for
|
||||||
|
/// the same "obscures the view" reason - the zombie glow's own 0.5 was deliberately subtle,
|
||||||
|
/// this one shouldn't be. DensityFactor=5 - the actual fix for "редкие-редкие", multiplies
|
||||||
|
/// both emission rate and maxParticles together (see GlowConfig's own doc on why both).</summary>
|
||||||
|
public static readonly GlowConfig PortalChannelGlow = new GlowConfig { Tint = new Color(0.1f, 0.85f, 0.8f), SizeFactor = 0.9f, AlphaFactor = 0.9f, DensityFactor = 5f };
|
||||||
|
|
||||||
|
public static void Postfix(MinEventActionAttachParticleEffectToEntity __instance, MinEventParams _params)
|
||||||
|
{
|
||||||
|
if (_params.Self == null || __instance.goToInstantiate == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (__instance.goToInstantiate.name != ParticleName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(_params.Self is EntityAlive entity) || entity.Buffs == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bool isPortalChannel = entity.Buffs.HasBuff(Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelBuffName);
|
||||||
|
bool isVictim = entity.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName);
|
||||||
|
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
|
||||||
|
if (!isPortalChannel && !isVictim && !isCharm)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Portal channel is player-only and never coexists with the zombie-facing buffs
|
||||||
|
// below in practice, so it's kept as a simple separate branch rather than folded
|
||||||
|
// into the same Lerp blend those two use with each other.
|
||||||
|
GlowConfig config;
|
||||||
|
if (isPortalChannel)
|
||||||
|
{
|
||||||
|
config = PortalChannelGlow;
|
||||||
|
}
|
||||||
|
else if (isVictim && isCharm)
|
||||||
|
{
|
||||||
|
config = new GlowConfig { Tint = Color.Lerp(CharmGlow.Tint, VictimGlow.Tint, 0.5f), SizeFactor = CharmGlow.SizeFactor, AlphaFactor = CharmGlow.AlphaFactor, DensityFactor = 1f };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config = isVictim ? VictimGlow : CharmGlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
Transform meshTransform = entity.emodel != null ? entity.emodel.meshTransform : null;
|
||||||
|
if (meshTransform == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Transform particleTransform = meshTransform.Find("Ptl_" + ParticleName);
|
||||||
|
if (particleTransform == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders for size: not every particle system's Scaling Mode respects
|
||||||
|
// transform scale, but startSizeMultiplier always does regardless of that setting.
|
||||||
|
particleTransform.localScale = Vector3.one * config.SizeFactor;
|
||||||
|
ParticleSystem[] systems = particleTransform.GetComponentsInChildren<ParticleSystem>(true);
|
||||||
|
foreach (ParticleSystem ps in systems)
|
||||||
|
{
|
||||||
|
ParticleSystem.MainModule main = ps.main;
|
||||||
|
main.startSizeMultiplier *= config.SizeFactor;
|
||||||
|
main.maxParticles = Mathf.Max(1, Mathf.RoundToInt(main.maxParticles * config.DensityFactor));
|
||||||
|
|
||||||
|
if (config.DensityFactor != 1f)
|
||||||
|
{
|
||||||
|
ParticleSystem.EmissionModule emission = ps.emission;
|
||||||
|
emission.rateOverTimeMultiplier *= config.DensityFactor;
|
||||||
|
emission.rateOverDistanceMultiplier *= config.DensityFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParticleSystem.MinMaxGradient startColor = main.startColor;
|
||||||
|
switch (startColor.mode)
|
||||||
|
{
|
||||||
|
case ParticleSystemGradientMode.Color:
|
||||||
|
{
|
||||||
|
Color c = config.Tint;
|
||||||
|
c.a = startColor.color.a * config.AlphaFactor;
|
||||||
|
startColor.color = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ParticleSystemGradientMode.TwoColors:
|
||||||
|
{
|
||||||
|
Color min = config.Tint;
|
||||||
|
Color max = config.Tint;
|
||||||
|
min.a = startColor.colorMin.a * config.AlphaFactor;
|
||||||
|
max.a = startColor.colorMax.a * config.AlphaFactor;
|
||||||
|
startColor.colorMin = min;
|
||||||
|
startColor.colorMax = max;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Gradient/TwoGradients modes bake color+alpha into the gradient asset itself -
|
||||||
|
// no generic way to override that from code, so those are left as-is.
|
||||||
|
}
|
||||||
|
main.startColor = startColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps a summoned pet (see SummonPatch.cs) from wandering off and getting permanently
|
||||||
|
/// lost/unsummonable - user report 2026-08-28: the Zombie Dog didn't follow like a drone
|
||||||
|
/// would, wandered off (heard, not seen), and a second summon attempt did nothing while the
|
||||||
|
/// first was presumably still out there somewhere.
|
||||||
|
///
|
||||||
|
/// Two separate problems, one fix:
|
||||||
|
///
|
||||||
|
/// 1. "Doesn't follow": there is no generic "follow a specific entity" AI task anywhere in
|
||||||
|
/// this game - confirmed by listing every EAI*-named type in Assembly-CSharp (EAIWander,
|
||||||
|
/// EAITerritorial, EAIApproachSpot, EAIApproachAndAttackTarget, etc. - nothing
|
||||||
|
/// follow-shaped). EntityDrone's follow behavior is hardcoded C# specific to that one
|
||||||
|
/// class, not something an XML entity_class can opt into. Writing a real custom AITask
|
||||||
|
/// (actual pathfinding, priority-tuned against the pet's existing Wander/Territorial/
|
||||||
|
/// ApproachSpot tasks) is real engineering the backlog didn't ask for - so this copies
|
||||||
|
/// vanilla's own fallback for the identical problem instead: DroneManager.Update()
|
||||||
|
/// teleports a drone back near its owner once it's more than 32m away (sqrMagnitude >
|
||||||
|
/// 1024) and not doing something else (OrderState != Stay) - confirmed by decompiling
|
||||||
|
/// it. Same threshold, same idea, generalized to any pet class instead of drones only,
|
||||||
|
/// and skipped while the pet has a live attack target (don't yank it out of a fight).
|
||||||
|
///
|
||||||
|
/// 2. "Doesn't reappear on retry": SummonPatch.cs's one-pet-per-species limit checks
|
||||||
|
/// EntityAlive.ownedEntities, but nothing was ever removing a pet from that list once it
|
||||||
|
/// died or its chunk unloaded - unlike the drone, where DroneManager's own death/unload
|
||||||
|
/// callbacks do that cleanup. A dead or vanished pet left the slot "occupied" forever.
|
||||||
|
/// The same periodic check below detects a tracked pet that's gone (world.GetEntity
|
||||||
|
/// returns null - dead, or its chunk unloaded and it despawned like any untracked
|
||||||
|
/// entity, per the known persistence gap documented in SummonPatch.cs) and clears
|
||||||
|
/// ownership so the player can summon a fresh one.
|
||||||
|
///
|
||||||
|
/// 3. (added 2026-08-28) Insect Swarm only: makes it move on to the next zombie once its
|
||||||
|
/// current target is already Deviator-charmed, instead of camping the same converted
|
||||||
|
/// zombie forever - see the inline comment below, right where it happens.
|
||||||
|
///
|
||||||
|
/// Hooked via ModEvents.UnityUpdate - the same supported per-frame mod event GameManager's own
|
||||||
|
/// gmUpdate() fires DroneManager.Update() from (confirmed by decompiling GameManager) - not a
|
||||||
|
/// Harmony patch, since this is a genuine public extension point, no reason to patch around
|
||||||
|
/// it. Throttled to run the real check once a second (CheckInterval): a distance/liveness
|
||||||
|
/// check on a handful of pets is cheap, but no reason to do it 60x/sec either.
|
||||||
|
/// </summary>
|
||||||
|
public static class PetFollowPatch
|
||||||
|
{
|
||||||
|
public const float LeashDistance = 32f; // matches DroneManager's own 32m leash
|
||||||
|
public const float LeashDistanceSq = LeashDistance * LeashDistance;
|
||||||
|
public const float CheckInterval = 1f;
|
||||||
|
|
||||||
|
public class TrackedPet
|
||||||
|
{
|
||||||
|
public int OwnerEntityId;
|
||||||
|
public int PetEntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
|
||||||
|
|
||||||
|
public static float timer;
|
||||||
|
|
||||||
|
public static void Init()
|
||||||
|
{
|
||||||
|
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
|
||||||
|
public static void Register(EntityAlive owner, Entity pet)
|
||||||
|
{
|
||||||
|
TrackedPets.Add(new TrackedPet { OwnerEntityId = owner.entityId, PetEntityId = pet.entityId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Called from SummonPatch.cs's manual recall path so a recalled pet stops being
|
||||||
|
/// tracked immediately, instead of lingering until the next tick notices it's gone.</summary>
|
||||||
|
public static void Unregister(int petEntityId)
|
||||||
|
{
|
||||||
|
for (int i = TrackedPets.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (TrackedPets[i].PetEntityId == petEntityId)
|
||||||
|
{
|
||||||
|
TrackedPets.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||||
|
{
|
||||||
|
timer += Time.deltaTime;
|
||||||
|
if (timer < CheckInterval)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = 0f;
|
||||||
|
if (TrackedPets.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = TrackedPets.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
TrackedPet tracked = TrackedPets[i];
|
||||||
|
EntityAlive pet = world.GetEntity(tracked.PetEntityId) as EntityAlive;
|
||||||
|
if (pet == null || pet.IsDead())
|
||||||
|
{
|
||||||
|
EntityAlive ownerForCleanup = world.GetEntity(tracked.OwnerEntityId) as EntityAlive;
|
||||||
|
if (ownerForCleanup != null)
|
||||||
|
{
|
||||||
|
ownerForCleanup.RemoveOwnedEntity(tracked.PetEntityId);
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + tracked.PetEntityId + " gone, cleared ownership for " + ownerForCleanup.entityId);
|
||||||
|
}
|
||||||
|
TrackedPets.RemoveAt(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityAlive owner = world.GetEntity(tracked.OwnerEntityId) as EntityAlive;
|
||||||
|
if (owner == null)
|
||||||
|
{
|
||||||
|
// Owner not currently loaded (e.g. disconnected) - leave the pet tracked,
|
||||||
|
// nothing useful to do until they're back.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User request 2026-08-28: "рой будет заражать зомби девиацией и лететь к
|
||||||
|
// следующему незаражённому?" - not on its own, so this makes it one. EntityVulture
|
||||||
|
// (the Swarm's real base class - see SwarmTargetPatch.cs) has no notion of
|
||||||
|
// "this target is already converted, go find another" - once it has a live target
|
||||||
|
// it just keeps attacking it until that target dies, charmed or not (repeated
|
||||||
|
// AddBuff on an already-charmed zombie is a harmless no-op, so it wasn't wrong,
|
||||||
|
// just stuck). Clearing the attack target here when it's already charmed makes
|
||||||
|
// EntityVulture's own FindTarget()/SetAttackTarget cycle kick back in on its next
|
||||||
|
// pass (SwarmTargetPatch.cs redirects that straight to the nearest zombie again,
|
||||||
|
// same as any other retarget) - not instant (that cycle runs on its own ~2s timer,
|
||||||
|
// not driven by us), but converts-then-moves-on within a couple seconds.
|
||||||
|
if (pet.entityClass == Patch_EntityAlive_SetAttackTarget_SwarmRetarget.SwarmOnlyClassId())
|
||||||
|
{
|
||||||
|
EntityAlive currentTarget = pet.GetAttackTarget();
|
||||||
|
if (currentTarget != null && currentTarget.Buffs != null && currentTarget.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
|
||||||
|
{
|
||||||
|
pet.SetAttackTarget(null, 0);
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: swarm " + pet.entityId + " dropped already-charmed target " + currentTarget.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pet.GetAttackTarget() != null)
|
||||||
|
{
|
||||||
|
// Mid-fight - let it finish rather than teleporting it away.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float distSq = (pet.position - owner.position).sqrMagnitude;
|
||||||
|
if (distSq > LeashDistanceSq)
|
||||||
|
{
|
||||||
|
// 2m in front of the owner, not exactly on top of them - landing right on the
|
||||||
|
// owner's own position stacks the pet's collider into the player's and shoves
|
||||||
|
// them (this is exactly what happened during the "<=0 vs -1" bug above: a pile
|
||||||
|
// of a dozen undespawned dogs all teleporting onto the same point as the player
|
||||||
|
// every second launched them into the air).
|
||||||
|
Vector3 dest = owner.position + owner.qrotation * new Vector3(0f, 0f, 2f);
|
||||||
|
pet.SetPosition(dest, true);
|
||||||
|
Debug.Log("[NecromancerTome] PetFollowPatch: teleported pet " + pet.entityId + " back to owner " + owner.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Portal stones - BACKLOG.md item 6. See items.xml (thrownStonePortalBlue/
|
||||||
|
/// thrownStonePortalBlack) for the item definitions.
|
||||||
|
///
|
||||||
|
/// REWRITTEN 2026-08-29 after the first version's core assumption turned out wrong, confirmed
|
||||||
|
/// by the user testing it ("срабатывает мгновенно" - fires instantly, no 10s indicator). The
|
||||||
|
/// original version used Class="Eat"/Delay="10" on the assumption that Delay was a HELD-hold
|
||||||
|
/// duration (like the workbench's TakeDelay). Re-decompiling ItemActionEat more carefully
|
||||||
|
/// shows that's wrong: ExecuteAction only runs once per click, ON RELEASE
|
||||||
|
/// (`if (!_bReleased || ...) return;`), and for UseAnimation items the actual "eating in
|
||||||
|
/// progress" duration comes from `AnimationDelayData.AnimationDelay[HoldType].RayCast` - a
|
||||||
|
/// fixed-per-HoldType table that isn't exposed anywhere in Data/Config's XML at all (checked
|
||||||
|
/// directly - no matches for "RayCast"/"AnimationDelay" in any vanilla XML), so `Delay` on a
|
||||||
|
/// Class="Eat" item is really just a re-click COOLDOWN (how soon it can fire again), not a
|
||||||
|
/// channel length. That's why it looked instant - the real channel was whatever HoldType 40's
|
||||||
|
/// (the rock's) built-in eating-animation length happens to be, a couple seconds at most, not
|
||||||
|
/// our intended 10.
|
||||||
|
///
|
||||||
|
/// Fix: stop trying to make Class="Eat" do a long channel at all. Instead, Prefix
|
||||||
|
/// ItemActionEat.ExecuteAction itself (the exact click-release entry point, still using
|
||||||
|
/// Class="Eat" in XML purely as "a clickable item action", nothing about its own timing is
|
||||||
|
/// used any more) and, for our two stones, skip the original method entirely and open the
|
||||||
|
/// game's own generic countdown-timer UI directly - XUiC_Timer.OpenTimer(xui, seconds,
|
||||||
|
/// TimerEventData, ...), the exact same low-level primitive Block.TakeItemWithTimer itself
|
||||||
|
/// calls for the workbench pickup timer (decompiled both to confirm - TakeItemWithTimer is
|
||||||
|
/// just a block-flavored wrapper around this same generic UI system, nothing block-specific
|
||||||
|
/// about the timer itself). This gives a REAL visible progress bar/percent-fill UI (confirmed
|
||||||
|
/// via XUiC_Timer's own "percent"/"timeleft" bindings) for the full 10 seconds, and
|
||||||
|
/// TimerEventData.CloseOnHit=true makes it cancel automatically if the player takes damage
|
||||||
|
/// mid-channel (a real engine feature, not something built by hand) - matching "прерывается
|
||||||
|
/// при получении урона" without any extra code. The actual teleport only runs from
|
||||||
|
/// FullTimeFinishEvent, i.e. only if the timer runs all the way to completion.
|
||||||
|
///
|
||||||
|
/// Local-player-only, like every other UI-touching thing in this mod (SummonPatch.cs's
|
||||||
|
/// tooltips, etc.) - the underlying XUi/LocalPlayerUI system this timer needs is a
|
||||||
|
/// client-side-only concept, not something that makes sense for a remote player in this mod's
|
||||||
|
/// existing (single-player-focused) design.
|
||||||
|
///
|
||||||
|
/// POWER-ATTACK CANCEL added 2026-08-29 (user request: "прервать кнопкой силовой атаки" - a
|
||||||
|
/// zombie could jump the player mid-channel and they want an explicit escape, not just
|
||||||
|
/// CloseOnHit's "already got hit" reaction). Confirmed a cancelled channel never teleports
|
||||||
|
/// either way - closing the timer window early (Escape/CloseOnHit/this) fires
|
||||||
|
/// TimerEventData.CloseEvent, not FullTimeFinishEvent, and TeleportToBedroll only ever runs
|
||||||
|
/// from the latter (see OnChannelComplete below) - so "cancel = no teleport" was already true
|
||||||
|
/// by construction, just needed a new way to trigger a cancel.
|
||||||
|
/// vanilla's own TimerEventData.CancelWithActivateButton (already set true above) only checks
|
||||||
|
/// PlayerActionsPermanent.Activate (decompiled XUiC_Timer.Update to confirm) - a small
|
||||||
|
/// always-live action set TFP built specifically to stay readable during modal UI, which does
|
||||||
|
/// NOT include Secondary/power-attack at all (checked its full field list). Rather than
|
||||||
|
/// hijack Activate (the same key that STARTS the channel) or Cancel (Escape, not the button
|
||||||
|
/// asked for), Patch_XUiC_Timer_Update_PortalStoneCancel below Postfixes XUiC_Timer.Update
|
||||||
|
/// itself and polls PlayerActionsLocal.Secondary.WasPressed directly (the same underlying
|
||||||
|
/// action already used elsewhere in this mod as "power attack", e.g. summon books' Action1)
|
||||||
|
/// - NOT decompiled-confirmed whether this action still registers while the timer's modal
|
||||||
|
/// window has input focus (SetControllable(false) fires on open, decompiled from XUiC_Timer,
|
||||||
|
/// but that's a character/gameplay-layer flag, separate from the InControl input-polling
|
||||||
|
/// layer PlayerAction reads from - the two are believed independent, not proven end-to-end).
|
||||||
|
/// Test in-game; if power attack doesn't register while the bar is up, that gap is the first
|
||||||
|
/// thing to dig into (possibly needs reading raw InControl device state instead of the
|
||||||
|
/// semantic PlayerAction).
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")]
|
||||||
|
public static class Patch_ItemActionEat_ExecuteAction_PortalStones
|
||||||
|
{
|
||||||
|
public const string BlueStoneName = "thrownStonePortalBlue";
|
||||||
|
public const string BlackStoneName = "thrownStonePortalBlack";
|
||||||
|
public const float ChannelSeconds = 10f;
|
||||||
|
|
||||||
|
/// <summary>Sentinel stashed in TimerEventData.Data (an unused generic object field on
|
||||||
|
/// vanilla's own class) purely so Patch_XUiC_Timer_Update_PortalStoneCancel below can
|
||||||
|
/// tell "this is one of our portal-stone timers" apart from any other TimerEventData the
|
||||||
|
/// engine or another mod might have open (e.g. a workstation pickup timer, BACKLOG.md
|
||||||
|
/// item 9) - a reference-equality check on a private static object, nothing exposed or
|
||||||
|
/// read by vanilla code.</summary>
|
||||||
|
public static readonly object ChannelMarker = new object();
|
||||||
|
|
||||||
|
/// <summary>See buffs.xml - a marker/particle-carrier buff, added/removed directly by
|
||||||
|
/// this file rather than by any buff-trigger vocabulary.</summary>
|
||||||
|
public const string ChannelBuffName = "buffNecroPortalChannel";
|
||||||
|
|
||||||
|
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||||
|
{
|
||||||
|
if (!_bReleased)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
|
||||||
|
if (itemName != BlueStoneName && itemName != BlackStoneName)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player))
|
||||||
|
{
|
||||||
|
// Not the local player (e.g. an AI or remote entity somehow holding this) - let
|
||||||
|
// vanilla Eat behavior run rather than silently doing nothing, same fallback
|
||||||
|
// shape used elsewhere in this mod for the local-player-only simplification.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel started for " + itemName + ", owner=" + player.entityId);
|
||||||
|
// Played directly here since ItemActionEat's own Sound_start handling is skipped
|
||||||
|
// entirely along with the rest of its ExecuteAction (see class comment) - a plain
|
||||||
|
// XML Sound_start property on this item would never fire otherwise.
|
||||||
|
player.PlayOneShot("swoosh");
|
||||||
|
// Black/smoke particle swirl for the duration of the channel (user request
|
||||||
|
// 2026-08-29) - see buffs.xml's buffNecroPortalChannel + ParticlePatch.cs (generalized
|
||||||
|
// to handle a player-targeted buff, not just the two zombie-facing ones it already
|
||||||
|
// had). A plain marker buff, added/removed directly here rather than through any
|
||||||
|
// 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.
|
||||||
|
player.Buffs.AddBuff(ChannelBuffName);
|
||||||
|
|
||||||
|
TimerEventData timerData = new TimerEventData
|
||||||
|
{
|
||||||
|
CloseOnHit = true,
|
||||||
|
CancelWithActivateButton = true,
|
||||||
|
Data = ChannelMarker,
|
||||||
|
};
|
||||||
|
timerData.FullTimeFinishEvent += delegate
|
||||||
|
{
|
||||||
|
OnChannelComplete(player, itemName);
|
||||||
|
};
|
||||||
|
// CloseEvent fires when the timer window closes WITHOUT completing (cancelled by
|
||||||
|
// damage/power-attack/Cancel) - confirmed by decompiling XUiC_Timer.OnClose/
|
||||||
|
// timeReachedNull: timeReachedNull sets skipCloseEvent=true around the completion
|
||||||
|
// path specifically so CloseEvent does NOT also fire on a successful finish, only on
|
||||||
|
// every other way the window can close. FullTimeFinishEvent and CloseEvent are
|
||||||
|
// therefore mutually exclusive per channel - exactly "however it ends" from the
|
||||||
|
// class-level comment.
|
||||||
|
timerData.CloseEvent += delegate
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
|
||||||
|
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||||
|
};
|
||||||
|
|
||||||
|
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
|
XUiC_Timer.OpenTimer(playerUI.xui, ChannelSeconds, timerData, -1f, Localization.Get(labelKey));
|
||||||
|
|
||||||
|
// Skip ItemActionEat's own logic entirely for these two items - the click has been
|
||||||
|
// fully handled by opening our own timer instead.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnChannelComplete(EntityPlayerLocal player, string itemName)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
|
||||||
|
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||||
|
if (itemName == BlackStoneName)
|
||||||
|
{
|
||||||
|
ShowBlackPortalConfirmation(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TeleportToBedroll(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Black portal confirmation + fullscreen video, user request 2026-08-30
|
||||||
|
/// ("диалоговое окно... вы уверены... Если Да, то игра останавливается и проигрывается
|
||||||
|
/// видео"). Real APIs, both decompiled directly:
|
||||||
|
/// - XUiC_MessageBoxWindowGroup.ShowCustom(xui, title, text, icon, setupCallback, ...) -
|
||||||
|
/// the same generic Yes/No popup vanilla itself uses (its own delete-item/disconnect
|
||||||
|
/// confirmations, etc). ShowOkCancel/ShowConfirmCancel exist too but hardcode their
|
||||||
|
/// button caption keys ("xuiOk"/"xuiCancel"/"btnConfirm") - ShowCustom's
|
||||||
|
/// _setupCallback is the only variant that lets the two buttons be captioned
|
||||||
|
/// "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys, confirmed
|
||||||
|
/// against Data/Config/Localization.csv), matching the user's literal "да/нет"
|
||||||
|
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/
|
||||||
|
/// 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: per direct user instruction 2026-08-30 ("Пока файл видео замени
|
||||||
|
/// заглушкой. Потом поставим нормальный"), Video/BlackPortal.webm is currently a COPY OF
|
||||||
|
/// VANILLA'S OWN TFP_Intro.webm (from 7DaysToDie_Data/StreamingAssets/Video/), not real
|
||||||
|
/// mod content - purely so the full dialog -> pause -> video -> unpause pipeline is
|
||||||
|
/// genuinely testable end-to-end right now. Swap that one file for the real video later;
|
||||||
|
/// nothing else needs to change (reuse the same filename, or update BlackPortalVideoPath
|
||||||
|
/// below if the real file gets a different name).
|
||||||
|
/// "@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)
|
||||||
|
{
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
|
XUiC_MessageBoxWindowGroup.ShowCustom(
|
||||||
|
playerUI.xui,
|
||||||
|
Localization.Get("thrownStonePortalBlackConfirmTitle"),
|
||||||
|
Localization.Get("thrownStonePortalBlackConfirmText"),
|
||||||
|
"",
|
||||||
|
delegate(XUiC_MessageBoxWindowGroup mb)
|
||||||
|
{
|
||||||
|
mb.Buttons[0].DefaultConfirm("xuiYes", delegate { ActivateBlackPortal(player); });
|
||||||
|
mb.Buttons[2].DefaultCancel("xuiNo", null);
|
||||||
|
},
|
||||||
|
_openMainMenuOnClose: false,
|
||||||
|
_modal: true,
|
||||||
|
_buttonOnOutsideClick: -1,
|
||||||
|
// Esc/outside-close counts as "No" - same convention ShowOkCancel/
|
||||||
|
// ShowConfirmCancel themselves use for their own Cancel slot (index 2).
|
||||||
|
_buttonOnExternalClose: 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
|
||||||
|
/// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом
|
||||||
|
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
|
||||||
|
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
|
||||||
|
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
|
||||||
|
/// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора
|
||||||
|
/// концовки на последнем слайде.
|
||||||
|
///
|
||||||
|
/// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и
|
||||||
|
/// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath /
|
||||||
|
/// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не
|
||||||
|
/// используется и оставлена только как документация к разбору "@modfolder(...)" и
|
||||||
|
/// XUiC_VideoPlayer.PlayVideo, на который FinalSlides ссылается.</summary>
|
||||||
|
public static void ActivateBlackPortal(EntityPlayerLocal player)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
|
||||||
|
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
|
||||||
|
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
|
||||||
|
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
|
||||||
|
/// HasBedrollPos, confirmed by decompiling that class directly). +0.5 on x/z centers the
|
||||||
|
/// block, +1 on y lifts the destination clear of the bedroll block itself - a reasonable
|
||||||
|
/// guess at a safe landing offset, not a decompiled/confirmed "correct" one (the
|
||||||
|
/// respawn-specific code that actually places a resurrected player likely does more
|
||||||
|
/// ground-safety checking than this; worth revisiting if the stone ever drops the player
|
||||||
|
/// inside a block). Teleport itself uses NetPackageTeleportPlayer, the exact same package
|
||||||
|
/// ConsoleCmdTeleportsAbs.ExecuteTeleport (the real "teleportplayer" console command)
|
||||||
|
/// uses - decompiled to confirm, not invented.</summary>
|
||||||
|
public static void TeleportToBedroll(EntityPlayerLocal player)
|
||||||
|
{
|
||||||
|
PersistentPlayerData data = player.PersistentPlayerData;
|
||||||
|
if (data == null || !data.HasBedrollPos)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(player, "thrownStonePortalBlueNoBedroll");
|
||||||
|
Debug.LogWarning("[NecromancerTome] PortalStonePatch: owner=" + player.entityId + " has no bedroll set, can't teleport");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Vector3i bedrollPos = data.BedrollPos;
|
||||||
|
Vector3 destination = new Vector3(bedrollPos.x + 0.5f, bedrollPos.y + 1f, bedrollPos.z + 0.5f);
|
||||||
|
NetPackageTeleportPlayer package = NetPackageManager.GetPackage<NetPackageTeleportPlayer>().Setup(destination, null);
|
||||||
|
package.ProcessPackage(GameManager.Instance.World, GameManager.Instance);
|
||||||
|
player.PlayOneShot("spawnInStinger");
|
||||||
|
Debug.Log("[NecromancerTome] PortalStonePatch: owner=" + player.entityId + " teleported to bedroll " + bedrollPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets the power-attack ("Secondary") input cancel an in-progress portal-stone
|
||||||
|
/// channel - see the long comment on Patch_ItemActionEat_ExecuteAction_PortalStones above for
|
||||||
|
/// the full reasoning. Separate patch class/target method (XUiC_Timer.Update, not
|
||||||
|
/// ItemActionEat.ExecuteAction) since this has to run every frame WHILE the timer is open, not
|
||||||
|
/// once at click time.
|
||||||
|
///
|
||||||
|
/// FIXED 2026-08-29 (user report: cancel didn't work at all) - the semantic
|
||||||
|
/// PlayerActionsLocal.Secondary check alone (first version) apparently never registered while
|
||||||
|
/// the timer's modal window has input focus, confirming the exact risk flagged when this was
|
||||||
|
/// first written. Root cause not fully pinned down by decompilation (XUiC_Timer.OnOpen sets
|
||||||
|
/// SetControllable(false) on the player, and nothing found ties that flag directly to
|
||||||
|
/// PlayerAction's own InControl polling layer - the two are presumed independent but the
|
||||||
|
/// actual suppression point wasn't located). Rather than keep guessing which exact system
|
||||||
|
/// swallows it, added a SECOND, independent check straight to Unity's raw
|
||||||
|
/// Input.GetMouseButtonDown(1) (right mouse button - confirmed as Secondary's real default
|
||||||
|
/// KBM binding by decompiling PlayerActionsLocal.CreateActions) - raw Input polling reads
|
||||||
|
/// hardware state directly, bypassing InControl/PlayerAction and whatever gates it, so this
|
||||||
|
/// should fire regardless of modal-window suppression. Either check firing cancels the
|
||||||
|
/// channel; keeping the semantic one too costs nothing and covers gamepad Secondary
|
||||||
|
/// (LeftTrigger) if that one turns out to work. KBM-only fallback - if a gamepad player still
|
||||||
|
/// can't cancel, that's the next gap to close (would need the equivalent raw axis read).</summary>
|
||||||
|
[HarmonyPatch(typeof(XUiC_Timer), "Update")]
|
||||||
|
public static class Patch_XUiC_Timer_Update_PortalStoneCancel
|
||||||
|
{
|
||||||
|
public static void Postfix(XUiC_Timer __instance)
|
||||||
|
{
|
||||||
|
if (__instance == null || __instance.eventData == null || __instance.eventData.Data != Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelMarker)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerActionsLocal input = __instance.xui?.playerUI?.playerInput;
|
||||||
|
bool cancelPressed = (input != null && input.Secondary.WasPressed) || Input.GetMouseButtonDown(1);
|
||||||
|
if (cancelPressed)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled via power attack");
|
||||||
|
__instance.xui.playerUI.windowManager.Close(__instance.windowGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// "Пирамида Ереси" (Pyramid of Heresy) - user request 2026-08-31, REWRITTEN 2026-09-01.
|
||||||
|
///
|
||||||
|
/// FIRST VERSION (see BACKLOG.md's original entry) used Harmony patches on plain Block's
|
||||||
|
/// OnBlockAdded/OnBlockRemoved/GetBlockActivationCommands/OnBlockActivated, with all state in a
|
||||||
|
/// static Dictionary keyed by block position. Two real problems came out of actually testing it:
|
||||||
|
/// 1. USER REPORT: "Навожу прицел, но подсказка про E не появляется" - no E-prompt at all,
|
||||||
|
/// pressing E did nothing. Root cause, decompiled: there's a SEPARATE gate method,
|
||||||
|
/// `Block.HasBlockActivationCommands(WorldBase, BlockValue, Vector3i, EntityAlive)`, with
|
||||||
|
/// its OWN independent logic (not calling GetBlockActivationCommands at all) that the
|
||||||
|
/// game's HUD/input layer checks FIRST to decide whether to even show the prompt. It was
|
||||||
|
/// never patched, and for a plain decorative block it always returns false (no
|
||||||
|
/// CanPickup, no CustomCmds) - so the prompt correctly never appeared, and E correctly did
|
||||||
|
/// nothing, regardless of how correct the other three patches were.
|
||||||
|
/// 2. USER REQUEST: "Сделай TileEntity" - wants EffectOn/ZoneShown to actually survive a
|
||||||
|
/// save/reload, which the static-Dictionary version explicitly could not do (documented as
|
||||||
|
/// a known caveat at the time).
|
||||||
|
///
|
||||||
|
/// Rather than patch a fourth Block method, this is a full rewrite onto the real, sanctioned
|
||||||
|
/// extension point for exactly this situation - the same one vanilla's own Land Claim block
|
||||||
|
/// uses: a CompositeTileEntity feature. Confirmed by decompiling the actual chain, not guessed:
|
||||||
|
/// - `TEFeatureAbs` (the real base class - `TEFeatureLandClaim : TEFeatureAbs`, decompiled to
|
||||||
|
/// confirm) already declares virtual OnAdded/OnRemove/UpdateTick/Read/Write/
|
||||||
|
/// InitBlockActivationCommands/AllowBlockActivationCommand/OnBlockActivated - literally
|
||||||
|
/// every hook this feature needs, with NO separate "HasBlockActivationCommands" gap: that
|
||||||
|
/// whole problem belongs to plain Block's activation path, not this one.
|
||||||
|
/// - `BlockCompositeTileEntity` (the Class="CompositeTileEntity" block class - decompiled
|
||||||
|
/// directly) correctly overrides HasBlockActivationCommands/GetBlockActivationCommands/
|
||||||
|
/// OnBlockActivated itself and wires them through `TileEntityComposite`/each feature - this
|
||||||
|
/// is the ALREADY-WORKING pipeline every vanilla composite block (Land Claim included) has
|
||||||
|
/// used for years; not new engineering, just finally the right entry point.
|
||||||
|
/// - Feature discovery is NOT a hardcoded switch (unlike raw `TileEntityType`/
|
||||||
|
/// `TileEntity.InstantiateFromRead`, which genuinely IS a closed hardcoded enum switch with
|
||||||
|
/// no mod slot - checked this first and ruled it out for exactly that reason).
|
||||||
|
/// `TileEntityCompositeData.Init()` (decompiled) calls
|
||||||
|
/// `ReflectionHelpers.FindTypesImplementingBase(typeof(ITileEntityFeature), ...)` and keys
|
||||||
|
/// the result by `_type.Name` (the short type name, NAMESPACE-INDEPENDENT - confirmed by
|
||||||
|
/// reading the exact line) - so `TEFeaturePyramidWard` below is found automatically by the
|
||||||
|
/// engine's own startup scan of every loaded assembly (including this mod's DLL) purely by
|
||||||
|
/// matching that literal class name against blocks.xml's own
|
||||||
|
/// `<property class="TEFeaturePyramidWard" />` - the same mechanism vanilla's own
|
||||||
|
/// TEFeatureLandClaim/TEFeatureStorage/TEFeatureAreaRepair (see keystoneBlock) already rely
|
||||||
|
/// on. Only real requirement (also confirmed by decompile, `TileEntityCompositeData.Init`
|
||||||
|
/// warns and skips otherwise): a public, non-abstract class with a parameterless
|
||||||
|
/// constructor - both true here without writing one explicitly.
|
||||||
|
/// - Activation command TEXT is a real constraint worth noting: `BlockCompositeTileEntity`
|
||||||
|
/// caches its `BlockActivationCommand[]` PER BLOCK TYPE (a field on the Block instance
|
||||||
|
/// itself, shared by every placed pyramid), rebuilt once from InitBlockActivationCommands
|
||||||
|
/// and never again - only `.enabled` gets refreshed per-activation (via
|
||||||
|
/// AllowBlockActivationCommand). So button TEXT can't dynamically say "Enable"/"Disable"
|
||||||
|
/// per-instance; the real vanilla pattern (confirmed in TEFeatureLandClaim's own
|
||||||
|
/// show_bounds/hide_bounds pair) is to register BOTH command variants up front and only
|
||||||
|
/// ENABLE whichever one currently applies - copied exactly here for effect_on/effect_off
|
||||||
|
/// and zone_show/zone_hide.
|
||||||
|
/// - Command display text is resolved via `Localization.Get("blockcommand_" + fullCommandName)`
|
||||||
|
/// (confirmed by finding vanilla's own `blockcommand_show_bounds`/
|
||||||
|
/// `blockcommand_TEFeatureLandClaim:show_bounds` keys in Data/Config/Localization.csv) -
|
||||||
|
/// NOT pre-resolved text passed directly to BlockActivationCommand's constructor (the
|
||||||
|
/// earlier version's mistake). See this mod's own Localization.csv for the
|
||||||
|
/// `blockcommand_TEFeaturePyramidWard:*` keys this relies on.
|
||||||
|
///
|
||||||
|
/// NOT a Harmony patch, despite the filename/this mod's usual convention and despite still
|
||||||
|
/// living in HarmonySrc/ for continuity with the rest of this mod's file layout - nothing here
|
||||||
|
/// patches anything. `Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName` (CharmPatch.cs) is
|
||||||
|
/// still reused as-is for the actual charm effect; that patch is untouched by this rewrite.
|
||||||
|
/// </summary>
|
||||||
|
public class TEFeaturePyramidWard : TEFeatureAbs
|
||||||
|
{
|
||||||
|
/// <summary>How far out (in blocks/meters) the ward reaches. Not specified by the user -
|
||||||
|
/// picked to roughly cover a small base perimeter, same ballpark as vanilla's own land
|
||||||
|
/// claim radius. Easy to retune, just one constant.</summary>
|
||||||
|
public const float EffectRadius = 15f;
|
||||||
|
|
||||||
|
/// <summary>CHANGED 2026-09-02 (user report: "Никаких частиц на включённом состоянии не
|
||||||
|
/// летает" - literally nothing spawned, at all, neither the main glow nor the zone ring).
|
||||||
|
/// Root cause: "RadiatedParticlesOnMesh" is loaded/played through a completely DIFFERENT
|
||||||
|
/// mechanism than the one this file actually calls. Decompiled `ParticleEffect.LoadResources()`
|
||||||
|
/// (the loader behind `GameManager.SpawnBlockParticleEffect`/`new ParticleEffect(string,...)`,
|
||||||
|
/// which this file uses): it bulk-loads addressables from the "particleeffects" group whose
|
||||||
|
/// FIRST FOLDER SEGMENT starts with "p_", then keys each loaded prefab into a dictionary by
|
||||||
|
/// its own filename via `ToId(name)`. "RadiatedParticlesOnMesh" is referenced elsewhere in
|
||||||
|
/// this mod (buffs.xml's `AttachParticleEffectToEntity`) via the literal path
|
||||||
|
/// "ParticleEffects/RadiatedParticlesOnMesh" - no "p_"-prefixed folder anywhere in that path,
|
||||||
|
/// meaning it almost certainly never gets bulk-loaded into that same lookup dictionary at all
|
||||||
|
/// (that XML action resolves its own particle reference through an entirely separate,
|
||||||
|
/// direct-path mechanism, not this bulk-addressables-by-folder-prefix one) - so
|
||||||
|
/// `GetDynamicTransform`/`ToId` lookups for it here would always silently fail (logged as
|
||||||
|
/// "Unknown particle effect", nothing spawned) - exactly matching what got reported. Switched
|
||||||
|
/// to "campfire" instead - confirmed loadable through THIS exact code path already (it's
|
||||||
|
/// vanilla's own `<property name="ParticleName" value="campfire" />` on the real campfire
|
||||||
|
/// block, going through the same GameManager block-particle registry) - and it happens to
|
||||||
|
/// double as the user's other request ("фиолетовое пламя, будто блок горит холодным
|
||||||
|
/// пламенем") almost for free: a real fire effect, tinted purple by ApplyGlowTint below
|
||||||
|
/// instead of its natural orange.</summary>
|
||||||
|
public const string GlowParticleName = "campfire";
|
||||||
|
|
||||||
|
/// <summary>CHANGED 2026-09-02 (user, after seeing the fire-ring in-game: "Границу лучше
|
||||||
|
/// показывать не огнём, а какими-нибудь частицами" - reversed their earlier "оставим так"
|
||||||
|
/// once they'd actually seen it). Only real vanilla `ParticleName` values confirmed to exist
|
||||||
|
/// at all (grepped every one in Data/Config/blocks.xml - the same property this whole
|
||||||
|
/// mechanism is built on): ember_pile/hotembers/campfire/forgeWorkstation/chemistryStation/
|
||||||
|
/// flame_hazard - every single one of them is fire/ember/industrial-themed, there is no
|
||||||
|
/// confirmed "generic sparkle/magic" particle name to fall back on. Picked
|
||||||
|
/// "chemistryStation" specifically because it's the one NOT visually built around an open
|
||||||
|
/// flame (a chemistry set's bubbling/vapor effect) - best available guess from a short list,
|
||||||
|
/// not a confirmed-good look; say if it still reads wrong once seen; it tints purple the
|
||||||
|
/// same way as everything else here regardless of its native color.</summary>
|
||||||
|
public const string ZoneRingParticleName = "chemistryStation";
|
||||||
|
|
||||||
|
/// <summary>Purple, per the user's explicit request ("окрашивается фиолетовым" for the
|
||||||
|
/// zone, "светится фиолетовыми частицами"/"холодным пламенем" for the effect glow) - same
|
||||||
|
/// color used for both.</summary>
|
||||||
|
public static readonly Color WardTint = new Color(0.6f, 0.15f, 0.95f);
|
||||||
|
|
||||||
|
/// <summary>Dropped from 32 - a full-size effect at every ring point would be both visually
|
||||||
|
/// overwhelming and comparatively expensive; 16 small markers still reads clearly as a
|
||||||
|
/// circle at EffectRadius=15.</summary>
|
||||||
|
public const int ZoneRingPointCount = 16;
|
||||||
|
|
||||||
|
/// <summary>ADDED 2026-09-02, direct user request ("Можешь накладывать кроме девиации ещё
|
||||||
|
/// и дебаф горения?"). This is the real vanilla "a zombie is on fire" buff (Data/Config/
|
||||||
|
/// buffs.xml - `damage_type="heat"`, cascades into `buffBurningElement`'s own 10s countdown/
|
||||||
|
/// damage-over-time/AddBuff(buffIsOnFire) chain, the same one torches/molotovs/fire traps
|
||||||
|
/// trigger), not a new buff invented for this mod. Re-applied every scan tick (not
|
||||||
|
/// gated behind "already has it" like the charm below) since `buffBurningElement` itself
|
||||||
|
/// resets its own countdown on every re-trigger (`stack_type="replace"`) - the intent is
|
||||||
|
/// "keeps burning the whole time it's in the zone", not "burns once".
|
||||||
|
///
|
||||||
|
/// Needs a REAL instigator entity id, unlike the charm buff: `EntityBuffs.AddBuff` (decompiled)
|
||||||
|
/// checks `buff.DamageType != None && ... && !FriendlyFireCheck(instigator)` and fails the
|
||||||
|
/// whole call outright if that trips - a buff with a real damage_type (this one has "heat";
|
||||||
|
/// buffNecroDeviatorCharm has none, which is why it never needed this) requires a
|
||||||
|
/// non-null/valid instigator that FriendlyFireCheck accepts, or the call can fail. Passed the
|
||||||
|
/// in-zone player's own entityId (already resolved above for the player-presence gate) -
|
||||||
|
/// matches the fictional framing anyway (the necromancer is the one wielding this ward).</summary>
|
||||||
|
public const string BurnBuffName = "buffBurningZombie";
|
||||||
|
|
||||||
|
/// <summary>Persisted (see Read/Write below) - real per-instance state now, one pyramid's
|
||||||
|
/// toggle no longer affects any other's, and both survive a save/reload.</summary>
|
||||||
|
public bool EffectOn = true;
|
||||||
|
|
||||||
|
public bool ZoneShown;
|
||||||
|
|
||||||
|
/// <summary>Glow/ring particle keys queued via SpawnBlockParticleEffect but not tinted yet -
|
||||||
|
/// GameManager.updateBlockParticles() only processes its spawn queue once per frame
|
||||||
|
/// (decompiled to confirm), so tinting has to be deferred at least one tick rather than done
|
||||||
|
/// inline right after spawning. Instance-level now (was a shared static list in the old
|
||||||
|
/// version) - each pyramid only tracks its own pending keys.</summary>
|
||||||
|
public readonly List<Vector3i> pendingTint = new List<Vector3i>();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Lifecycle.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public override void CopyFromInternal(TileEntityComposite _other)
|
||||||
|
{
|
||||||
|
if (_other.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard other))
|
||||||
|
{
|
||||||
|
EffectOn = other.EffectOn;
|
||||||
|
ZoneShown = other.ZoneShown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnAdded(Vector3i _blockPos, BlockValue _blockValue)
|
||||||
|
{
|
||||||
|
base.OnAdded(_blockPos, _blockValue);
|
||||||
|
if (EffectOn)
|
||||||
|
{
|
||||||
|
SpawnGlow();
|
||||||
|
}
|
||||||
|
if (ZoneShown)
|
||||||
|
{
|
||||||
|
SpawnZoneRing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnRemove(World _world)
|
||||||
|
{
|
||||||
|
base.OnRemove(_world);
|
||||||
|
if (ZoneShown)
|
||||||
|
{
|
||||||
|
RemoveZoneRing();
|
||||||
|
}
|
||||||
|
RemoveGlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Persistence - real save/load now, per the user's direct request.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Routed through PyramidWardWriteHelper.Write (TEPersistenceSrc/, a separate
|
||||||
|
/// satellite project+DLL) rather than calling PooledBinaryWriter.Write directly - that call
|
||||||
|
/// does not compile from THIS project at all. Real, decompile/compiler-confirmed reason, not
|
||||||
|
/// a style choice: see NecromancerTEPersistence.csproj's own comment for the full story
|
||||||
|
/// (short version: Assembly-CSharp.dll's Write overload set includes a
|
||||||
|
/// ReadOnlySpan<byte> variant that only resolves against Unity/Mono's own mscorlib,
|
||||||
|
/// which conflicts with this project's UnityEngine-type usage everywhere else if referenced
|
||||||
|
/// directly here - isolating the one call that needs it into its own tiny project was the
|
||||||
|
/// only combination found that keeps both working). No version byte (kept simple per the
|
||||||
|
/// user's "как проще" - this is a brand-new feature, nothing to migrate from yet;
|
||||||
|
/// PooledBinaryReader.ReadBoolean() below has no such compile restriction, confirmed
|
||||||
|
/// separately, so Read() needs no equivalent workaround.</summary>
|
||||||
|
public override void Write(PooledBinaryWriter _bw, TileEntity.StreamModeWrite _eStreamMode)
|
||||||
|
{
|
||||||
|
base.Write(_bw, _eStreamMode);
|
||||||
|
PyramidWardWriteHelper.Write(_bw, EffectOn, ZoneShown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Read(PooledBinaryReader _br, TileEntity.StreamModeRead _eStreamMode)
|
||||||
|
{
|
||||||
|
base.Read(_br, _eStreamMode);
|
||||||
|
EffectOn = _br.ReadBoolean();
|
||||||
|
ZoneShown = _br.ReadBoolean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// E-menu (activation commands) - see class doc comment for why both states of each
|
||||||
|
// toggle are registered up front rather than swapping text dynamically.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Icons FIXED 2026-09-02 (user report: "на 'Отключить эффект' нету иконки").
|
||||||
|
/// "ui_game_symbol_zombie"/"hand" (the first version's guesses) were never real
|
||||||
|
/// BlockActivationCommand icon names - that field takes a small closed set of simple
|
||||||
|
/// glyph-font names, NOT full UI sprite-atlas names (confirmed by harvesting every real
|
||||||
|
/// `new BlockActivationCommand(...)` call across every other TEFeature class decompiled so
|
||||||
|
/// far: "frames"/"x" (TEFeatureLandClaim), "door" (TEFeatureDoor), "lock"/"unlock"/"keypad"
|
||||||
|
/// (TEFeatureLockable), "search" (TEFeatureStorage), "wrench" (the trigger command on
|
||||||
|
/// TileEntityComposite itself)). Reused two of those real, confirmed names instead of
|
||||||
|
/// guessing again: "unlock"/"lock" for effect on/off (a locked/unlocked padlock reads fine
|
||||||
|
/// as "active"/"inactive"), and "frames" - literally the SAME icon vanilla's own Land Claim
|
||||||
|
/// uses for its own show_bounds/hide_bounds pair - for our own zone_show/zone_hide, since
|
||||||
|
/// it's the exact same kind of toggle.</summary>
|
||||||
|
public override void InitBlockActivationCommands(Action<BlockActivationCommand, TileEntityComposite.EBlockCommandOrder, TileEntityFeatureData> _addCallback)
|
||||||
|
{
|
||||||
|
base.InitBlockActivationCommands(_addCallback);
|
||||||
|
_addCallback(new BlockActivationCommand("effect_on", "unlock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||||
|
_addCallback(new BlockActivationCommand("effect_off", "lock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||||
|
_addCallback(new BlockActivationCommand("zone_show", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||||
|
_addCallback(new BlockActivationCommand("zone_hide", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ADDED 2026-09-02 (user report: "при наведении нет никакой надписи-подсказки про
|
||||||
|
/// E"). TEFeatureAbs.GetActivationText was never overridden at all, so it fell through to
|
||||||
|
/// the base's default `return null` - no ReadOnlySpan in this method's signature (confirmed
|
||||||
|
/// by decompile), so unlike AllowBlockActivationCommand/OnBlockActivated below, this one
|
||||||
|
/// overrides cleanly with no workaround needed. Mirrors TEFeatureLandClaim's own
|
||||||
|
/// GetActivationText shape (`_activateHotkeyMarkup` + the block's own localized name) -
|
||||||
|
/// same real, decompiled API, not guessed.</summary>
|
||||||
|
public override string GetActivationText(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityAlive _entityFocusing, string _activateHotkeyMarkup, string _focusedTileEntityName)
|
||||||
|
{
|
||||||
|
base.GetActivationText(_world, _blockPos, _blockValue, _entityFocusing, _activateHotkeyMarkup, _focusedTileEntityName);
|
||||||
|
return _activateHotkeyMarkup + " " + _blockValue.Block.GetLocalizedBlockName();
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowBlockActivationCommand/OnBlockActivated deliberately NOT overridden here - see the
|
||||||
|
// long comment block below (right above the two Harmony patches that replace them) for why
|
||||||
|
// this specific pair of TEFeatureAbs virtuals cannot be overridden from this mod's project
|
||||||
|
// at all, and how the same behavior is achieved instead.
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Visuals - same GameManager block-particle registry as the first version, just called on
|
||||||
|
// `ToWorldPos()` (this feature's own position) instead of a dictionary-passed key.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public void SpawnGlow()
|
||||||
|
{
|
||||||
|
if (GameManager.Instance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Vector3i pos = ToWorldPos();
|
||||||
|
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FIXED 2026-09-02 (user report: "горение пирамидки почему-то смещено на куб вверх и
|
||||||
|
// вбок"): World.blockToTransformPos(Vector3i) ALREADY returns (x+0.5, y, z+0.5)
|
||||||
|
// (decompiled to confirm - horizontally centered, y left raw/un-centered) - adding
|
||||||
|
// another +0.5 on x/z on top of that (the original bug) double-centered it, landing a
|
||||||
|
// full extra block over on both horizontal axes. Only the vertical lift (how far above
|
||||||
|
// the block the flame sits) is actually ours to add. Height LOWERED again same day
|
||||||
|
// ("сделай ниже не 2/3 куба" - after the sideways offset was gone, still sat too high):
|
||||||
|
// 1.2 (0.2 above the full block top) -> 0.6, under 2/3 (0.667) of a block as asked.
|
||||||
|
// Height lowered twice same day: 1.2 -> 0.6 ("сделай ниже не 2/3 куба"), then -> 0.4
|
||||||
|
// (direct follow-up: "Снизь высоту пламени до +0.4").
|
||||||
|
Vector3 worldPos = World.blockToTransformPos(pos) + new Vector3(0f, 0.4f, 0f);
|
||||||
|
// WardTint (not Color.white) as the ParticleEffect's own _color: ParticleEffect.
|
||||||
|
// SpawnParticleEffect applies this directly to any non-ParticleSystem Renderer on the
|
||||||
|
// prefab (decompiled to confirm) - covers a sub-emitter/glow sprite ApplyGlowTint's own
|
||||||
|
// ParticleSystem-only loop wouldn't reach, belt-and-suspenders alongside it.
|
||||||
|
GameManager.Instance.SpawnBlockParticleEffect(pos, new ParticleEffect(GlowParticleName, worldPos, Quaternion.identity, 0f, WardTint));
|
||||||
|
pendingTint.Add(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveGlow()
|
||||||
|
{
|
||||||
|
if (GameManager.Instance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Vector3i pos = ToWorldPos();
|
||||||
|
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||||
|
{
|
||||||
|
GameManager.Instance.RemoveBlockParticleEffect(pos);
|
||||||
|
}
|
||||||
|
pendingTint.Remove(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ring of glow points marking EffectRadius, keyed by y = -1000-i (real block
|
||||||
|
/// y-coordinates never go negative that far, so these keys can never collide with an
|
||||||
|
/// actual placed block's own glow key).</summary>
|
||||||
|
public void SpawnZoneRing()
|
||||||
|
{
|
||||||
|
if (GameManager.Instance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Vector3i basePos = ToWorldPos();
|
||||||
|
// Same double-centering bug as SpawnGlow's own fix above - blockToTransformPos already
|
||||||
|
// centers x/z, only the vertical offset (0.5, mid-block height) is ours to add.
|
||||||
|
Vector3 center = World.blockToTransformPos(basePos) + new Vector3(0f, 0.5f, 0f);
|
||||||
|
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||||
|
{
|
||||||
|
float angle = i * (360f / ZoneRingPointCount) * Mathf.Deg2Rad;
|
||||||
|
Vector3 point = center + new Vector3(Mathf.Cos(angle) * EffectRadius, 0f, Mathf.Sin(angle) * EffectRadius);
|
||||||
|
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||||
|
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ZoneRingParticleName (not GlowParticleName) - see that constant's own comment:
|
||||||
|
// user asked for the boundary to read as "some particles", not literal fire.
|
||||||
|
GameManager.Instance.SpawnBlockParticleEffect(key, new ParticleEffect(ZoneRingParticleName, point, Quaternion.identity, 0f, WardTint));
|
||||||
|
pendingTint.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveZoneRing()
|
||||||
|
{
|
||||||
|
if (GameManager.Instance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Vector3i basePos = ToWorldPos();
|
||||||
|
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||||
|
{
|
||||||
|
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||||
|
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||||
|
{
|
||||||
|
GameManager.Instance.RemoveBlockParticleEffect(key);
|
||||||
|
}
|
||||||
|
pendingTint.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors ParticlePatch.cs's own tint technique (same ParticleSystem.MainModule
|
||||||
|
/// fields, same reasoning: the particle prefab always instantiates at its authored color,
|
||||||
|
/// nothing in XML/the block-particle API can override that) - kept as its own copy here
|
||||||
|
/// rather than refactoring ParticlePatch.cs itself, so this feature can't regress the
|
||||||
|
/// already-working zombie glow if something about this call site needs different handling
|
||||||
|
/// once tested in-game.
|
||||||
|
///
|
||||||
|
/// EXTENDED 2026-09-02 for "campfire" (see GlowParticleName's own comment for why the
|
||||||
|
/// particle changed) with Gradient/TwoGradients handling - ParticlePatch.cs's original
|
||||||
|
/// only ever needed Color/TwoColors (RadiatedParticlesOnMesh's own authored mode) and
|
||||||
|
/// explicitly left Gradient/TwoGradients alone as "no generic way to override". That's not
|
||||||
|
/// actually true - a Gradient's color keys ARE reassignable at runtime via
|
||||||
|
/// `Gradient.SetKeys` - so it's handled here now, since a real fire effect plausibly
|
||||||
|
/// animates through multiple colors (yellow-orange-red-smoke) via an actual Gradient rather
|
||||||
|
/// than one flat color, and the request is specifically "холодным пламенем" (COLD flame) -
|
||||||
|
/// if this branch never actually runs because campfire turns out to use plain Color/
|
||||||
|
/// TwoColors after all, no harm, the two branches above still cover it.</summary>
|
||||||
|
public static void ApplyGlowTint(Transform particleTransform, float sizeFactor, float alphaFactor)
|
||||||
|
{
|
||||||
|
if (particleTransform == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
particleTransform.localScale = Vector3.one * sizeFactor;
|
||||||
|
ParticleSystem[] systems = particleTransform.GetComponentsInChildren<ParticleSystem>(true);
|
||||||
|
foreach (ParticleSystem ps in systems)
|
||||||
|
{
|
||||||
|
ParticleSystem.MainModule main = ps.main;
|
||||||
|
main.startSizeMultiplier *= sizeFactor;
|
||||||
|
ParticleSystem.MinMaxGradient startColor = main.startColor;
|
||||||
|
switch (startColor.mode)
|
||||||
|
{
|
||||||
|
case ParticleSystemGradientMode.Color:
|
||||||
|
{
|
||||||
|
Color c = WardTint;
|
||||||
|
c.a = startColor.color.a * alphaFactor;
|
||||||
|
startColor.color = c;
|
||||||
|
main.startColor = startColor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ParticleSystemGradientMode.TwoColors:
|
||||||
|
{
|
||||||
|
Color min = WardTint;
|
||||||
|
Color max = WardTint;
|
||||||
|
min.a = startColor.colorMin.a * alphaFactor;
|
||||||
|
max.a = startColor.colorMax.a * alphaFactor;
|
||||||
|
startColor.colorMin = min;
|
||||||
|
startColor.colorMax = max;
|
||||||
|
main.startColor = startColor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ParticleSystemGradientMode.Gradient:
|
||||||
|
startColor.gradient = TintGradient(startColor.gradient, alphaFactor);
|
||||||
|
main.startColor = startColor;
|
||||||
|
break;
|
||||||
|
case ParticleSystemGradientMode.TwoGradients:
|
||||||
|
startColor.gradientMin = TintGradient(startColor.gradientMin, alphaFactor);
|
||||||
|
startColor.gradientMax = TintGradient(startColor.gradientMax, alphaFactor);
|
||||||
|
main.startColor = startColor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders for any sub-emitter/light-flicker Renderer that isn't a
|
||||||
|
// ParticleSystem itself - SpawnGlow/SpawnZoneRing already pass WardTint as the
|
||||||
|
// ParticleEffect's own _color (which ParticleEffect.SpawnParticleEffect applies to
|
||||||
|
// exactly this kind of non-ParticleSystem Renderer automatically), this loop only
|
||||||
|
// covers the ParticleSystem-driven part. A real-time Light component (if "campfire"
|
||||||
|
// has one for dynamic scene lighting) is NOT touched by either mechanism - if the
|
||||||
|
// flame reads purple but still casts an orange glow on nearby surfaces, that's why,
|
||||||
|
// and would need its own separate fix once actually seen in-game.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebuilds a Gradient with every color key replaced by WardTint, keeping the
|
||||||
|
/// original alpha keys (and their timing) intact so the fade-in/fade-out shape of the
|
||||||
|
/// effect is preserved - only the color changes, not the timing/opacity curve.</summary>
|
||||||
|
public static Gradient TintGradient(Gradient original, float alphaFactor)
|
||||||
|
{
|
||||||
|
Gradient g = new Gradient();
|
||||||
|
GradientAlphaKey[] alphaKeys = original != null ? original.alphaKeys : new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f) };
|
||||||
|
for (int i = 0; i < alphaKeys.Length; i++)
|
||||||
|
{
|
||||||
|
alphaKeys[i].alpha *= alphaFactor;
|
||||||
|
}
|
||||||
|
GradientColorKey[] colorKeys = new GradientColorKey[] { new GradientColorKey(WardTint, 0f), new GradientColorKey(WardTint, 1f) };
|
||||||
|
g.SetKeys(colorKeys, alphaKeys);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Per-tick: deferred tint + charm/burn scan. Replaces the first version's global
|
||||||
|
// ModEvents.UnityUpdate handler + static Dictionary loop entirely - each pyramid now ticks
|
||||||
|
// itself via this real per-feature hook, called directly by the engine (no throttle of our
|
||||||
|
// own - see the comment on `center` below for why a self-imposed one is actively wrong here).
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public override void UpdateTick(World _world)
|
||||||
|
{
|
||||||
|
base.UpdateTick(_world);
|
||||||
|
if (GameManager.Instance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = pendingTint.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
Vector3i key = pendingTint[i];
|
||||||
|
if (!GameManager.Instance.HasBlockParticleEffect(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Transform t = GameManager.Instance.GetBlockParticleEffect(key);
|
||||||
|
bool isRingPoint = key.y <= -1000;
|
||||||
|
// 1.0 = natural "campfire" size for the main glow; ring markers shrunk hard (0.35)
|
||||||
|
// so the 16 of them read as small flame-markers instead of a circle of bonfires.
|
||||||
|
// Near-opaque alpha (0.9) so the purple tint reads clearly.
|
||||||
|
ApplyGlowTint(t, isRingPoint ? 0.35f : 1.0f, 0.9f);
|
||||||
|
pendingTint.RemoveAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EffectOn)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3i posI = ToWorldPos();
|
||||||
|
// World.blockToTransformPos already returns X/Z centered on the block (confirmed by
|
||||||
|
// decompile) - only the vertical lift is ours to add. (A past version double-added the
|
||||||
|
// X/Z centering here, offsetting the whole detection circle by about a block - fixed.)
|
||||||
|
Vector3 center = World.blockToTransformPos(posI) + new Vector3(0f, 0.5f, 0f);
|
||||||
|
// Horizontal-only (X/Z) distance, ignoring Y: the zone-ring visual is a flat disc at one
|
||||||
|
// height, so the real detection area is a matching vertical column, not a shrinking
|
||||||
|
// sphere - also just more useful for a base with any stairs/floors. Bounds query
|
||||||
|
// widened vertically (256 = full world height) since the real filter below doesn't
|
||||||
|
// restrict Y at all.
|
||||||
|
Bounds bounds = new Bounds(center, new Vector3(EffectRadius * 2f, 256f, EffectRadius * 2f));
|
||||||
|
|
||||||
|
// Skip the (more expensive) zombie query/loop entirely unless a player is actually in
|
||||||
|
// range - also doubles as the burning debuff's required instigator id (a buff with a
|
||||||
|
// real damage_type, unlike the charm, needs one or EntityBuffs.AddBuff fails its
|
||||||
|
// FriendlyFireCheck outright - see BurnBuffName's own comment).
|
||||||
|
List<Entity> playersNearby = new List<Entity>();
|
||||||
|
_world.GetEntitiesInBounds(typeof(EntityPlayer), bounds, playersNearby);
|
||||||
|
int playerInstigatorId = -1;
|
||||||
|
foreach (Entity p in playersNearby)
|
||||||
|
{
|
||||||
|
float pdx = p.position.x - center.x;
|
||||||
|
float pdz = p.position.z - center.z;
|
||||||
|
if (pdx * pdx + pdz * pdz <= EffectRadius * EffectRadius)
|
||||||
|
{
|
||||||
|
playerInstigatorId = p.entityId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (playerInstigatorId == -1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Entity> nearby = new List<Entity>();
|
||||||
|
_world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby);
|
||||||
|
foreach (Entity e in nearby)
|
||||||
|
{
|
||||||
|
if (!(e is EntityZombie zombie) || zombie.IsDead() || zombie.Buffs == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
float dx = zombie.position.x - center.x;
|
||||||
|
float dz = zombie.position.z - center.z;
|
||||||
|
if (dx * dx + dz * dz > EffectRadius * EffectRadius)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
|
||||||
|
{
|
||||||
|
zombie.Buffs.AddBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
|
||||||
|
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: charmed zombie " + zombie.entityId + " near pyramid " + posI);
|
||||||
|
}
|
||||||
|
// Burning re-applied every tick a zombie is in the zone (not gated behind "already
|
||||||
|
// has it" like the charm above) - buffBurningElement resets its own countdown on
|
||||||
|
// every re-trigger, so this keeps it topped up rather than a one-shot.
|
||||||
|
zombie.Buffs.AddBuff(BurnBuffName, playerInstigatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real, verified-by-compiler blocker found while writing TEFeaturePyramidWard above:
|
||||||
|
/// TEFeatureAbs.AllowBlockActivationCommand and TEFeatureAbs.OnBlockActivated cannot be
|
||||||
|
/// overridden from this mod's project AT ALL, on any target framework tried (netstandard2.1
|
||||||
|
/// AND net8.0 both fail identically, confirmed with an isolated throwaway repro project - this
|
||||||
|
/// is not a langversion/TargetFramework setting to tune away). Root cause, found by dumping raw
|
||||||
|
/// IL (`ilspycmd -il`): Assembly-CSharp.dll declares these two methods' shared parameter type as
|
||||||
|
/// `valuetype [mscorlib]System.ReadOnlySpan\`1<char>` - i.e. Unity's own Mono/IL2CPP
|
||||||
|
/// runtime backports Span/ReadOnlySpan INTO mscorlib itself, unlike a normal modern .NET SDK
|
||||||
|
/// project (this mod's own csproj included), where ReadOnlySpan<T> instead lives in
|
||||||
|
/// System.Private.CoreLib/System.Memory. Same type NAME, but the CLR treats a type's identity
|
||||||
|
/// as (name + DECLARING ASSEMBLY) - these are two different types to the compiler, so an
|
||||||
|
/// override that looks byte-for-byte identical in source (confirmed via a live reflection probe
|
||||||
|
/// against the real DLL, not just the decompiled source) still fails to bind as a valid
|
||||||
|
/// override. The only real fix on our side would be adding an explicit reference to the game's
|
||||||
|
/// own Managed/mscorlib.dll so our ReadOnlySpan<char> resolves from the same assembly -
|
||||||
|
/// not attempted, since forcing a second mscorlib into this project risks colliding with every
|
||||||
|
/// other basic type (string, object, List<T>...) the SDK's own implicit framework
|
||||||
|
/// reference already provides, for every file in this mod, not just this one method pair. Not
|
||||||
|
/// worth that blast radius for two methods this patch below covers just as well anyway.
|
||||||
|
///
|
||||||
|
/// WORKAROUND: everything both blocked methods needed to do is instead done one layer up, on
|
||||||
|
/// the STRING/array-based (no ReadOnlySpan anywhere) methods that wrap them:
|
||||||
|
/// - `TileEntityComposite.UpdateBlockActivationCommands(BlockActivationCommand[], ...)` -
|
||||||
|
/// confirmed by decompile to run AFTER every feature's (unoverridden, always-true-by-
|
||||||
|
/// default) AllowBlockActivationCommand, so a Postfix here can simply overwrite `.enabled`
|
||||||
|
/// for our 4 known commands with the real per-instance answer - same end result.
|
||||||
|
/// - `BlockCompositeTileEntity.OnBlockActivated(string _commandName, ...)` - the SAME method
|
||||||
|
/// the pre-rewrite plain-Block version patched, just on the composite block class instead;
|
||||||
|
/// `_commandName` here is still the FULL "TEFeaturePyramidWard:effect_on" form (splitting
|
||||||
|
/// into module+bare command only happens one level deeper, inside TileEntityComposite's own
|
||||||
|
/// OnBlockActivated) - checked with plain string.EndsWith, no ReadOnlySpan needed.
|
||||||
|
/// Both patches guard on TryGetSelfOrFeature<TEFeaturePyramidWard> first and bail
|
||||||
|
/// immediately for every other composite block in the game (doors, Land Claim, etc.) - same
|
||||||
|
/// "patch the shared method, filter by identity" idiom as everywhere else in this mod.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(TileEntityComposite), "UpdateBlockActivationCommands")]
|
||||||
|
public static class Patch_TileEntityComposite_UpdateBlockActivationCommands_PyramidWard
|
||||||
|
{
|
||||||
|
public static void Postfix(TileEntityComposite __instance, BlockActivationCommand[] _commands)
|
||||||
|
{
|
||||||
|
if (__instance == null || _commands == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!__instance.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard feature))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < _commands.Length; i++)
|
||||||
|
{
|
||||||
|
string text = _commands[i].text;
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text.EndsWith("effect_on"))
|
||||||
|
{
|
||||||
|
_commands[i].enabled = !feature.EffectOn;
|
||||||
|
}
|
||||||
|
else if (text.EndsWith("effect_off"))
|
||||||
|
{
|
||||||
|
_commands[i].enabled = feature.EffectOn;
|
||||||
|
}
|
||||||
|
else if (text.EndsWith("zone_show"))
|
||||||
|
{
|
||||||
|
_commands[i].enabled = !feature.ZoneShown;
|
||||||
|
}
|
||||||
|
else if (text.EndsWith("zone_hide"))
|
||||||
|
{
|
||||||
|
_commands[i].enabled = feature.ZoneShown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(BlockCompositeTileEntity), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })]
|
||||||
|
public static class Patch_BlockCompositeTileEntity_OnBlockActivated_PyramidWard
|
||||||
|
{
|
||||||
|
public static bool Prefix(string _commandName, Vector3i _blockPos, ref bool __result)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_commandName))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool isEffectCommand = _commandName.EndsWith("effect_on") || _commandName.EndsWith("effect_off");
|
||||||
|
bool isZoneCommand = _commandName.EndsWith("zone_show") || _commandName.EndsWith("zone_hide");
|
||||||
|
if (!isEffectCommand && !isZoneCommand)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
TileEntity te = world != null ? world.GetTileEntity(_blockPos) : null;
|
||||||
|
if (!(te is TileEntityComposite composite) || !composite.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard feature))
|
||||||
|
{
|
||||||
|
__result = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEffectCommand)
|
||||||
|
{
|
||||||
|
feature.EffectOn = !feature.EffectOn;
|
||||||
|
if (feature.EffectOn)
|
||||||
|
{
|
||||||
|
feature.SpawnGlow();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
feature.RemoveGlow();
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: effect " + (feature.EffectOn ? "ON" : "OFF") + " at " + _blockPos);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
feature.ZoneShown = !feature.ZoneShown;
|
||||||
|
if (feature.ZoneShown)
|
||||||
|
{
|
||||||
|
feature.SpawnZoneRing();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
feature.RemoveZoneRing();
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: zone display " + (feature.ZoneShown ? "ON" : "OFF") + " at " + _blockPos);
|
||||||
|
}
|
||||||
|
feature.SetModified();
|
||||||
|
__result = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// "Пространственный браслет" (Spatial Bracelet) - dictated 2026-08-30, implemented same
|
||||||
|
/// day. See items.xml (braceletSpatialVault) for the item - both Action0 and Action1 use
|
||||||
|
/// Class="Eat" purely as a click-catcher (same trick as every other Harmony-driven item this
|
||||||
|
/// mod already has), distinguished here by ItemActionData.indexInEntityOfAction (0/1), the
|
||||||
|
/// same field SummonPatch.cs already uses to tell a summon book's summon-click from its
|
||||||
|
/// recall-click.
|
||||||
|
///
|
||||||
|
/// POWER ATTACK (index 1) - personal storage, size scaling with Necromancy skill level:
|
||||||
|
/// - XUiC_BagStorageWindowGroup.Open(xui, entity, bag, lootContainer, title, ...) is the
|
||||||
|
/// REAL API EntityDrone.openStorageWindow() itself calls to show the drone's own cargo
|
||||||
|
/// window (decompiled EntityDrone directly to find this, not guessed) - reused directly
|
||||||
|
/// rather than reinventing a storage UI. LootContainer.GetLootContainer("roboticDrone")
|
||||||
|
/// is the same display/behavior template the drone's own window uses too - "как у дрона"
|
||||||
|
/// taken literally, not just as a vague size comparison.
|
||||||
|
/// - Slot count = Mathf.RoundToInt(necromancyLevel / 10f), per the user's own exact formula
|
||||||
|
/// ("1*скилл_некроманта/10 округлённый до целого") - read live from
|
||||||
|
/// player.Progression.GetProgressionValue("craftingNecroNecromancy").Level (decompiled
|
||||||
|
/// EntityAlive/Progression/ProgressionValue directly to confirm this exact call shape,
|
||||||
|
/// not guessed) - the SAME skill the Knife's own damage already scales with (capped at
|
||||||
|
/// level 5000, one level per zombie kill - see buffs.xml/progression.xml), so this grows
|
||||||
|
/// at the same pace as every other kill-count-tied payoff in this mod. Below level 10
|
||||||
|
/// 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
|
||||||
|
/// instead of silently opening a useless empty window.
|
||||||
|
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently
|
||||||
|
/// assumed: the Bag backing each player's vault lives in a plain in-memory
|
||||||
|
/// Dictionary<int, Bag> in this file (PlayerVaults below), keyed by entityId. This
|
||||||
|
/// is reliable for as long as the game process keeps running (survives death/respawn/
|
||||||
|
/// relogging within one play session, confirmed by how a static field behaves) but has
|
||||||
|
/// NOT been wired into any save/load system - closing the game entirely and reloading the
|
||||||
|
/// save later will NOT bring the vault's contents back (no persistence file, no hook into
|
||||||
|
/// PersistentPlayerData or a world-save event). Building real cross-session persistence
|
||||||
|
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned
|
||||||
|
/// 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:
|
||||||
|
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
||||||
|
/// braceletThiefLoop (GetLookRay + Physics.Raycast + RootTransformRefEntity.
|
||||||
|
/// FindEntityUpwards) - reused verbatim, just resolving to EntityZombie instead of
|
||||||
|
/// EntityLootContainer.
|
||||||
|
/// - Slow: zombie.Buffs.AddBuff("buffInjurySlow") - the exact same vanilla debuff already
|
||||||
|
/// reused elsewhere in this mod (the Dog's own bite, necroMeleeHandZombieDog).
|
||||||
|
/// - Knockback: DELIBERATELY a straight Entity.SetPosition "shove" (same API
|
||||||
|
/// PetFollowPatch.cs already uses to reposition pets), NOT a physics/ragdoll impulse.
|
||||||
|
/// Found real candidates for "proper" knockback while researching this
|
||||||
|
/// (EntityAlive.DoRagdoll(in DamageResponse), DamageResponse.ImpulseScale/HitDirection),
|
||||||
|
/// but fully reverse-engineering how a real DamageResponse gets built and fed into that
|
||||||
|
/// during normal combat - all its other fields (Source, Strength, Stun, ArmorSlot, etc.)
|
||||||
|
/// - would have taken real additional decompilation with no guarantee of getting all the
|
||||||
|
/// coordinate/enum conventions right on the first try. A direct position shove is cruder
|
||||||
|
/// (no animation, the zombie just appears further away) but uses an API this exact file's
|
||||||
|
/// own family already relies on successfully - chosen for certainty over polish. Revisit
|
||||||
|
/// with DoRagdoll if the teleport-shove feels too crude in testing.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")]
|
||||||
|
public static class Patch_ItemActionEat_ExecuteAction_SpatialVault
|
||||||
|
{
|
||||||
|
public const string ItemName = "braceletSpatialVault";
|
||||||
|
public const string NecromancySkillName = "craftingNecroNecromancy";
|
||||||
|
public const float MaxRange = 50f;
|
||||||
|
public const float ShoveDistance = 6f;
|
||||||
|
|
||||||
|
/// <summary>See the class-level comment above for exactly what this does and doesn't
|
||||||
|
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary>
|
||||||
|
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||||||
|
|
||||||
|
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||||
|
{
|
||||||
|
if (!_bReleased)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
|
||||||
|
if (itemName != ItemName)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_actionData.indexInEntityOfAction == 1)
|
||||||
|
{
|
||||||
|
OpenVault(player);
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OpenVault(EntityPlayerLocal player)
|
||||||
|
{
|
||||||
|
ProgressionValue progressionValue = player.Progression?.GetProgressionValue(NecromancySkillName);
|
||||||
|
int level = progressionValue != null ? progressionValue.Level : 0;
|
||||||
|
int slotCount = Mathf.RoundToInt(level / 10f);
|
||||||
|
if (slotCount <= 0)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(player, "braceletSpatialVaultTooWeak");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
||||||
|
{
|
||||||
|
bag = new Bag(slotCount);
|
||||||
|
PlayerVaults[player.entityId] = bag;
|
||||||
|
}
|
||||||
|
else if (bag.SlotCount < slotCount)
|
||||||
|
{
|
||||||
|
// Grow, never shrink - the skill level only ever goes up, so this only ever
|
||||||
|
// copies existing stacks into a bigger array, same shape
|
||||||
|
// EntityLootContainer.SetContent itself uses when it needs to resize a bag.
|
||||||
|
ItemStack[] oldSlots = bag.GetSlots();
|
||||||
|
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
||||||
|
Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
||||||
|
bag.SetSlots(newSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
|
XUiC_BagStorageWindowGroup.Open(playerUI.xui, player, bag, LootContainer.GetLootContainer("roboticDrone"), Localization.Get("braceletSpatialVaultWindowTitle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
||||||
|
{
|
||||||
|
Ray ray = player.GetLookRay();
|
||||||
|
if (!Physics.Raycast(ray, out RaycastHit hit, MaxRange))
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Transform entityTransform = RootTransformRefEntity.FindEntityUpwards(hit.collider.transform);
|
||||||
|
Entity entity = entityTransform != null ? entityTransform.GetComponent<Entity>() : null;
|
||||||
|
if (!(entity is EntityZombie zombie) || zombie.IsDead())
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zombie.Buffs?.AddBuff("buffInjurySlow");
|
||||||
|
|
||||||
|
Vector3 shoveDir = zombie.position - player.position;
|
||||||
|
shoveDir.y = 0f;
|
||||||
|
shoveDir = shoveDir.sqrMagnitude > 0.01f ? shoveDir.normalized : player.transform.forward;
|
||||||
|
Vector3 destination = zombie.position + shoveDir * ShoveDistance + Vector3.up * 1f;
|
||||||
|
zombie.SetPosition(destination, true);
|
||||||
|
|
||||||
|
player.PlayOneShot("swoosh");
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " shoved zombie " + zombie.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Necromancer pet summon/ownership/recall - BACKLOG.md item 3 (Zombie Dog) plus the Insect
|
||||||
|
/// Swarm added alongside its bugfix (2026-08-28). Done the same way vanilla's own drone works
|
||||||
|
/// (per user request): one active pet per player PER SPECIES, and the pet is recorded as
|
||||||
|
/// owned via the same EntityAlive.ownedEntities API the drone itself uses
|
||||||
|
/// (ItemActionSpawnTurret.ExecuteAction calls holdingEntity.AddOwnedEntity(entityDrone) -
|
||||||
|
/// confirmed by decompiling it).
|
||||||
|
///
|
||||||
|
/// Each summon book's item block now carries the SpawnEntity action on BOTH Action0 and
|
||||||
|
/// Action1 (user's own suggestion 2026-08-28, in response to "how do I put the dog back in
|
||||||
|
/// the book?"), pointed at the same pet entity in both slots, but the two slots mean
|
||||||
|
/// something different: Action0 (primary click) SUMMONS, blocked with a tooltip if one is
|
||||||
|
/// already out; Action1 (secondary/"power attack" click) RECALLS the owned one if there is
|
||||||
|
/// one, or no-ops with a tooltip if there isn't. This split is entirely our own Prefix logic
|
||||||
|
/// below, keyed off ItemActionData.indexInEntityOfAction (0/1, public field, confirmed by
|
||||||
|
/// decompiling ItemActionData) - ItemActionSpawnEntity itself has no concept of "recall",
|
||||||
|
/// it's purely a spawner.
|
||||||
|
///
|
||||||
|
/// LimitedPets keys off entity_class name -> the three localization keys each species needs
|
||||||
|
/// (already-active, recalled, nothing-to-recall). Each species gets its own independent
|
||||||
|
/// 1-active limit (a Dog and a Swarm can be out at once; two Dogs can't).
|
||||||
|
///
|
||||||
|
/// Two patch points for the SUMMON path, because entity creation and item consumption/
|
||||||
|
/// ownership can't both live in one method without either a transpiler (to grab a local
|
||||||
|
/// variable) or a fragile "guess the newest entity of this class" lookup:
|
||||||
|
///
|
||||||
|
/// 1. Prefix on ItemActionSpawnEntity.Spawn - runs BEFORE anything is created. Handles both
|
||||||
|
/// the summon-side block/allow AND the entire recall path (recall never lets the
|
||||||
|
/// original method run at all - there's nothing for vanilla Spawn to do on a recall).
|
||||||
|
/// 2. Postfix on EntityFactory.CreateEntity(int,Vector3,Vector3) - the exact overload
|
||||||
|
/// ItemActionSpawnEntity.Spawn() calls, and it returns the created Entity directly
|
||||||
|
/// (unlike Spawn() itself, which is void), so this is the only point that has both "an
|
||||||
|
/// entity was just created" and "here it is" without needing IL tricks. Filtered to our
|
||||||
|
/// pets' entityClassIds so it's a no-op for every other CreateEntity call in the game
|
||||||
|
/// (turrets, drones, zombie spawns, everything else uses this exact same overload).
|
||||||
|
/// Because the Prefix above already guarantees at most one pet of that species per
|
||||||
|
/// player, this postfix doesn't need to re-check the limit - it only ever fires for
|
||||||
|
/// allowed summons.
|
||||||
|
///
|
||||||
|
/// This overload of CreateEntity has no "who spawned this" parameter, so the postfix finds
|
||||||
|
/// the owner as the nearest player to the spawn position - always correct here since
|
||||||
|
/// ItemActionSpawnEntity.Spawn() always spawns at (roughly) the caster's own head position.
|
||||||
|
/// Fine for this mod (no multiplayer/persistence layer exists anywhere else in it either);
|
||||||
|
/// not a general-purpose "find the real spawner" solution.
|
||||||
|
///
|
||||||
|
/// Known gap vs. the real drone: no drones.dat-style save file, no despawn-on-owner-death.
|
||||||
|
/// A pet is a plain EntityAlive with no special unload/reload handling, so if its chunk
|
||||||
|
/// unloads while the player is away it despawns like any other wandering entity - the real
|
||||||
|
/// drone avoids that via DroneManager's own persistence system, which is a lot of machinery
|
||||||
|
/// (network sync, its own save file) this mod doesn't have a reason to take on for a couple
|
||||||
|
/// of pet types. Revisit only if that turns out to matter.
|
||||||
|
///
|
||||||
|
/// See PetFollowPatch.cs (added 2026-08-28, after the Dog wandered off in-game and a second
|
||||||
|
/// one wouldn't summon) for the leash-back-to-owner behavior and the ownership cleanup that
|
||||||
|
/// runs when a tracked pet dies or its chunk unloads - both plug directly into this file's
|
||||||
|
/// LimitedPets/AddOwnedEntity machinery. The manual recall path added here calls
|
||||||
|
/// PetFollowPatch.Unregister so a recalled pet stops being tracked immediately instead of
|
||||||
|
/// waiting for that cleanup to notice it's gone.
|
||||||
|
///
|
||||||
|
/// DEBUG LOGGING: added 2026-08-28 after the Zombie Dog silently failed to spawn in-game
|
||||||
|
/// (root cause was unrelated to this patch - see items.xml's AnimWait comment - but there was
|
||||||
|
/// no logging anywhere in this file to even rule that out quickly, unlike CharmPatch.cs's
|
||||||
|
/// verbose Debug.Log on every step). Keeping these permanently, same as CharmPatch.cs does.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(ItemActionSpawnEntity), "Spawn", new System.Type[] { typeof(ItemActionData) })]
|
||||||
|
public static class Patch_ItemActionSpawnEntity_Spawn_PetLimit
|
||||||
|
{
|
||||||
|
public class PetInfo
|
||||||
|
{
|
||||||
|
public string AlreadyActiveKey;
|
||||||
|
public string RecalledKey;
|
||||||
|
public string NothingToRecallKey;
|
||||||
|
public string SummonItemName;
|
||||||
|
/// <summary>User request 2026-08-28: "пусть книга вообще не тратится на призыв
|
||||||
|
/// собаки" - the Dog's book is a permanent bonded item, never consumed on summon (and
|
||||||
|
/// RecallPet never has anything to give back, since nothing was ever taken). The
|
||||||
|
/// Swarm keeps consuming its book per cast - it's the one-time/no-recall species, that
|
||||||
|
/// consumption is the actual "cost" of casting it. This single flag also happens to
|
||||||
|
/// 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>
|
||||||
|
public bool ConsumesBook;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"necroZombieDog",
|
||||||
|
new PetInfo
|
||||||
|
{
|
||||||
|
AlreadyActiveKey = "necroZombieDogAlreadyActive",
|
||||||
|
RecalledKey = "necroZombieDogRecalled",
|
||||||
|
NothingToRecallKey = "necroZombieDogNothingToRecall",
|
||||||
|
SummonItemName = "bookSummonZombieDog",
|
||||||
|
ConsumesBook = false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// User request 2026-08-28: "пусть вызов насекомых будет одноразовым" - no
|
||||||
|
// Action1 on bookSummonInsectSwarm any more (see items.xml), so
|
||||||
|
// indexInEntityOfAction can never be RecallActionIndex for this species and
|
||||||
|
// RecalledKey/NothingToRecallKey below are simply never read. Left null rather
|
||||||
|
// than pointed at deleted localization keys.
|
||||||
|
"necroInsectSwarm",
|
||||||
|
new PetInfo
|
||||||
|
{
|
||||||
|
AlreadyActiveKey = "necroInsectSwarmAlreadyActive",
|
||||||
|
RecalledKey = null,
|
||||||
|
NothingToRecallKey = null,
|
||||||
|
SummonItemName = "bookSummonInsectSwarm",
|
||||||
|
ConsumesBook = true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Three more pets, BACKLOG.md item 4a. REPLACED 2026-08-29 - the original
|
||||||
|
// Stripper/Cop/Soldier (extending real EntityZombie-classed vanilla zombies) came
|
||||||
|
// back hostile to the player in testing despite copying the Dog's own recipe; see
|
||||||
|
// entityclasses.xml for the full story. New concept: Зомбогриф/Зомбомедведь/
|
||||||
|
// Зомбоволк, extending animal-family bases (same category as the Dog itself, which
|
||||||
|
// DID work) - same shape otherwise (recallable, book never consumed).
|
||||||
|
{
|
||||||
|
"necroZombieGriffin",
|
||||||
|
new PetInfo
|
||||||
|
{
|
||||||
|
AlreadyActiveKey = "necroZombieGriffinAlreadyActive",
|
||||||
|
RecalledKey = "necroZombieGriffinRecalled",
|
||||||
|
NothingToRecallKey = "necroZombieGriffinNothingToRecall",
|
||||||
|
SummonItemName = "bookSummonZombieGriffin",
|
||||||
|
ConsumesBook = false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"necroZombieBear",
|
||||||
|
new PetInfo
|
||||||
|
{
|
||||||
|
AlreadyActiveKey = "necroZombieBearAlreadyActive",
|
||||||
|
RecalledKey = "necroZombieBearRecalled",
|
||||||
|
NothingToRecallKey = "necroZombieBearNothingToRecall",
|
||||||
|
SummonItemName = "bookSummonZombieBear",
|
||||||
|
ConsumesBook = false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"necroZombieWolf",
|
||||||
|
new PetInfo
|
||||||
|
{
|
||||||
|
AlreadyActiveKey = "necroZombieWolfAlreadyActive",
|
||||||
|
RecalledKey = "necroZombieWolfRecalled",
|
||||||
|
NothingToRecallKey = "necroZombieWolfNothingToRecall",
|
||||||
|
SummonItemName = "bookSummonZombieWolf",
|
||||||
|
ConsumesBook = false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Action1 ("power attack" slot) is always recall-only - see items.xml, both
|
||||||
|
/// summon books now declare Action1 with the same Class="SpawnEntity"/Entity as Action0.</summary>
|
||||||
|
public const int RecallActionIndex = 1;
|
||||||
|
|
||||||
|
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData)
|
||||||
|
{
|
||||||
|
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo tooltips))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
EntityAlive holdingEntity = _actionData?.invData?.holdingEntity;
|
||||||
|
if (holdingEntity == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// BUG FIXED 2026-08-28 (unlimited summons, recall never ran, dogs pile-launching the
|
||||||
|
// player): this used to check "<= 0" for "not found", on the wrong assumption that
|
||||||
|
// valid ids are small positive numbers. Confirmed by decompiling EntityClass.GetId:
|
||||||
|
// it returns -1 (a clean sentinel) when not found, and otherwise the real class id -
|
||||||
|
// which is hash-based and can absolutely be negative (necroZombieDog's is, e.g.,
|
||||||
|
// -779816341, confirmed by this method's own Debug.Log below during the actual bug).
|
||||||
|
// With "<= 0", that real, valid, negative id was misread as "not found" on every
|
||||||
|
// single call, so this returned true unconditionally - which skipped BOTH the
|
||||||
|
// summon-limit check AND the entire recall branch below (recall's check for it is
|
||||||
|
// also past this point), so every Action0 OR Action1 click just summoned yet another
|
||||||
|
// pet, forever, book and all.
|
||||||
|
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
|
||||||
|
if (petClassId == -1)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SummonPatch: entity class '" + __instance.entityToSpawn + "' not found");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
|
||||||
|
Debug.Log("[NecromancerTome] SummonPatch: Spawn prefix for " + __instance.entityToSpawn + ", action index=" + _actionData.indexInEntityOfAction + ", owned count=" + owned.Count);
|
||||||
|
|
||||||
|
if (_actionData.indexInEntityOfAction == RecallActionIndex)
|
||||||
|
{
|
||||||
|
if (owned.Count > 0)
|
||||||
|
{
|
||||||
|
RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null);
|
||||||
|
}
|
||||||
|
else if (holdingEntity.world != null)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owned.Count > 0)
|
||||||
|
{
|
||||||
|
if (holdingEntity.world != null)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
|
||||||
|
{
|
||||||
|
World world = owner.world;
|
||||||
|
if (world != null)
|
||||||
|
{
|
||||||
|
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
|
||||||
|
}
|
||||||
|
owner.RemoveOwnedEntity(petEntityId);
|
||||||
|
PetFollowPatch.Unregister(petEntityId);
|
||||||
|
if (summonItemName != null)
|
||||||
|
{
|
||||||
|
GiveBackSummonItem(owner, summonItemName);
|
||||||
|
}
|
||||||
|
if (world != null)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), recalledTooltipKey);
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " recalled pet " + petEntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands one copy of the summon book back to whoever just recalled their pet -
|
||||||
|
/// the dog/swarm "goes back into the book" literally, not just for free. Tries the
|
||||||
|
/// toolbelt first (Inventory.AddItem - confirmed by decompiling it, only searches the
|
||||||
|
/// toolbelt's own slots), then the backpack (EntityPlayer.bag, same AddItem shape) if that
|
||||||
|
/// didn't fit. If both are full the book is just lost - not worth building actual overflow
|
||||||
|
/// handling (a "drop it on the ground" fallback) for something this minor.</summary>
|
||||||
|
public static void GiveBackSummonItem(EntityAlive owner, string itemName)
|
||||||
|
{
|
||||||
|
ItemValue itemValue = ItemClass.GetItem(itemName);
|
||||||
|
if (itemValue == null || itemValue.type <= 0)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SummonPatch: could not resolve item '" + itemName + "' to give back on recall");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ItemStack stack = new ItemStack(itemValue, 1);
|
||||||
|
bool added = owner.inventory != null && owner.inventory.AddItem(stack, out int slot);
|
||||||
|
if (!added && owner is EntityPlayer player && player.bag != null)
|
||||||
|
{
|
||||||
|
// Bag (InventoryBase) only exposes the single-arg AddItem overload, unlike
|
||||||
|
// Inventory's (ItemStack, out int) - confirmed by decompiling both.
|
||||||
|
added = player.bag.AddItem(stack);
|
||||||
|
}
|
||||||
|
if (!added)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SummonPatch: " + itemName + " didn't fit back into " + owner.entityId + "'s inventory on recall (full?)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
|
||||||
|
public static class Patch_EntityFactory_CreateEntity_PetOwnership
|
||||||
|
{
|
||||||
|
public static void Postfix(int _et, Entity __result)
|
||||||
|
{
|
||||||
|
if (__result == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo = null;
|
||||||
|
foreach (KeyValuePair<string, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo> entry in Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets)
|
||||||
|
{
|
||||||
|
if (EntityClass.GetId(entry.Key) == _et)
|
||||||
|
{
|
||||||
|
petInfo = entry.Value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (petInfo == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] SummonPatch: CreateEntity postfix, entity=" + __result.entityId + " et=" + _et);
|
||||||
|
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null || world.Players == null || world.Players.list == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityPlayer owner = null;
|
||||||
|
float bestDistSq = float.MaxValue;
|
||||||
|
foreach (EntityPlayer player in world.Players.list)
|
||||||
|
{
|
||||||
|
if (player == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
float distSq = (player.position - __result.position).sqrMagnitude;
|
||||||
|
if (distSq < bestDistSq)
|
||||||
|
{
|
||||||
|
bestDistSq = distSq;
|
||||||
|
owner = player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (owner == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SummonPatch: no player found to own " + __result.entityId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
owner.AddOwnedEntity(__result);
|
||||||
|
if (petInfo.ConsumesBook && owner.inventory != null)
|
||||||
|
{
|
||||||
|
owner.inventory.DecHoldingItem(1);
|
||||||
|
}
|
||||||
|
PetFollowPatch.Register(owner, __result);
|
||||||
|
IgnoreCollisionWithOwner(owner, __result);
|
||||||
|
ApplyGhostlyTransparency(__result);
|
||||||
|
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>User request 2026-08-28 ("нематериальными") - makes the pet pass through its
|
||||||
|
/// own owner specifically, via Physics.IgnoreCollision on every collider pair between the
|
||||||
|
/// 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
|
||||||
|
/// ("Game Particles/surfaceShader_masked_particleEnhanced") that has no "_Color" property
|
||||||
|
/// at all - setting .color on it doesn't throw, but Unity logs "doesn't have a color
|
||||||
|
/// 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
|
||||||
|
/// added so this silently skips any renderer whose shader doesn't support it instead of
|
||||||
|
/// spamming the log - the visual effect was never going to work on those anyway.</summary>
|
||||||
|
public static void ApplyGhostlyTransparency(Entity pet)
|
||||||
|
{
|
||||||
|
Renderer[] renderers = pet.GetComponentsInChildren<Renderer>();
|
||||||
|
foreach (Renderer renderer in renderers)
|
||||||
|
{
|
||||||
|
if (renderer == null || renderer.material == null || !renderer.material.HasProperty("_Color"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Color color = renderer.material.color;
|
||||||
|
color.a = 0.55f;
|
||||||
|
renderer.material.color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// BUG FIXED 2026-08-28 (Insect Swarm attacked the player instead of zombies, even after the
|
||||||
|
/// entityclasses.xml AITask-2/AITarget-4 fix): wasted effort, because none of that XML
|
||||||
|
/// mattered. Confirmed by decompiling the actual class chain -
|
||||||
|
/// necroInsectSwarm -> animalInsectSwarm -> Class="EntitySwarm" -> EntitySwarm : EntityVulture.
|
||||||
|
/// EntityVulture does NOT use the generic AITask/AITarget system for target selection at all -
|
||||||
|
/// it has its own hardcoded C# targeting (updateTasks()'s State.Wander branch calls
|
||||||
|
/// FindTarget(), which calls World.GetClosestPlayerSeen/GetClosestPlayer - literally typed to
|
||||||
|
/// return EntityPlayer, there is no "closest zombie" variant to point it at). Every ground
|
||||||
|
/// creature in this mod (the Dog, all vanilla animals) goes through the declarative AITask
|
||||||
|
/// system just fine; flying "swarm" creatures (insect swarm, bee swarm, vultures) are a
|
||||||
|
/// completely separate hardcoded-C# codepath. No XML property changes that.
|
||||||
|
///
|
||||||
|
/// Fix: Prefix on EntityAlive.SetAttackTarget - the one non-EntityPlayer-typed choke point
|
||||||
|
/// every one of EntityVulture's several call sites funnels through (FindTarget() results,
|
||||||
|
/// revenge-target retaliation, sleeper wake-up - all of them end in a SetAttackTarget call,
|
||||||
|
/// confirmed by decompiling EntityVulture). Whenever the entity is one of OUR
|
||||||
|
/// EntityVulture-based pets AND the target it's about to be given is an EntityPlayer, swap in
|
||||||
|
/// the nearest EntityZombie instead (or null if none are nearby - just idles, better than
|
||||||
|
/// attacking the player). Only filters on our own entity classes, so vanilla's own
|
||||||
|
/// animalInsectSwarm/animalBeeSwarm/real vultures are entirely unaffected and keep hunting
|
||||||
|
/// players normally.
|
||||||
|
///
|
||||||
|
/// GENERALIZED 2026-08-29, THEN UN-GENERALIZED SAME DAY: briefly also covered
|
||||||
|
/// necroZombieGriffin (extending animalZombieVulture, the same Class="EntityVulture" root as
|
||||||
|
/// the Swarm) via this same redirect. Confirmed live in-game that this didn't actually fix
|
||||||
|
/// the Griffin - it just flew around doing EntityVulture's own default Wander behavior,
|
||||||
|
/// never engaging zombies at all ("летает где-то в небе, и зомби его вообще не интересуют").
|
||||||
|
/// Rather than keep debugging the redirect blind (each guess needs a full test cycle the
|
||||||
|
/// user has to run), the Griffin was converted to extend necroZombieDog directly instead (see
|
||||||
|
/// entityclasses.xml) - same proven-reliable ground-AI trick as the Bear/Wolf, no longer
|
||||||
|
/// EntityVulture-based at all, so it no longer needs this patch. Kept the Dictionary-based
|
||||||
|
/// shape below (rather than reverting to a single cached id) in case a genuinely flying pet
|
||||||
|
/// gets added again later - SpeciesByName just has one entry for now.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(EntityAlive), "SetAttackTarget", new System.Type[] { typeof(EntityAlive), typeof(int) })]
|
||||||
|
public static class Patch_EntityAlive_SetAttackTarget_SwarmRetarget
|
||||||
|
{
|
||||||
|
public class VultureBasedPetInfo
|
||||||
|
{
|
||||||
|
public bool SkipAlreadyCharmedZombies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HARDENED 2026-08-29 while chasing the user's "Griffin still attacks me"
|
||||||
|
/// report - the Griffin's own AI is not XML-driven at all (see class comment), so this
|
||||||
|
/// Harmony redirect not firing was the prime remaining suspect. Could not fully confirm
|
||||||
|
/// or rule this out by decompilation alone, but the ORIGINAL lazy-cache pattern here had
|
||||||
|
/// two real, independent failure modes worth closing regardless of which (if either) was
|
||||||
|
/// the actual cause: (1) EntityClass.GetId("necroInsectSwarm") and
|
||||||
|
/// EntityClass.GetId("necroZombieGriffin") were both looked up inside ONE dictionary
|
||||||
|
/// object-initializer - if EITHER happened to still return -1 (not yet registered) at
|
||||||
|
/// the exact moment some entirely unrelated zombie's very first SetAttackTarget call
|
||||||
|
/// triggered this lazy build (plausible - that can happen extremely early, before every
|
||||||
|
/// mod entity_class is guaranteed loaded), the -1 got cached FOREVER via the
|
||||||
|
/// cachedClassIds==null guard, silently never re-resolving even once the real class WAS
|
||||||
|
/// registered a moment later - and if BOTH happened to be -1 at once, the dictionary
|
||||||
|
/// initializer would throw (duplicate key), which could break unrelated zombie AI too.
|
||||||
|
/// Rewritten to resolve each species independently and only cache a REAL (non -1) id -
|
||||||
|
/// an unresolved species is retried on every subsequent call instead of being poisoned
|
||||||
|
/// permanently, and two entries can never collide on a shared -1 key.</summary>
|
||||||
|
public static readonly Dictionary<string, VultureBasedPetInfo> SpeciesByName = new Dictionary<string, VultureBasedPetInfo>
|
||||||
|
{
|
||||||
|
{ "necroInsectSwarm", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly Dictionary<int, VultureBasedPetInfo> cachedClassIds = new Dictionary<int, VultureBasedPetInfo>();
|
||||||
|
|
||||||
|
public static Dictionary<int, VultureBasedPetInfo> ClassIds()
|
||||||
|
{
|
||||||
|
// Fast path once every species has resolved (the overwhelmingly common case, since
|
||||||
|
// this runs on EntityAlive.SetAttackTarget - a hot path called for every zombie in
|
||||||
|
// the game, not just ours) - skips the resolution loop below entirely instead of
|
||||||
|
// re-scanning it on every single call.
|
||||||
|
if (cachedClassIds.Count >= SpeciesByName.Count)
|
||||||
|
{
|
||||||
|
return cachedClassIds;
|
||||||
|
}
|
||||||
|
foreach (KeyValuePair<string, VultureBasedPetInfo> species in SpeciesByName)
|
||||||
|
{
|
||||||
|
bool alreadyCached = false;
|
||||||
|
foreach (KeyValuePair<int, VultureBasedPetInfo> cached in cachedClassIds)
|
||||||
|
{
|
||||||
|
if (cached.Value == species.Value)
|
||||||
|
{
|
||||||
|
alreadyCached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (alreadyCached)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int id = EntityClass.GetId(species.Key);
|
||||||
|
if (id == -1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cachedClassIds[id] = species.Value;
|
||||||
|
}
|
||||||
|
return cachedClassIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kept separate from ClassIds() above (which is about retargeting, shared by
|
||||||
|
/// both pets) - this one is Swarm-ONLY, used by PetFollowPatch.cs's "drop an
|
||||||
|
/// already-charmed target so it moves on" behavior, which only makes sense for a species
|
||||||
|
/// that actually charms zombies (the Griffin doesn't). Same retry-until-resolved shape as
|
||||||
|
/// ClassIds() above, for the same reason - never cache a -1.</summary>
|
||||||
|
public static int cachedSwarmOnlyClassId = -1;
|
||||||
|
|
||||||
|
public static int SwarmOnlyClassId()
|
||||||
|
{
|
||||||
|
if (cachedSwarmOnlyClassId == -1)
|
||||||
|
{
|
||||||
|
cachedSwarmOnlyClassId = EntityClass.GetId("necroInsectSwarm");
|
||||||
|
}
|
||||||
|
return cachedSwarmOnlyClassId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Prefix(EntityAlive __instance, ref EntityAlive _attackTarget)
|
||||||
|
{
|
||||||
|
if (__instance == null || !(_attackTarget is EntityPlayer))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ClassIds().TryGetValue(__instance.entityClass, out VultureBasedPetInfo petInfo))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EntityAlive nearestZombie = FindNearestZombie(__instance, petInfo.SkipAlreadyCharmedZombies);
|
||||||
|
Debug.Log("[NecromancerTome] SwarmTargetPatch: redirected " + __instance.entityId + " from player " + _attackTarget.entityId + " to " + (nearestZombie != null ? nearestZombie.entityId.ToString() : "nothing nearby"));
|
||||||
|
_attackTarget = nearestZombie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Same World.GetEntitiesInBounds(Type, Bounds, List<Entity>) API
|
||||||
|
/// EntityVulture itself uses for its own player search (confirmed by decompiling it) -
|
||||||
|
/// just pointed at EntityZombie instead of EntityPlayer. 80m box, matching FindTarget's
|
||||||
|
/// own cTargetDistanceMax constant, for "ищут всех зомби в радиусе".
|
||||||
|
///
|
||||||
|
/// BUG FIXED 2026-08-28 ("покусав одного, летят куда-то далеко, вместо соседнего
|
||||||
|
/// незаражённого"): this didn't skip already-charmed zombies, so when
|
||||||
|
/// PetFollowPatch.cs's "drop an already-charmed target" cleared the swarm's target, the
|
||||||
|
/// very next FindTarget()->SetAttackTarget cycle would often just re-pick the SAME
|
||||||
|
/// zombie it had just charmed (still the physically nearest one right after biting it) -
|
||||||
|
/// PetFollowPatch would clear it again next tick, and in between, EntityVulture (a
|
||||||
|
/// flying creature) fell into its own Wander state, which for a flier means big aerial
|
||||||
|
/// loops away from its current spot, not calm circling. A second, genuinely uncharmed
|
||||||
|
/// zombie standing right next to the first one would lose out to this loop instead of
|
||||||
|
/// being picked immediately. Now skips any zombie that already carries
|
||||||
|
/// buffNecroDeviatorCharm - the real "next AND uncharmed" search the user asked for. Only
|
||||||
|
/// falls through to wide wandering when there truly isn't one nearby, same as before.
|
||||||
|
///
|
||||||
|
/// <paramref name="skipAlreadyCharmed"/> added 2026-08-29 alongside the Griffin
|
||||||
|
/// generalization above - true for the Swarm (its own charm-on-bite behavior, unchanged),
|
||||||
|
/// false for the Griffin (a plain fighter with no reason to avoid already-charmed
|
||||||
|
/// zombies).</summary>
|
||||||
|
public static EntityAlive FindNearestZombie(EntityAlive swarm, bool skipAlreadyCharmed)
|
||||||
|
{
|
||||||
|
World world = swarm.world;
|
||||||
|
if (world == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Entity> nearby = new List<Entity>();
|
||||||
|
Bounds bounds = new Bounds(swarm.position, new Vector3(80f, 80f, 80f));
|
||||||
|
world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby);
|
||||||
|
|
||||||
|
EntityAlive nearest = null;
|
||||||
|
float bestDistSq = float.MaxValue;
|
||||||
|
foreach (Entity entity in nearby)
|
||||||
|
{
|
||||||
|
if (!(entity is EntityAlive zombie) || zombie.IsDead())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (skipAlreadyCharmed && zombie.Buffs != null && zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
float distSq = (zombie.position - swarm.position).sqrMagnitude;
|
||||||
|
if (distSq < bestDistSq)
|
||||||
|
{
|
||||||
|
bestDistSq = distSq;
|
||||||
|
nearest = zombie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Necromancer's Knife (BACKLOG.md item 5, user request 2026-08-28): a zombie hit by the
|
||||||
|
/// knife carries buffNecroVictim (buffs.xml) until it dies. On death, it's guaranteed to drop
|
||||||
|
/// a green "Жертва" loot bag (EntityLootContainerVictim in entityclasses.xml) instead of
|
||||||
|
/// whatever the normal random loot roll would have given it.
|
||||||
|
///
|
||||||
|
/// Two patch points, found the hard way (2026-08-28, "лута всё ещё нет" after confirming via
|
||||||
|
/// the CharmPatch.cs-style AddBuff log that the debuff itself WAS being applied fine):
|
||||||
|
///
|
||||||
|
/// 1. Patch_EntityAlive_dropItemOnDeath_VictimBag - the actual gate. Confirmed by
|
||||||
|
/// decompiling EntityAlive.dropItemOnDeath(): it only calls DropBagServer() at all if
|
||||||
|
/// `lootDropProb > rand.RandomFloat` passes first - vanilla zombies have LootDropProb
|
||||||
|
/// around .04 (4%), so DropBagServer() simply never runs for ~96% of deaths. The first
|
||||||
|
/// version of this file only patched DropBagServer() itself, which was correct once
|
||||||
|
/// inside it but never got a chance to run for most kills - confirmed by the "AddBuff
|
||||||
|
/// Added" log firing repeatedly while the "zombie died" log from the DropBagServer patch
|
||||||
|
/// never fired even once for a real, logged kill. This Prefix on dropItemOnDeath() calls
|
||||||
|
/// DropBagServer() directly for a Victim-tagged zombie, bypassing the probability roll
|
||||||
|
/// entirely, then skips the rest of the original method (the only other thing it does -
|
||||||
|
/// dropping a non-AI entity's own inventory - never applies to a zombie anyway, hasAI is
|
||||||
|
/// always true for those).
|
||||||
|
/// 2. Patch_Entity_DropBagServer_VictimBag - decides WHICH bag. Confirmed by decompiling
|
||||||
|
/// Entity.DropBagServer(): it reads entityClass.lootDrops/LootDropPick(rand) - a STATIC
|
||||||
|
/// per-species value from the zombie's own entity_class (XML's LootDropEntityClass
|
||||||
|
/// property), not anything a live buff or CVar can influence declaratively (unlike the
|
||||||
|
/// knife's damage, which only needed a CVar the passive_effect system already reads
|
||||||
|
/// live) - this needed an actual Harmony patch, not an XML trick.
|
||||||
|
///
|
||||||
|
/// Both are Prefixes returning false: they fully replace what they intercept rather than
|
||||||
|
/// running alongside it.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(EntityAlive), "dropItemOnDeath")]
|
||||||
|
public static class Patch_EntityAlive_dropItemOnDeath_VictimBag
|
||||||
|
{
|
||||||
|
public static bool Prefix(EntityAlive __instance)
|
||||||
|
{
|
||||||
|
// Diagnostic-only, added 2026-08-28: unconditional, before any branching, to answer
|
||||||
|
// definitively whether Harmony is even entering this method at all - "лута всё ещё
|
||||||
|
// нет" after the first fix, but with zero sign of even the unconditional part of this
|
||||||
|
// Prefix ever running (not even a false-branch silently returning - literally no log
|
||||||
|
// line at all), which is otherwise unexplained since decompiling
|
||||||
|
// EntityAlive.OnEntityDeath() confirms it calls dropItemOnDeath() directly,
|
||||||
|
// unconditionally, right after the exact "Entity X killed by Y" line seen in the log.
|
||||||
|
Debug.Log("[NecromancerTome] VictimPatch: dropItemOnDeath Prefix entered for " + __instance.entityId + " (" + __instance.GetType().Name + "), hasVictimBuff=" + (__instance.Buffs != null && __instance.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName)));
|
||||||
|
if (__instance.Buffs == null || !__instance.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId + " died carrying buffNecroVictim - forcing guaranteed bag, bypassing LootDropProb roll");
|
||||||
|
__instance.DropBagServer();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(Entity), "DropBagServer")]
|
||||||
|
public static class Patch_Entity_DropBagServer_VictimBag
|
||||||
|
{
|
||||||
|
public const string VictimBuffName = "buffNecroVictim";
|
||||||
|
public const string VictimContainerClassName = "EntityLootContainerVictim";
|
||||||
|
|
||||||
|
public static bool Prefix(Entity __instance)
|
||||||
|
{
|
||||||
|
if (!(__instance is EntityAlive alive) || alive.Buffs == null || !alive.Buffs.HasBuff(VictimBuffName))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer || __instance is EntityLootContainer)
|
||||||
|
{
|
||||||
|
// Same guard the original method opens with - not our place to override these cases.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int containerClassId = EntityClass.GetId(VictimContainerClassName);
|
||||||
|
if (containerClassId == -1)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] VictimPatch: entity class '" + VictimContainerClassName + "' not found, falling back to normal loot");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3 pos = __instance.GetPosition();
|
||||||
|
pos.y += 0.9f;
|
||||||
|
Entity spawned = EntityFactory.CreateEntity(containerClassId, pos, Vector3.zero);
|
||||||
|
if (spawned is EntityLootContainer lootContainer)
|
||||||
|
{
|
||||||
|
GameManager.Instance.World.SpawnEntityInWorld(lootContainer);
|
||||||
|
Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId + " (victim) dropped guaranteed loot bag " + lootContainer.entityId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] VictimPatch: created entity for '" + VictimContainerClassName + "' wasn't an EntityLootContainer");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xml>
|
||||||
|
<Name value="NecromancerTome" />
|
||||||
|
<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." />
|
||||||
|
<Author value="Alex Cube" />
|
||||||
|
<Version value="1.0.0" />
|
||||||
|
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
||||||
|
</xml>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
||||||
|
|
||||||
|
**Версия 1.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://git.08h.ru/alex/necromants-tome-7d2d-3-2
|
||||||
|
- YouTube-канал автора: https://www.youtube.com/@alexcube
|
||||||
|
|
||||||
|
Мод для 7 Days to Die о пути от обычного выжившего до некроманта — с собственной веткой
|
||||||
|
прогрессии, тёмным оружием, призываемыми существами и сюжетной концовкой.
|
||||||
|
|
||||||
|
## Завязка
|
||||||
|
|
||||||
|
Всё начинается с записки, читая которую персонаж видит неясный флэшбек.
|
||||||
|
|
||||||
|
Некромантия в этом моде — ответ на проклятие, а не побочная ветка крафта. Вместо того чтобы
|
||||||
|
однажды пасть перед ордой и присоединиться к ней, игрок учится подчинять мёртвых себе: заражать
|
||||||
|
зомби безумием, стравливать их друг с другом, поднимать против них собственных тварей. Не
|
||||||
|
выживание вопреки смерти, а власть.
|
||||||
|
|
||||||
|
## Прогрессия
|
||||||
|
|
||||||
|
Отдельный скилл **"Некромантия"** растёт не от опыта, а от счётчика упокоенных зомби — свой
|
||||||
|
счётчик, своя механика. Пять тиров, каждый открывает часть арсенала:
|
||||||
|
|
||||||
|
| Тир | Порог | Что открывается |
|
||||||
|
|---|---|---|
|
||||||
|
| Адепт | сразу | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов |
|
||||||
|
| Адепт (доп.) | 20 зомби | Пространственный браслет |
|
||||||
|
| Адепт (доп.) | 30 / 60 / 100 / 300 зомби | Моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё |
|
||||||
|
| Подмастерье | 500 зомби | Свиток девиации |
|
||||||
|
| Подмастерье (доп.) | 1400 / 1700 зомби | Моды ножа: Хватка мертвеца, Мёртвая буря |
|
||||||
|
| Ученик | 2000 зомби | Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа |
|
||||||
|
| Некромант | 3000 зомби | Призыв зомбомедведя, Призыв зомбоволка, Свиток банши |
|
||||||
|
| Мастер | 5000 зомби | Чёрный портальный камень |
|
||||||
|
|
||||||
|
## Арсенал
|
||||||
|
|
||||||
|
- **Камень духов** — брошенный камень, светящийся некромантской энергией. Задевает одного зомби:
|
||||||
|
тот переходит на вашу сторону и начинает атаковать других зомби вместо вас.
|
||||||
|
- **Свиток девиации** — тот же эффект, но мощнее: при броске переманивает на вашу сторону сразу
|
||||||
|
всех зомби в области взрыва, а не одного. Расходуется при использовании.
|
||||||
|
- **Нож некроманта** — почерневший костяной клинок. Урон растёт вместе с навыком Некромантии:
|
||||||
|
почти бесполезен в неопытных руках, смертоносен у прокачанного игрока. Лечит владельца на
|
||||||
|
половину нанесённого урона и метит раненого зомби как Жертву — тот при смерти гарантированно
|
||||||
|
оставляет особый мешок с ингредиентами.
|
||||||
|
- **Шесть модов только для Ножа некроманта** — обычные моды для ножей в это оружие не встают, а
|
||||||
|
эти не встают ни во что другое: **Слёзы мертвеца** (2 воды с каждого убитого клинком зомби),
|
||||||
|
**Пир падальщика** (2 еды за труп), **Могильный покой** (защита от перегрева и переохлаждения,
|
||||||
|
пока нож в руках), **Хватка мертвеца** (раненый клинком зомби замедляется), **Мёртвая буря**
|
||||||
|
(силовая атака бьёт по площади с кровотечением, за 10 здоровья вместо 5 и двойную выносливость),
|
||||||
|
**Тёмное чутьё** (все зомби поблизости отмечены на компасе и карте, пока нож в руках).
|
||||||
|
- **Кровь некроманта** — ритуальный ресурс: чтобы получить банку, нужны пустая банка, любой нож
|
||||||
|
в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов — Чёрного
|
||||||
|
портала и самого Ножа.
|
||||||
|
- **Кожа жертвы** и **Прах зомби** — остаются от зомби, помеченного Ножом некроманта как Жертва.
|
||||||
|
Ингредиенты для книг призыва и большинства некромантских рецептов соответственно.
|
||||||
|
- **Синий портальный камень** — держите кнопку использования 10 секунд, чтобы телепортироваться к
|
||||||
|
своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не
|
||||||
|
расходуется.
|
||||||
|
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер
|
||||||
|
растёт вместе с уровнем Некромантии. Обычная атака пока ничего не делает.
|
||||||
|
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
|
||||||
|
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
|
||||||
|
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
|
||||||
|
кипячёная безопасна. Вмещают меньше воды, чем стеклянные банки.
|
||||||
|
|
||||||
|
## Питомцы
|
||||||
|
|
||||||
|
Призывные книги поднимают союзных существ, которые дерутся с зомби, а не с игроком. Питомец не
|
||||||
|
«следует» за игроком в строгом смысле — просто бродит сам по себе, а если отойдёт дальше
|
||||||
|
32 блоков и в этот момент не занят боем, его телепортирует обратно к владельцу:
|
||||||
|
|
||||||
|
- **Зомбособака**, **Зомбомедведь**, **Зомбоволк**, **Зомбогриф** — постоянные спутники. Можно
|
||||||
|
держать по одному экземпляру каждого вида одновременно; отзываются обратно в книгу силовой
|
||||||
|
атакой.
|
||||||
|
- **Жуки Властелина** — разовый свиток, выпускающий рой. Жуки сами разлетаются по широкому
|
||||||
|
радиусу и жалят зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой нельзя
|
||||||
|
отозвать обратно, активен может быть только один. Расходуется при использовании.
|
||||||
|
- **Свиток банши** — одноразовый: при открытии вопит голосом банши и поднимает рядом с игроком
|
||||||
|
небольшую враждебную орду. Это не союзники — эти зомби так же опасны для вас, как любые другие.
|
||||||
|
|
||||||
|
## Оборона базы
|
||||||
|
|
||||||
|
- **Пирамида духов** — деплоябл-блок, не удержимый предмет. Пока вы стоите в её радиусе, она сама
|
||||||
|
заряжает девиацией любого незаряженного зомби поблизости и поджигает его холодным фиолетовым
|
||||||
|
пламенем, обращая против других зомби вместо вас или вашей базы. В меню блока можно
|
||||||
|
включать/выключать эффект и показывать границу радиуса действия.
|
||||||
|
|
||||||
|
## Чёрный портал — финал истории
|
||||||
|
|
||||||
|
**Чёрный портальный камень открывается на вершине прогрессии (5000 зомби) и является концовкой
|
||||||
|
мода.** Активация запускает диалог подтверждения, останавливает игру и разворачивает
|
||||||
|
полноэкранную финальную сцену — она досказывает историю, начатую запиской в первый день.
|
||||||
|
Заканчивается сцена выбором из двух вариантов: один закрывает историю и выходит в главное меню,
|
||||||
|
другой возвращает игрока в мир и позволяет играть дальше.
|
||||||
|
|
||||||
|
Сюжетные тексты — в `Config/Localization.csv`, ключи `necroFinal*`. Здесь они не пересказываются
|
||||||
|
намеренно: README читают до прохождения.
|
||||||
|
|
||||||
|
## По мелочи
|
||||||
|
|
||||||
|
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
|
||||||
|
- Некоторые декоративные блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием,
|
||||||
|
как верстак.
|
||||||
|
|
||||||
|
## Локализация
|
||||||
|
|
||||||
|
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
|
||||||
|
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
|
||||||
|
традиционный. Все 123 ключа `Config/Localization.csv` заполнены, пустых ячеек нет.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Распакуйте папку `NecromancerTome` в `<папка игры>/Mods/` (или в `%APPDATA%/7DaysToDie/Mods/`) и
|
||||||
|
запустите игру. Мод содержит Harmony-библиотеки, поэтому **EAC должен быть выключен**.
|
||||||
|
|
||||||
|
Рассчитан на одиночную игру: ванильная пауза работает только в сингле, поэтому в мультиплеере
|
||||||
|
сюжетные сцены проиграются без остановки времени.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
Версия 1.0 — весь заявленный контент реализован и проходит тесты в игре. Из запланированного не
|
||||||
|
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
|
||||||
|
`SITE_DESCRIPTION.md`. Полная техническая история разработки и текст финала лежат рядом с модом
|
||||||
|
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Книга некроманта / Necromancer's Tome — описание для сайта
|
||||||
|
|
||||||
|
Версия 1.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://git.08h.ru/alex/necromants-tome-7d2d-3-2
|
||||||
|
- YouTube-канал: https://www.youtube.com/@alexcube
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# RU
|
||||||
|
|
||||||
|
## Книга некроманта
|
||||||
|
|
||||||
|
**Мод для 7 Days to Die 3.2: путь от обычного выжившего до некроманта — со своей веткой прогрессии,
|
||||||
|
тёмным оружием, призывной нежитью и сюжетной концовкой.**
|
||||||
|
|
||||||
|
Всё начинается с записки. Читая её, персонаж видит неясный флэшбек — обрывок того, что с ним
|
||||||
|
случилось до пробуждения в этом мире.
|
||||||
|
|
||||||
|
Некромантия здесь — ответ на проклятие, а не побочная ветка крафта. Вместо того чтобы однажды
|
||||||
|
пасть перед ордой и присоединиться к ней, вы учитесь подчинять мёртвых: заражать зомби безумием,
|
||||||
|
стравливать их друг с другом, поднимать против них собственных тварей. Не выживание вопреки
|
||||||
|
смерти, а власть над ней.
|
||||||
|
|
||||||
|
### Прогрессия, которая растёт от убийств
|
||||||
|
|
||||||
|
Отдельный навык **«Некромантия»** качается не за очки перков и не за книги, а по счётчику
|
||||||
|
упокоенных зомби. Пять тиров, 5000 убийств до вершины, каждый порог открывает часть арсенала:
|
||||||
|
|
||||||
|
| Тир | Порог | Что открывается |
|
||||||
|
|---|---|---|
|
||||||
|
| Адепт | сразу | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов |
|
||||||
|
| Адепт | 20 / 30 / 60 / 100 / 300 | Пространственный браслет, затем моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё |
|
||||||
|
| Подмастерье | 500 / 1400 / 1700 | Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря |
|
||||||
|
| Ученик | 2000 | Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа |
|
||||||
|
| Некромант | 3000 | Призыв зомбомедведя, Призыв зомбоволка, Свиток банши |
|
||||||
|
| Мастер | 5000 | Чёрный портальный камень |
|
||||||
|
|
||||||
|
### Арсенал
|
||||||
|
|
||||||
|
- **Камень духов** — брошенный камень, светящийся некромантской энергией. Задетый зомби переходит
|
||||||
|
на вашу сторону и начинает рвать других зомби вместо вас.
|
||||||
|
- **Свиток девиации** — то же самое, но по площади: переманивает сразу всех зомби в радиусе
|
||||||
|
взрыва. Расходуется при использовании.
|
||||||
|
- **Нож некроманта** — почерневший костяной клинок. Урон растёт вместе с навыком Некромантии:
|
||||||
|
почти бесполезен в неопытных руках, смертоносен у прокачанного игрока. Лечит владельца на
|
||||||
|
половину нанесённого урона и метит раненого зомби как Жертву — тот гарантированно оставляет
|
||||||
|
особый мешок с ингредиентами.
|
||||||
|
- **Шесть модов только для этого ножа** — вода и еда прямо из трупов (Слёзы мертвеца, Пир
|
||||||
|
падальщика), защита от жары и холода (Могильный покой), замедление раненых (Хватка мертвеца),
|
||||||
|
силовая атака-буря по площади за двойную цену здоровья и выносливости (Мёртвая буря) и подсветка
|
||||||
|
всех зомби поблизости на карте и компасе (Тёмное чутьё). Обычные моды в этот нож не встают, а
|
||||||
|
эти не встают ни во что другое.
|
||||||
|
- **Кровь некроманта** — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего
|
||||||
|
здоровья за одну порцию. Ингредиент для самых тёмных рецептов.
|
||||||
|
- **Кожа жертвы** и **Прах зомби** — падают с зомби, помеченного ножом как Жертва. Основа книг
|
||||||
|
призыва и большинства некромантских рецептов.
|
||||||
|
- **Синий портальный камень** — держите кнопку использования 10 секунд, чтобы телепортироваться
|
||||||
|
к своему спальнику. Любой урон прерывает переход. Не расходуется.
|
||||||
|
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, размер которого
|
||||||
|
растёт вместе с уровнем Некромантии.
|
||||||
|
- **Консервные банки** — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на
|
||||||
|
костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию,
|
||||||
|
кипячёная безопасна. Вмещают меньше стеклянных.
|
||||||
|
|
||||||
|
### Питомцы
|
||||||
|
|
||||||
|
Призывные книги поднимают союзников, которые дерутся с зомби, а не с вами. Питомец бродит сам по
|
||||||
|
себе, а если отойдёт дальше 32 блоков и не занят боем — телепортируется обратно к владельцу.
|
||||||
|
|
||||||
|
- **Зомбособака, зомбомедведь, зомбоволк, зомбогриф** — постоянные спутники, по одному
|
||||||
|
экземпляру каждого вида одновременно. Отзываются обратно в книгу силовой атакой.
|
||||||
|
- **Жуки Властелина** — разовый свиток с роем. Жуки разлетаются по широкому радиусу и жалят
|
||||||
|
зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой не отзывается, активен
|
||||||
|
может быть только один.
|
||||||
|
- **Свиток банши** — одноразовый: вопит голосом банши и поднимает рядом небольшую орду. Это
|
||||||
|
**не** союзники — эти зомби так же опасны для вас, как любые другие.
|
||||||
|
|
||||||
|
### Оборона базы
|
||||||
|
|
||||||
|
**Пирамида духов** — устанавливаемый блок. Пока вы в её радиусе, она сама заряжает девиацией
|
||||||
|
любого незаряженного зомби поблизости и поджигает его холодным фиолетовым пламенем, обращая
|
||||||
|
против других зомби вместо вашей базы. В меню блока эффект включается и выключается, границу
|
||||||
|
радиуса можно показать.
|
||||||
|
|
||||||
|
### Чёрный портал — финал истории
|
||||||
|
|
||||||
|
**Чёрный портальный камень открывается на вершине прогрессии, на 5000 убийств, и является
|
||||||
|
концовкой мода.** Он уносит некроманта туда, откуда всё началось, — и досказывает историю,
|
||||||
|
начатую запиской в первый день.
|
||||||
|
|
||||||
|
Активация просит подтверждения, останавливает игру и разворачивает полноэкранную финальную
|
||||||
|
сцену. В конце — выбор из двух вариантов; один из них закрывает историю и выходит в главное
|
||||||
|
меню, другой позволяет вернуться и играть дальше. Что там, по ту сторону портала, лучше
|
||||||
|
увидеть самому.
|
||||||
|
|
||||||
|
### По мелочи
|
||||||
|
|
||||||
|
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
|
||||||
|
- Часть декоративных блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как
|
||||||
|
верстак.
|
||||||
|
|
||||||
|
### Локализация
|
||||||
|
|
||||||
|
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
|
||||||
|
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
|
||||||
|
традиционный. Все 123 строки переведены, пустых ячеек нет.
|
||||||
|
|
||||||
|
### Установка
|
||||||
|
|
||||||
|
Требуется **7 Days to Die 3.2**. Распакуйте папку `NecromancerTome` в `<папка игры>/Mods/`
|
||||||
|
(или в `%APPDATA%/7DaysToDie/Mods/`) и запустите игру. Мод содержит Harmony-библиотеки, поэтому
|
||||||
|
**EAC должен быть выключен**.
|
||||||
|
|
||||||
|
Рассчитан на одиночную игру. В мультиплеере ванильная пауза не срабатывает — сюжетные сцены
|
||||||
|
проиграются без остановки времени.
|
||||||
|
|
||||||
|
### Ссылки
|
||||||
|
|
||||||
|
- Исходники и загрузка: [https://git.08h.ru/alex/necromants-tome-7d2d-3-2](https://git.08h.ru/alex/necromants-tome-7d2d-3-2)
|
||||||
|
- YouTube-канал автора: [@alexcube](https://www.youtube.com/@alexcube)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EN
|
||||||
|
|
||||||
|
## Necromancer's Tome
|
||||||
|
|
||||||
|
**A 7 Days to Die 3.2 mod: the road from ordinary survivor to necromancer — its own progression tree,
|
||||||
|
cursed weapons, summonable undead and a story ending.**
|
||||||
|
|
||||||
|
It starts with a note. Reading it, your character sees a blurred flashback — a fragment of
|
||||||
|
whatever happened to them before they woke up in this world.
|
||||||
|
|
||||||
|
Necromancy here is an answer to a curse, not a side branch of crafting. Instead of falling before
|
||||||
|
the horde one day and joining it, you learn to command the dead: infect zombies with madness, turn
|
||||||
|
them on each other, raise your own creatures against them. Not survival in spite of death — power
|
||||||
|
over it.
|
||||||
|
|
||||||
|
### Progression driven by kills
|
||||||
|
|
||||||
|
A dedicated **Necromancy** 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:
|
||||||
|
|
||||||
|
| Tier | Threshold | What unlocks |
|
||||||
|
|---|---|---|
|
||||||
|
| Adept | from the start | Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits |
|
||||||
|
| Adept | 20 / 30 / 60 / 100 / 300 | Spatial Bracelet, then the knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense |
|
||||||
|
| Journeyman | 500 / 1400 / 1700 | Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm |
|
||||||
|
| Apprentice | 2000 | Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin |
|
||||||
|
| Necromancer | 3000 | Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll |
|
||||||
|
| Master | 5000 | Black Portal Stone |
|
||||||
|
|
||||||
|
### Arsenal
|
||||||
|
|
||||||
|
- **Spirit Stone** — a thrown stone lit with necromantic energy. The zombie it touches switches to
|
||||||
|
your side and starts tearing into other zombies instead of you.
|
||||||
|
- **Scroll of Deviation** — the same effect, but area-wide: it charms every zombie in the blast
|
||||||
|
radius at once. Consumed on use.
|
||||||
|
- **Necromancer's Knife** — a blackened bone blade. Its damage scales 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 — it is guaranteed to drop a special bag
|
||||||
|
of ingredients.
|
||||||
|
- **Six mods for this knife only** — water and food straight out of corpses (Tears of the Dead,
|
||||||
|
Scavenger's Feast), protection from heat and cold (Grave's Repose), slowed victims (Dead Man's
|
||||||
|
Grip), an area power-attack storm at double the health and stamina cost (Dead Storm), and every
|
||||||
|
nearby zombie marked on your map and compass (Dark Sense). Ordinary knife mods will not fit this
|
||||||
|
weapon, and these will not fit any other.
|
||||||
|
- **Necromancer's Blood** — a ritual resource: an empty jar, any knife in hand and 90% of your
|
||||||
|
current health per portion. An ingredient for the darkest recipes.
|
||||||
|
- **Victim's Skin** and **Zombie Ash** — dropped by a zombie marked as a Victim. The basis of the
|
||||||
|
summoning books and most necromantic recipes.
|
||||||
|
- **Blue Portal Stone** — hold the use button for 10 seconds to teleport to your bedroll. Any
|
||||||
|
damage interrupts the channel. Not consumed.
|
||||||
|
- **Spatial Bracelet** — a power attack opens a personal storage rift whose size grows with your
|
||||||
|
Necromancy level.
|
||||||
|
- **Tin cans** — 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.
|
||||||
|
|
||||||
|
### Pets
|
||||||
|
|
||||||
|
Summoning books raise allies that fight zombies, not you. A pet wanders on its own, and if it
|
||||||
|
strays more than 32 blocks away while out of combat, it teleports back to its owner.
|
||||||
|
|
||||||
|
- **Zombie dog, bear, wolf and griffin** — permanent companions, one of each kind at a time.
|
||||||
|
A power attack recalls them into the book.
|
||||||
|
- **Beetles of the Lord** — 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.
|
||||||
|
- **Banshee's Scroll** — single use: it screams with a banshee's voice and raises a small horde
|
||||||
|
next to you. These are **not** allies — they are as dangerous to you as any other zombies.
|
||||||
|
|
||||||
|
### Base defence
|
||||||
|
|
||||||
|
**Pyramid of Spirits** — 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 other zombies instead of your base. Its block menu toggles the effect and shows the radius.
|
||||||
|
|
||||||
|
### The Black Portal — the story's ending
|
||||||
|
|
||||||
|
**The Black Portal Stone unlocks at the top of the progression, at 5000 kills, and it is the
|
||||||
|
mod's ending.** It carries the necromancer back to where all of this began, and finishes the
|
||||||
|
story the note started on day one.
|
||||||
|
|
||||||
|
Activating it asks for confirmation, stops the game and unfolds a full-screen finale. It closes
|
||||||
|
on a choice of two: one ends the story and returns you to the main menu, the other lets you come
|
||||||
|
back and keep playing. What waits on the far side of the portal is better seen than described.
|
||||||
|
|
||||||
|
### 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 pick-up key, like a workbench.
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
**13 languages, complete:** 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.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Requires **7 Days to Die 3.2**. Unpack the `NecromancerTome` folder into `<game folder>/Mods/`
|
||||||
|
(or `%APPDATA%/7DaysToDie/Mods/`) and launch the game. The mod ships Harmony libraries, so
|
||||||
|
**EAC must be turned off**.
|
||||||
|
|
||||||
|
Built for single-player. In multiplayer the vanilla pause does not apply, so the story scenes
|
||||||
|
play without stopping time.
|
||||||
|
|
||||||
|
### Links
|
||||||
|
|
||||||
|
- Source and download: [https://git.08h.ru/alex/necromants-tome-7d2d-3-2](https://git.08h.ru/alex/necromants-tome-7d2d-3-2)
|
||||||
|
- The author's YouTube channel: [@alexcube](https://www.youtube.com/@alexcube)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- Tiny satellite assembly, separate from NecromancerHarmony.csproj on purpose - see
|
||||||
|
PyramidWardWriteHelper.cs's own doc comment for the full reasoning. In short: calling
|
||||||
|
PooledBinaryWriter.Write(bool) (or any of its overloads) from the main project fails to
|
||||||
|
compile with CS7069 ("Reference to type 'ReadOnlySpan<>' requires it be defined in
|
||||||
|
'mscorlib', but it could not be found") - Assembly-CSharp.dll's own ReadOnlySpan<char> usage
|
||||||
|
resolves against Unity/Mono's own mscorlib.dll, not the modern .NET SDK's corlib the main
|
||||||
|
project's plain netstandard2.1 setup implicitly references, and the compiler needs to fully
|
||||||
|
resolve BinaryWriter.Write's entire overload set (including its ReadOnlySpan<byte> overload)
|
||||||
|
just to pick the bool one. Confirmed via an isolated throwaway repro (multiple configurations
|
||||||
|
tried - NoStdLib+explicit mscorlib.dll DOES fix it, but ALSO referencing UnityEngine's own
|
||||||
|
DLLs alongside that setup breaks on UnityEngine.Vector3/Color/etc. needing `System.ValueType`
|
||||||
|
from netstandard instead, and referencing BOTH mscorlib and netstandard together in the same
|
||||||
|
project makes ReadOnlySpan<T> itself ambiguous and breaks System.Object/System.String
|
||||||
|
resolution entirely) - there is no single project-wide combination of references that
|
||||||
|
satisfies both "call BinaryWriter.Write" and "use UnityEngine types" at once. Rather than
|
||||||
|
risk that fragility across this mod's ENTIRE existing, working codebase (every other
|
||||||
|
HarmonySrc file uses UnityEngine types constantly), this one tiny helper - which needs
|
||||||
|
NOTHING but PooledBinaryWriter/bool - gets its own project with the NoStdLib+mscorlib-only
|
||||||
|
setup that specifically fixes it, compiled to its own small DLL dropped into the mod root
|
||||||
|
alongside NecromancerHarmony.dll (the game scans every assembly in a mod's folder, not just
|
||||||
|
one - confirmed by ModEntry.cs's own doc comment on how IModApi is discovered). The main
|
||||||
|
project calls into this DLL instead of calling PooledBinaryWriter.Write directly. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<AssemblyName>NecromancerTEPersistence</AssemblyName>
|
||||||
|
<RootNamespace>NecromancerTome</RootNamespace>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<GenerateDependencyFile>false</GenerateDependencyFile>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<OutputPath>bin\</OutputPath>
|
||||||
|
<DisableImplicitFrameworkReferences>true</DisableImplicitFrameworkReferences>
|
||||||
|
<NoStdLib>true</NoStdLib>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The game's OWN mscorlib (Mono/IL2CPP's backport, where Assembly-CSharp.dll's
|
||||||
|
ReadOnlySpan<char> actually lives) instead of the .NET SDK's implicit one - this is the
|
||||||
|
one substitution that actually fixes the overload-resolution error, confirmed empirically. -->
|
||||||
|
<Reference Include="mscorlib">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\mscorlib.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Assembly-CSharp">
|
||||||
|
<HintPath>..\..\..\7DaysToDie_Data\Managed\Assembly-CSharp.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exists ONLY because `PooledBinaryWriter.Write(bool)` (and every other Write overload) cannot
|
||||||
|
/// be called from the main NecromancerHarmony project at all - see
|
||||||
|
/// NecromancerTEPersistence.csproj's own comment for the full decompiled/tested reasoning
|
||||||
|
/// (Assembly-CSharp.dll's overload set for Write includes a ReadOnlySpan<byte> variant
|
||||||
|
/// that resolves against Unity/Mono's own mscorlib, which the main project's plain netstandard2.1
|
||||||
|
/// setup can't see - the compiler needs to resolve the WHOLE overload set just to pick the bool
|
||||||
|
/// one, and fails hard). PooledBinaryReader.ReadBoolean() has no such problem (confirmed
|
||||||
|
/// separately - it's a plain no-overload method, not an ambiguous Write-style one) and is called
|
||||||
|
/// directly from TEFeaturePyramidWard.Read() in the main project as normal; only the WRITE side
|
||||||
|
/// needs to go through this satellite assembly.
|
||||||
|
///
|
||||||
|
/// TEFeaturePyramidWard.Write() (HarmonySrc/PyramidWardPatch.cs, in the main project) calls this
|
||||||
|
/// instead of touching PooledBinaryWriter.Write itself.
|
||||||
|
/// </summary>
|
||||||
|
public static class PyramidWardWriteHelper
|
||||||
|
{
|
||||||
|
public static void Write(PooledBinaryWriter _bw, bool _effectOn, bool _zoneShown)
|
||||||
|
{
|
||||||
|
_bw.Write(_effectOn);
|
||||||
|
_bw.Write(_zoneShown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |