using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// "Книга банши" (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. /// [HarmonyPatch(typeof(ItemActionEat))] [HarmonyPatch("consume")] public static class Patch_ItemActionEat_Consume_Banshee { public const string ItemName = "bookBanshee"; /// 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). public static readonly string[] ZombiePool = { "zombieArlene", "zombieBoe", "zombieYo", "zombieJoe" }; /// "Небольшая орда" - not specified by the user as an exact number, guessed /// modest (comparable to a small blood-moon wave, not a screen-filling swarm). 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); } } } }