Files
necromants-tome-7d2d-3-2/HarmonySrc/NecroBurst.cs
T
AlexCubeandClaude Opus 5.5 6ff76d174e Режим змеи для Ножа некроманта и чёрный разрыв
Ночью по R с ножом - дух змеи: тело падает (-95% HP), игрок управляет
призрачной змеёй от первого лица. Свободный ход по миру (свой наблюдатель
чанков, отрисовка, звук, спавн и спящие считаются от змеи), прыжок,
атака на ЛКМ - рывок сквозь препятствия и чёрный разрыв, ПКМ - выбраться
из текстур. Тёмное чутьё, тревога у тела, отсчёт в радиации края карты,
пурпурный фильтр зрения (necrovision). Утром змея развеивается.

Чёрный разрыв (NecroBurst.cs): клон частиц confetti в чёрном, команда
necroburst. Проверено в игре 28.09.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:31:37 +03:00

222 lines
10 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Чёрный разрыв: зомби на смерти рассыпается облаком чёрных частиц со звуком «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 - это та
/// точка, через которую идёт любой спавн частиц и на сервере, и на клиенте.
///
/// Счёт Некромантии и добыча не страдают: к моменту постфикса они уже отработали. А вот
/// красный труп Жертвы с разрывом несовместим - тело удаляется (решать при дистанционной атаке).
/// </summary>
public static class NecroBurst
{
public const string ParticleName = "necroblackburst";
public const string SourceParticleName = "confetti";
public const string SoundName = "gore_head";
/// <summary>Почти чёрный, а не 0,0,0: у чистого чёрного пропадает объём частиц.</summary>
public static readonly Color Tint = new Color(0.04f, 0.035f, 0.045f, 1f);
/// <summary>Зомби, которые на ближайшей смерти должны разорваться. Сервер.</summary>
public static readonly HashSet<int> Pending = new HashSet<int>();
/// <summary>Разрыв случился: жертва и Transform частиц (может быть null).</summary>
public static System.Action<EntityAlive, Transform> OnBurst;
static GameObject _holder;
public static int ParticleId => ParticleEffect.ToId(ParticleName);
/// <summary>Строит чёрный клон конфетти и регистрирует его. true - клон есть.</summary>
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<ParticleSystem>(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<ParticleSystemRenderer>();
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;
}
/// <summary>Разорвать уже мёртвого зомби: частицы, звук, удаление тела. Только сервер.</summary>
public static void Burst(EntityAlive _victim, Entity _soundSource)
{
if (!SingletonMonoBehaviour<ConnectionManager>.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<ConnectionManager>.Instance.SendPackage(
NetPackageManager.GetPackage<NetPackageParticleEffect>().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);
}
}
/// <summary>Ленивая регистрация клона: любой спавн по нашему id сначала строит его.</summary>
[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();
}
}
}
/// <summary>Помеченный зомби умер - разрываем. Постфикс: добыча и опыт уже выданы.</summary>
[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);
}
}
/// <summary>
/// necroburst - убить чёрным разрывом зомби, на которого смотришь (до 30 м). Проверка эффекта,
/// пока дистанционной атаки нет. Убийство засчитывается игроку как обычное.
/// </summary>
[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<string> _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<SdtdConsole>.Instance.Output("necroburst: не вижу зомби перед собой (до 30 м)");
return;
}
NecroBurst.Pending.Add(best.entityId);
best.DamageEntity(new DamageSourceEntity(EnumDamageSource.External, EnumDamageTypes.Piercing, player.entityId),
99999, false);
SingletonMonoBehaviour<SdtdConsole>.Instance.Output("necroburst: " + best.EntityName + " (" + best.entityId + ")");
}
}
}