Книга некроманта 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:
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// "Книга банши" (Banshee's Book) - BACKLOG.md item 7 (dictated 2026-08-28, implemented
|
||||
/// 2026-08-29 "без вопросов" per user request). On use, the book is consumed, plays a
|
||||
/// screamer's own scream sound, and spawns a small HOSTILE horde near the player - hostile to
|
||||
/// the player, unlike the Dog/Swarm/three new zombie pets, which are all summoned allies.
|
||||
///
|
||||
/// DELIBERATE SIMPLIFICATION, flagged rather than guessed past - read before touching this
|
||||
/// file again: the backlog's own research pointed at AIScoutHordeSpawner (decompiled here,
|
||||
/// see the class itself in Assembly-CSharp) as "the" mechanism a real zombieScreamer's scream
|
||||
/// uses. Decompiling it in full shows it is NOT a simple "spawn N zombies now" call - it's a
|
||||
/// whole per-tick simulation object (constructed with an EntitySpawner, driven by
|
||||
/// AIDirector.CanSpawn()/EntitySpawner.CurrentWave/SpawnManually, tracking a scout zombie
|
||||
/// that has to physically wander off, spot a player, and only THEN calls its own
|
||||
/// spawnHordeNear near that scout) - built for the existing "distant scout triggers a
|
||||
/// blood-moon-style horde" system, not for "an item makes a horde appear right now". Wiring a
|
||||
/// real AIScoutHordeSpawner up from a one-shot item click would mean also owning a per-tick
|
||||
/// driver for it (another ModEvents.UnityUpdate loop, same shape as PetFollowPatch.cs) and an
|
||||
/// EntitySpawner instance to hand it, for a payoff (a scout that has to run off and get
|
||||
/// spotted first) the user's own description doesn't ask for - they asked for the scream and
|
||||
/// the horde appearing "poblizosti" (nearby), not a scout-fetch quest.
|
||||
///
|
||||
/// So instead: this directly builds a small set of ordinary hostile zombies near the player
|
||||
/// using the exact same EntityFactory.CreateEntity -> SetSpawnerSource -> SpawnEntityInWorld
|
||||
/// sequence ItemActionSpawnEntity.Spawn itself uses (decompiled to confirm, not guessed) -
|
||||
/// the same primitive this mod's own pet summons are already built on
|
||||
/// (HarmonySrc/SummonPatch.cs), just spawning several real vanilla zombie classes instead of
|
||||
/// one tamed pet, and never touching their AI/flags at all (they're meant to be hostile,
|
||||
/// which is what an untouched vanilla zombie already is by default - the OPPOSITE of the
|
||||
/// pet-taming work the rest of this mod does).
|
||||
///
|
||||
/// Zombie pool (zombieArlene/zombieBoe/zombieYo/zombieJoe) is a hardcoded guess at "generic
|
||||
/// early/mid walker" flavor, picked because all four are confirmed real vanilla entity_class
|
||||
/// names (checked directly against Data/Config/entityclasses.xml) with no special
|
||||
/// gimmick (no explosion, no ranged attack) - NOT gamestage-scaled or otherwise tied to the
|
||||
/// real difficulty-scaling horde-selection system real hordes use. Spawn positions come from
|
||||
/// World.GetMobRandomSpawnPosWithWater (decompiled from AIScoutHordeSpawner's own use of it) -
|
||||
/// the same "find a valid ground spot near here" helper the real scout-horde system itself
|
||||
/// calls, just pointed at the player directly instead of a scout zombie's position.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemActionEat))]
|
||||
[HarmonyPatch("consume")]
|
||||
public static class Patch_ItemActionEat_Consume_Banshee
|
||||
{
|
||||
public const string ItemName = "bookBanshee";
|
||||
|
||||
/// <summary>Real, confirmed vanilla entity_class names - not invented. Deliberately no
|
||||
/// exploders/spitters/screamers-of-their-own in the pool (would either be anticlimactic -
|
||||
/// a screamer summoning more screamers - or risk chain-reaction explosions on the caster).</summary>
|
||||
public static readonly string[] ZombiePool = { "zombieArlene", "zombieBoe", "zombieYo", "zombieJoe" };
|
||||
|
||||
/// <summary>"Небольшая орда" - not specified by the user as an exact number, guessed
|
||||
/// modest (comparable to a small blood-moon wave, not a screen-filling swarm).</summary>
|
||||
public const int HordeSize = 5;
|
||||
|
||||
public static readonly System.Random Rand = new System.Random();
|
||||
|
||||
public static void Postfix(ItemActionData _actionData)
|
||||
{
|
||||
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
|
||||
if (itemName != ItemName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityAlive holdingEntity = _actionData.invData.holdingEntity;
|
||||
World world = holdingEntity?.world;
|
||||
if (holdingEntity == null || world == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] BansheePatch: casting near owner=" + holdingEntity.entityId);
|
||||
|
||||
// Reuses the real screamer's own alert sound (zombiefemalescoutalert, see
|
||||
// zombieScreamer in Data/Config/entityclasses.xml's SoundAlert) rather than inventing
|
||||
// a new one - PlayOneShot(string) confirmed via ItemActionEat's own
|
||||
// ExecuteInstantAction, same call shape used there.
|
||||
holdingEntity.PlayOneShot("zombiefemalescoutalert");
|
||||
|
||||
Vector3 casterPos = holdingEntity.GetPosition();
|
||||
for (int i = 0; i < HordeSize; i++)
|
||||
{
|
||||
string zombieClassName = ZombiePool[Rand.Next(ZombiePool.Length)];
|
||||
int classId = EntityClass.GetId(zombieClassName);
|
||||
if (classId == -1)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] BansheePatch: entity class '" + zombieClassName + "' not found");
|
||||
continue;
|
||||
}
|
||||
// 15-30m out, never closer than 15m to the caster - same shape of call
|
||||
// AIScoutHordeSpawner itself makes to place a zombie near a point without
|
||||
// dropping it inside terrain/right on top of a player.
|
||||
if (!world.GetMobRandomSpawnPosWithWater(casterPos, 15, 30, 15, true, out Vector3 spawnPos))
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] BansheePatch: no valid spawn position found for " + zombieClassName);
|
||||
continue;
|
||||
}
|
||||
Entity entity = EntityFactory.CreateEntity(classId, spawnPos, new Vector3(0f, holdingEntity.rotation.y, 0f));
|
||||
entity.SetSpawnerSource(EnumSpawnerSource.StaticSpawner);
|
||||
world.SpawnEntityInWorld(entity);
|
||||
Debug.Log("[NecromancerTome] BansheePatch: spawned " + zombieClassName + " (" + entity.entityId + ") at " + spawnPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// BACKLOG.md item 9 (dictated 2026-08-29, implemented same day "без вопросов" per user
|
||||
/// request, clarified same day to drop the land-claim requirement entirely). Lets specific
|
||||
/// decorative/prop blocks be picked back up as an item via hold-E + a hand icon + a progress
|
||||
/// timer, the same VISUAL mechanic vanilla workstations (workbench/forge/etc.) already use
|
||||
/// when placed inside your own land claim - but WITHOUT the land-claim requirement, works
|
||||
/// anywhere on the map, per the user's explicit clarification.
|
||||
///
|
||||
/// RESEARCH FIRST, not guessed (decompiled Assembly-CSharp's Block/BlockWorkstation/
|
||||
/// BlockCompositeTileEntity/BlockTrunkTip classes directly):
|
||||
///
|
||||
/// - The actual "hold E, see hand icon + timer, get the item back" mechanic is a GENERIC
|
||||
/// pair of methods already on the base `Block` class itself, not something
|
||||
/// BlockWorkstation invented: `Block.takeItemWithTimer(...)` (instance) calls the static
|
||||
/// `Block.TakeItemWithTimer(pos, blockValue, player, delaySeconds, canTakeCallback)`, which
|
||||
/// opens the real timer UI (XUiC_Timer.OpenTimer) and, on completion, converts the block to
|
||||
/// an item, adds it to inventory (or drops it if full), and clears the block - all engine-
|
||||
/// native, nothing reimplemented here. BlockWorkstation's own "take" activation command is
|
||||
/// just ONE caller of this generic method, gated behind
|
||||
/// `_world.IsMyLandProtectedBlock(...) && tileEntityWorkstation.IsPlayerPlaced` (that IS
|
||||
/// real land-claim gating in vanilla, confirmed - the backlog's original worry about
|
||||
/// needing Harmony either way was right) - this patch calls the SAME generic
|
||||
/// TakeItemWithTimer directly, deliberately WITHOUT that land-claim check, per the user's
|
||||
/// own clarification.
|
||||
/// - Which "take" appears on a block at all is decided by `Block.GetBlockActivationCommands`/
|
||||
/// `HasBlockActivationCommands`/`OnBlockActivated(string,...)` - all three are `virtual` on
|
||||
/// the base `Block` class, so a plain undecorated block (no Class= override in XML) runs the
|
||||
/// base implementation and can be patched there directly. But several candidate blocks use a
|
||||
/// DIFFERENT C# class that overrides all three (confirmed by decompiling it) -
|
||||
/// `BlockCompositeTileEntity` (used by the water cooler/cardboard box below) - so those need
|
||||
/// their own separate patches on that type; a patch on the base `Block` type alone would
|
||||
/// never run for them (Harmony patches the actual method that executes via virtual dispatch,
|
||||
/// not every subclass "logically implementing the same slot").
|
||||
///
|
||||
/// TARGET BLOCKS - best-guess mapping from the user's four Russian category names to real
|
||||
/// Data/Config/blocks.xml block names (checked directly, matched by name PREFIX since most
|
||||
/// categories have many color/variant blocks) - tell me if any of these aren't what was
|
||||
/// meant, this is an interpretation, not a spec:
|
||||
/// - "Кровати" (beds) -> bedMadeNoFrame*/bedMessyNoFrame* ONLY. Deliberately excludes
|
||||
/// bed02*/bunkBedMade*/bunkBedMessy* even though they look like beds too - decompiling
|
||||
/// showed those all use Class="SleepingBag" (they're actually functional sleeping-bag/
|
||||
/// respawn-anchor blocks, not pure decoration - same family as the player's own bedroll,
|
||||
/// which the user explicitly said NOT to touch). "NoFrame" variants have no Class=
|
||||
/// override at all (plain decorative furniture), a clean match for "decorative bed".
|
||||
/// - "Колья" (stakes) -> NOT IMPLEMENTED. The real spike-trap blocks
|
||||
/// (trapSpikesWood*/trapSpikesIron*) use Class="TrunkTip" (BlockTrunkTip : BlockDamage),
|
||||
/// which does NOT override GetBlockActivationCommands/OnBlockActivated at all - it isn't
|
||||
/// built on the activation-command system this "take" mechanic depends on (harvest-node-
|
||||
/// style blocks are typically hit-to-harvest instead). Making these pickable would need a
|
||||
/// genuinely different mechanism, not a variant of this one - left out rather than forced
|
||||
/// in broken. Say if a different "колья" block was meant.
|
||||
/// - "Кулеры с водой" (water coolers) -> cntWaterCooler* (Class="CompositeTileEntity").
|
||||
/// - "Коробки" (boxes) -> cntCardboardBox (Class="CompositeTileEntity") - the one
|
||||
/// unambiguous plain-cardboard-box block; there are dozens of OTHER "*box*" blocks in
|
||||
/// vanilla (mailboxes, breaker boxes, truck cargo) not included here since they don't
|
||||
/// read as "coробки, расставленные на карте" the way a cardboard box does.
|
||||
///
|
||||
/// TakeDelay (8s) is a guess, not specified by the user - shorter than the workstation
|
||||
/// default (15s) since these are simpler props, not a full crafting station.
|
||||
/// NOT VERIFIED IN-GAME - same caution as everything else added 2026-08-29.
|
||||
/// </summary>
|
||||
public static class BlockPickupPatch
|
||||
{
|
||||
public const float TakeDelay = 8f;
|
||||
|
||||
public static readonly string[] TargetPrefixes = new string[]
|
||||
{
|
||||
"bedMadeNoFrame",
|
||||
"bedMessyNoFrame",
|
||||
"cntWaterCooler",
|
||||
"cntCardboardBox",
|
||||
};
|
||||
|
||||
public static bool IsTargetBlock(BlockValue _blockValue)
|
||||
{
|
||||
Block block = _blockValue.Block;
|
||||
if (block == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string name = block.GetBlockName();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (string prefix in TargetPrefixes)
|
||||
{
|
||||
if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void AppendTakeCommand(BlockValue _blockValue, ref BlockActivationCommand[] __result)
|
||||
{
|
||||
if (!IsTargetBlock(_blockValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<BlockActivationCommand> commands = new List<BlockActivationCommand>(__result ?? Array.Empty<BlockActivationCommand>());
|
||||
commands.Add(new BlockActivationCommand("take", "hand", true));
|
||||
__result = commands.ToArray();
|
||||
}
|
||||
|
||||
public static bool HandleTakeActivation(string _commandName, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player, ref bool __result)
|
||||
{
|
||||
if (_commandName != "take" || !IsTargetBlock(_blockValue))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] BlockPickupPatch: take activated on " + _blockValue.Block.GetBlockName() + " at " + _blockPos);
|
||||
// Deliberately calls the static TakeItemWithTimer directly (no canTakeCallback -
|
||||
// null means "always takeable", same default as the base Block.takeItemWithTimer
|
||||
// virtual's own unconditional `return true`) rather than going through
|
||||
// BlockWorkstation's land-claim-gated instance wrapper.
|
||||
Block.TakeItemWithTimer(_blockPos, _blockValue, _player, TakeDelay);
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Plain Block-class targets (bedMadeNoFrame*/bedMessyNoFrame*) ---
|
||||
|
||||
[HarmonyPatch(typeof(Block), "HasBlockActivationCommands")]
|
||||
public static class Patch_Block_HasBlockActivationCommands
|
||||
{
|
||||
public static void Postfix(BlockValue _blockValue, ref bool __result)
|
||||
{
|
||||
if (IsTargetBlock(_blockValue))
|
||||
{
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Block), "GetBlockActivationCommands")]
|
||||
public static class Patch_Block_GetBlockActivationCommands
|
||||
{
|
||||
public static void Postfix(BlockValue _blockValue, ref BlockActivationCommand[] __result)
|
||||
{
|
||||
AppendTakeCommand(_blockValue, ref __result);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Block), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })]
|
||||
public static class Patch_Block_OnBlockActivated
|
||||
{
|
||||
public static bool Prefix(string _commandName, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player, ref bool __result)
|
||||
{
|
||||
return HandleTakeActivation(_commandName, _blockPos, _blockValue, _player, ref __result);
|
||||
}
|
||||
}
|
||||
|
||||
// --- BlockCompositeTileEntity targets (cntWaterCooler*/cntCardboardBox) - a DIFFERENT
|
||||
// C# class that overrides the same three methods, so needs its own separate patches;
|
||||
// see the class-level comment above for why patching Block alone wouldn't reach these. ---
|
||||
|
||||
[HarmonyPatch(typeof(BlockCompositeTileEntity), "HasBlockActivationCommands")]
|
||||
public static class Patch_Composite_HasBlockActivationCommands
|
||||
{
|
||||
public static void Postfix(BlockValue _blockValue, ref bool __result)
|
||||
{
|
||||
if (IsTargetBlock(_blockValue))
|
||||
{
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BlockCompositeTileEntity), "GetBlockActivationCommands")]
|
||||
public static class Patch_Composite_GetBlockActivationCommands
|
||||
{
|
||||
public static void Postfix(BlockValue _blockValue, ref BlockActivationCommand[] __result)
|
||||
{
|
||||
AppendTakeCommand(_blockValue, ref __result);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BlockCompositeTileEntity), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })]
|
||||
public static class Patch_Composite_OnBlockActivated
|
||||
{
|
||||
public static bool Prefix(string _commandName, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player, ref bool __result)
|
||||
{
|
||||
return HandleTakeActivation(_commandName, _blockPos, _blockValue, _player, ref __result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// The core "Deviator" effect: whichever zombie the buff named in CharmBuffName lands on
|
||||
/// (via the item's own XML - see Config/items.xml/buffs.xml) stops targeting the player
|
||||
/// and starts targeting other zombies instead.
|
||||
///
|
||||
/// Why this needs Harmony at all: a zombie's valid-target class list (who it's even allowed
|
||||
/// to look for/attack) is parsed once from its entity_class XML into per-instance AI task
|
||||
/// objects at spawn time (EAIApproachAndAttackTarget / EAISetNearestEntityAsTarget). There is
|
||||
/// no XML-level action to change it afterwards - the buff system alone cannot flip a live
|
||||
/// zombie's allegiance. This patch is the smallest hook that can: it reacts to our specific
|
||||
/// buff being added and then rewrites those same per-instance task objects directly, using
|
||||
/// the exact same fields the game's own EAIManager.SetTargetOnlyPlayers() helper uses for the
|
||||
/// mirror-image trick (restricting a task to players only). We do the same thing in reverse:
|
||||
/// restrict to zombies only.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EntityBuffs), "AddBuff", new System.Type[] { typeof(string), typeof(Vector3i), typeof(int), typeof(bool), typeof(bool), typeof(float) })]
|
||||
public static class Patch_EntityBuffs_AddBuff_DeviatorCharm
|
||||
{
|
||||
public const string CharmBuffName = "buffNecroDeviatorCharm";
|
||||
|
||||
/// <summary>Diagnostic-only, added 2026-08-28 while chasing "the Knife's Victim debuff
|
||||
/// never sticks" - this patch already logs every AddBuff call for our other marker buff,
|
||||
/// so logging buffNecroVictim's calls here too (no CharmZombie() side effects for it,
|
||||
/// just visibility) answers the actual open question directly: does
|
||||
/// necroWpnBladeNecroKnife's onSelfAttackedOther trigger ever even call AddBuff at all,
|
||||
/// or does it call it but something downstream (target class mismatch, requirement gate,
|
||||
/// stacking) rejects it. No log line at all next time means the XML trigger itself isn't
|
||||
/// firing (likely because EntityDamage computes to 0 and a 0-damage swing doesn't count
|
||||
/// as a landed hit); a log line with a non-Added result narrows it further.</summary>
|
||||
public const string VictimBuffName = "buffNecroVictim";
|
||||
|
||||
public static void Postfix(EntityBuffs __instance, string _name, EntityBuffs.BuffStatus __result)
|
||||
{
|
||||
if (_name == CharmBuffName || _name == VictimBuffName)
|
||||
{
|
||||
// entityId added 2026-08-28 while chasing "the zombie that died didn't have the
|
||||
// buff even though AddBuff Added fired repeatedly" - logging just the TYPE name
|
||||
// couldn't tell whether it landed on the same zombie that later died or a
|
||||
// different one nearby (the knife swings a 90-degree arc, plausible it's hitting
|
||||
// more than one zombie in a group). This settles it directly against
|
||||
// VictimPatch's own "dropItemOnDeath Prefix entered for <id>" line.
|
||||
string parentId = __instance.parent != null ? __instance.parent.entityId.ToString() : "?";
|
||||
Debug.Log("[NecromancerTome] AddBuff(" + _name + ") result=" + __result + " parent=" + (__instance.parent != null ? __instance.parent.GetType().Name : "null") + " id=" + parentId);
|
||||
}
|
||||
if (__result != EntityBuffs.BuffStatus.Added)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_name != CharmBuffName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// HUMANOID-ONLY IS A DELIBERATE DESIGN DECISION, NOT A GAP TO CLOSE (user, 2026-09-07:
|
||||
// "Камень духов не годится, он для зомбо-гуманоидов, и пусть так и остаётся").
|
||||
// Do not "fix" this by widening the type check.
|
||||
//
|
||||
// The user had noticed the behaviour in play first ("камень душ на зомбособаках не
|
||||
// работает, это я проверил однажды") and was right. The reason is this line: zombie
|
||||
// ANIMALS sit on a completely different branch of the C# hierarchy, so the pattern
|
||||
// match below is false for every one of them -
|
||||
// EntityZombie : EntityHuman <- the only charmable one
|
||||
// EntityZombieDog : EntityEnemyAnimal : EntityEnemy
|
||||
// EntityEnemyAnimal : EntityEnemy (bear, boar)
|
||||
// EntityVulture : EntityFlying (not even EntityEnemy)
|
||||
// - while the XML side still lets the buff land on them, because they DO carry the
|
||||
// "zombie" tag ("entity,animal,zombie,zombieAnimal,hostile,..."), which is what
|
||||
// EntityTagCompare gates on. So the buff is added, this check rejects it, and the log
|
||||
// line below fires. That log line is expected on zombie animals and is not an error.
|
||||
//
|
||||
// Note the same decision applies to the other half of this patch: CharmZombie() below
|
||||
// restricts targetClasses to typeof(EntityZombie), so a charmed humanoid also ignores
|
||||
// zombie animals as targets. Consistent with "the Spirit Stone is a humanoid tool".
|
||||
// Countering zombie animals/birds is meant to be a separate mechanic - open topic,
|
||||
// see BACKLOG.md 2026-09-07.
|
||||
if (!(__instance.parent is EntityZombie zombie) || zombie.aiManager == null)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] charm buff added but parent is not a charmable EntityZombie (aiManager null or wrong type)");
|
||||
return;
|
||||
}
|
||||
CharmZombie(zombie);
|
||||
}
|
||||
|
||||
public static void CharmZombie(EntityZombie zombie)
|
||||
{
|
||||
// AITask: whichever task actually chases/melees the current attack target.
|
||||
// Restrict it to EntityZombie only, same shape as SetTargetOnlyPlayers() but reversed.
|
||||
List<EAIApproachAndAttackTarget> approachTasks = zombie.aiManager.GetTasks<EAIApproachAndAttackTarget>();
|
||||
Debug.Log("[NecromancerTome] CharmZombie: approachTasks=" + (approachTasks != null ? approachTasks.Count.ToString() : "null"));
|
||||
if (approachTasks != null)
|
||||
{
|
||||
foreach (EAIApproachAndAttackTarget task in approachTasks)
|
||||
{
|
||||
task.targetClasses.Clear();
|
||||
task.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass
|
||||
{
|
||||
type = typeof(EntityZombie),
|
||||
chaseTimeMax = 0f
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// AITarget: whichever task picks the nearest valid entity to go attack in the first
|
||||
// place. Same restriction here, or the zombie would never even pick a target to hand
|
||||
// off to the approach task above.
|
||||
List<EAISetNearestEntityAsTarget> targetTasks = zombie.aiManager.GetTargetTasks<EAISetNearestEntityAsTarget>();
|
||||
Debug.Log("[NecromancerTome] CharmZombie: targetTasks=" + (targetTasks != null ? targetTasks.Count.ToString() : "null"));
|
||||
if (targetTasks != null)
|
||||
{
|
||||
foreach (EAISetNearestEntityAsTarget task in targetTasks)
|
||||
{
|
||||
task.targetClasses.Clear();
|
||||
task.targetClasses.Add(new EAISetNearestEntityAsTarget.TargetClass
|
||||
{
|
||||
type = typeof(EntityZombie),
|
||||
hearDistMax = 50f,
|
||||
seeDistMax = 0f
|
||||
});
|
||||
task.playerTargetClassIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Drop whatever it was mid-attack on (almost certainly the player who just threw the
|
||||
// book at it) so the switch takes effect immediately instead of after its current target dies/despawns.
|
||||
zombie.SetAttackTarget(null, 0);
|
||||
Debug.Log("[NecromancerTome] CharmZombie: done, attack target cleared for " + zombie.EntityName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Lets a charmed ("Deviator"-hit) zombie actually hurt other zombies.
|
||||
///
|
||||
/// EntityAlive.DamageEntity has a hardcoded block, independent of anything AI/targeting
|
||||
/// related: `if (!isHeatDamage && (entityFlags & attacker.entityFlags & EntityFlags.Zombie) != None)
|
||||
/// return -1;` - i.e. any two entities that are BOTH flagged EntityFlags.Zombie (which is
|
||||
/// every zombie, always) simply cannot damage each other, full stop, no matter what
|
||||
/// CharmPatch.cs did to the attacker's AI targeting. Without this, a charmed zombie would
|
||||
/// walk up to another zombie and "attack" it forever with every hit landing as a no-op.
|
||||
///
|
||||
/// Fix: for the duration of a single DamageEntity call where the attacker currently has
|
||||
/// buffNecroDeviatorCharm and the target is also a zombie, temporarily clear
|
||||
/// EntityFlags.Zombie on the ATTACKER only (never the target, never persisted) so the AND
|
||||
/// check in the original method comes up empty and damage proceeds normally. Restored
|
||||
/// immediately afterward, so nothing else about that zombie (kill-counting via its Tags
|
||||
/// property, quest tracking, anything else keyed off EntityFlags) is ever affected outside
|
||||
/// this one call. A Stack (not a single field) survives re-entrant DamageEntity calls
|
||||
/// correctly (e.g. explosions/knockback triggering further damage inside the same callstack).
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
|
||||
public static class Patch_EntityAlive_DamageEntity_CharmedZombieVsZombie
|
||||
{
|
||||
public static readonly Stack<EntityZombie> ToggledAttackers = new Stack<EntityZombie>();
|
||||
|
||||
public static void Prefix(EntityAlive __instance, DamageSource _damageSource)
|
||||
{
|
||||
EntityZombie toggled = null;
|
||||
if (__instance is EntityZombie && __instance.world != null)
|
||||
{
|
||||
EntityAlive attacker = __instance.world.GetEntity(_damageSource.getEntityId()) as EntityAlive;
|
||||
if (attacker is EntityZombie attackerZombie
|
||||
&& attackerZombie.Buffs != null
|
||||
&& attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
|
||||
&& (attackerZombie.entityFlags & EntityFlags.Zombie) != EntityFlags.None)
|
||||
{
|
||||
attackerZombie.entityFlags &= ~EntityFlags.Zombie;
|
||||
toggled = attackerZombie;
|
||||
}
|
||||
}
|
||||
ToggledAttackers.Push(toggled);
|
||||
}
|
||||
|
||||
public static void Postfix()
|
||||
{
|
||||
EntityZombie toggled = ToggledAttackers.Pop();
|
||||
if (toggled != null)
|
||||
{
|
||||
toggled.entityFlags |= EntityFlags.Zombie;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Финальная сцена Чёрного портального камня: шесть полноэкранных слайдов с текстом и
|
||||
/// кнопками Назад/Дальше, а на последнем - выбор концовки. Заменяет собой прежнюю связку
|
||||
/// "подтверждение -> сразу видео" (см. PortalStonePatch.ActivateBlackPortal, которая теперь
|
||||
/// зовёт Begin вместо PlayVideo).
|
||||
///
|
||||
/// ЗАЧЕМ ЭТО ВООБЩЕ (BACKLOG.md, "концовка серией диалоговых окон вместо видео", запись
|
||||
/// 2026-09-07): видео не локализуется - под английскую версию пришлось бы держать второй
|
||||
/// файл. Текст слайдов идёт через Localization.Get + Localization.csv, то есть переводится
|
||||
/// строкой, как всё остальное в моде.
|
||||
///
|
||||
/// НИ ОДНОГО НОВОГО UI-ПРИМИТИВА ЗДЕСЬ НЕ ИЗОБРЕТЕНО - вся сцена собрана из двух уже
|
||||
/// работающих в этом моде кусков:
|
||||
/// - Картинка: своё окно на слайд (Config/XUi_InGame/windows.xml, шесть штук
|
||||
/// necroFinalSlide1..6) плюс свой атлас UIAtlases/NecroFinal. Механизм атласа тот же,
|
||||
/// что у ItemIconAtlas/ItemIconAtlasGreyscale. Окна БЕЗ контроллера и без привязок -
|
||||
/// спрайт в каждом прописан жёстко, поэтому листание это просто Close одного окна и
|
||||
/// Open другого, см. ShowSlideWindow. Открываются НЕмодально: в
|
||||
/// GUIWindowManager.openInternal модальное открытие зовёт CloseAllOpenModalWindows(), и
|
||||
/// модальный messageBox снёс бы модальный слайд.
|
||||
/// - Текст и кнопки: ванильный XUiC_MessageBoxWindowGroup.ShowCustom - ровно тот же вызов,
|
||||
/// которым PortalStonePatch уже показывает подтверждение "да/нет". Шаблон
|
||||
/// <messagebox> (Data/Config/XUi_Common/templates.xml:142) объявляет таблицу кнопок с
|
||||
/// repeat_count="3", то есть три слота - их хватает и на Назад/Дальше, и на выбор из
|
||||
/// двух вариантов с Назад в придачу.
|
||||
///
|
||||
/// ПОЧЕМУ ПЕРЕЛИСТЫВАНИЕ НЕ МОРГАЕТ: showMessage (декомпилировано) делает
|
||||
/// "if (windowGroup.isShowing) OnOpen(); else windowManager.Open(...)" - то есть повторный
|
||||
/// ShowCustom на уже открытой коробке просто обновляет её содержимое на месте, не закрывая
|
||||
/// и не открывая окно заново.
|
||||
///
|
||||
/// ESC. _buttonOnExternalClose говорит, какую кнопку "нажать", если окно закрыли снаружи
|
||||
/// (Esc). Ставим 2 ("Дальше") на слайдах 1-5 и 0 ("Назад") на шестом. Первое - чтобы Esc не
|
||||
/// оставлял игрока в замершей паузе с картинкой на весь экран и без единого элемента
|
||||
/// управления; второе - чтобы случайным Esc нельзя было ВЫБРАТЬ концовку. При -1 окно
|
||||
/// закрылось бы, не нажав ничего, и сцена повисла бы намертво.
|
||||
///
|
||||
/// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
|
||||
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
|
||||
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
|
||||
/// PortalStonePatch.ActivateBlackPortal). Как и вся остальная UI-часть этого мода, сцена
|
||||
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
|
||||
/// ограничение самой ванили, а не мода.
|
||||
/// </summary>
|
||||
public static class FinalSlides
|
||||
{
|
||||
public const int PageCount = 6;
|
||||
|
||||
/// <summary>Имена окон и групп в Config/XUi_InGame/windows.xml + xui.xml: к префиксу
|
||||
/// приписывается номер страницы, 1..PageCount.</summary>
|
||||
public const string SlideWindowPrefix = "necroFinalSlide";
|
||||
|
||||
/// <summary>Окно-заливка под текст эпилога: картинки к этому моменту кончились, текст
|
||||
/// идёт по чёрному. Объявлено в Config/XUi_InGame/windows.xml + xui.xml.</summary>
|
||||
public const string BlackWindow = "necroFinalBlack";
|
||||
|
||||
/// <summary>Имя Чёрного портального камня - его отбирают у игрока на концовке
|
||||
/// "Вернуться" (см. ConsumeBlackStone). Совпадает с
|
||||
/// Patch_ItemActionEat_ExecuteAction_PortalStones.BlackStoneName, продублировано здесь
|
||||
/// строкой, чтобы этот класс не зависел от патча в другом файле.</summary>
|
||||
public const string BlackStoneName = "thrownStonePortalBlack";
|
||||
|
||||
/// <summary>Имя спрайта с картинкой внутри окна слайда. ОБЯЗАНО совпадать с
|
||||
/// name="slideArt" в Config/XUi_InGame/windows.xml - по нему ищется Transform, который
|
||||
/// потом масштабируется наездом (см. RevealPage).</summary>
|
||||
public const string SlideArtId = "slideArt";
|
||||
|
||||
/// <summary>Пауза между появлением картинки и появлением текстовой коробки поверх неё,
|
||||
/// в секундах. Продиктовано 2026-09-09: "можно ли выводить диалоговое окно с задержкой в
|
||||
/// 5 секунд, чтобы пользователь успевал увидеть картинку".
|
||||
///
|
||||
/// ВРЕМЯ СЧИТАЕТСЯ НЕМАСШТАБИРУЕМОЕ, И ЭТО НЕ ПРИДИРКА. Сцена стартует из Begin сразу
|
||||
/// после GameManager.Instance.Pause(true), а тот выставляет Time.timeScale = 0. Обычный
|
||||
/// WaitForSeconds и Time.deltaTime считают как раз по масштабированному времени, то есть
|
||||
/// при timeScale = 0 не досчитали бы НИКОГДА: текст не появился бы вообще, и игрок
|
||||
/// остался бы с картинкой и без единой кнопки. Поэтому везде ниже - unscaledDeltaTime.
|
||||
/// (Ровно на этом уже обжигались в PyramidWardPatch, см. BACKLOG.md.)
|
||||
///
|
||||
/// Поставить 0, чтобы вернуть прежнее поведение "текст сразу, без наезда".</summary>
|
||||
public const float SlideRevealSeconds = 5f;
|
||||
|
||||
/// <summary>Насколько картинка увеличивается за эти секунды: 0.10 = медленный наезд на
|
||||
/// 10%. Продиктовано 2026-09-09. Ноль - наезда нет, картинка просто стоит.
|
||||
///
|
||||
/// Делается через localScale спрайта, а не через пересчёт якорей. Спрайт растянут по
|
||||
/// #cam на все четыре стороны, то есть его РАЗМЕР каждый кадр пересчитывает сама NGUI по
|
||||
/// якорям - трогать размер бессмысленно, его тут же перезапишут. А localScale к этому
|
||||
/// отношения не имеет: XUiView выставляет его один раз при создании (Vector3.one) и
|
||||
/// больше не трогает, так что наше значение держится. Масштабирование идёт от центра
|
||||
/// виджета, поэтому кадр наезжает симметрично и ничего не перекашивает.
|
||||
///
|
||||
/// Пропорции при этом не плывут: картинка уже приведена к 16:9 чёрными полями по бокам
|
||||
/// (см. UIAtlases/NecroFinal и комментарий в windows.xml), а наезд на 10% срезает по 4.5%
|
||||
/// с каждой стороны - поля становятся уже, но не исчезают и не растягиваются.</summary>
|
||||
public const float SlideZoomAmount = 0.10f;
|
||||
|
||||
/// <summary>Номер сейчас открытого слайда, 0 = ни одного. Статика, а не поле игрока:
|
||||
/// сцена по построению одна на весь клиент и заканчивается выходом в главное меню.</summary>
|
||||
private static int openSlide;
|
||||
|
||||
/// <summary>Слайды, которые игрок уже видел. Задержка и наезд нужны только при ПЕРВОМ
|
||||
/// показе: на кнопку "Назад" картинка уже знакома, и повторное пятисекундное ожидание
|
||||
/// читалось бы как зависание, а не как пауза на разглядывание.</summary>
|
||||
private static readonly HashSet<int> revealedPages = new HashSet<int>();
|
||||
|
||||
public static void Begin(EntityPlayerLocal player)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] FinalSlides: starting finale for owner=" + player.entityId);
|
||||
GameManager.Instance.Pause(true);
|
||||
openSlide = 0;
|
||||
revealedPages.Clear();
|
||||
ShowPage(player, 1);
|
||||
}
|
||||
|
||||
private static void ShowPage(EntityPlayerLocal player, int page)
|
||||
{
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
ShowSlideWindow(playerUI, page);
|
||||
|
||||
// Первый показ слайда - сначала голая картинка с медленным наездом, текстовая коробка
|
||||
// приезжает через SlideRevealSeconds. Возврат на уже виденный слайд - сразу с текстом
|
||||
// и без повторного наезда.
|
||||
if (SlideRevealSeconds > 0f && revealedPages.Add(page))
|
||||
{
|
||||
GameManager.Instance.StartCoroutine(RevealPage(player, page));
|
||||
return;
|
||||
}
|
||||
ShowText(player, page);
|
||||
}
|
||||
|
||||
/// <summary>Пять секунд наезда, потом текст. Крутится покадрово, а не через
|
||||
/// WaitForSecondsRealtime, потому что наезд всё равно надо обновлять каждый кадр - так
|
||||
/// одна корутина делает обе вещи.
|
||||
///
|
||||
/// GameManager - обычный MonoBehaviour, и корутины на нём тикают из Update, то есть при
|
||||
/// timeScale = 0 продолжают идти; замирает только само ожидание, если считать его
|
||||
/// масштабированным временем (см. SlideRevealSeconds). Тот же способ запуска корутины
|
||||
/// использует и ваниль - MinEventActionModifyStats.executeDelayed для Delay= у
|
||||
/// triggered_effect.</summary>
|
||||
private static IEnumerator RevealPage(EntityPlayerLocal player, int page)
|
||||
{
|
||||
Transform art = FindSlideArt(player, page);
|
||||
if (art != null)
|
||||
{
|
||||
art.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < SlideRevealSeconds)
|
||||
{
|
||||
// Сцену успели закрыть (например, игрок вышел через Esc-меню, пока коробки на
|
||||
// экране не было) - бросаем и наезд, и показ текста.
|
||||
if (openSlide != page)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
if (art != null)
|
||||
{
|
||||
float k = 1f + SlideZoomAmount * Mathf.Clamp01(elapsed / SlideRevealSeconds);
|
||||
art.localScale = new Vector3(k, k, 1f);
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (openSlide == page)
|
||||
{
|
||||
ShowText(player, page);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ищет Transform картинки внутри окна слайда: группа по имени -> потомок по
|
||||
/// id -> его view. GetChildById рекурсивный (декомпилировано), так что вложенность
|
||||
/// значения не имеет. Возвращает null, если что-то из цепочки не нашлось - тогда наезда
|
||||
/// просто не будет, а текст всё равно покажется: анимация не должна уметь сломать
|
||||
/// сцену.</summary>
|
||||
private static Transform FindSlideArt(EntityPlayerLocal player, int page)
|
||||
{
|
||||
XUiController group = LocalPlayerUI.GetUIForPlayer(player).xui.FindWindowGroupByName(SlideWindowPrefix + page);
|
||||
XUiController art = (group != null) ? group.GetChildById(SlideArtId) : null;
|
||||
if (art == null || art.ViewComponent == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] FinalSlides: no \"" + SlideArtId + "\" view on slide " + page + ", zoom skipped");
|
||||
return null;
|
||||
}
|
||||
return art.ViewComponent.UiTransform;
|
||||
}
|
||||
|
||||
private static void ShowText(EntityPlayerLocal player, int page)
|
||||
{
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
bool lastPage = page >= PageCount;
|
||||
XUiC_MessageBoxWindowGroup.ShowCustom(
|
||||
playerUI.xui,
|
||||
Localization.Get("necroFinalPage" + page + "Title"),
|
||||
Localization.Get("necroFinalPage" + page + "Text"),
|
||||
"",
|
||||
delegate(XUiC_MessageBoxWindowGroup mb)
|
||||
{
|
||||
// Слот 0 - "Назад", кроме самого первого слайда, где назад некуда.
|
||||
// Хоткея нарочно нет (null): Esc уже разобран через
|
||||
// _buttonOnExternalClose ниже, и вешать его сюда же значило бы обработать
|
||||
// одно нажатие дважды.
|
||||
if (page > 1)
|
||||
{
|
||||
mb.Buttons[0].Set("necroFinalBtnBack", null, delegate { ShowPage(player, page - 1); });
|
||||
}
|
||||
|
||||
if (!lastPage)
|
||||
{
|
||||
// DefaultConfirm вешает на кнопку хоткей Submit (Enter) - листать можно
|
||||
// и с клавиатуры, не целясь мышью.
|
||||
mb.Buttons[2].DefaultConfirm("necroFinalBtnNext", delegate { ShowPage(player, page + 1); });
|
||||
}
|
||||
else
|
||||
{
|
||||
mb.Buttons[1].Set("necroFinalBtnStay", null, delegate { ShowEpilogue(player, _stay: true); });
|
||||
mb.Buttons[2].Set("necroFinalBtnReturn", null, delegate { ShowEpilogue(player, _stay: false); });
|
||||
}
|
||||
},
|
||||
_openMainMenuOnClose: false,
|
||||
_modal: true,
|
||||
_buttonOnOutsideClick: -1,
|
||||
// См. блок "ESC" в комментарии к классу.
|
||||
_buttonOnExternalClose: lastPage ? 0 : 2);
|
||||
}
|
||||
|
||||
/// <summary>Закрывает предыдущее окно-слайд и открывает нужное. Открытие немодальное -
|
||||
/// иначе следующий же модальный ShowCustom закрыл бы картинку (GUIWindowManager
|
||||
/// .openInternal -> CloseAllOpenModalWindows).</summary>
|
||||
private static void ShowSlideWindow(LocalPlayerUI playerUI, int page)
|
||||
{
|
||||
if (openSlide == page)
|
||||
{
|
||||
return;
|
||||
}
|
||||
CloseSlideWindow(playerUI);
|
||||
playerUI.windowManager.Open(SlideWindowPrefix + page, false);
|
||||
openSlide = page;
|
||||
}
|
||||
|
||||
private static void CloseSlideWindow(LocalPlayerUI playerUI)
|
||||
{
|
||||
if (openSlide > 0)
|
||||
{
|
||||
playerUI.windowManager.Close(SlideWindowPrefix + openSlide);
|
||||
openSlide = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Седьмой экран: чёрный фон и текст выбранной концовки, единственная кнопка -
|
||||
/// "Конец".
|
||||
///
|
||||
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
|
||||
/// из финала"). Файлы Video/FinalStay.webm и FinalReturn.webm остались лежать на месте, а
|
||||
/// сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в
|
||||
/// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая
|
||||
/// код по кускам.
|
||||
///
|
||||
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
|
||||
/// текст показывается сразу.</summary>
|
||||
private static void ShowEpilogue(EntityPlayerLocal player, bool _stay)
|
||||
{
|
||||
string choice = _stay ? "stay" : "return";
|
||||
Debug.Log("[NecromancerTome] FinalSlides: ending chosen (" + choice + ") by owner=" + player.entityId);
|
||||
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
CloseSlideWindow(playerUI);
|
||||
playerUI.windowManager.Open(BlackWindow, false);
|
||||
|
||||
string prefix = _stay ? "necroFinalStay" : "necroFinalReturn";
|
||||
XUiC_MessageBoxWindowGroup.ShowCustom(
|
||||
playerUI.xui,
|
||||
Localization.Get(prefix + "Title"),
|
||||
Localization.Get(prefix + "Text"),
|
||||
"",
|
||||
delegate(XUiC_MessageBoxWindowGroup mb)
|
||||
{
|
||||
mb.Buttons[0].DefaultConfirm("necroFinalBtnTheEnd", delegate { FinishEnding(player, _stay); });
|
||||
},
|
||||
_openMainMenuOnClose: false,
|
||||
_modal: true,
|
||||
_buttonOnOutsideClick: -1,
|
||||
// Esc здесь равносилен "Конец": выбор уже сделан, отменять нечего, а оставить
|
||||
// игрока на чёрном экране в замершей паузе нельзя.
|
||||
_buttonOnExternalClose: 0);
|
||||
}
|
||||
|
||||
/// <summary>Две концовки расходятся именно здесь, и только здесь.</summary>
|
||||
private static void FinishEnding(EntityPlayerLocal player, bool _stay)
|
||||
{
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
playerUI.windowManager.Close(BlackWindow);
|
||||
|
||||
if (_stay)
|
||||
{
|
||||
// "Остаться" - игра действительно кончилась. Disconnect() это ровно то, что зовёт
|
||||
// кнопка "Выйти в главное меню" из игрового Esc-меню: она закрывает окна, сама
|
||||
// снимает паузу (Pause(false) внутри), сохраняет и гасит локальный сервер.
|
||||
Debug.Log("[NecromancerTome] FinalSlides: staying - exiting to main menu");
|
||||
GameManager.Instance.Disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// "Вернуться" - игрок продолжает играть в том же мире. Паузу здесь снимаем сами:
|
||||
// Disconnect(), который делал это за нас, не вызывается.
|
||||
Debug.Log("[NecromancerTome] FinalSlides: returning to the game");
|
||||
ConsumeBlackStone(player);
|
||||
GameManager.Instance.Pause(false);
|
||||
}
|
||||
|
||||
/// <summary>Забирает у игрока один Чёрный портальный камень. Продиктовано 2026-09-09:
|
||||
/// "портальный камень исчезает из инвентаря. Разумеется, его потом можно скрафтить
|
||||
/// заново" - рецепт не трогаем, только предмет.
|
||||
///
|
||||
/// Ищем и в поясе (inventory), и в рюкзаке (bag): камень применяется из руки, то есть
|
||||
/// лежит в поясе, но игрок мог за время сцены... вообще-то не мог - игра на паузе, а
|
||||
/// сцена модальная. Проверяем оба всё равно, это дешевле, чем полагаться на догадку о
|
||||
/// том, где предмет обязан оказаться.
|
||||
///
|
||||
/// DecItem у Bag и Inventory имеет одинаковую сигнатуру и возвращает, сколько СНЯТЬ НЕ
|
||||
/// УДАЛОСЬ (декомпилировано) - поэтому остаток от первого вызова передаётся во второй.
|
||||
/// Если камня не нашлось нигде, пишем варнинг и молча продолжаем: концовка не должна
|
||||
/// падать из-за инвентаря.</summary>
|
||||
private static void ConsumeBlackStone(EntityPlayerLocal player)
|
||||
{
|
||||
ItemValue stone = ItemClass.GetItem(BlackStoneName);
|
||||
if (stone == null || stone.IsEmpty())
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] FinalSlides: item \"" + BlackStoneName + "\" not found, nothing consumed");
|
||||
return;
|
||||
}
|
||||
|
||||
int left = 1;
|
||||
if (player.inventory != null)
|
||||
{
|
||||
left = player.inventory.DecItem(stone, left);
|
||||
}
|
||||
if (left > 0 && player.bag != null)
|
||||
{
|
||||
left = player.bag.DecItem(stone, left);
|
||||
}
|
||||
|
||||
if (left > 0)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] FinalSlides: no " + BlackStoneName + " found on owner=" + player.entityId + " to consume");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("[NecromancerTome] FinalSlides: consumed one " + BlackStoneName + " from owner=" + player.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Mod entry point. The game finds this by scanning every assembly dropped in a
|
||||
/// Mods/<ModFolder>/ directory for a type implementing IModApi.
|
||||
/// </summary>
|
||||
public class ModEntry : IModApi
|
||||
{
|
||||
public void InitMod(Mod _modInstance)
|
||||
{
|
||||
var harmony = new Harmony("necromancertome.harmony");
|
||||
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||
PetFollowPatch.Init();
|
||||
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||
|
||||
// Diagnostic-only, added 2026-08-28 while chasing "VictimPatch never logs anything at
|
||||
// all for dropItemOnDeath, even though decompiling EntityAlive.OnEntityDeath()
|
||||
// confirms it's called unconditionally right after the 'killed by' line seen in the
|
||||
// log". This checks, at load time, whether Harmony actually attached our Prefix to
|
||||
// that method at all - rules "patch never applied" in or out without waiting on
|
||||
// another in-game death.
|
||||
VerifyPrefixAttached(typeof(EntityAlive), "dropItemOnDeath");
|
||||
VerifyPrefixAttached(typeof(Entity), "DropBagServer");
|
||||
}
|
||||
|
||||
public static void VerifyPrefixAttached(System.Type type, string methodName)
|
||||
{
|
||||
MethodBase method = AccessTools.Method(type, methodName);
|
||||
if (method == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] ModEntry: could not resolve " + type.Name + "." + methodName + " via AccessTools - method not found");
|
||||
return;
|
||||
}
|
||||
Patches info = Harmony.GetPatchInfo(method);
|
||||
int prefixCount = info != null && info.Prefixes != null ? info.Prefixes.Count : 0;
|
||||
Debug.Log("[NecromancerTome] ModEntry: " + type.Name + "." + methodName + " resolved, has " + prefixCount + " prefix patch(es) attached after PatchAll");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml
|
||||
/// (resourceNecromancerBlood) for the item, recipes.xml for the base recipe (an empty jar,
|
||||
/// like any other resource conversion). Two rules the user asked for have NO vanilla XML
|
||||
/// equivalent at all, so both are enforced here instead:
|
||||
/// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or
|
||||
/// backpack) to craft this, but is NOT consumed. recipes.xml has no "required but not
|
||||
/// consumed" ingredient flag (confirmed - only `craft_tool="itemName"` exists for
|
||||
/// something adjacent, but it takes exactly ONE item name, not "any item of a category",
|
||||
/// and its actual runtime enforcement point wasn't confirmed by decompilation either -
|
||||
/// not risking an untested mechanism for this).
|
||||
/// 2. "При крафте нужно отнимать у персонажа 90% имеющегося ХП" - crafting this recipe
|
||||
/// costs 90% of the player's CURRENT health. Recipes have no cost hook beyond their
|
||||
/// ingredient list at all.
|
||||
///
|
||||
/// "любого ножа" (ANY knife) is detected via ItemClass.DisplayType == "meleeKnife" - decompiled
|
||||
/// Data/Config/items.xml directly: every real vanilla knife (meleeWpnBladeT0BoneKnife,
|
||||
/// meleeWpnBladeT1HuntingKnife, even meleeWpnBladeT3Machete) shares this exact DisplayType,
|
||||
/// which is how the game itself categorizes "the knife family" in its own UI - a single,
|
||||
/// reliable check instead of hand-maintaining a list of item names. necroWpnBladeNecroKnife
|
||||
/// (Extends meleeWpnBladeT0BoneKnife, never overrides DisplayType) is covered by the same
|
||||
/// check automatically.
|
||||
///
|
||||
/// PATCH POINTS - both on XUiC_RecipeStack, decompiled directly (not guessed):
|
||||
/// - SetRecipe(...) Prefix: the earliest confirmed point a "craft this recipe" click reaches
|
||||
/// (XUiC_CraftingQueue.AddRecipeToCraftAtIndex calls straight into this). Blocking here
|
||||
/// (return false) stops the craft from ever starting - recipe/recipeCount never get set,
|
||||
/// isCrafting never becomes true.
|
||||
/// - outputStack() Prefix+Postfix (via __state): outputStack() is where the output item is
|
||||
/// actually granted, once per queued unit - HP is only deducted when __result is true,
|
||||
/// i.e. the item genuinely was produced this call, not on a failed/blocked attempt.
|
||||
///
|
||||
/// CAVEAT - not glossed over: decompiling XUiC_RecipeStack/XUiC_CraftingQueue/XUiM_Recipes did
|
||||
/// NOT turn up the exact line that removes ingredients from the player's inventory (it happens
|
||||
/// somewhere upstream of SetRecipe, in whatever UI code handles the actual "Craft" button click
|
||||
/// - not found within reasonable search). XUiC_RecipeStack.HandleOnPress (the CANCEL button)
|
||||
/// refunds ingredients, which proves they're already gone by the time SetRecipe runs - so if
|
||||
/// SetRecipe's Prefix blocks a no-knife attempt, the jar may already be spent with nothing
|
||||
/// granted back. Blocking at the earliest CONFIRMED point was judged better than not blocking
|
||||
/// at all; a lost jar on a rare misclick is a minor rough edge, not a correctness bug. Revisit
|
||||
/// if this turns out to happen often in practice.
|
||||
/// </summary>
|
||||
public static class NecromancerBloodPatch
|
||||
{
|
||||
public const string BloodItemName = "resourceNecromancerBlood";
|
||||
public const float HealthCostFraction = 0.9f;
|
||||
|
||||
public static bool HasAnyKnife(EntityPlayerLocal player)
|
||||
{
|
||||
return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots());
|
||||
}
|
||||
|
||||
private static bool ContainsKnife(ItemStack[] slots)
|
||||
{
|
||||
if (slots == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (ItemStack stack in slots)
|
||||
{
|
||||
if (stack == null || stack.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (stack.itemValue?.ItemClass?.DisplayType == "meleeKnife")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_RecipeStack), "SetRecipe")]
|
||||
public static class Patch_XUiC_RecipeStack_SetRecipe_NecromancerBlood
|
||||
{
|
||||
public static bool Prefix(XUiC_RecipeStack __instance, Recipe _recipe, bool recipeModification)
|
||||
{
|
||||
// recipeModification covers the "clear this slot" calls (ClearQueue/RefreshQueue/
|
||||
// cancel) - never block those, only an actual attempt to start crafting our recipe.
|
||||
if (recipeModification || _recipe == null || _recipe.GetName() != NecromancerBloodPatch.BloodItemName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
EntityPlayerLocal player = __instance.xui?.playerUI?.entityPlayer;
|
||||
if (player == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!NecromancerBloodPatch.HasAnyKnife(player))
|
||||
{
|
||||
GameManager.ShowTooltip(player, "resourceNecromancerBloodNeedsKnife");
|
||||
Debug.Log("[NecromancerTome] NecromancerBloodPatch: blocked craft (no knife present) for owner=" + player.entityId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_RecipeStack), "outputStack")]
|
||||
public static class Patch_XUiC_RecipeStack_outputStack_NecromancerBlood
|
||||
{
|
||||
public static void Prefix(XUiC_RecipeStack __instance, out EntityPlayerLocal __state)
|
||||
{
|
||||
__state = null;
|
||||
if (__instance.recipe != null && __instance.recipe.GetName() == NecromancerBloodPatch.BloodItemName)
|
||||
{
|
||||
__state = __instance.xui?.playerUI?.entityPlayer;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Postfix(bool __result, EntityPlayerLocal __state)
|
||||
{
|
||||
if (!__result || __state == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Deliberately not clamped to leave the player at least 1 HP - the user asked for a
|
||||
// straight 90% cost, and a blood ritual that can genuinely kill you if you're already
|
||||
// badly hurt fits the theme. AddHealth is the same safe, non-combat HP-modification
|
||||
// API vanilla itself uses (decompiled EntityAlive.AddHealth) - not DamageEntity/
|
||||
// DamageResponse, since this isn't damage from a source, it's a direct self-cost.
|
||||
int amount = Mathf.RoundToInt(__state.Health * NecromancerBloodPatch.HealthCostFraction);
|
||||
if (amount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
__state.AddHealth(-amount);
|
||||
Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<AssemblyName>NecromancerHarmony</AssemblyName>
|
||||
<RootNamespace>NecromancerTome</RootNamespace>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<!-- The DLL is loaded straight out of the mod folder by the game, not via NuGet/deps.json. -->
|
||||
<GenerateDependencyFile>false</GenerateDependencyFile>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<OutputPath>bin\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Game/Harmony assemblies: compile-time only, never copied into our output (they already
|
||||
exist where the game loads them from). -->
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>..\..\0_TFP_Harmony\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Assembly-CSharp">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\Assembly-CSharp.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ParticleSystemModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.ParticleSystemModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- Collider/Physics.IgnoreCollision for SummonPatch.cs's "pet passes through its owner"
|
||||
(user request 2026-08-28) - lives in its own module, not CoreModule. -->
|
||||
<Reference Include="UnityEngine.PhysicsModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.PhysicsModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- EntityAlive.PlayOneShot(string)'s own overload set touches AnimationEvent (2026-08-29,
|
||||
BansheePatch.cs) - needed even though this mod never uses AnimationEvent directly, just
|
||||
to satisfy the compiler's reference-resolution for that overload. -->
|
||||
<Reference Include="UnityEngine.AnimationModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- PlayerActionsLocal.Secondary (PlayerAction) for PortalStonePatch.cs's power-attack
|
||||
channel-cancel (2026-08-29) - the game's own input layer, not something this mod
|
||||
previously needed to touch directly. -->
|
||||
<Reference Include="InControl">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\InControl.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- UnityEngine.Input (raw mouse polling) for the same channel-cancel fix - Unity split
|
||||
Input into its own module, not part of CoreModule. -->
|
||||
<Reference Include="UnityEngine.InputLegacyModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.InputLegacyModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- PyramidWardPatch.cs's TEFeaturePyramidWard.Write() calls into this instead of
|
||||
PooledBinaryWriter.Write directly - see TEPersistenceSrc/NecromancerTEPersistence.csproj's
|
||||
own comment for why that call can't compile in THIS project at all. Private=false: it's
|
||||
built and deployed separately (its own dotnet build + copy to the mod root), same as
|
||||
0Harmony above - not something this project's own build should try to copy/rebuild. -->
|
||||
<Reference Include="NecromancerTEPersistence">
|
||||
<HintPath>..\TEPersistenceSrc\bin\NecromancerTEPersistence.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент
|
||||
/// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is
|
||||
/// opened, pause the game and play a flashback). Reuses the exact pause+video pipeline
|
||||
/// already built and tested for the Black Portal Stone (see PortalStonePatch.cs's
|
||||
/// ActivateBlackPortal - GameManager.Instance.Pause/XUiC_VideoPlayer.PlayVideo, both APIs
|
||||
/// decompiled there already, same reasoning applies unchanged here).
|
||||
///
|
||||
/// FINDING THE RIGHT PATCH POINT: noteDuke01 has no custom C# class of its own - it's a
|
||||
/// plain Class="Eat" item (items.xml) whose entire "reading" experience is a vanilla trick:
|
||||
/// PromptTitle="noteDuke01"/PromptDescription="noteDuke01Desc" make the ENGINE ITSELF show
|
||||
/// the note as a XUiC_MessageBoxWindowGroup.ShowOkCancel(...) confirm box - decompiled
|
||||
/// ItemActionEat directly and confirmed it has no UI-showing code of its own at all
|
||||
/// (NeedPrompt/PromptTitle/PromptDescription/bPromptChecked are all read, never acted on,
|
||||
/// inside that class); the actual ShowOkCancel call lives in the CALLERS instead - two
|
||||
/// separate, decompiled call sites:
|
||||
/// 1. ItemClass.ExecuteAction(int, ItemInventoryData, bool, PlayerActionsLocal) - the
|
||||
/// holding-the-item-and-clicking path.
|
||||
/// 2. XUiC_ItemStack's inventory "Use" (double-click / context-menu) path.
|
||||
/// Both funnel through the exact same static XUiC_MessageBoxWindowGroup.ShowOkCancel call -
|
||||
/// patching THAT one method, instead of either call site separately, covers both input paths
|
||||
/// with a single patch.
|
||||
///
|
||||
/// IDENTIFYING OUR NOTE: ShowOkCancel receives only already-localized strings, not an item
|
||||
/// reference - decompilation confirms this overload has no ItemValue/ItemClass parameter at
|
||||
/// all. Matched by comparing the incoming title against Localization.Get("noteDuke01") (the
|
||||
/// exact PromptTitle key from items.xml) - unique to this one item in the whole game, not a
|
||||
/// generic vanilla dialog string, so this is a safe match, not a guess.
|
||||
///
|
||||
/// FLOW: on match, suppress the real dialog for now (Prefix returns false), pause the game,
|
||||
/// and play the flashback; only once the video finishes (or is skipped/errors - PlayVideo's
|
||||
/// own onFinished callback fires in every case, confirmed by decompiling
|
||||
/// XUiC_VideoPlayer.OnClose/FinishAndClose, so this can never soft-lock the pause) does it
|
||||
/// unpause and open the REAL note-text dialog (a re-entrant call to ShowOkCancel itself, via
|
||||
/// a bypass flag so the Prefix doesn't intercept its own follow-up call) - "open note ->
|
||||
/// flashback -> read text -> confirm", rather than overlapping the video with the text box.
|
||||
///
|
||||
/// VIDEO FILE: Video/DukeNoteFlashback.mp4 - the user's real flashback clip (delivered
|
||||
/// 2026-08-30 as exch/flashbback.mp4), kept as .mp4 rather than renamed to .webm like the
|
||||
/// Black Portal placeholder: Unity's VideoPlayer component (confirmed by decompiling
|
||||
/// XUiV_Video - it wraps a plain UnityEngine.Video.VideoPlayer) natively decodes MP4/H.264 on
|
||||
/// Windows via Media Foundation, and re-labeling an actual MP4 container as .webm would just
|
||||
/// make it fail to decode (VP8/VP9 container expected, not H.264) - not decompiled/proven
|
||||
/// that MP4 plays correctly in THIS build, but there is no reason implied by the decompiled
|
||||
/// code to expect otherwise, and even a decode failure only degrades to a skipped video (see
|
||||
/// FLOW above), never a stuck pause. **Not confirmed in game.**
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(XUiC_MessageBoxWindowGroup), "ShowOkCancel")]
|
||||
public static class Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback
|
||||
{
|
||||
public const string NoteFlashbackVideoPath = "@modfolder(NecromancerTome):Video/DukeNoteFlashback.mp4";
|
||||
|
||||
/// <summary>Guards the re-entrant call this patch makes to the very method it patches
|
||||
/// (to actually show the note text once the flashback is done) - without this, that
|
||||
/// second call would just trigger the Prefix again and loop back into another flashback
|
||||
/// instead of showing the dialog. Not [ThreadStatic]: XUi/UI code in this game only ever
|
||||
/// runs on the main thread (every other UI-touching patch in this mod makes the same
|
||||
/// assumption, e.g. PortalStonePatch.cs's local-player-only UI calls), so a plain static
|
||||
/// bool is enough here.</summary>
|
||||
public static bool bypass;
|
||||
|
||||
public static bool Prefix(XUi _xuiInstance, string _title, string _text, string _icon, Action _onOk, Action _onCancel, bool _openMainMenuOnClose, bool _modal, bool _cancelOnOutsideClick)
|
||||
{
|
||||
if (bypass)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (_xuiInstance == null || _title != Localization.Get("noteDuke01"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.Log("[NecromancerTome] NoteFlashbackPatch: Duke's note opened, pausing + playing flashback");
|
||||
GameManager.Instance.Pause(true);
|
||||
VideoData videoData = new VideoData { url = NoteFlashbackVideoPath };
|
||||
XUiC_VideoPlayer.PlayVideo(_xuiInstance, videoData, true, delegate(bool skipped)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] NoteFlashbackPatch: flashback finished (skipped=" + skipped + "), unpausing and showing note text");
|
||||
GameManager.Instance.Pause(false);
|
||||
bypass = true;
|
||||
try
|
||||
{
|
||||
XUiC_MessageBoxWindowGroup.ShowOkCancel(_xuiInstance, _title, _text, _icon, _onOk, _onCancel, _openMainMenuOnClose, _modal, _cancelOnOutsideClick);
|
||||
}
|
||||
finally
|
||||
{
|
||||
bypass = false;
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Shrinks and recolors the "RadiatedParticlesOnMesh" glow that both buffNecroDeviatorCharm
|
||||
/// and buffNecroVictim attach to a zombie (see buffs.xml, action="AttachParticleEffectToEntity"
|
||||
/// - both buffs reuse the same particle prefab rather than needing two different ones).
|
||||
///
|
||||
/// Why this needs Harmony: AttachParticleEffectToEntity's XML attributes are limited to
|
||||
/// particle/parent_transform/local_offset/local_rotation/oneshot/shape_mesh/sound - there is
|
||||
/// no scale or color/alpha attribute (confirmed by decompiling
|
||||
/// MinEventActionAttachParticleEffectToEntity.ParseXmlAttribute - that's the exhaustive list).
|
||||
/// The prefab always instantiates at its own authored size/color; nothing in XML can change
|
||||
/// that. So instead we let the vanilla action run as normal (Postfix, not Prefix - the
|
||||
/// particle GameObject has to already exist), then find the same child object it just created
|
||||
/// and adjust it directly - same lookup the engine itself uses internally: a child transform
|
||||
/// named "Ptl_" + the particle prefab's name, parented under the entity's mesh transform.
|
||||
///
|
||||
/// SizeFactor dropped 0.5 -> 0.2 2026-08-28 (user: "выглядят как шар вне зомби" - even the
|
||||
/// original half-size shrink still read as a floating ball rather than a mesh-hugging glow).
|
||||
///
|
||||
/// Color (2026-08-28): Deviator green and Victim purple are both explicit now (Deviator used
|
||||
/// to just be whatever RadiatedParticlesOnMesh's own baked-in color happens to be - reads
|
||||
/// "green/energy" on its own, never actually set). Explicit per user request: "если оба бафа,
|
||||
/// то пусть свечения смешиваются" - a zombie carrying both gets Color.Lerp(charm, victim, .5),
|
||||
/// not one color just overriding the other.
|
||||
///
|
||||
/// Gated to only affect entities carrying at least one of OUR buffs (checked per-buff, not
|
||||
/// just "is this the right particle name") - not vanilla naturally-irradiated zombies that
|
||||
/// happen to reuse the same particle prefab elsewhere.
|
||||
///
|
||||
/// GENERALIZED 2026-08-29 for buffNecroPortalChannel (BACKLOG.md item 6, user request:
|
||||
/// dense green-blue particles while a portal stone channels, thick enough to partially
|
||||
/// obscure the player) - was hard-gated to `EntityZombie` specifically (`_params.Self is
|
||||
/// EntityZombie`) since the two original buffs are both zombie-facing; this new one targets
|
||||
/// the PLAYER, so the check is now against the common `EntityAlive` base (where
|
||||
/// `.Buffs`/`.emodel` actually live) instead. Also needed its own size/alpha/DENSITY numbers
|
||||
/// separate from the zombie glow's - the first version reused the same SizeFactor/AlphaFactor
|
||||
/// constants for all three buffs, which the user confirmed reads as "редкие-редкие" (way too
|
||||
/// sparse) for a "should partly cover you" effect - see GlowConfig below, one per buff now
|
||||
/// instead of two shared constants.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(MinEventActionAttachParticleEffectToEntity), "Execute")]
|
||||
public static class Patch_AttachParticleEffectToEntity_ShrinkCharmGlow
|
||||
{
|
||||
public const string ParticleName = "RadiatedParticlesOnMesh";
|
||||
|
||||
public class GlowConfig
|
||||
{
|
||||
public Color Tint;
|
||||
public float SizeFactor;
|
||||
public float AlphaFactor;
|
||||
/// <summary>Multiplies both the emission rate (particles/second) AND maxParticles by
|
||||
/// this factor together - raising rate alone caps out silently once the system hits
|
||||
/// its authored maxParticles ceiling, so both have to move together to actually get a
|
||||
/// visibly denser cloud instead of the same particle count arriving faster.</summary>
|
||||
public float DensityFactor;
|
||||
}
|
||||
|
||||
/// <summary>Unchanged from the original 2026-08-28 tuning - the zombie-facing glow was
|
||||
/// never asked to get denser/bigger, only the new portal-channel one was.</summary>
|
||||
public static readonly GlowConfig CharmGlow = new GlowConfig { Tint = new Color(0.2f, 1f, 0.3f), SizeFactor = 0.2f, AlphaFactor = 0.5f, DensityFactor = 1f };
|
||||
/// <summary>Purple, per user request 2026-08-28 ("подсвети бафнутого зомби... фиолетовым").</summary>
|
||||
public static readonly GlowConfig VictimGlow = new GlowConfig { Tint = new Color(0.55f, 0.05f, 0.85f), SizeFactor = 0.2f, AlphaFactor = 0.5f, DensityFactor = 1f };
|
||||
/// <summary>RE-TUNED 2026-08-29 (user: "частицы есть, но они редкие-редкие. А надо чтобы
|
||||
/// прямо густо располагались... чтобы частично перекрывали внешний вид" + colour changed
|
||||
/// from the first version's near-black to green-blue/teal, "зелёноголубые"). SizeFactor
|
||||
/// bumped from a shrink (0.2, matching the mesh-hugging zombie glow) to just under full
|
||||
/// size (0.9) - a swirl meant to partly obscure the player needs to actually be
|
||||
/// body-sized, not a tight skin-hugging glow. AlphaFactor raised to near-opaque (0.9) for
|
||||
/// the same "obscures the view" reason - the zombie glow's own 0.5 was deliberately subtle,
|
||||
/// this one shouldn't be. DensityFactor=5 - the actual fix for "редкие-редкие", multiplies
|
||||
/// both emission rate and maxParticles together (see GlowConfig's own doc on why both).</summary>
|
||||
public static readonly GlowConfig PortalChannelGlow = new GlowConfig { Tint = new Color(0.1f, 0.85f, 0.8f), SizeFactor = 0.9f, AlphaFactor = 0.9f, DensityFactor = 5f };
|
||||
|
||||
public static void Postfix(MinEventActionAttachParticleEffectToEntity __instance, MinEventParams _params)
|
||||
{
|
||||
if (_params.Self == null || __instance.goToInstantiate == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (__instance.goToInstantiate.name != ParticleName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!(_params.Self is EntityAlive entity) || entity.Buffs == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
bool isPortalChannel = entity.Buffs.HasBuff(Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelBuffName);
|
||||
bool isVictim = entity.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName);
|
||||
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
|
||||
if (!isPortalChannel && !isVictim && !isCharm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Portal channel is player-only and never coexists with the zombie-facing buffs
|
||||
// below in practice, so it's kept as a simple separate branch rather than folded
|
||||
// into the same Lerp blend those two use with each other.
|
||||
GlowConfig config;
|
||||
if (isPortalChannel)
|
||||
{
|
||||
config = PortalChannelGlow;
|
||||
}
|
||||
else if (isVictim && isCharm)
|
||||
{
|
||||
config = new GlowConfig { Tint = Color.Lerp(CharmGlow.Tint, VictimGlow.Tint, 0.5f), SizeFactor = CharmGlow.SizeFactor, AlphaFactor = CharmGlow.AlphaFactor, DensityFactor = 1f };
|
||||
}
|
||||
else
|
||||
{
|
||||
config = isVictim ? VictimGlow : CharmGlow;
|
||||
}
|
||||
|
||||
Transform meshTransform = entity.emodel != null ? entity.emodel.meshTransform : null;
|
||||
if (meshTransform == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Transform particleTransform = meshTransform.Find("Ptl_" + ParticleName);
|
||||
if (particleTransform == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders for size: not every particle system's Scaling Mode respects
|
||||
// transform scale, but startSizeMultiplier always does regardless of that setting.
|
||||
particleTransform.localScale = Vector3.one * config.SizeFactor;
|
||||
ParticleSystem[] systems = particleTransform.GetComponentsInChildren<ParticleSystem>(true);
|
||||
foreach (ParticleSystem ps in systems)
|
||||
{
|
||||
ParticleSystem.MainModule main = ps.main;
|
||||
main.startSizeMultiplier *= config.SizeFactor;
|
||||
main.maxParticles = Mathf.Max(1, Mathf.RoundToInt(main.maxParticles * config.DensityFactor));
|
||||
|
||||
if (config.DensityFactor != 1f)
|
||||
{
|
||||
ParticleSystem.EmissionModule emission = ps.emission;
|
||||
emission.rateOverTimeMultiplier *= config.DensityFactor;
|
||||
emission.rateOverDistanceMultiplier *= config.DensityFactor;
|
||||
}
|
||||
|
||||
ParticleSystem.MinMaxGradient startColor = main.startColor;
|
||||
switch (startColor.mode)
|
||||
{
|
||||
case ParticleSystemGradientMode.Color:
|
||||
{
|
||||
Color c = config.Tint;
|
||||
c.a = startColor.color.a * config.AlphaFactor;
|
||||
startColor.color = c;
|
||||
break;
|
||||
}
|
||||
case ParticleSystemGradientMode.TwoColors:
|
||||
{
|
||||
Color min = config.Tint;
|
||||
Color max = config.Tint;
|
||||
min.a = startColor.colorMin.a * config.AlphaFactor;
|
||||
max.a = startColor.colorMax.a * config.AlphaFactor;
|
||||
startColor.colorMin = min;
|
||||
startColor.colorMax = max;
|
||||
break;
|
||||
}
|
||||
// Gradient/TwoGradients modes bake color+alpha into the gradient asset itself -
|
||||
// no generic way to override that from code, so those are left as-is.
|
||||
}
|
||||
main.startColor = startColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps a summoned pet (see SummonPatch.cs) from wandering off and getting permanently
|
||||
/// lost/unsummonable - user report 2026-08-28: the Zombie Dog didn't follow like a drone
|
||||
/// would, wandered off (heard, not seen), and a second summon attempt did nothing while the
|
||||
/// first was presumably still out there somewhere.
|
||||
///
|
||||
/// Two separate problems, one fix:
|
||||
///
|
||||
/// 1. "Doesn't follow": there is no generic "follow a specific entity" AI task anywhere in
|
||||
/// this game - confirmed by listing every EAI*-named type in Assembly-CSharp (EAIWander,
|
||||
/// EAITerritorial, EAIApproachSpot, EAIApproachAndAttackTarget, etc. - nothing
|
||||
/// follow-shaped). EntityDrone's follow behavior is hardcoded C# specific to that one
|
||||
/// class, not something an XML entity_class can opt into. Writing a real custom AITask
|
||||
/// (actual pathfinding, priority-tuned against the pet's existing Wander/Territorial/
|
||||
/// ApproachSpot tasks) is real engineering the backlog didn't ask for - so this copies
|
||||
/// vanilla's own fallback for the identical problem instead: DroneManager.Update()
|
||||
/// teleports a drone back near its owner once it's more than 32m away (sqrMagnitude >
|
||||
/// 1024) and not doing something else (OrderState != Stay) - confirmed by decompiling
|
||||
/// it. Same threshold, same idea, generalized to any pet class instead of drones only,
|
||||
/// and skipped while the pet has a live attack target (don't yank it out of a fight).
|
||||
///
|
||||
/// 2. "Doesn't reappear on retry": SummonPatch.cs's one-pet-per-species limit checks
|
||||
/// EntityAlive.ownedEntities, but nothing was ever removing a pet from that list once it
|
||||
/// died or its chunk unloaded - unlike the drone, where DroneManager's own death/unload
|
||||
/// callbacks do that cleanup. A dead or vanished pet left the slot "occupied" forever.
|
||||
/// The same periodic check below detects a tracked pet that's gone (world.GetEntity
|
||||
/// returns null - dead, or its chunk unloaded and it despawned like any untracked
|
||||
/// entity, per the known persistence gap documented in SummonPatch.cs) and clears
|
||||
/// ownership so the player can summon a fresh one.
|
||||
///
|
||||
/// 3. (added 2026-08-28) Insect Swarm only: makes it move on to the next zombie once its
|
||||
/// current target is already Deviator-charmed, instead of camping the same converted
|
||||
/// zombie forever - see the inline comment below, right where it happens.
|
||||
///
|
||||
/// Hooked via ModEvents.UnityUpdate - the same supported per-frame mod event GameManager's own
|
||||
/// gmUpdate() fires DroneManager.Update() from (confirmed by decompiling GameManager) - not a
|
||||
/// Harmony patch, since this is a genuine public extension point, no reason to patch around
|
||||
/// it. Throttled to run the real check once a second (CheckInterval): a distance/liveness
|
||||
/// check on a handful of pets is cheap, but no reason to do it 60x/sec either.
|
||||
/// </summary>
|
||||
public static class PetFollowPatch
|
||||
{
|
||||
public const float LeashDistance = 32f; // matches DroneManager's own 32m leash
|
||||
public const float LeashDistanceSq = LeashDistance * LeashDistance;
|
||||
public const float CheckInterval = 1f;
|
||||
|
||||
public class TrackedPet
|
||||
{
|
||||
public int OwnerEntityId;
|
||||
public int PetEntityId;
|
||||
}
|
||||
|
||||
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
|
||||
|
||||
public static float timer;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||
}
|
||||
|
||||
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
|
||||
public static void Register(EntityAlive owner, Entity pet)
|
||||
{
|
||||
TrackedPets.Add(new TrackedPet { OwnerEntityId = owner.entityId, PetEntityId = pet.entityId });
|
||||
}
|
||||
|
||||
/// <summary>Called from SummonPatch.cs's manual recall path so a recalled pet stops being
|
||||
/// tracked immediately, instead of lingering until the next tick notices it's gone.</summary>
|
||||
public static void Unregister(int petEntityId)
|
||||
{
|
||||
for (int i = TrackedPets.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (TrackedPets[i].PetEntityId == petEntityId)
|
||||
{
|
||||
TrackedPets.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
if (timer < CheckInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
timer = 0f;
|
||||
if (TrackedPets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
if (world == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = TrackedPets.Count - 1; i >= 0; i--)
|
||||
{
|
||||
TrackedPet tracked = TrackedPets[i];
|
||||
EntityAlive pet = world.GetEntity(tracked.PetEntityId) as EntityAlive;
|
||||
if (pet == null || pet.IsDead())
|
||||
{
|
||||
EntityAlive ownerForCleanup = world.GetEntity(tracked.OwnerEntityId) as EntityAlive;
|
||||
if (ownerForCleanup != null)
|
||||
{
|
||||
ownerForCleanup.RemoveOwnedEntity(tracked.PetEntityId);
|
||||
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + tracked.PetEntityId + " gone, cleared ownership for " + ownerForCleanup.entityId);
|
||||
}
|
||||
TrackedPets.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
EntityAlive owner = world.GetEntity(tracked.OwnerEntityId) as EntityAlive;
|
||||
if (owner == null)
|
||||
{
|
||||
// Owner not currently loaded (e.g. disconnected) - leave the pet tracked,
|
||||
// nothing useful to do until they're back.
|
||||
continue;
|
||||
}
|
||||
|
||||
// User request 2026-08-28: "рой будет заражать зомби девиацией и лететь к
|
||||
// следующему незаражённому?" - not on its own, so this makes it one. EntityVulture
|
||||
// (the Swarm's real base class - see SwarmTargetPatch.cs) has no notion of
|
||||
// "this target is already converted, go find another" - once it has a live target
|
||||
// it just keeps attacking it until that target dies, charmed or not (repeated
|
||||
// AddBuff on an already-charmed zombie is a harmless no-op, so it wasn't wrong,
|
||||
// just stuck). Clearing the attack target here when it's already charmed makes
|
||||
// EntityVulture's own FindTarget()/SetAttackTarget cycle kick back in on its next
|
||||
// pass (SwarmTargetPatch.cs redirects that straight to the nearest zombie again,
|
||||
// same as any other retarget) - not instant (that cycle runs on its own ~2s timer,
|
||||
// not driven by us), but converts-then-moves-on within a couple seconds.
|
||||
if (pet.entityClass == Patch_EntityAlive_SetAttackTarget_SwarmRetarget.SwarmOnlyClassId())
|
||||
{
|
||||
EntityAlive currentTarget = pet.GetAttackTarget();
|
||||
if (currentTarget != null && currentTarget.Buffs != null && currentTarget.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
|
||||
{
|
||||
pet.SetAttackTarget(null, 0);
|
||||
Debug.Log("[NecromancerTome] PetFollowPatch: swarm " + pet.entityId + " dropped already-charmed target " + currentTarget.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
if (pet.GetAttackTarget() != null)
|
||||
{
|
||||
// Mid-fight - let it finish rather than teleporting it away.
|
||||
continue;
|
||||
}
|
||||
|
||||
float distSq = (pet.position - owner.position).sqrMagnitude;
|
||||
if (distSq > LeashDistanceSq)
|
||||
{
|
||||
// 2m in front of the owner, not exactly on top of them - landing right on the
|
||||
// owner's own position stacks the pet's collider into the player's and shoves
|
||||
// them (this is exactly what happened during the "<=0 vs -1" bug above: a pile
|
||||
// of a dozen undespawned dogs all teleporting onto the same point as the player
|
||||
// every second launched them into the air).
|
||||
Vector3 dest = owner.position + owner.qrotation * new Vector3(0f, 0f, 2f);
|
||||
pet.SetPosition(dest, true);
|
||||
Debug.Log("[NecromancerTome] PetFollowPatch: teleported pet " + pet.entityId + " back to owner " + owner.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Portal stones - BACKLOG.md item 6. See items.xml (thrownStonePortalBlue/
|
||||
/// thrownStonePortalBlack) for the item definitions.
|
||||
///
|
||||
/// REWRITTEN 2026-08-29 after the first version's core assumption turned out wrong, confirmed
|
||||
/// by the user testing it ("срабатывает мгновенно" - fires instantly, no 10s indicator). The
|
||||
/// original version used Class="Eat"/Delay="10" on the assumption that Delay was a HELD-hold
|
||||
/// duration (like the workbench's TakeDelay). Re-decompiling ItemActionEat more carefully
|
||||
/// shows that's wrong: ExecuteAction only runs once per click, ON RELEASE
|
||||
/// (`if (!_bReleased || ...) return;`), and for UseAnimation items the actual "eating in
|
||||
/// progress" duration comes from `AnimationDelayData.AnimationDelay[HoldType].RayCast` - a
|
||||
/// fixed-per-HoldType table that isn't exposed anywhere in Data/Config's XML at all (checked
|
||||
/// directly - no matches for "RayCast"/"AnimationDelay" in any vanilla XML), so `Delay` on a
|
||||
/// Class="Eat" item is really just a re-click COOLDOWN (how soon it can fire again), not a
|
||||
/// channel length. That's why it looked instant - the real channel was whatever HoldType 40's
|
||||
/// (the rock's) built-in eating-animation length happens to be, a couple seconds at most, not
|
||||
/// our intended 10.
|
||||
///
|
||||
/// Fix: stop trying to make Class="Eat" do a long channel at all. Instead, Prefix
|
||||
/// ItemActionEat.ExecuteAction itself (the exact click-release entry point, still using
|
||||
/// Class="Eat" in XML purely as "a clickable item action", nothing about its own timing is
|
||||
/// used any more) and, for our two stones, skip the original method entirely and open the
|
||||
/// game's own generic countdown-timer UI directly - XUiC_Timer.OpenTimer(xui, seconds,
|
||||
/// TimerEventData, ...), the exact same low-level primitive Block.TakeItemWithTimer itself
|
||||
/// calls for the workbench pickup timer (decompiled both to confirm - TakeItemWithTimer is
|
||||
/// just a block-flavored wrapper around this same generic UI system, nothing block-specific
|
||||
/// about the timer itself). This gives a REAL visible progress bar/percent-fill UI (confirmed
|
||||
/// via XUiC_Timer's own "percent"/"timeleft" bindings) for the full 10 seconds, and
|
||||
/// TimerEventData.CloseOnHit=true makes it cancel automatically if the player takes damage
|
||||
/// mid-channel (a real engine feature, not something built by hand) - matching "прерывается
|
||||
/// при получении урона" without any extra code. The actual teleport only runs from
|
||||
/// FullTimeFinishEvent, i.e. only if the timer runs all the way to completion.
|
||||
///
|
||||
/// Local-player-only, like every other UI-touching thing in this mod (SummonPatch.cs's
|
||||
/// tooltips, etc.) - the underlying XUi/LocalPlayerUI system this timer needs is a
|
||||
/// client-side-only concept, not something that makes sense for a remote player in this mod's
|
||||
/// existing (single-player-focused) design.
|
||||
///
|
||||
/// POWER-ATTACK CANCEL added 2026-08-29 (user request: "прервать кнопкой силовой атаки" - a
|
||||
/// zombie could jump the player mid-channel and they want an explicit escape, not just
|
||||
/// CloseOnHit's "already got hit" reaction). Confirmed a cancelled channel never teleports
|
||||
/// either way - closing the timer window early (Escape/CloseOnHit/this) fires
|
||||
/// TimerEventData.CloseEvent, not FullTimeFinishEvent, and TeleportToBedroll only ever runs
|
||||
/// from the latter (see OnChannelComplete below) - so "cancel = no teleport" was already true
|
||||
/// by construction, just needed a new way to trigger a cancel.
|
||||
/// vanilla's own TimerEventData.CancelWithActivateButton (already set true above) only checks
|
||||
/// PlayerActionsPermanent.Activate (decompiled XUiC_Timer.Update to confirm) - a small
|
||||
/// always-live action set TFP built specifically to stay readable during modal UI, which does
|
||||
/// NOT include Secondary/power-attack at all (checked its full field list). Rather than
|
||||
/// hijack Activate (the same key that STARTS the channel) or Cancel (Escape, not the button
|
||||
/// asked for), Patch_XUiC_Timer_Update_PortalStoneCancel below Postfixes XUiC_Timer.Update
|
||||
/// itself and polls PlayerActionsLocal.Secondary.WasPressed directly (the same underlying
|
||||
/// action already used elsewhere in this mod as "power attack", e.g. summon books' Action1)
|
||||
/// - NOT decompiled-confirmed whether this action still registers while the timer's modal
|
||||
/// window has input focus (SetControllable(false) fires on open, decompiled from XUiC_Timer,
|
||||
/// but that's a character/gameplay-layer flag, separate from the InControl input-polling
|
||||
/// layer PlayerAction reads from - the two are believed independent, not proven end-to-end).
|
||||
/// Test in-game; if power attack doesn't register while the bar is up, that gap is the first
|
||||
/// thing to dig into (possibly needs reading raw InControl device state instead of the
|
||||
/// semantic PlayerAction).
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")]
|
||||
public static class Patch_ItemActionEat_ExecuteAction_PortalStones
|
||||
{
|
||||
public const string BlueStoneName = "thrownStonePortalBlue";
|
||||
public const string BlackStoneName = "thrownStonePortalBlack";
|
||||
public const float ChannelSeconds = 10f;
|
||||
|
||||
/// <summary>Sentinel stashed in TimerEventData.Data (an unused generic object field on
|
||||
/// vanilla's own class) purely so Patch_XUiC_Timer_Update_PortalStoneCancel below can
|
||||
/// tell "this is one of our portal-stone timers" apart from any other TimerEventData the
|
||||
/// engine or another mod might have open (e.g. a workstation pickup timer, BACKLOG.md
|
||||
/// item 9) - a reference-equality check on a private static object, nothing exposed or
|
||||
/// read by vanilla code.</summary>
|
||||
public static readonly object ChannelMarker = new object();
|
||||
|
||||
/// <summary>See buffs.xml - a marker/particle-carrier buff, added/removed directly by
|
||||
/// this file rather than by any buff-trigger vocabulary.</summary>
|
||||
public const string ChannelBuffName = "buffNecroPortalChannel";
|
||||
|
||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||
{
|
||||
if (!_bReleased)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
|
||||
if (itemName != BlueStoneName && itemName != BlackStoneName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player))
|
||||
{
|
||||
// Not the local player (e.g. an AI or remote entity somehow holding this) - let
|
||||
// vanilla Eat behavior run rather than silently doing nothing, same fallback
|
||||
// shape used elsewhere in this mod for the local-player-only simplification.
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel started for " + itemName + ", owner=" + player.entityId);
|
||||
// Played directly here since ItemActionEat's own Sound_start handling is skipped
|
||||
// entirely along with the rest of its ExecuteAction (see class comment) - a plain
|
||||
// XML Sound_start property on this item would never fire otherwise.
|
||||
player.PlayOneShot("swoosh");
|
||||
// Black/smoke particle swirl for the duration of the channel (user request
|
||||
// 2026-08-29) - see buffs.xml's buffNecroPortalChannel + ParticlePatch.cs (generalized
|
||||
// to handle a player-targeted buff, not just the two zombie-facing ones it already
|
||||
// had). A plain marker buff, added/removed directly here rather than through any
|
||||
// buff-trigger vocabulary, since there's no "for as long as this XUiC_Timer is open"
|
||||
// trigger to hang it off - this IS that lifecycle.
|
||||
player.Buffs.AddBuff(ChannelBuffName);
|
||||
|
||||
TimerEventData timerData = new TimerEventData
|
||||
{
|
||||
CloseOnHit = true,
|
||||
CancelWithActivateButton = true,
|
||||
Data = ChannelMarker,
|
||||
};
|
||||
timerData.FullTimeFinishEvent += delegate
|
||||
{
|
||||
OnChannelComplete(player, itemName);
|
||||
};
|
||||
// CloseEvent fires when the timer window closes WITHOUT completing (cancelled by
|
||||
// damage/power-attack/Cancel) - confirmed by decompiling XUiC_Timer.OnClose/
|
||||
// timeReachedNull: timeReachedNull sets skipCloseEvent=true around the completion
|
||||
// path specifically so CloseEvent does NOT also fire on a successful finish, only on
|
||||
// every other way the window can close. FullTimeFinishEvent and CloseEvent are
|
||||
// therefore mutually exclusive per channel - exactly "however it ends" from the
|
||||
// class-level comment.
|
||||
timerData.CloseEvent += delegate
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
|
||||
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||
};
|
||||
|
||||
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
XUiC_Timer.OpenTimer(playerUI.xui, ChannelSeconds, timerData, -1f, Localization.Get(labelKey));
|
||||
|
||||
// Skip ItemActionEat's own logic entirely for these two items - the click has been
|
||||
// fully handled by opening our own timer instead.
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void OnChannelComplete(EntityPlayerLocal player, string itemName)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
|
||||
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||
if (itemName == BlackStoneName)
|
||||
{
|
||||
ShowBlackPortalConfirmation(player);
|
||||
return;
|
||||
}
|
||||
TeleportToBedroll(player);
|
||||
}
|
||||
|
||||
/// <summary>Black portal confirmation + fullscreen video, user request 2026-08-30
|
||||
/// ("диалоговое окно... вы уверены... Если Да, то игра останавливается и проигрывается
|
||||
/// видео"). Real APIs, both decompiled directly:
|
||||
/// - XUiC_MessageBoxWindowGroup.ShowCustom(xui, title, text, icon, setupCallback, ...) -
|
||||
/// the same generic Yes/No popup vanilla itself uses (its own delete-item/disconnect
|
||||
/// confirmations, etc). ShowOkCancel/ShowConfirmCancel exist too but hardcode their
|
||||
/// button caption keys ("xuiOk"/"xuiCancel"/"btnConfirm") - ShowCustom's
|
||||
/// _setupCallback is the only variant that lets the two buttons be captioned
|
||||
/// "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys, confirmed
|
||||
/// against Data/Config/Localization.csv), matching the user's literal "да/нет"
|
||||
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/
|
||||
/// ShowConfirmCancel themselves use internally - Buttons[1] is left unused, same as
|
||||
/// vanilla's own 2-button dialogs.
|
||||
/// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets
|
||||
/// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check
|
||||
/// baked into vanilla itself, not a limitation added by this mod) - a deliberate,
|
||||
/// documented no-op in multiplayer rather than something silently broken.
|
||||
/// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same
|
||||
/// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use.
|
||||
/// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it
|
||||
/// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/
|
||||
/// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived
|
||||
/// already auto-closes on a bad file on its own, this is just a second, player-facing
|
||||
/// way out.
|
||||
///
|
||||
/// VIDEO FILE: per direct user instruction 2026-08-30 ("Пока файл видео замени
|
||||
/// заглушкой. Потом поставим нормальный"), Video/BlackPortal.webm is currently a COPY OF
|
||||
/// VANILLA'S OWN TFP_Intro.webm (from 7DaysToDie_Data/StreamingAssets/Video/), not real
|
||||
/// mod content - purely so the full dialog -> pause -> video -> unpause pipeline is
|
||||
/// genuinely testable end-to-end right now. Swap that one file for the real video later;
|
||||
/// nothing else needs to change (reuse the same filename, or update BlackPortalVideoPath
|
||||
/// below if the real file gets a different name).
|
||||
/// "@modfolder(NecromancerTome):..." is the exact mod-relative path syntax
|
||||
/// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to
|
||||
/// confirm - looks for "@modfolder(<mod name>):" and substitutes the mod's real
|
||||
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
|
||||
/// DisplayName).</summary>
|
||||
public const string BlackPortalVideoPath = "@modfolder(NecromancerTome):Video/BlackPortal.webm";
|
||||
|
||||
public static void ShowBlackPortalConfirmation(EntityPlayerLocal player)
|
||||
{
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
XUiC_MessageBoxWindowGroup.ShowCustom(
|
||||
playerUI.xui,
|
||||
Localization.Get("thrownStonePortalBlackConfirmTitle"),
|
||||
Localization.Get("thrownStonePortalBlackConfirmText"),
|
||||
"",
|
||||
delegate(XUiC_MessageBoxWindowGroup mb)
|
||||
{
|
||||
mb.Buttons[0].DefaultConfirm("xuiYes", delegate { ActivateBlackPortal(player); });
|
||||
mb.Buttons[2].DefaultCancel("xuiNo", null);
|
||||
},
|
||||
_openMainMenuOnClose: false,
|
||||
_modal: true,
|
||||
_buttonOnOutsideClick: -1,
|
||||
// Esc/outside-close counts as "No" - same convention ShowOkCancel/
|
||||
// ShowConfirmCancel themselves use for their own Cancel slot (index 2).
|
||||
_buttonOnExternalClose: 2);
|
||||
}
|
||||
|
||||
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
|
||||
/// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом
|
||||
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
|
||||
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
|
||||
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
|
||||
/// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора
|
||||
/// концовки на последнем слайде.
|
||||
///
|
||||
/// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и
|
||||
/// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath /
|
||||
/// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не
|
||||
/// используется и оставлена только как документация к разбору "@modfolder(...)" и
|
||||
/// XUiC_VideoPlayer.PlayVideo, на который FinalSlides ссылается.</summary>
|
||||
public static void ActivateBlackPortal(EntityPlayerLocal player)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
|
||||
FinalSlides.Begin(player);
|
||||
}
|
||||
|
||||
/// <summary>Прежняя концовка "сразу видео, потом главное меню". Больше ниоткуда не
|
||||
/// вызывается (см. ActivateBlackPortal выше) - оставлена целиком, потому что весь разбор
|
||||
/// Pause/PlayVideo/Disconnect в её комментариях остаётся актуальным и на неё ссылается
|
||||
/// FinalSlides. Удалять при следующей уборке, если так и не понадобится.</summary>
|
||||
public static void PlayBlackPortalVideoLegacy(EntityPlayerLocal player)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", pausing + playing video");
|
||||
GameManager.Instance.Pause(true);
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
VideoData videoData = new VideoData { url = BlackPortalVideoPath };
|
||||
XUiC_VideoPlayer.PlayVideo(playerUI.xui, videoData, true, delegate(bool skipped)
|
||||
{
|
||||
// EXIT TO MAIN MENU after the video, user request 2026-08-30 ("После видео нужно
|
||||
// выходить из игры в главное меню") - fires whether the video played to the end
|
||||
// or was skipped (Cancel key / a bad file), same as any other "the video is over"
|
||||
// outcome. GameManager.Instance.Disconnect() is not a guess - it's the EXACT same
|
||||
// call the real in-game ESC menu's own "Exit to Main Menu" button uses
|
||||
// (decompiled XUiC_InGameMenuWindow.exitGame/BtnExit_OnPressed to confirm: it's a
|
||||
// thin wrapper straight to this method). Handles everything a clean exit needs by
|
||||
// itself - closes modal windows, un-pauses (calls Pause(false) internally, so no
|
||||
// separate unpause call needed here), saves/shuts down the local server, and
|
||||
// returns to XUiC_MainMenu - not reinventing any of that by hand. Replaces the
|
||||
// earlier "thrownStonePortalBlackNotBound" tooltip placeholder entirely: with a
|
||||
// real exit-to-menu ending, staying in-game and showing a tooltip no longer makes
|
||||
// sense (BACKLOG.md item 6's "destination not decided" placeholder is now this
|
||||
// exit itself, not a tooltip).
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal video finished (skipped=" + skipped + "), exiting to main menu");
|
||||
GameManager.Instance.Disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>BedrollPos comes from EntityPlayer.PersistentPlayerData (decompiled - reads
|
||||
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
|
||||
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
|
||||
/// HasBedrollPos, confirmed by decompiling that class directly). +0.5 on x/z centers the
|
||||
/// block, +1 on y lifts the destination clear of the bedroll block itself - a reasonable
|
||||
/// guess at a safe landing offset, not a decompiled/confirmed "correct" one (the
|
||||
/// respawn-specific code that actually places a resurrected player likely does more
|
||||
/// ground-safety checking than this; worth revisiting if the stone ever drops the player
|
||||
/// inside a block). Teleport itself uses NetPackageTeleportPlayer, the exact same package
|
||||
/// ConsoleCmdTeleportsAbs.ExecuteTeleport (the real "teleportplayer" console command)
|
||||
/// uses - decompiled to confirm, not invented.</summary>
|
||||
public static void TeleportToBedroll(EntityPlayerLocal player)
|
||||
{
|
||||
PersistentPlayerData data = player.PersistentPlayerData;
|
||||
if (data == null || !data.HasBedrollPos)
|
||||
{
|
||||
GameManager.ShowTooltip(player, "thrownStonePortalBlueNoBedroll");
|
||||
Debug.LogWarning("[NecromancerTome] PortalStonePatch: owner=" + player.entityId + " has no bedroll set, can't teleport");
|
||||
return;
|
||||
}
|
||||
Vector3i bedrollPos = data.BedrollPos;
|
||||
Vector3 destination = new Vector3(bedrollPos.x + 0.5f, bedrollPos.y + 1f, bedrollPos.z + 0.5f);
|
||||
NetPackageTeleportPlayer package = NetPackageManager.GetPackage<NetPackageTeleportPlayer>().Setup(destination, null);
|
||||
package.ProcessPackage(GameManager.Instance.World, GameManager.Instance);
|
||||
player.PlayOneShot("spawnInStinger");
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: owner=" + player.entityId + " teleported to bedroll " + bedrollPos);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lets the power-attack ("Secondary") input cancel an in-progress portal-stone
|
||||
/// channel - see the long comment on Patch_ItemActionEat_ExecuteAction_PortalStones above for
|
||||
/// the full reasoning. Separate patch class/target method (XUiC_Timer.Update, not
|
||||
/// ItemActionEat.ExecuteAction) since this has to run every frame WHILE the timer is open, not
|
||||
/// once at click time.
|
||||
///
|
||||
/// FIXED 2026-08-29 (user report: cancel didn't work at all) - the semantic
|
||||
/// PlayerActionsLocal.Secondary check alone (first version) apparently never registered while
|
||||
/// the timer's modal window has input focus, confirming the exact risk flagged when this was
|
||||
/// first written. Root cause not fully pinned down by decompilation (XUiC_Timer.OnOpen sets
|
||||
/// SetControllable(false) on the player, and nothing found ties that flag directly to
|
||||
/// PlayerAction's own InControl polling layer - the two are presumed independent but the
|
||||
/// actual suppression point wasn't located). Rather than keep guessing which exact system
|
||||
/// swallows it, added a SECOND, independent check straight to Unity's raw
|
||||
/// Input.GetMouseButtonDown(1) (right mouse button - confirmed as Secondary's real default
|
||||
/// KBM binding by decompiling PlayerActionsLocal.CreateActions) - raw Input polling reads
|
||||
/// hardware state directly, bypassing InControl/PlayerAction and whatever gates it, so this
|
||||
/// should fire regardless of modal-window suppression. Either check firing cancels the
|
||||
/// channel; keeping the semantic one too costs nothing and covers gamepad Secondary
|
||||
/// (LeftTrigger) if that one turns out to work. KBM-only fallback - if a gamepad player still
|
||||
/// can't cancel, that's the next gap to close (would need the equivalent raw axis read).</summary>
|
||||
[HarmonyPatch(typeof(XUiC_Timer), "Update")]
|
||||
public static class Patch_XUiC_Timer_Update_PortalStoneCancel
|
||||
{
|
||||
public static void Postfix(XUiC_Timer __instance)
|
||||
{
|
||||
if (__instance == null || __instance.eventData == null || __instance.eventData.Data != Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelMarker)
|
||||
{
|
||||
return;
|
||||
}
|
||||
PlayerActionsLocal input = __instance.xui?.playerUI?.playerInput;
|
||||
bool cancelPressed = (input != null && input.Secondary.WasPressed) || Input.GetMouseButtonDown(1);
|
||||
if (cancelPressed)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled via power attack");
|
||||
__instance.xui.playerUI.windowManager.Close(__instance.windowGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// "Пирамида Ереси" (Pyramid of Heresy) - user request 2026-08-31, REWRITTEN 2026-09-01.
|
||||
///
|
||||
/// FIRST VERSION (see BACKLOG.md's original entry) used Harmony patches on plain Block's
|
||||
/// OnBlockAdded/OnBlockRemoved/GetBlockActivationCommands/OnBlockActivated, with all state in a
|
||||
/// static Dictionary keyed by block position. Two real problems came out of actually testing it:
|
||||
/// 1. USER REPORT: "Навожу прицел, но подсказка про E не появляется" - no E-prompt at all,
|
||||
/// pressing E did nothing. Root cause, decompiled: there's a SEPARATE gate method,
|
||||
/// `Block.HasBlockActivationCommands(WorldBase, BlockValue, Vector3i, EntityAlive)`, with
|
||||
/// its OWN independent logic (not calling GetBlockActivationCommands at all) that the
|
||||
/// game's HUD/input layer checks FIRST to decide whether to even show the prompt. It was
|
||||
/// never patched, and for a plain decorative block it always returns false (no
|
||||
/// CanPickup, no CustomCmds) - so the prompt correctly never appeared, and E correctly did
|
||||
/// nothing, regardless of how correct the other three patches were.
|
||||
/// 2. USER REQUEST: "Сделай TileEntity" - wants EffectOn/ZoneShown to actually survive a
|
||||
/// save/reload, which the static-Dictionary version explicitly could not do (documented as
|
||||
/// a known caveat at the time).
|
||||
///
|
||||
/// Rather than patch a fourth Block method, this is a full rewrite onto the real, sanctioned
|
||||
/// extension point for exactly this situation - the same one vanilla's own Land Claim block
|
||||
/// uses: a CompositeTileEntity feature. Confirmed by decompiling the actual chain, not guessed:
|
||||
/// - `TEFeatureAbs` (the real base class - `TEFeatureLandClaim : TEFeatureAbs`, decompiled to
|
||||
/// confirm) already declares virtual OnAdded/OnRemove/UpdateTick/Read/Write/
|
||||
/// InitBlockActivationCommands/AllowBlockActivationCommand/OnBlockActivated - literally
|
||||
/// every hook this feature needs, with NO separate "HasBlockActivationCommands" gap: that
|
||||
/// whole problem belongs to plain Block's activation path, not this one.
|
||||
/// - `BlockCompositeTileEntity` (the Class="CompositeTileEntity" block class - decompiled
|
||||
/// directly) correctly overrides HasBlockActivationCommands/GetBlockActivationCommands/
|
||||
/// OnBlockActivated itself and wires them through `TileEntityComposite`/each feature - this
|
||||
/// is the ALREADY-WORKING pipeline every vanilla composite block (Land Claim included) has
|
||||
/// used for years; not new engineering, just finally the right entry point.
|
||||
/// - Feature discovery is NOT a hardcoded switch (unlike raw `TileEntityType`/
|
||||
/// `TileEntity.InstantiateFromRead`, which genuinely IS a closed hardcoded enum switch with
|
||||
/// no mod slot - checked this first and ruled it out for exactly that reason).
|
||||
/// `TileEntityCompositeData.Init()` (decompiled) calls
|
||||
/// `ReflectionHelpers.FindTypesImplementingBase(typeof(ITileEntityFeature), ...)` and keys
|
||||
/// the result by `_type.Name` (the short type name, NAMESPACE-INDEPENDENT - confirmed by
|
||||
/// reading the exact line) - so `TEFeaturePyramidWard` below is found automatically by the
|
||||
/// engine's own startup scan of every loaded assembly (including this mod's DLL) purely by
|
||||
/// matching that literal class name against blocks.xml's own
|
||||
/// `<property class="TEFeaturePyramidWard" />` - the same mechanism vanilla's own
|
||||
/// TEFeatureLandClaim/TEFeatureStorage/TEFeatureAreaRepair (see keystoneBlock) already rely
|
||||
/// on. Only real requirement (also confirmed by decompile, `TileEntityCompositeData.Init`
|
||||
/// warns and skips otherwise): a public, non-abstract class with a parameterless
|
||||
/// constructor - both true here without writing one explicitly.
|
||||
/// - Activation command TEXT is a real constraint worth noting: `BlockCompositeTileEntity`
|
||||
/// caches its `BlockActivationCommand[]` PER BLOCK TYPE (a field on the Block instance
|
||||
/// itself, shared by every placed pyramid), rebuilt once from InitBlockActivationCommands
|
||||
/// and never again - only `.enabled` gets refreshed per-activation (via
|
||||
/// AllowBlockActivationCommand). So button TEXT can't dynamically say "Enable"/"Disable"
|
||||
/// per-instance; the real vanilla pattern (confirmed in TEFeatureLandClaim's own
|
||||
/// show_bounds/hide_bounds pair) is to register BOTH command variants up front and only
|
||||
/// ENABLE whichever one currently applies - copied exactly here for effect_on/effect_off
|
||||
/// and zone_show/zone_hide.
|
||||
/// - Command display text is resolved via `Localization.Get("blockcommand_" + fullCommandName)`
|
||||
/// (confirmed by finding vanilla's own `blockcommand_show_bounds`/
|
||||
/// `blockcommand_TEFeatureLandClaim:show_bounds` keys in Data/Config/Localization.csv) -
|
||||
/// NOT pre-resolved text passed directly to BlockActivationCommand's constructor (the
|
||||
/// earlier version's mistake). See this mod's own Localization.csv for the
|
||||
/// `blockcommand_TEFeaturePyramidWard:*` keys this relies on.
|
||||
///
|
||||
/// NOT a Harmony patch, despite the filename/this mod's usual convention and despite still
|
||||
/// living in HarmonySrc/ for continuity with the rest of this mod's file layout - nothing here
|
||||
/// patches anything. `Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName` (CharmPatch.cs) is
|
||||
/// still reused as-is for the actual charm effect; that patch is untouched by this rewrite.
|
||||
/// </summary>
|
||||
public class TEFeaturePyramidWard : TEFeatureAbs
|
||||
{
|
||||
/// <summary>How far out (in blocks/meters) the ward reaches. Not specified by the user -
|
||||
/// picked to roughly cover a small base perimeter, same ballpark as vanilla's own land
|
||||
/// claim radius. Easy to retune, just one constant.</summary>
|
||||
public const float EffectRadius = 15f;
|
||||
|
||||
/// <summary>CHANGED 2026-09-02 (user report: "Никаких частиц на включённом состоянии не
|
||||
/// летает" - literally nothing spawned, at all, neither the main glow nor the zone ring).
|
||||
/// Root cause: "RadiatedParticlesOnMesh" is loaded/played through a completely DIFFERENT
|
||||
/// mechanism than the one this file actually calls. Decompiled `ParticleEffect.LoadResources()`
|
||||
/// (the loader behind `GameManager.SpawnBlockParticleEffect`/`new ParticleEffect(string,...)`,
|
||||
/// which this file uses): it bulk-loads addressables from the "particleeffects" group whose
|
||||
/// FIRST FOLDER SEGMENT starts with "p_", then keys each loaded prefab into a dictionary by
|
||||
/// its own filename via `ToId(name)`. "RadiatedParticlesOnMesh" is referenced elsewhere in
|
||||
/// this mod (buffs.xml's `AttachParticleEffectToEntity`) via the literal path
|
||||
/// "ParticleEffects/RadiatedParticlesOnMesh" - no "p_"-prefixed folder anywhere in that path,
|
||||
/// meaning it almost certainly never gets bulk-loaded into that same lookup dictionary at all
|
||||
/// (that XML action resolves its own particle reference through an entirely separate,
|
||||
/// direct-path mechanism, not this bulk-addressables-by-folder-prefix one) - so
|
||||
/// `GetDynamicTransform`/`ToId` lookups for it here would always silently fail (logged as
|
||||
/// "Unknown particle effect", nothing spawned) - exactly matching what got reported. Switched
|
||||
/// to "campfire" instead - confirmed loadable through THIS exact code path already (it's
|
||||
/// vanilla's own `<property name="ParticleName" value="campfire" />` on the real campfire
|
||||
/// block, going through the same GameManager block-particle registry) - and it happens to
|
||||
/// double as the user's other request ("фиолетовое пламя, будто блок горит холодным
|
||||
/// пламенем") almost for free: a real fire effect, tinted purple by ApplyGlowTint below
|
||||
/// instead of its natural orange.</summary>
|
||||
public const string GlowParticleName = "campfire";
|
||||
|
||||
/// <summary>CHANGED 2026-09-02 (user, after seeing the fire-ring in-game: "Границу лучше
|
||||
/// показывать не огнём, а какими-нибудь частицами" - reversed their earlier "оставим так"
|
||||
/// once they'd actually seen it). Only real vanilla `ParticleName` values confirmed to exist
|
||||
/// at all (grepped every one in Data/Config/blocks.xml - the same property this whole
|
||||
/// mechanism is built on): ember_pile/hotembers/campfire/forgeWorkstation/chemistryStation/
|
||||
/// flame_hazard - every single one of them is fire/ember/industrial-themed, there is no
|
||||
/// confirmed "generic sparkle/magic" particle name to fall back on. Picked
|
||||
/// "chemistryStation" specifically because it's the one NOT visually built around an open
|
||||
/// flame (a chemistry set's bubbling/vapor effect) - best available guess from a short list,
|
||||
/// not a confirmed-good look; say if it still reads wrong once seen; it tints purple the
|
||||
/// same way as everything else here regardless of its native color.</summary>
|
||||
public const string ZoneRingParticleName = "chemistryStation";
|
||||
|
||||
/// <summary>Purple, per the user's explicit request ("окрашивается фиолетовым" for the
|
||||
/// zone, "светится фиолетовыми частицами"/"холодным пламенем" for the effect glow) - same
|
||||
/// color used for both.</summary>
|
||||
public static readonly Color WardTint = new Color(0.6f, 0.15f, 0.95f);
|
||||
|
||||
/// <summary>Dropped from 32 - a full-size effect at every ring point would be both visually
|
||||
/// overwhelming and comparatively expensive; 16 small markers still reads clearly as a
|
||||
/// circle at EffectRadius=15.</summary>
|
||||
public const int ZoneRingPointCount = 16;
|
||||
|
||||
/// <summary>ADDED 2026-09-02, direct user request ("Можешь накладывать кроме девиации ещё
|
||||
/// и дебаф горения?"). This is the real vanilla "a zombie is on fire" buff (Data/Config/
|
||||
/// buffs.xml - `damage_type="heat"`, cascades into `buffBurningElement`'s own 10s countdown/
|
||||
/// damage-over-time/AddBuff(buffIsOnFire) chain, the same one torches/molotovs/fire traps
|
||||
/// trigger), not a new buff invented for this mod. Re-applied every scan tick (not
|
||||
/// gated behind "already has it" like the charm below) since `buffBurningElement` itself
|
||||
/// resets its own countdown on every re-trigger (`stack_type="replace"`) - the intent is
|
||||
/// "keeps burning the whole time it's in the zone", not "burns once".
|
||||
///
|
||||
/// Needs a REAL instigator entity id, unlike the charm buff: `EntityBuffs.AddBuff` (decompiled)
|
||||
/// checks `buff.DamageType != None && ... && !FriendlyFireCheck(instigator)` and fails the
|
||||
/// whole call outright if that trips - a buff with a real damage_type (this one has "heat";
|
||||
/// buffNecroDeviatorCharm has none, which is why it never needed this) requires a
|
||||
/// non-null/valid instigator that FriendlyFireCheck accepts, or the call can fail. Passed the
|
||||
/// in-zone player's own entityId (already resolved above for the player-presence gate) -
|
||||
/// matches the fictional framing anyway (the necromancer is the one wielding this ward).</summary>
|
||||
public const string BurnBuffName = "buffBurningZombie";
|
||||
|
||||
/// <summary>Persisted (see Read/Write below) - real per-instance state now, one pyramid's
|
||||
/// toggle no longer affects any other's, and both survive a save/reload.</summary>
|
||||
public bool EffectOn = true;
|
||||
|
||||
public bool ZoneShown;
|
||||
|
||||
/// <summary>Glow/ring particle keys queued via SpawnBlockParticleEffect but not tinted yet -
|
||||
/// GameManager.updateBlockParticles() only processes its spawn queue once per frame
|
||||
/// (decompiled to confirm), so tinting has to be deferred at least one tick rather than done
|
||||
/// inline right after spawning. Instance-level now (was a shared static list in the old
|
||||
/// version) - each pyramid only tracks its own pending keys.</summary>
|
||||
public readonly List<Vector3i> pendingTint = new List<Vector3i>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lifecycle.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public override void CopyFromInternal(TileEntityComposite _other)
|
||||
{
|
||||
if (_other.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard other))
|
||||
{
|
||||
EffectOn = other.EffectOn;
|
||||
ZoneShown = other.ZoneShown;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnAdded(Vector3i _blockPos, BlockValue _blockValue)
|
||||
{
|
||||
base.OnAdded(_blockPos, _blockValue);
|
||||
if (EffectOn)
|
||||
{
|
||||
SpawnGlow();
|
||||
}
|
||||
if (ZoneShown)
|
||||
{
|
||||
SpawnZoneRing();
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnRemove(World _world)
|
||||
{
|
||||
base.OnRemove(_world);
|
||||
if (ZoneShown)
|
||||
{
|
||||
RemoveZoneRing();
|
||||
}
|
||||
RemoveGlow();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Persistence - real save/load now, per the user's direct request.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Routed through PyramidWardWriteHelper.Write (TEPersistenceSrc/, a separate
|
||||
/// satellite project+DLL) rather than calling PooledBinaryWriter.Write directly - that call
|
||||
/// does not compile from THIS project at all. Real, decompile/compiler-confirmed reason, not
|
||||
/// a style choice: see NecromancerTEPersistence.csproj's own comment for the full story
|
||||
/// (short version: Assembly-CSharp.dll's Write overload set includes a
|
||||
/// ReadOnlySpan<byte> variant that only resolves against Unity/Mono's own mscorlib,
|
||||
/// which conflicts with this project's UnityEngine-type usage everywhere else if referenced
|
||||
/// directly here - isolating the one call that needs it into its own tiny project was the
|
||||
/// only combination found that keeps both working). No version byte (kept simple per the
|
||||
/// user's "как проще" - this is a brand-new feature, nothing to migrate from yet;
|
||||
/// PooledBinaryReader.ReadBoolean() below has no such compile restriction, confirmed
|
||||
/// separately, so Read() needs no equivalent workaround.</summary>
|
||||
public override void Write(PooledBinaryWriter _bw, TileEntity.StreamModeWrite _eStreamMode)
|
||||
{
|
||||
base.Write(_bw, _eStreamMode);
|
||||
PyramidWardWriteHelper.Write(_bw, EffectOn, ZoneShown);
|
||||
}
|
||||
|
||||
public override void Read(PooledBinaryReader _br, TileEntity.StreamModeRead _eStreamMode)
|
||||
{
|
||||
base.Read(_br, _eStreamMode);
|
||||
EffectOn = _br.ReadBoolean();
|
||||
ZoneShown = _br.ReadBoolean();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// E-menu (activation commands) - see class doc comment for why both states of each
|
||||
// toggle are registered up front rather than swapping text dynamically.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Icons FIXED 2026-09-02 (user report: "на 'Отключить эффект' нету иконки").
|
||||
/// "ui_game_symbol_zombie"/"hand" (the first version's guesses) were never real
|
||||
/// BlockActivationCommand icon names - that field takes a small closed set of simple
|
||||
/// glyph-font names, NOT full UI sprite-atlas names (confirmed by harvesting every real
|
||||
/// `new BlockActivationCommand(...)` call across every other TEFeature class decompiled so
|
||||
/// far: "frames"/"x" (TEFeatureLandClaim), "door" (TEFeatureDoor), "lock"/"unlock"/"keypad"
|
||||
/// (TEFeatureLockable), "search" (TEFeatureStorage), "wrench" (the trigger command on
|
||||
/// TileEntityComposite itself)). Reused two of those real, confirmed names instead of
|
||||
/// guessing again: "unlock"/"lock" for effect on/off (a locked/unlocked padlock reads fine
|
||||
/// as "active"/"inactive"), and "frames" - literally the SAME icon vanilla's own Land Claim
|
||||
/// uses for its own show_bounds/hide_bounds pair - for our own zone_show/zone_hide, since
|
||||
/// it's the exact same kind of toggle.</summary>
|
||||
public override void InitBlockActivationCommands(Action<BlockActivationCommand, TileEntityComposite.EBlockCommandOrder, TileEntityFeatureData> _addCallback)
|
||||
{
|
||||
base.InitBlockActivationCommands(_addCallback);
|
||||
_addCallback(new BlockActivationCommand("effect_on", "unlock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
_addCallback(new BlockActivationCommand("effect_off", "lock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
_addCallback(new BlockActivationCommand("zone_show", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
_addCallback(new BlockActivationCommand("zone_hide", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
}
|
||||
|
||||
/// <summary>ADDED 2026-09-02 (user report: "при наведении нет никакой надписи-подсказки про
|
||||
/// E"). TEFeatureAbs.GetActivationText was never overridden at all, so it fell through to
|
||||
/// the base's default `return null` - no ReadOnlySpan in this method's signature (confirmed
|
||||
/// by decompile), so unlike AllowBlockActivationCommand/OnBlockActivated below, this one
|
||||
/// overrides cleanly with no workaround needed. Mirrors TEFeatureLandClaim's own
|
||||
/// GetActivationText shape (`_activateHotkeyMarkup` + the block's own localized name) -
|
||||
/// same real, decompiled API, not guessed.</summary>
|
||||
public override string GetActivationText(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityAlive _entityFocusing, string _activateHotkeyMarkup, string _focusedTileEntityName)
|
||||
{
|
||||
base.GetActivationText(_world, _blockPos, _blockValue, _entityFocusing, _activateHotkeyMarkup, _focusedTileEntityName);
|
||||
return _activateHotkeyMarkup + " " + _blockValue.Block.GetLocalizedBlockName();
|
||||
}
|
||||
|
||||
// AllowBlockActivationCommand/OnBlockActivated deliberately NOT overridden here - see the
|
||||
// long comment block below (right above the two Harmony patches that replace them) for why
|
||||
// this specific pair of TEFeatureAbs virtuals cannot be overridden from this mod's project
|
||||
// at all, and how the same behavior is achieved instead.
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Visuals - same GameManager block-particle registry as the first version, just called on
|
||||
// `ToWorldPos()` (this feature's own position) instead of a dictionary-passed key.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public void SpawnGlow()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i pos = ToWorldPos();
|
||||
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// FIXED 2026-09-02 (user report: "горение пирамидки почему-то смещено на куб вверх и
|
||||
// вбок"): World.blockToTransformPos(Vector3i) ALREADY returns (x+0.5, y, z+0.5)
|
||||
// (decompiled to confirm - horizontally centered, y left raw/un-centered) - adding
|
||||
// another +0.5 on x/z on top of that (the original bug) double-centered it, landing a
|
||||
// full extra block over on both horizontal axes. Only the vertical lift (how far above
|
||||
// the block the flame sits) is actually ours to add. Height LOWERED again same day
|
||||
// ("сделай ниже не 2/3 куба" - after the sideways offset was gone, still sat too high):
|
||||
// 1.2 (0.2 above the full block top) -> 0.6, under 2/3 (0.667) of a block as asked.
|
||||
// Height lowered twice same day: 1.2 -> 0.6 ("сделай ниже не 2/3 куба"), then -> 0.4
|
||||
// (direct follow-up: "Снизь высоту пламени до +0.4").
|
||||
Vector3 worldPos = World.blockToTransformPos(pos) + new Vector3(0f, 0.4f, 0f);
|
||||
// WardTint (not Color.white) as the ParticleEffect's own _color: ParticleEffect.
|
||||
// SpawnParticleEffect applies this directly to any non-ParticleSystem Renderer on the
|
||||
// prefab (decompiled to confirm) - covers a sub-emitter/glow sprite ApplyGlowTint's own
|
||||
// ParticleSystem-only loop wouldn't reach, belt-and-suspenders alongside it.
|
||||
GameManager.Instance.SpawnBlockParticleEffect(pos, new ParticleEffect(GlowParticleName, worldPos, Quaternion.identity, 0f, WardTint));
|
||||
pendingTint.Add(pos);
|
||||
}
|
||||
|
||||
public void RemoveGlow()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i pos = ToWorldPos();
|
||||
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||
{
|
||||
GameManager.Instance.RemoveBlockParticleEffect(pos);
|
||||
}
|
||||
pendingTint.Remove(pos);
|
||||
}
|
||||
|
||||
/// <summary>Ring of glow points marking EffectRadius, keyed by y = -1000-i (real block
|
||||
/// y-coordinates never go negative that far, so these keys can never collide with an
|
||||
/// actual placed block's own glow key).</summary>
|
||||
public void SpawnZoneRing()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i basePos = ToWorldPos();
|
||||
// Same double-centering bug as SpawnGlow's own fix above - blockToTransformPos already
|
||||
// centers x/z, only the vertical offset (0.5, mid-block height) is ours to add.
|
||||
Vector3 center = World.blockToTransformPos(basePos) + new Vector3(0f, 0.5f, 0f);
|
||||
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||
{
|
||||
float angle = i * (360f / ZoneRingPointCount) * Mathf.Deg2Rad;
|
||||
Vector3 point = center + new Vector3(Mathf.Cos(angle) * EffectRadius, 0f, Mathf.Sin(angle) * EffectRadius);
|
||||
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ZoneRingParticleName (not GlowParticleName) - see that constant's own comment:
|
||||
// user asked for the boundary to read as "some particles", not literal fire.
|
||||
GameManager.Instance.SpawnBlockParticleEffect(key, new ParticleEffect(ZoneRingParticleName, point, Quaternion.identity, 0f, WardTint));
|
||||
pendingTint.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveZoneRing()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i basePos = ToWorldPos();
|
||||
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||
{
|
||||
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
GameManager.Instance.RemoveBlockParticleEffect(key);
|
||||
}
|
||||
pendingTint.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors ParticlePatch.cs's own tint technique (same ParticleSystem.MainModule
|
||||
/// fields, same reasoning: the particle prefab always instantiates at its authored color,
|
||||
/// nothing in XML/the block-particle API can override that) - kept as its own copy here
|
||||
/// rather than refactoring ParticlePatch.cs itself, so this feature can't regress the
|
||||
/// already-working zombie glow if something about this call site needs different handling
|
||||
/// once tested in-game.
|
||||
///
|
||||
/// EXTENDED 2026-09-02 for "campfire" (see GlowParticleName's own comment for why the
|
||||
/// particle changed) with Gradient/TwoGradients handling - ParticlePatch.cs's original
|
||||
/// only ever needed Color/TwoColors (RadiatedParticlesOnMesh's own authored mode) and
|
||||
/// explicitly left Gradient/TwoGradients alone as "no generic way to override". That's not
|
||||
/// actually true - a Gradient's color keys ARE reassignable at runtime via
|
||||
/// `Gradient.SetKeys` - so it's handled here now, since a real fire effect plausibly
|
||||
/// animates through multiple colors (yellow-orange-red-smoke) via an actual Gradient rather
|
||||
/// than one flat color, and the request is specifically "холодным пламенем" (COLD flame) -
|
||||
/// if this branch never actually runs because campfire turns out to use plain Color/
|
||||
/// TwoColors after all, no harm, the two branches above still cover it.</summary>
|
||||
public static void ApplyGlowTint(Transform particleTransform, float sizeFactor, float alphaFactor)
|
||||
{
|
||||
if (particleTransform == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
particleTransform.localScale = Vector3.one * sizeFactor;
|
||||
ParticleSystem[] systems = particleTransform.GetComponentsInChildren<ParticleSystem>(true);
|
||||
foreach (ParticleSystem ps in systems)
|
||||
{
|
||||
ParticleSystem.MainModule main = ps.main;
|
||||
main.startSizeMultiplier *= sizeFactor;
|
||||
ParticleSystem.MinMaxGradient startColor = main.startColor;
|
||||
switch (startColor.mode)
|
||||
{
|
||||
case ParticleSystemGradientMode.Color:
|
||||
{
|
||||
Color c = WardTint;
|
||||
c.a = startColor.color.a * alphaFactor;
|
||||
startColor.color = c;
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
}
|
||||
case ParticleSystemGradientMode.TwoColors:
|
||||
{
|
||||
Color min = WardTint;
|
||||
Color max = WardTint;
|
||||
min.a = startColor.colorMin.a * alphaFactor;
|
||||
max.a = startColor.colorMax.a * alphaFactor;
|
||||
startColor.colorMin = min;
|
||||
startColor.colorMax = max;
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
}
|
||||
case ParticleSystemGradientMode.Gradient:
|
||||
startColor.gradient = TintGradient(startColor.gradient, alphaFactor);
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
case ParticleSystemGradientMode.TwoGradients:
|
||||
startColor.gradientMin = TintGradient(startColor.gradientMin, alphaFactor);
|
||||
startColor.gradientMax = TintGradient(startColor.gradientMax, alphaFactor);
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders for any sub-emitter/light-flicker Renderer that isn't a
|
||||
// ParticleSystem itself - SpawnGlow/SpawnZoneRing already pass WardTint as the
|
||||
// ParticleEffect's own _color (which ParticleEffect.SpawnParticleEffect applies to
|
||||
// exactly this kind of non-ParticleSystem Renderer automatically), this loop only
|
||||
// covers the ParticleSystem-driven part. A real-time Light component (if "campfire"
|
||||
// has one for dynamic scene lighting) is NOT touched by either mechanism - if the
|
||||
// flame reads purple but still casts an orange glow on nearby surfaces, that's why,
|
||||
// and would need its own separate fix once actually seen in-game.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds a Gradient with every color key replaced by WardTint, keeping the
|
||||
/// original alpha keys (and their timing) intact so the fade-in/fade-out shape of the
|
||||
/// effect is preserved - only the color changes, not the timing/opacity curve.</summary>
|
||||
public static Gradient TintGradient(Gradient original, float alphaFactor)
|
||||
{
|
||||
Gradient g = new Gradient();
|
||||
GradientAlphaKey[] alphaKeys = original != null ? original.alphaKeys : new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f) };
|
||||
for (int i = 0; i < alphaKeys.Length; i++)
|
||||
{
|
||||
alphaKeys[i].alpha *= alphaFactor;
|
||||
}
|
||||
GradientColorKey[] colorKeys = new GradientColorKey[] { new GradientColorKey(WardTint, 0f), new GradientColorKey(WardTint, 1f) };
|
||||
g.SetKeys(colorKeys, alphaKeys);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-tick: deferred tint + charm/burn scan. Replaces the first version's global
|
||||
// ModEvents.UnityUpdate handler + static Dictionary loop entirely - each pyramid now ticks
|
||||
// itself via this real per-feature hook, called directly by the engine (no throttle of our
|
||||
// own - see the comment on `center` below for why a self-imposed one is actively wrong here).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public override void UpdateTick(World _world)
|
||||
{
|
||||
base.UpdateTick(_world);
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = pendingTint.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Vector3i key = pendingTint[i];
|
||||
if (!GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Transform t = GameManager.Instance.GetBlockParticleEffect(key);
|
||||
bool isRingPoint = key.y <= -1000;
|
||||
// 1.0 = natural "campfire" size for the main glow; ring markers shrunk hard (0.35)
|
||||
// so the 16 of them read as small flame-markers instead of a circle of bonfires.
|
||||
// Near-opaque alpha (0.9) so the purple tint reads clearly.
|
||||
ApplyGlowTint(t, isRingPoint ? 0.35f : 1.0f, 0.9f);
|
||||
pendingTint.RemoveAt(i);
|
||||
}
|
||||
|
||||
if (!EffectOn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3i posI = ToWorldPos();
|
||||
// World.blockToTransformPos already returns X/Z centered on the block (confirmed by
|
||||
// decompile) - only the vertical lift is ours to add. (A past version double-added the
|
||||
// X/Z centering here, offsetting the whole detection circle by about a block - fixed.)
|
||||
Vector3 center = World.blockToTransformPos(posI) + new Vector3(0f, 0.5f, 0f);
|
||||
// Horizontal-only (X/Z) distance, ignoring Y: the zone-ring visual is a flat disc at one
|
||||
// height, so the real detection area is a matching vertical column, not a shrinking
|
||||
// sphere - also just more useful for a base with any stairs/floors. Bounds query
|
||||
// widened vertically (256 = full world height) since the real filter below doesn't
|
||||
// restrict Y at all.
|
||||
Bounds bounds = new Bounds(center, new Vector3(EffectRadius * 2f, 256f, EffectRadius * 2f));
|
||||
|
||||
// Skip the (more expensive) zombie query/loop entirely unless a player is actually in
|
||||
// range - also doubles as the burning debuff's required instigator id (a buff with a
|
||||
// real damage_type, unlike the charm, needs one or EntityBuffs.AddBuff fails its
|
||||
// FriendlyFireCheck outright - see BurnBuffName's own comment).
|
||||
List<Entity> playersNearby = new List<Entity>();
|
||||
_world.GetEntitiesInBounds(typeof(EntityPlayer), bounds, playersNearby);
|
||||
int playerInstigatorId = -1;
|
||||
foreach (Entity p in playersNearby)
|
||||
{
|
||||
float pdx = p.position.x - center.x;
|
||||
float pdz = p.position.z - center.z;
|
||||
if (pdx * pdx + pdz * pdz <= EffectRadius * EffectRadius)
|
||||
{
|
||||
playerInstigatorId = p.entityId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (playerInstigatorId == -1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<Entity> nearby = new List<Entity>();
|
||||
_world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby);
|
||||
foreach (Entity e in nearby)
|
||||
{
|
||||
if (!(e is EntityZombie zombie) || zombie.IsDead() || zombie.Buffs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dx = zombie.position.x - center.x;
|
||||
float dz = zombie.position.z - center.z;
|
||||
if (dx * dx + dz * dz > EffectRadius * EffectRadius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
|
||||
{
|
||||
zombie.Buffs.AddBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
|
||||
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: charmed zombie " + zombie.entityId + " near pyramid " + posI);
|
||||
}
|
||||
// Burning re-applied every tick a zombie is in the zone (not gated behind "already
|
||||
// has it" like the charm above) - buffBurningElement resets its own countdown on
|
||||
// every re-trigger, so this keeps it topped up rather than a one-shot.
|
||||
zombie.Buffs.AddBuff(BurnBuffName, playerInstigatorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real, verified-by-compiler blocker found while writing TEFeaturePyramidWard above:
|
||||
/// TEFeatureAbs.AllowBlockActivationCommand and TEFeatureAbs.OnBlockActivated cannot be
|
||||
/// overridden from this mod's project AT ALL, on any target framework tried (netstandard2.1
|
||||
/// AND net8.0 both fail identically, confirmed with an isolated throwaway repro project - this
|
||||
/// is not a langversion/TargetFramework setting to tune away). Root cause, found by dumping raw
|
||||
/// IL (`ilspycmd -il`): Assembly-CSharp.dll declares these two methods' shared parameter type as
|
||||
/// `valuetype [mscorlib]System.ReadOnlySpan\`1<char>` - i.e. Unity's own Mono/IL2CPP
|
||||
/// runtime backports Span/ReadOnlySpan INTO mscorlib itself, unlike a normal modern .NET SDK
|
||||
/// project (this mod's own csproj included), where ReadOnlySpan<T> instead lives in
|
||||
/// System.Private.CoreLib/System.Memory. Same type NAME, but the CLR treats a type's identity
|
||||
/// as (name + DECLARING ASSEMBLY) - these are two different types to the compiler, so an
|
||||
/// override that looks byte-for-byte identical in source (confirmed via a live reflection probe
|
||||
/// against the real DLL, not just the decompiled source) still fails to bind as a valid
|
||||
/// override. The only real fix on our side would be adding an explicit reference to the game's
|
||||
/// own Managed/mscorlib.dll so our ReadOnlySpan<char> resolves from the same assembly -
|
||||
/// not attempted, since forcing a second mscorlib into this project risks colliding with every
|
||||
/// other basic type (string, object, List<T>...) the SDK's own implicit framework
|
||||
/// reference already provides, for every file in this mod, not just this one method pair. Not
|
||||
/// worth that blast radius for two methods this patch below covers just as well anyway.
|
||||
///
|
||||
/// WORKAROUND: everything both blocked methods needed to do is instead done one layer up, on
|
||||
/// the STRING/array-based (no ReadOnlySpan anywhere) methods that wrap them:
|
||||
/// - `TileEntityComposite.UpdateBlockActivationCommands(BlockActivationCommand[], ...)` -
|
||||
/// confirmed by decompile to run AFTER every feature's (unoverridden, always-true-by-
|
||||
/// default) AllowBlockActivationCommand, so a Postfix here can simply overwrite `.enabled`
|
||||
/// for our 4 known commands with the real per-instance answer - same end result.
|
||||
/// - `BlockCompositeTileEntity.OnBlockActivated(string _commandName, ...)` - the SAME method
|
||||
/// the pre-rewrite plain-Block version patched, just on the composite block class instead;
|
||||
/// `_commandName` here is still the FULL "TEFeaturePyramidWard:effect_on" form (splitting
|
||||
/// into module+bare command only happens one level deeper, inside TileEntityComposite's own
|
||||
/// OnBlockActivated) - checked with plain string.EndsWith, no ReadOnlySpan needed.
|
||||
/// Both patches guard on TryGetSelfOrFeature<TEFeaturePyramidWard> first and bail
|
||||
/// immediately for every other composite block in the game (doors, Land Claim, etc.) - same
|
||||
/// "patch the shared method, filter by identity" idiom as everywhere else in this mod.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(TileEntityComposite), "UpdateBlockActivationCommands")]
|
||||
public static class Patch_TileEntityComposite_UpdateBlockActivationCommands_PyramidWard
|
||||
{
|
||||
public static void Postfix(TileEntityComposite __instance, BlockActivationCommand[] _commands)
|
||||
{
|
||||
if (__instance == null || _commands == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!__instance.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard feature))
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < _commands.Length; i++)
|
||||
{
|
||||
string text = _commands[i].text;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (text.EndsWith("effect_on"))
|
||||
{
|
||||
_commands[i].enabled = !feature.EffectOn;
|
||||
}
|
||||
else if (text.EndsWith("effect_off"))
|
||||
{
|
||||
_commands[i].enabled = feature.EffectOn;
|
||||
}
|
||||
else if (text.EndsWith("zone_show"))
|
||||
{
|
||||
_commands[i].enabled = !feature.ZoneShown;
|
||||
}
|
||||
else if (text.EndsWith("zone_hide"))
|
||||
{
|
||||
_commands[i].enabled = feature.ZoneShown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BlockCompositeTileEntity), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })]
|
||||
public static class Patch_BlockCompositeTileEntity_OnBlockActivated_PyramidWard
|
||||
{
|
||||
public static bool Prefix(string _commandName, Vector3i _blockPos, ref bool __result)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_commandName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
bool isEffectCommand = _commandName.EndsWith("effect_on") || _commandName.EndsWith("effect_off");
|
||||
bool isZoneCommand = _commandName.EndsWith("zone_show") || _commandName.EndsWith("zone_hide");
|
||||
if (!isEffectCommand && !isZoneCommand)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
TileEntity te = world != null ? world.GetTileEntity(_blockPos) : null;
|
||||
if (!(te is TileEntityComposite composite) || !composite.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard feature))
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isEffectCommand)
|
||||
{
|
||||
feature.EffectOn = !feature.EffectOn;
|
||||
if (feature.EffectOn)
|
||||
{
|
||||
feature.SpawnGlow();
|
||||
}
|
||||
else
|
||||
{
|
||||
feature.RemoveGlow();
|
||||
}
|
||||
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: effect " + (feature.EffectOn ? "ON" : "OFF") + " at " + _blockPos);
|
||||
}
|
||||
else
|
||||
{
|
||||
feature.ZoneShown = !feature.ZoneShown;
|
||||
if (feature.ZoneShown)
|
||||
{
|
||||
feature.SpawnZoneRing();
|
||||
}
|
||||
else
|
||||
{
|
||||
feature.RemoveZoneRing();
|
||||
}
|
||||
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: zone display " + (feature.ZoneShown ? "ON" : "OFF") + " at " + _blockPos);
|
||||
}
|
||||
feature.SetModified();
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// "Пространственный браслет" (Spatial Bracelet) - dictated 2026-08-30, implemented same
|
||||
/// day. See items.xml (braceletSpatialVault) for the item - both Action0 and Action1 use
|
||||
/// Class="Eat" purely as a click-catcher (same trick as every other Harmony-driven item this
|
||||
/// mod already has), distinguished here by ItemActionData.indexInEntityOfAction (0/1), the
|
||||
/// same field SummonPatch.cs already uses to tell a summon book's summon-click from its
|
||||
/// recall-click.
|
||||
///
|
||||
/// POWER ATTACK (index 1) - personal storage, size scaling with Necromancy skill level:
|
||||
/// - XUiC_BagStorageWindowGroup.Open(xui, entity, bag, lootContainer, title, ...) is the
|
||||
/// REAL API EntityDrone.openStorageWindow() itself calls to show the drone's own cargo
|
||||
/// window (decompiled EntityDrone directly to find this, not guessed) - reused directly
|
||||
/// rather than reinventing a storage UI. LootContainer.GetLootContainer("roboticDrone")
|
||||
/// is the same display/behavior template the drone's own window uses too - "как у дрона"
|
||||
/// taken literally, not just as a vague size comparison.
|
||||
/// - Slot count = Mathf.RoundToInt(necromancyLevel / 10f), per the user's own exact formula
|
||||
/// ("1*скилл_некроманта/10 округлённый до целого") - read live from
|
||||
/// player.Progression.GetProgressionValue("craftingNecroNecromancy").Level (decompiled
|
||||
/// EntityAlive/Progression/ProgressionValue directly to confirm this exact call shape,
|
||||
/// not guessed) - the SAME skill the Knife's own damage already scales with (capped at
|
||||
/// level 5000, one level per zombie kill - see buffs.xml/progression.xml), so this grows
|
||||
/// at the same pace as every other kill-count-tied payoff in this mod. Below level 10
|
||||
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
||||
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
||||
/// instead of silently opening a useless empty window.
|
||||
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently
|
||||
/// assumed: the Bag backing each player's vault lives in a plain in-memory
|
||||
/// Dictionary<int, Bag> in this file (PlayerVaults below), keyed by entityId. This
|
||||
/// is reliable for as long as the game process keeps running (survives death/respawn/
|
||||
/// relogging within one play session, confirmed by how a static field behaves) but has
|
||||
/// NOT been wired into any save/load system - closing the game entirely and reloading the
|
||||
/// save later will NOT bring the vault's contents back (no persistence file, no hook into
|
||||
/// PersistentPlayerData or a world-save event). Building real cross-session persistence
|
||||
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned
|
||||
/// world entity the way the summoned pets do - unconfirmed whether THOSE actually survive
|
||||
/// a full restart either) is real, separate follow-up work, not attempted here. Treat
|
||||
/// this like a session-scoped stash until that's built and confirmed - don't rely on it
|
||||
/// across game restarts yet.
|
||||
///
|
||||
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
||||
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
||||
/// braceletThiefLoop (GetLookRay + Physics.Raycast + RootTransformRefEntity.
|
||||
/// FindEntityUpwards) - reused verbatim, just resolving to EntityZombie instead of
|
||||
/// EntityLootContainer.
|
||||
/// - Slow: zombie.Buffs.AddBuff("buffInjurySlow") - the exact same vanilla debuff already
|
||||
/// reused elsewhere in this mod (the Dog's own bite, necroMeleeHandZombieDog).
|
||||
/// - Knockback: DELIBERATELY a straight Entity.SetPosition "shove" (same API
|
||||
/// PetFollowPatch.cs already uses to reposition pets), NOT a physics/ragdoll impulse.
|
||||
/// Found real candidates for "proper" knockback while researching this
|
||||
/// (EntityAlive.DoRagdoll(in DamageResponse), DamageResponse.ImpulseScale/HitDirection),
|
||||
/// but fully reverse-engineering how a real DamageResponse gets built and fed into that
|
||||
/// during normal combat - all its other fields (Source, Strength, Stun, ArmorSlot, etc.)
|
||||
/// - would have taken real additional decompilation with no guarantee of getting all the
|
||||
/// coordinate/enum conventions right on the first try. A direct position shove is cruder
|
||||
/// (no animation, the zombie just appears further away) but uses an API this exact file's
|
||||
/// own family already relies on successfully - chosen for certainty over polish. Revisit
|
||||
/// with DoRagdoll if the teleport-shove feels too crude in testing.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")]
|
||||
public static class Patch_ItemActionEat_ExecuteAction_SpatialVault
|
||||
{
|
||||
public const string ItemName = "braceletSpatialVault";
|
||||
public const string NecromancySkillName = "craftingNecroNecromancy";
|
||||
public const float MaxRange = 50f;
|
||||
public const float ShoveDistance = 6f;
|
||||
|
||||
/// <summary>See the class-level comment above for exactly what this does and doesn't
|
||||
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary>
|
||||
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||||
|
||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||
{
|
||||
if (!_bReleased)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
|
||||
if (itemName != ItemName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_actionData.indexInEntityOfAction == 1)
|
||||
{
|
||||
OpenVault(player);
|
||||
}
|
||||
// else: regular attack (index 0) deliberately does nothing, per direct user request
|
||||
// 2026-08-30 ("пусть тогда обычная атака у пространственного браслета не делает
|
||||
// ничего") after the knockback+slow version didn't visibly do anything in testing -
|
||||
// rather than debug ShoveZombieAtCrosshair blind (kept below, unused, in case this
|
||||
// gets revisited), just absorb the click silently.
|
||||
|
||||
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void OpenVault(EntityPlayerLocal player)
|
||||
{
|
||||
ProgressionValue progressionValue = player.Progression?.GetProgressionValue(NecromancySkillName);
|
||||
int level = progressionValue != null ? progressionValue.Level : 0;
|
||||
int slotCount = Mathf.RoundToInt(level / 10f);
|
||||
if (slotCount <= 0)
|
||||
{
|
||||
GameManager.ShowTooltip(player, "braceletSpatialVaultTooWeak");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
||||
{
|
||||
bag = new Bag(slotCount);
|
||||
PlayerVaults[player.entityId] = bag;
|
||||
}
|
||||
else if (bag.SlotCount < slotCount)
|
||||
{
|
||||
// Grow, never shrink - the skill level only ever goes up, so this only ever
|
||||
// copies existing stacks into a bigger array, same shape
|
||||
// EntityLootContainer.SetContent itself uses when it needs to resize a bag.
|
||||
ItemStack[] oldSlots = bag.GetSlots();
|
||||
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
||||
Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
||||
bag.SetSlots(newSlots);
|
||||
}
|
||||
|
||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
XUiC_BagStorageWindowGroup.Open(playerUI.xui, player, bag, LootContainer.GetLootContainer("roboticDrone"), Localization.Get("braceletSpatialVaultWindowTitle"));
|
||||
}
|
||||
|
||||
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
||||
{
|
||||
Ray ray = player.GetLookRay();
|
||||
if (!Physics.Raycast(ray, out RaycastHit hit, MaxRange))
|
||||
{
|
||||
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
|
||||
return;
|
||||
}
|
||||
Transform entityTransform = RootTransformRefEntity.FindEntityUpwards(hit.collider.transform);
|
||||
Entity entity = entityTransform != null ? entityTransform.GetComponent<Entity>() : null;
|
||||
if (!(entity is EntityZombie zombie) || zombie.IsDead())
|
||||
{
|
||||
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
|
||||
return;
|
||||
}
|
||||
|
||||
zombie.Buffs?.AddBuff("buffInjurySlow");
|
||||
|
||||
Vector3 shoveDir = zombie.position - player.position;
|
||||
shoveDir.y = 0f;
|
||||
shoveDir = shoveDir.sqrMagnitude > 0.01f ? shoveDir.normalized : player.transform.forward;
|
||||
Vector3 destination = zombie.position + shoveDir * ShoveDistance + Vector3.up * 1f;
|
||||
zombie.SetPosition(destination, true);
|
||||
|
||||
player.PlayOneShot("swoosh");
|
||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " shoved zombie " + zombie.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Necromancer pet summon/ownership/recall - BACKLOG.md item 3 (Zombie Dog) plus the Insect
|
||||
/// Swarm added alongside its bugfix (2026-08-28). Done the same way vanilla's own drone works
|
||||
/// (per user request): one active pet per player PER SPECIES, and the pet is recorded as
|
||||
/// owned via the same EntityAlive.ownedEntities API the drone itself uses
|
||||
/// (ItemActionSpawnTurret.ExecuteAction calls holdingEntity.AddOwnedEntity(entityDrone) -
|
||||
/// confirmed by decompiling it).
|
||||
///
|
||||
/// Each summon book's item block now carries the SpawnEntity action on BOTH Action0 and
|
||||
/// Action1 (user's own suggestion 2026-08-28, in response to "how do I put the dog back in
|
||||
/// the book?"), pointed at the same pet entity in both slots, but the two slots mean
|
||||
/// something different: Action0 (primary click) SUMMONS, blocked with a tooltip if one is
|
||||
/// already out; Action1 (secondary/"power attack" click) RECALLS the owned one if there is
|
||||
/// one, or no-ops with a tooltip if there isn't. This split is entirely our own Prefix logic
|
||||
/// below, keyed off ItemActionData.indexInEntityOfAction (0/1, public field, confirmed by
|
||||
/// decompiling ItemActionData) - ItemActionSpawnEntity itself has no concept of "recall",
|
||||
/// it's purely a spawner.
|
||||
///
|
||||
/// LimitedPets keys off entity_class name -> the three localization keys each species needs
|
||||
/// (already-active, recalled, nothing-to-recall). Each species gets its own independent
|
||||
/// 1-active limit (a Dog and a Swarm can be out at once; two Dogs can't).
|
||||
///
|
||||
/// Two patch points for the SUMMON path, because entity creation and item consumption/
|
||||
/// ownership can't both live in one method without either a transpiler (to grab a local
|
||||
/// variable) or a fragile "guess the newest entity of this class" lookup:
|
||||
///
|
||||
/// 1. Prefix on ItemActionSpawnEntity.Spawn - runs BEFORE anything is created. Handles both
|
||||
/// the summon-side block/allow AND the entire recall path (recall never lets the
|
||||
/// original method run at all - there's nothing for vanilla Spawn to do on a recall).
|
||||
/// 2. Postfix on EntityFactory.CreateEntity(int,Vector3,Vector3) - the exact overload
|
||||
/// ItemActionSpawnEntity.Spawn() calls, and it returns the created Entity directly
|
||||
/// (unlike Spawn() itself, which is void), so this is the only point that has both "an
|
||||
/// entity was just created" and "here it is" without needing IL tricks. Filtered to our
|
||||
/// pets' entityClassIds so it's a no-op for every other CreateEntity call in the game
|
||||
/// (turrets, drones, zombie spawns, everything else uses this exact same overload).
|
||||
/// Because the Prefix above already guarantees at most one pet of that species per
|
||||
/// player, this postfix doesn't need to re-check the limit - it only ever fires for
|
||||
/// allowed summons.
|
||||
///
|
||||
/// This overload of CreateEntity has no "who spawned this" parameter, so the postfix finds
|
||||
/// the owner as the nearest player to the spawn position - always correct here since
|
||||
/// ItemActionSpawnEntity.Spawn() always spawns at (roughly) the caster's own head position.
|
||||
/// Fine for this mod (no multiplayer/persistence layer exists anywhere else in it either);
|
||||
/// not a general-purpose "find the real spawner" solution.
|
||||
///
|
||||
/// Known gap vs. the real drone: no drones.dat-style save file, no despawn-on-owner-death.
|
||||
/// A pet is a plain EntityAlive with no special unload/reload handling, so if its chunk
|
||||
/// unloads while the player is away it despawns like any other wandering entity - the real
|
||||
/// drone avoids that via DroneManager's own persistence system, which is a lot of machinery
|
||||
/// (network sync, its own save file) this mod doesn't have a reason to take on for a couple
|
||||
/// of pet types. Revisit only if that turns out to matter.
|
||||
///
|
||||
/// See PetFollowPatch.cs (added 2026-08-28, after the Dog wandered off in-game and a second
|
||||
/// one wouldn't summon) for the leash-back-to-owner behavior and the ownership cleanup that
|
||||
/// runs when a tracked pet dies or its chunk unloads - both plug directly into this file's
|
||||
/// LimitedPets/AddOwnedEntity machinery. The manual recall path added here calls
|
||||
/// PetFollowPatch.Unregister so a recalled pet stops being tracked immediately instead of
|
||||
/// waiting for that cleanup to notice it's gone.
|
||||
///
|
||||
/// DEBUG LOGGING: added 2026-08-28 after the Zombie Dog silently failed to spawn in-game
|
||||
/// (root cause was unrelated to this patch - see items.xml's AnimWait comment - but there was
|
||||
/// no logging anywhere in this file to even rule that out quickly, unlike CharmPatch.cs's
|
||||
/// verbose Debug.Log on every step). Keeping these permanently, same as CharmPatch.cs does.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemActionSpawnEntity), "Spawn", new System.Type[] { typeof(ItemActionData) })]
|
||||
public static class Patch_ItemActionSpawnEntity_Spawn_PetLimit
|
||||
{
|
||||
public class PetInfo
|
||||
{
|
||||
public string AlreadyActiveKey;
|
||||
public string RecalledKey;
|
||||
public string NothingToRecallKey;
|
||||
public string SummonItemName;
|
||||
/// <summary>User request 2026-08-28: "пусть книга вообще не тратится на призыв
|
||||
/// собаки" - the Dog's book is a permanent bonded item, never consumed on summon (and
|
||||
/// RecallPet never has anything to give back, since nothing was ever taken). The
|
||||
/// Swarm keeps consuming its book per cast - it's the one-time/no-recall species, that
|
||||
/// consumption is the actual "cost" of casting it. This single flag also happens to
|
||||
/// fix the earlier "dog died, no way to get the book back" complaint: with nothing
|
||||
/// ever taken, there's nothing to lose when the dog dies off-screen.</summary>
|
||||
public bool ConsumesBook;
|
||||
}
|
||||
|
||||
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
|
||||
{
|
||||
{
|
||||
"necroZombieDog",
|
||||
new PetInfo
|
||||
{
|
||||
AlreadyActiveKey = "necroZombieDogAlreadyActive",
|
||||
RecalledKey = "necroZombieDogRecalled",
|
||||
NothingToRecallKey = "necroZombieDogNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieDog",
|
||||
ConsumesBook = false,
|
||||
}
|
||||
},
|
||||
{
|
||||
// User request 2026-08-28: "пусть вызов насекомых будет одноразовым" - no
|
||||
// Action1 on bookSummonInsectSwarm any more (see items.xml), so
|
||||
// indexInEntityOfAction can never be RecallActionIndex for this species and
|
||||
// RecalledKey/NothingToRecallKey below are simply never read. Left null rather
|
||||
// than pointed at deleted localization keys.
|
||||
"necroInsectSwarm",
|
||||
new PetInfo
|
||||
{
|
||||
AlreadyActiveKey = "necroInsectSwarmAlreadyActive",
|
||||
RecalledKey = null,
|
||||
NothingToRecallKey = null,
|
||||
SummonItemName = "bookSummonInsectSwarm",
|
||||
ConsumesBook = true,
|
||||
}
|
||||
},
|
||||
// Three more pets, BACKLOG.md item 4a. REPLACED 2026-08-29 - the original
|
||||
// Stripper/Cop/Soldier (extending real EntityZombie-classed vanilla zombies) came
|
||||
// back hostile to the player in testing despite copying the Dog's own recipe; see
|
||||
// entityclasses.xml for the full story. New concept: Зомбогриф/Зомбомедведь/
|
||||
// Зомбоволк, extending animal-family bases (same category as the Dog itself, which
|
||||
// DID work) - same shape otherwise (recallable, book never consumed).
|
||||
{
|
||||
"necroZombieGriffin",
|
||||
new PetInfo
|
||||
{
|
||||
AlreadyActiveKey = "necroZombieGriffinAlreadyActive",
|
||||
RecalledKey = "necroZombieGriffinRecalled",
|
||||
NothingToRecallKey = "necroZombieGriffinNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieGriffin",
|
||||
ConsumesBook = false,
|
||||
}
|
||||
},
|
||||
{
|
||||
"necroZombieBear",
|
||||
new PetInfo
|
||||
{
|
||||
AlreadyActiveKey = "necroZombieBearAlreadyActive",
|
||||
RecalledKey = "necroZombieBearRecalled",
|
||||
NothingToRecallKey = "necroZombieBearNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieBear",
|
||||
ConsumesBook = false,
|
||||
}
|
||||
},
|
||||
{
|
||||
"necroZombieWolf",
|
||||
new PetInfo
|
||||
{
|
||||
AlreadyActiveKey = "necroZombieWolfAlreadyActive",
|
||||
RecalledKey = "necroZombieWolfRecalled",
|
||||
NothingToRecallKey = "necroZombieWolfNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieWolf",
|
||||
ConsumesBook = false,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Action1 ("power attack" slot) is always recall-only - see items.xml, both
|
||||
/// summon books now declare Action1 with the same Class="SpawnEntity"/Entity as Action0.</summary>
|
||||
public const int RecallActionIndex = 1;
|
||||
|
||||
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData)
|
||||
{
|
||||
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo tooltips))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
EntityAlive holdingEntity = _actionData?.invData?.holdingEntity;
|
||||
if (holdingEntity == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// BUG FIXED 2026-08-28 (unlimited summons, recall never ran, dogs pile-launching the
|
||||
// player): this used to check "<= 0" for "not found", on the wrong assumption that
|
||||
// valid ids are small positive numbers. Confirmed by decompiling EntityClass.GetId:
|
||||
// it returns -1 (a clean sentinel) when not found, and otherwise the real class id -
|
||||
// which is hash-based and can absolutely be negative (necroZombieDog's is, e.g.,
|
||||
// -779816341, confirmed by this method's own Debug.Log below during the actual bug).
|
||||
// With "<= 0", that real, valid, negative id was misread as "not found" on every
|
||||
// single call, so this returned true unconditionally - which skipped BOTH the
|
||||
// summon-limit check AND the entire recall branch below (recall's check for it is
|
||||
// also past this point), so every Action0 OR Action1 click just summoned yet another
|
||||
// pet, forever, book and all.
|
||||
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
|
||||
if (petClassId == -1)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] SummonPatch: entity class '" + __instance.entityToSpawn + "' not found");
|
||||
return true;
|
||||
}
|
||||
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
|
||||
Debug.Log("[NecromancerTome] SummonPatch: Spawn prefix for " + __instance.entityToSpawn + ", action index=" + _actionData.indexInEntityOfAction + ", owned count=" + owned.Count);
|
||||
|
||||
if (_actionData.indexInEntityOfAction == RecallActionIndex)
|
||||
{
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null);
|
||||
}
|
||||
else if (holdingEntity.world != null)
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
if (holdingEntity.world != null)
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>summonItemName is null when this species doesn't consume its book on summon
|
||||
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
|
||||
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
|
||||
{
|
||||
World world = owner.world;
|
||||
if (world != null)
|
||||
{
|
||||
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
|
||||
}
|
||||
owner.RemoveOwnedEntity(petEntityId);
|
||||
PetFollowPatch.Unregister(petEntityId);
|
||||
if (summonItemName != null)
|
||||
{
|
||||
GiveBackSummonItem(owner, summonItemName);
|
||||
}
|
||||
if (world != null)
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), recalledTooltipKey);
|
||||
}
|
||||
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " recalled pet " + petEntityId);
|
||||
}
|
||||
|
||||
/// <summary>Hands one copy of the summon book back to whoever just recalled their pet -
|
||||
/// the dog/swarm "goes back into the book" literally, not just for free. Tries the
|
||||
/// toolbelt first (Inventory.AddItem - confirmed by decompiling it, only searches the
|
||||
/// toolbelt's own slots), then the backpack (EntityPlayer.bag, same AddItem shape) if that
|
||||
/// didn't fit. If both are full the book is just lost - not worth building actual overflow
|
||||
/// handling (a "drop it on the ground" fallback) for something this minor.</summary>
|
||||
public static void GiveBackSummonItem(EntityAlive owner, string itemName)
|
||||
{
|
||||
ItemValue itemValue = ItemClass.GetItem(itemName);
|
||||
if (itemValue == null || itemValue.type <= 0)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] SummonPatch: could not resolve item '" + itemName + "' to give back on recall");
|
||||
return;
|
||||
}
|
||||
ItemStack stack = new ItemStack(itemValue, 1);
|
||||
bool added = owner.inventory != null && owner.inventory.AddItem(stack, out int slot);
|
||||
if (!added && owner is EntityPlayer player && player.bag != null)
|
||||
{
|
||||
// Bag (InventoryBase) only exposes the single-arg AddItem overload, unlike
|
||||
// Inventory's (ItemStack, out int) - confirmed by decompiling both.
|
||||
added = player.bag.AddItem(stack);
|
||||
}
|
||||
if (!added)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] SummonPatch: " + itemName + " didn't fit back into " + owner.entityId + "'s inventory on recall (full?)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
|
||||
public static class Patch_EntityFactory_CreateEntity_PetOwnership
|
||||
{
|
||||
public static void Postfix(int _et, Entity __result)
|
||||
{
|
||||
if (__result == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo = null;
|
||||
foreach (KeyValuePair<string, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo> entry in Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets)
|
||||
{
|
||||
if (EntityClass.GetId(entry.Key) == _et)
|
||||
{
|
||||
petInfo = entry.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (petInfo == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] SummonPatch: CreateEntity postfix, entity=" + __result.entityId + " et=" + _et);
|
||||
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
if (world == null || world.Players == null || world.Players.list == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EntityPlayer owner = null;
|
||||
float bestDistSq = float.MaxValue;
|
||||
foreach (EntityPlayer player in world.Players.list)
|
||||
{
|
||||
if (player == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float distSq = (player.position - __result.position).sqrMagnitude;
|
||||
if (distSq < bestDistSq)
|
||||
{
|
||||
bestDistSq = distSq;
|
||||
owner = player;
|
||||
}
|
||||
}
|
||||
if (owner == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] SummonPatch: no player found to own " + __result.entityId);
|
||||
return;
|
||||
}
|
||||
|
||||
owner.AddOwnedEntity(__result);
|
||||
if (petInfo.ConsumesBook && owner.inventory != null)
|
||||
{
|
||||
owner.inventory.DecHoldingItem(1);
|
||||
}
|
||||
PetFollowPatch.Register(owner, __result);
|
||||
IgnoreCollisionWithOwner(owner, __result);
|
||||
ApplyGhostlyTransparency(__result);
|
||||
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId);
|
||||
}
|
||||
|
||||
/// <summary>User request 2026-08-28 ("нематериальными") - makes the pet pass through its
|
||||
/// own owner specifically, via Physics.IgnoreCollision on every collider pair between the
|
||||
/// two, rather than stripping the pet's PhysicsBody entirely the way vanilla's own
|
||||
/// animalInsectSwarm does for its "no physics body at all" look (confirmed by checking
|
||||
/// entityclasses.xml). That works for a flying swarm; a ground-walking dog with no
|
||||
/// collider at all would fall through the terrain. This keeps it solid against ground and
|
||||
/// zombies - just not its owner - which also directly closes the last piece of the
|
||||
/// spawn-launch bug documented in items.xml/PetFollowPatch.cs (nothing left to shove the
|
||||
/// player if the two colliders can't touch in the first place).</summary>
|
||||
public static void IgnoreCollisionWithOwner(EntityPlayer owner, Entity pet)
|
||||
{
|
||||
Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>();
|
||||
Collider[] petColliders = pet.GetComponentsInChildren<Collider>();
|
||||
foreach (Collider oc in ownerColliders)
|
||||
{
|
||||
if (oc == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (Collider pc in petColliders)
|
||||
{
|
||||
if (pc == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Physics.IgnoreCollision(oc, pc, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>User request 2026-08-28 ("слегка прозрачными") - best effort only. Directly
|
||||
/// sets renderer.material.color's alpha, the same technique ItemActionSpawnTurret uses for
|
||||
/// its own placement-preview tint (confirmed by decompiling it), but that only visibly
|
||||
/// shows up if the model's actual shader supports alpha blending - most opaque mob
|
||||
/// shaders in this game don't, and there's no reliable XML/reflection-only way to swap a
|
||||
/// live renderer's shader to a transparent variant without risking breaking how it's lit.
|
||||
///
|
||||
/// BUG FIXED 2026-08-28: the Insect Swarm's renderers use a particle shader
|
||||
/// ("Game Particles/surfaceShader_masked_particleEnhanced") that has no "_Color" property
|
||||
/// at all - setting .color on it doesn't throw, but Unity logs "doesn't have a color
|
||||
/// property '_Color'" on every single access, once per renderer per spawn (confirmed in
|
||||
/// output_log - this is what the user saw as "an error about colors"). HasProperty check
|
||||
/// added so this silently skips any renderer whose shader doesn't support it instead of
|
||||
/// spamming the log - the visual effect was never going to work on those anyway.</summary>
|
||||
public static void ApplyGhostlyTransparency(Entity pet)
|
||||
{
|
||||
Renderer[] renderers = pet.GetComponentsInChildren<Renderer>();
|
||||
foreach (Renderer renderer in renderers)
|
||||
{
|
||||
if (renderer == null || renderer.material == null || !renderer.material.HasProperty("_Color"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Color color = renderer.material.color;
|
||||
color.a = 0.55f;
|
||||
renderer.material.color = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// BUG FIXED 2026-08-28 (Insect Swarm attacked the player instead of zombies, even after the
|
||||
/// entityclasses.xml AITask-2/AITarget-4 fix): wasted effort, because none of that XML
|
||||
/// mattered. Confirmed by decompiling the actual class chain -
|
||||
/// necroInsectSwarm -> animalInsectSwarm -> Class="EntitySwarm" -> EntitySwarm : EntityVulture.
|
||||
/// EntityVulture does NOT use the generic AITask/AITarget system for target selection at all -
|
||||
/// it has its own hardcoded C# targeting (updateTasks()'s State.Wander branch calls
|
||||
/// FindTarget(), which calls World.GetClosestPlayerSeen/GetClosestPlayer - literally typed to
|
||||
/// return EntityPlayer, there is no "closest zombie" variant to point it at). Every ground
|
||||
/// creature in this mod (the Dog, all vanilla animals) goes through the declarative AITask
|
||||
/// system just fine; flying "swarm" creatures (insect swarm, bee swarm, vultures) are a
|
||||
/// completely separate hardcoded-C# codepath. No XML property changes that.
|
||||
///
|
||||
/// Fix: Prefix on EntityAlive.SetAttackTarget - the one non-EntityPlayer-typed choke point
|
||||
/// every one of EntityVulture's several call sites funnels through (FindTarget() results,
|
||||
/// revenge-target retaliation, sleeper wake-up - all of them end in a SetAttackTarget call,
|
||||
/// confirmed by decompiling EntityVulture). Whenever the entity is one of OUR
|
||||
/// EntityVulture-based pets AND the target it's about to be given is an EntityPlayer, swap in
|
||||
/// the nearest EntityZombie instead (or null if none are nearby - just idles, better than
|
||||
/// attacking the player). Only filters on our own entity classes, so vanilla's own
|
||||
/// animalInsectSwarm/animalBeeSwarm/real vultures are entirely unaffected and keep hunting
|
||||
/// players normally.
|
||||
///
|
||||
/// GENERALIZED 2026-08-29, THEN UN-GENERALIZED SAME DAY: briefly also covered
|
||||
/// necroZombieGriffin (extending animalZombieVulture, the same Class="EntityVulture" root as
|
||||
/// the Swarm) via this same redirect. Confirmed live in-game that this didn't actually fix
|
||||
/// the Griffin - it just flew around doing EntityVulture's own default Wander behavior,
|
||||
/// never engaging zombies at all ("летает где-то в небе, и зомби его вообще не интересуют").
|
||||
/// Rather than keep debugging the redirect blind (each guess needs a full test cycle the
|
||||
/// user has to run), the Griffin was converted to extend necroZombieDog directly instead (see
|
||||
/// entityclasses.xml) - same proven-reliable ground-AI trick as the Bear/Wolf, no longer
|
||||
/// EntityVulture-based at all, so it no longer needs this patch. Kept the Dictionary-based
|
||||
/// shape below (rather than reverting to a single cached id) in case a genuinely flying pet
|
||||
/// gets added again later - SpeciesByName just has one entry for now.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "SetAttackTarget", new System.Type[] { typeof(EntityAlive), typeof(int) })]
|
||||
public static class Patch_EntityAlive_SetAttackTarget_SwarmRetarget
|
||||
{
|
||||
public class VultureBasedPetInfo
|
||||
{
|
||||
public bool SkipAlreadyCharmedZombies;
|
||||
}
|
||||
|
||||
/// <summary>HARDENED 2026-08-29 while chasing the user's "Griffin still attacks me"
|
||||
/// report - the Griffin's own AI is not XML-driven at all (see class comment), so this
|
||||
/// Harmony redirect not firing was the prime remaining suspect. Could not fully confirm
|
||||
/// or rule this out by decompilation alone, but the ORIGINAL lazy-cache pattern here had
|
||||
/// two real, independent failure modes worth closing regardless of which (if either) was
|
||||
/// the actual cause: (1) EntityClass.GetId("necroInsectSwarm") and
|
||||
/// EntityClass.GetId("necroZombieGriffin") were both looked up inside ONE dictionary
|
||||
/// object-initializer - if EITHER happened to still return -1 (not yet registered) at
|
||||
/// the exact moment some entirely unrelated zombie's very first SetAttackTarget call
|
||||
/// triggered this lazy build (plausible - that can happen extremely early, before every
|
||||
/// mod entity_class is guaranteed loaded), the -1 got cached FOREVER via the
|
||||
/// cachedClassIds==null guard, silently never re-resolving even once the real class WAS
|
||||
/// registered a moment later - and if BOTH happened to be -1 at once, the dictionary
|
||||
/// initializer would throw (duplicate key), which could break unrelated zombie AI too.
|
||||
/// Rewritten to resolve each species independently and only cache a REAL (non -1) id -
|
||||
/// an unresolved species is retried on every subsequent call instead of being poisoned
|
||||
/// permanently, and two entries can never collide on a shared -1 key.</summary>
|
||||
public static readonly Dictionary<string, VultureBasedPetInfo> SpeciesByName = new Dictionary<string, VultureBasedPetInfo>
|
||||
{
|
||||
{ "necroInsectSwarm", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = true } },
|
||||
};
|
||||
|
||||
public static readonly Dictionary<int, VultureBasedPetInfo> cachedClassIds = new Dictionary<int, VultureBasedPetInfo>();
|
||||
|
||||
public static Dictionary<int, VultureBasedPetInfo> ClassIds()
|
||||
{
|
||||
// Fast path once every species has resolved (the overwhelmingly common case, since
|
||||
// this runs on EntityAlive.SetAttackTarget - a hot path called for every zombie in
|
||||
// the game, not just ours) - skips the resolution loop below entirely instead of
|
||||
// re-scanning it on every single call.
|
||||
if (cachedClassIds.Count >= SpeciesByName.Count)
|
||||
{
|
||||
return cachedClassIds;
|
||||
}
|
||||
foreach (KeyValuePair<string, VultureBasedPetInfo> species in SpeciesByName)
|
||||
{
|
||||
bool alreadyCached = false;
|
||||
foreach (KeyValuePair<int, VultureBasedPetInfo> cached in cachedClassIds)
|
||||
{
|
||||
if (cached.Value == species.Value)
|
||||
{
|
||||
alreadyCached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alreadyCached)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int id = EntityClass.GetId(species.Key);
|
||||
if (id == -1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
cachedClassIds[id] = species.Value;
|
||||
}
|
||||
return cachedClassIds;
|
||||
}
|
||||
|
||||
/// <summary>Kept separate from ClassIds() above (which is about retargeting, shared by
|
||||
/// both pets) - this one is Swarm-ONLY, used by PetFollowPatch.cs's "drop an
|
||||
/// already-charmed target so it moves on" behavior, which only makes sense for a species
|
||||
/// that actually charms zombies (the Griffin doesn't). Same retry-until-resolved shape as
|
||||
/// ClassIds() above, for the same reason - never cache a -1.</summary>
|
||||
public static int cachedSwarmOnlyClassId = -1;
|
||||
|
||||
public static int SwarmOnlyClassId()
|
||||
{
|
||||
if (cachedSwarmOnlyClassId == -1)
|
||||
{
|
||||
cachedSwarmOnlyClassId = EntityClass.GetId("necroInsectSwarm");
|
||||
}
|
||||
return cachedSwarmOnlyClassId;
|
||||
}
|
||||
|
||||
public static void Prefix(EntityAlive __instance, ref EntityAlive _attackTarget)
|
||||
{
|
||||
if (__instance == null || !(_attackTarget is EntityPlayer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!ClassIds().TryGetValue(__instance.entityClass, out VultureBasedPetInfo petInfo))
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityAlive nearestZombie = FindNearestZombie(__instance, petInfo.SkipAlreadyCharmedZombies);
|
||||
Debug.Log("[NecromancerTome] SwarmTargetPatch: redirected " + __instance.entityId + " from player " + _attackTarget.entityId + " to " + (nearestZombie != null ? nearestZombie.entityId.ToString() : "nothing nearby"));
|
||||
_attackTarget = nearestZombie;
|
||||
}
|
||||
|
||||
/// <summary>Same World.GetEntitiesInBounds(Type, Bounds, List<Entity>) API
|
||||
/// EntityVulture itself uses for its own player search (confirmed by decompiling it) -
|
||||
/// just pointed at EntityZombie instead of EntityPlayer. 80m box, matching FindTarget's
|
||||
/// own cTargetDistanceMax constant, for "ищут всех зомби в радиусе".
|
||||
///
|
||||
/// BUG FIXED 2026-08-28 ("покусав одного, летят куда-то далеко, вместо соседнего
|
||||
/// незаражённого"): this didn't skip already-charmed zombies, so when
|
||||
/// PetFollowPatch.cs's "drop an already-charmed target" cleared the swarm's target, the
|
||||
/// very next FindTarget()->SetAttackTarget cycle would often just re-pick the SAME
|
||||
/// zombie it had just charmed (still the physically nearest one right after biting it) -
|
||||
/// PetFollowPatch would clear it again next tick, and in between, EntityVulture (a
|
||||
/// flying creature) fell into its own Wander state, which for a flier means big aerial
|
||||
/// loops away from its current spot, not calm circling. A second, genuinely uncharmed
|
||||
/// zombie standing right next to the first one would lose out to this loop instead of
|
||||
/// being picked immediately. Now skips any zombie that already carries
|
||||
/// buffNecroDeviatorCharm - the real "next AND uncharmed" search the user asked for. Only
|
||||
/// falls through to wide wandering when there truly isn't one nearby, same as before.
|
||||
///
|
||||
/// <paramref name="skipAlreadyCharmed"/> added 2026-08-29 alongside the Griffin
|
||||
/// generalization above - true for the Swarm (its own charm-on-bite behavior, unchanged),
|
||||
/// false for the Griffin (a plain fighter with no reason to avoid already-charmed
|
||||
/// zombies).</summary>
|
||||
public static EntityAlive FindNearestZombie(EntityAlive swarm, bool skipAlreadyCharmed)
|
||||
{
|
||||
World world = swarm.world;
|
||||
if (world == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
List<Entity> nearby = new List<Entity>();
|
||||
Bounds bounds = new Bounds(swarm.position, new Vector3(80f, 80f, 80f));
|
||||
world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby);
|
||||
|
||||
EntityAlive nearest = null;
|
||||
float bestDistSq = float.MaxValue;
|
||||
foreach (Entity entity in nearby)
|
||||
{
|
||||
if (!(entity is EntityAlive zombie) || zombie.IsDead())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (skipAlreadyCharmed && zombie.Buffs != null && zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float distSq = (zombie.position - swarm.position).sqrMagnitude;
|
||||
if (distSq < bestDistSq)
|
||||
{
|
||||
bestDistSq = distSq;
|
||||
nearest = zombie;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Necromancer's Knife (BACKLOG.md item 5, user request 2026-08-28): a zombie hit by the
|
||||
/// knife carries buffNecroVictim (buffs.xml) until it dies. On death, it's guaranteed to drop
|
||||
/// a green "Жертва" loot bag (EntityLootContainerVictim in entityclasses.xml) instead of
|
||||
/// whatever the normal random loot roll would have given it.
|
||||
///
|
||||
/// Two patch points, found the hard way (2026-08-28, "лута всё ещё нет" after confirming via
|
||||
/// the CharmPatch.cs-style AddBuff log that the debuff itself WAS being applied fine):
|
||||
///
|
||||
/// 1. Patch_EntityAlive_dropItemOnDeath_VictimBag - the actual gate. Confirmed by
|
||||
/// decompiling EntityAlive.dropItemOnDeath(): it only calls DropBagServer() at all if
|
||||
/// `lootDropProb > rand.RandomFloat` passes first - vanilla zombies have LootDropProb
|
||||
/// around .04 (4%), so DropBagServer() simply never runs for ~96% of deaths. The first
|
||||
/// version of this file only patched DropBagServer() itself, which was correct once
|
||||
/// inside it but never got a chance to run for most kills - confirmed by the "AddBuff
|
||||
/// Added" log firing repeatedly while the "zombie died" log from the DropBagServer patch
|
||||
/// never fired even once for a real, logged kill. This Prefix on dropItemOnDeath() calls
|
||||
/// DropBagServer() directly for a Victim-tagged zombie, bypassing the probability roll
|
||||
/// entirely, then skips the rest of the original method (the only other thing it does -
|
||||
/// dropping a non-AI entity's own inventory - never applies to a zombie anyway, hasAI is
|
||||
/// always true for those).
|
||||
/// 2. Patch_Entity_DropBagServer_VictimBag - decides WHICH bag. Confirmed by decompiling
|
||||
/// Entity.DropBagServer(): it reads entityClass.lootDrops/LootDropPick(rand) - a STATIC
|
||||
/// per-species value from the zombie's own entity_class (XML's LootDropEntityClass
|
||||
/// property), not anything a live buff or CVar can influence declaratively (unlike the
|
||||
/// knife's damage, which only needed a CVar the passive_effect system already reads
|
||||
/// live) - this needed an actual Harmony patch, not an XML trick.
|
||||
///
|
||||
/// Both are Prefixes returning false: they fully replace what they intercept rather than
|
||||
/// running alongside it.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "dropItemOnDeath")]
|
||||
public static class Patch_EntityAlive_dropItemOnDeath_VictimBag
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance)
|
||||
{
|
||||
// Diagnostic-only, added 2026-08-28: unconditional, before any branching, to answer
|
||||
// definitively whether Harmony is even entering this method at all - "лута всё ещё
|
||||
// нет" after the first fix, but with zero sign of even the unconditional part of this
|
||||
// Prefix ever running (not even a false-branch silently returning - literally no log
|
||||
// line at all), which is otherwise unexplained since decompiling
|
||||
// EntityAlive.OnEntityDeath() confirms it calls dropItemOnDeath() directly,
|
||||
// unconditionally, right after the exact "Entity X killed by Y" line seen in the log.
|
||||
Debug.Log("[NecromancerTome] VictimPatch: dropItemOnDeath Prefix entered for " + __instance.entityId + " (" + __instance.GetType().Name + "), hasVictimBuff=" + (__instance.Buffs != null && __instance.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName)));
|
||||
if (__instance.Buffs == null || !__instance.Buffs.HasBuff(Patch_Entity_DropBagServer_VictimBag.VictimBuffName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId + " died carrying buffNecroVictim - forcing guaranteed bag, bypassing LootDropProb roll");
|
||||
__instance.DropBagServer();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Entity), "DropBagServer")]
|
||||
public static class Patch_Entity_DropBagServer_VictimBag
|
||||
{
|
||||
public const string VictimBuffName = "buffNecroVictim";
|
||||
public const string VictimContainerClassName = "EntityLootContainerVictim";
|
||||
|
||||
public static bool Prefix(Entity __instance)
|
||||
{
|
||||
if (!(__instance is EntityAlive alive) || alive.Buffs == null || !alive.Buffs.HasBuff(VictimBuffName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer || __instance is EntityLootContainer)
|
||||
{
|
||||
// Same guard the original method opens with - not our place to override these cases.
|
||||
return true;
|
||||
}
|
||||
|
||||
int containerClassId = EntityClass.GetId(VictimContainerClassName);
|
||||
if (containerClassId == -1)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] VictimPatch: entity class '" + VictimContainerClassName + "' not found, falling back to normal loot");
|
||||
return true;
|
||||
}
|
||||
|
||||
Vector3 pos = __instance.GetPosition();
|
||||
pos.y += 0.9f;
|
||||
Entity spawned = EntityFactory.CreateEntity(containerClassId, pos, Vector3.zero);
|
||||
if (spawned is EntityLootContainer lootContainer)
|
||||
{
|
||||
GameManager.Instance.World.SpawnEntityInWorld(lootContainer);
|
||||
Debug.Log("[NecromancerTome] VictimPatch: " + __instance.entityId + " (victim) dropped guaranteed loot bag " + lootContainer.entityId);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] VictimPatch: created entity for '" + VictimContainerClassName + "' wasn't an EntityLootContainer");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user