Ночью по R с ножом - дух змеи: тело падает (-95% HP), игрок управляет призрачной змеёй от первого лица. Свободный ход по миру (свой наблюдатель чанков, отрисовка, звук, спавн и спящие считаются от змеи), прыжок, атака на ЛКМ - рывок сквозь препятствия и чёрный разрыв, ПКМ - выбраться из текстур. Тёмное чутьё, тревога у тела, отсчёт в радиации края карты, пурпурный фильтр зрения (necrovision). Утром змея развеивается. Чёрный разрыв (NecroBurst.cs): клон частиц confetti в чёрном, команда necroburst. Проверено в игре 28.09. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
10 KiB
C#
222 lines
10 KiB
C#
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 + ")");
|
||
}
|
||
}
|
||
}
|