Files
necromants-tome-7d2d-3-2/HarmonySrc/SwarmTargetPatch.cs
T
Alex CubeandClaude Opus 5 e8f064f5ec Книга некроманта 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
2026-09-09 21:13:03 +03:00

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&lt;Entity&gt;) API
/// EntityVulture itself uses for its own player search (confirmed by decompiling it) -
/// just pointed at EntityZombie instead of EntityPlayer. 80m box, matching FindTarget's
/// own cTargetDistanceMax constant, for "ищут всех зомби в радиусе".
///
/// BUG FIXED 2026-08-28 ("покусав одного, летят куда-то далеко, вместо соседнего
/// незаражённого"): this didn't skip already-charmed zombies, so when
/// PetFollowPatch.cs's "drop an already-charmed target" cleared the swarm's target, the
/// very next FindTarget()->SetAttackTarget cycle would often just re-pick the SAME
/// zombie it had just charmed (still the physically nearest one right after biting it) -
/// PetFollowPatch would clear it again next tick, and in between, EntityVulture (a
/// flying creature) fell into its own Wander state, which for a flier means big aerial
/// loops away from its current spot, not calm circling. A second, genuinely uncharmed
/// zombie standing right next to the first one would lose out to this loop instead of
/// being picked immediately. Now skips any zombie that already carries
/// buffNecroDeviatorCharm - the real "next AND uncharmed" search the user asked for. Only
/// falls through to wide wandering when there truly isn't one nearby, same as before.
///
/// <paramref name="skipAlreadyCharmed"/> added 2026-08-29 alongside the Griffin
/// generalization above - true for the Swarm (its own charm-on-bite behavior, unchanged),
/// false for the Griffin (a plain fighter with no reason to avoid already-charmed
/// zombies).</summary>
public static EntityAlive FindNearestZombie(EntityAlive swarm, bool skipAlreadyCharmed)
{
World world = swarm.world;
if (world == null)
{
return null;
}
List<Entity> nearby = new List<Entity>();
Bounds bounds = new Bounds(swarm.position, new Vector3(80f, 80f, 80f));
world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby);
EntityAlive nearest = null;
float bestDistSq = float.MaxValue;
foreach (Entity entity in nearby)
{
if (!(entity is EntityAlive zombie) || zombie.IsDead())
{
continue;
}
if (skipAlreadyCharmed && zombie.Buffs != null && zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
{
continue;
}
float distSq = (zombie.position - swarm.position).sqrMagnitude;
if (distSq < bestDistSq)
{
bestDistSq = distSq;
nearest = zombie;
}
}
return nearest;
}
}
}