Книга некроманта 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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user