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