Книга некроманта 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
+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;
}
}
}