Мод для 7 Days to Die 3.2: навык «Некромантия», растущий от счётчика убитых зомби, тёмное оружие с шестью собственными модами, призывная нежить, пирамида духов и сюжетный финал через Чёрный портал. Локализация на 13 языках. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaNro5hAGTzcQ7rJNN2tCX
392 lines
18 KiB
C#
392 lines
18 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|