using System.Collections.Generic; using HarmonyLib; using UnityEngine; using UnityEngine.Scripting; namespace NecromancerTome { /// /// Чёрный разрыв: зомби на смерти рассыпается облаком чёрных частиц со звуком «gore_head», и /// трупа не остаётся. Свой аналог ванильного Twitch-эффекта «Celebrate» (конфетти), продиктовано /// 28.09.2026 как заготовка под дистанционную атаку (BACKLOG: «рывок и взрыв зомби»). /// /// КАК УСТРОЕН ОРИГИНАЛ (EntityAlive.ClientKill, игра 3.3). Уже после выпадения добычи /// (OnEntityDeath) и начисления опыта (AwardKillXPServer), если убийца - игрок, а на нём /// пассивка CelebrationKill, сервер делает три вещи: SpawnParticleEffectServer("confetti"), /// Manager.BroadcastPlay("twitch_celebrate") и World.RemoveEntity(Killed) - вместо рэгдолла. /// Имена частиц и звука там зашиты строками, поэтому ванильный путь не переиспользуется: мы /// повторяем те же три вызова своим постфиксом на ClientKill для помеченных зомби. /// /// ПОЧЕМУ ЧАСТИЦЫ - КОПИЯ, А НЕ ПЕРЕКРАСКА. Цвет из ParticleEffect игра применяет только к /// рендерерам БЕЗ ParticleSystem (ParticleEffect.SpawnParticleEffect), то есть конфетти он /// не красит. Поэтому при первом обращении клонируем префаб «confetti», красим в клоне сами /// системы частиц и кладём его в реестр игры (ParticleEffect.loadedTs) под своим именем. /// Ванильные конфетти остаются цветными. Клон строится лениво в GetDynamicTransform - это та /// точка, через которую идёт любой спавн частиц и на сервере, и на клиенте. /// /// Счёт Некромантии и добыча не страдают: к моменту постфикса они уже отработали. А вот /// красный труп Жертвы с разрывом несовместим - тело удаляется (решать при дистанционной атаке). /// public static class NecroBurst { public const string ParticleName = "necroblackburst"; public const string SourceParticleName = "confetti"; public const string SoundName = "gore_head"; /// Почти чёрный, а не 0,0,0: у чистого чёрного пропадает объём частиц. public static readonly Color Tint = new Color(0.04f, 0.035f, 0.045f, 1f); /// Зомби, которые на ближайшей смерти должны разорваться. Сервер. public static readonly HashSet Pending = new HashSet(); /// Разрыв случился: жертва и Transform частиц (может быть null). public static System.Action OnBurst; static GameObject _holder; public static int ParticleId => ParticleEffect.ToId(ParticleName); /// Строит чёрный клон конфетти и регистрирует его. true - клон есть. public static bool EnsureRegistered() { int id = ParticleId; if (ParticleEffect.loadedTs.ContainsKey(id)) { return true; } if (!ParticleEffect.loadedTs.TryGetValue(ParticleEffect.ToId(SourceParticleName), out Transform src) || src == null) { Debug.LogWarning("[NecromancerTome] NecroBurst: ванильный эффект '" + SourceParticleName + "' не найден"); return false; } // Неактивный держатель: клон под ним не играет сам, а копии, которые игра делает из // клона через Instantiate, выходят активными (активность берётся у самого клона). if (_holder == null) { _holder = new GameObject("NecroBurstPrefabs"); _holder.SetActive(false); Object.DontDestroyOnLoad(_holder); } Transform clone = Object.Instantiate(src, _holder.transform); clone.name = ParticleEffect.prefix + ParticleName; foreach (ParticleSystem ps in clone.GetComponentsInChildren(true)) { ParticleSystem.MainModule main = ps.main; main.startColor = new ParticleSystem.MinMaxGradient(Tint); ParticleSystem.ColorOverLifetimeModule col = ps.colorOverLifetime; if (col.enabled) { // Прозрачность по времени жизни оставляем как есть, цвет - в чёрный. Gradient g = new Gradient(); GradientAlphaKey[] alpha = col.color.gradient != null ? col.color.gradient.alphaKeys : new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }; g.SetKeys(new[] { new GradientColorKey(Tint, 0f), new GradientColorKey(Tint, 1f) }, alpha); col.color = new ParticleSystem.MinMaxGradient(g); } ParticleSystemRenderer r = ps.GetComponent(); if (r != null && r.sharedMaterial != null) { // Печатаем шейдер: если он аддитивный, чёрный на нём невидим, и это сразу видно // в логе, а не гаданием по пустому месту на экране. Debug.Log("[NecromancerTome] NecroBurst: система '" + ps.name + "', шейдер '" + r.sharedMaterial.shader.name + "', материал '" + r.sharedMaterial.name + "'"); } } ParticleEffect.loadedTs[id] = clone; Debug.Log("[NecromancerTome] NecroBurst: зарегистрирован эффект '" + ParticleName + "'"); return true; } /// Разорвать уже мёртвого зомби: частицы, звук, удаление тела. Только сервер. public static void Burst(EntityAlive _victim, Entity _soundSource) { if (!SingletonMonoBehaviour.Instance.IsServer) { return; } World world = GameManager.Instance.World; Vector3 pos = _victim.position; Transform effect = null; if (EnsureRegistered()) { float light = world.GetLightBrightness(_victim.GetBlockPosition()); ParticleEffect pe = new ParticleEffect(ParticleName, pos, light, Color.white, null, null, _OLDCreateColliders: false); // Как GameManager.SpawnParticleEffectServer, но с Transform на руках - режиму змеи // нужно вести частицы за собой (OnBurst). effect = ParticleEffect.SpawnParticleEffect(pe, _victim.entityId, false, true); SingletonMonoBehaviour.Instance.SendPackage( NetPackageManager.GetPackage().Setup(pe, _victim.entityId, false, true), _onlyClientsAttachedToAnEntity: false, -1, _victim.entityId); } OnBurst?.Invoke(_victim, effect); Audio.Manager.BroadcastPlay(_soundSource, pos, SoundName); world.RemoveEntity(_victim.entityId, EnumRemoveEntityReason.Killed); } } /// Ленивая регистрация клона: любой спавн по нашему id сначала строит его. [HarmonyPatch(typeof(ParticleEffect), nameof(ParticleEffect.GetDynamicTransform))] public static class Patch_ParticleEffect_GetDynamicTransform_NecroBurst { public static void Prefix(int _particleId) { if (_particleId == NecroBurst.ParticleId) { NecroBurst.EnsureRegistered(); } } } /// Помеченный зомби умер - разрываем. Постфикс: добыча и опыт уже выданы. [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.ClientKill))] public static class Patch_EntityAlive_ClientKill_NecroBurst { public static void Postfix(EntityAlive __instance) { if (!NecroBurst.Pending.Remove(__instance.entityId) || !__instance.IsDead()) { return; } NecroBurst.Burst(__instance, __instance.entityThatKilledMe != null ? (Entity)__instance.entityThatKilledMe : __instance); } } /// /// necroburst - убить чёрным разрывом зомби, на которого смотришь (до 30 м). Проверка эффекта, /// пока дистанционной атаки нет. Убийство засчитывается игроку как обычное. /// [Preserve] public class ConsoleCmdNecroBurst : ConsoleCmdAbstract { public override bool IsExecuteOnClient => true; public override bool AllowedInMainMenu => false; public override string[] getCommands() { return new string[] { "necroburst" }; } public override string getDescription() { return "Necromancer's Tome: kill the zombie you look at with the black burst (effect test)."; } public override void Execute(List _params, CommandSenderInfo _senderInfo) { World world = GameManager.Instance.World; EntityPlayerLocal player = world?.GetPrimaryPlayer(); if (player == null) { return; } Ray look = player.GetLookRay(); EntityAlive best = null; float bestAngle = 15f; foreach (Entity e in world.Entities.list) { if (!(e is EntityZombie z) || z.IsDead()) { continue; } Vector3 to = z.getChestPosition() - look.origin - Origin.position; if (to.magnitude > 30f) { continue; } float angle = Vector3.Angle(look.direction, to); if (angle < bestAngle) { bestAngle = angle; best = z; } } if (best == null) { SingletonMonoBehaviour.Instance.Output("necroburst: не вижу зомби перед собой (до 30 м)"); return; } NecroBurst.Pending.Add(best.entityId); best.DamageEntity(new DamageSourceEntity(EnumDamageSource.External, EnumDamageTypes.Piercing, player.entityId), 99999, false); SingletonMonoBehaviour.Instance.Output("necroburst: " + best.EntityName + " (" + best.entityId + ")"); } } }