Режим змеи для Ножа некроманта и чёрный разрыв

Ночью по R с ножом - дух змеи: тело падает (-95% HP), игрок управляет
призрачной змеёй от первого лица. Свободный ход по миру (свой наблюдатель
чанков, отрисовка, звук, спавн и спящие считаются от змеи), прыжок,
атака на ЛКМ - рывок сквозь препятствия и чёрный разрыв, ПКМ - выбраться
из текстур. Тёмное чутьё, тревога у тела, отсчёт в радиации края карты,
пурпурный фильтр зрения (necrovision). Утром змея развеивается.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-09-28 15:31:37 +03:00
co-authored by Claude Opus 5.5
parent 4cdd315b98
commit 6ff76d174e
13 changed files with 2289 additions and 1 deletions
+221
View File
@@ -0,0 +1,221 @@
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 + ")");
}
}
}