Книга некроманта 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
This commit is contained in:
Alex Cube
2026-09-09 21:13:03 +03:00
co-authored by Claude Opus 5
commit e8f064f5ec
102 changed files with 7010 additions and 0 deletions
+8
View File
@@ -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
File diff suppressed because one or more lines are too long
+119
View File
@@ -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>
+45
View File
@@ -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>
+90
View File
@@ -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>
+188
View File
@@ -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>
+284
View File
@@ -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>
+317
View File
@@ -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>
+1346
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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>
+194
View File
@@ -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>
+379
View File
@@ -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>
+109
View File
@@ -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);
}
}
}
}
+196
View File
@@ -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(...) &amp;&amp; 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);
}
}
}
}
+134
View File
@@ -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);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Collections.Generic;
using HarmonyLib;
namespace NecromancerTome
{
/// <summary>
/// Lets a charmed ("Devi­ator"-hit) zombie actually hurt other zombies.
///
/// EntityAlive.DamageEntity has a hardcoded block, independent of anything AI/targeting
/// related: `if (!isHeatDamage &amp;&amp; (entityFlags &amp; attacker.entityFlags &amp; 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;
}
}
}
}
+360
View File
@@ -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 уже показывает подтверждение "да/нет". Шаблон
/// &lt;messagebox&gt; (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);
}
}
}
}
+45
View File
@@ -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/&lt;ModFolder&gt;/ 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");
}
}
}
+137
View File
@@ -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);
}
}
}
+71
View File
@@ -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>
+99
View File
@@ -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;
}
}
}
+171
View File
@@ -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;
}
}
}
}
+171
View File
@@ -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);
}
}
}
}
}
+340
View File
@@ -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(&lt;mod name&gt;):" 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);
}
}
}
}
+680
View File
@@ -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&lt;byte&gt; 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&lt;char&gt;` - 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&lt;T&gt; 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&lt;char&gt; 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&lt;T&gt;...) 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&lt;TEFeaturePyramidWard&gt; 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;
}
}
}
+168
View File
@@ -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&lt;int, Bag&gt; 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);
}
}
}
+391
View File
@@ -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;
}
}
}
}
+195
View File
@@ -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&lt;Entity&gt;) 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;
}
}
}
+100
View File
@@ -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;
}
}
}
+9
View File
@@ -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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+126
View File
@@ -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` — в репозиторий они не входят (спойлеры и внутренняя кухня).
+236
View File
@@ -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&lt;byte&gt; 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);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More