Files
necromants-tome-7d2d-3-2/HarmonySrc/ParticlePatch.cs
T
AlexCubeandClaude Opus 5 98ddcd7bce Добыча «Жертвы» переехала в сам труп: он красится, лежит час и обыскивается
Указание: «зомби после упокоения оставляет свой труп. Можем ли мы подменять
этот труп точно таким же, но с другой текстурой и с функцией мешка жертвы?»

ПОДМЕНЯТЬ НЕ ПРИШЛОСЬ НИЧЕГО. Труп - не отдельная модель, а та же скелетная
модель зомби в позе смерти, так что «такой же, но другой текстуры» делается
перекраской уже лежащего трупа. Ни нового префаба, ни новой сущности.

Три части, каждая на своём ванильном механизме:

  НЕ ИСЧЕЗАЕТ. EntityAlive.timeStayAfterDeath - обычное публичное поле в тиках,
  у зомби 30 секунд, у ванильного мешка 3600. Пишем второе.

  ОБЫСКИВАЕТСЯ. У сущностей своя система команд, отдельная от блочной:
  InitLocalActivationCommands / AllowActivationCommand / OnEntityActivated.
  Объявляем ту же команду "search", что и ванильный мешок (EntityItem), поэтому
  подпись достаётся готовой и локализованной. Список команд кешируется при
  первом обращении - пока зомби жив, - поэтому на смерти он обнуляется.

  ВНУТРИ ЛУТ. Обход сознательный: хранилище живёт внутри EntityLootContainer
  вместе со своим сейвом и сетевой синхронизацией, и приделывать это трупу
  значит трогать сейвы. Поэтому обыск ПРЕВРАЩАЕТ труп в мешок «Жертва».

ПЕРЕКРАСКА ЧЕРЕЗ ТЕКСТУРУ, А НЕ ЧЕРЕЗ _Color. Первая версия не работала вовсе:
у шейдеров Game/Character и Game/Animal/Fur свойства цвета НЕТ (видно в пробе,
которую печатает GhostTraderPatch: "tint property: <none>"). Красится сама
текстура - прогоном через RenderTexture, как обесцвечиваются торговцы, только
вместо сведения к яркости умножение на цвет. Результат кешируется по исходной
текстуре. Если модель на кадре смерти не собрана, труп встаёт в очередь и
докрашивается односекундным тиком.

ПОДСКАЗКА ПРИ НАВЕДЕНИИ - отдельный механизм, и сначала я настроил не тот.
Иконка EntityActivationCommand рисуется в РАДИАЛЬНОМ меню, то есть на зажатии.
А то, что видно при простом наведении, приходит из Entity.GetActivationText(),
и у EntityAlive он для существа возвращает пустую строку. Теперь возвращает то
же, что ванильный лутаемый объект: строку lootTooltipNew со значком клавиши из
разметки [action:...]. Имя берётся у зомби, так что читается «обыскать Арлин».

ОБЫСК НЕ МГНОВЕННЫЙ: три секунды с круговым индикатором (XUiC_Timer, тот же,
что при снятии верстака). Поле activateTime у команды не подошло - по нему игра
дёргает игровое событие из entity_class, а не наш код.

Обычный мешок это не отменяет: бросок вероятности остался как был, и теперь он
печатается в лог целиком (шанс против броска) - чтобы «не повезло» отличалось
от «сломано» без гадания. У ванильного зомби шанс 4%, то есть ноль мешков на
два десятка убийств - исход с вероятностью около 40%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:23:15 +03:00

172 lines
8.6 KiB
C#

using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Shrinks and recolors the "RadiatedParticlesOnMesh" glow that both buffNecroDeviatorCharm
/// and buffNecroVictim attach to a zombie (see buffs.xml, action="AttachParticleEffectToEntity"
/// - both buffs reuse the same particle prefab rather than needing two different ones).
///
/// Why this needs Harmony: AttachParticleEffectToEntity's XML attributes are limited to
/// particle/parent_transform/local_offset/local_rotation/oneshot/shape_mesh/sound - there is
/// no scale or color/alpha attribute (confirmed by decompiling
/// MinEventActionAttachParticleEffectToEntity.ParseXmlAttribute - that's the exhaustive list).
/// The prefab always instantiates at its own authored size/color; nothing in XML can change
/// that. So instead we let the vanilla action run as normal (Postfix, not Prefix - the
/// particle GameObject has to already exist), then find the same child object it just created
/// and adjust it directly - same lookup the engine itself uses internally: a child transform
/// named "Ptl_" + the particle prefab's name, parented under the entity's mesh transform.
///
/// SizeFactor dropped 0.5 -> 0.2 2026-08-28 (user: "выглядят как шар вне зомби" - even the
/// original half-size shrink still read as a floating ball rather than a mesh-hugging glow).
///
/// Color (2026-08-28): Deviator green and Victim purple are both explicit now (Deviator used
/// to just be whatever RadiatedParticlesOnMesh's own baked-in color happens to be - reads
/// "green/energy" on its own, never actually set). Explicit per user request: "если оба бафа,
/// то пусть свечения смешиваются" - a zombie carrying both gets Color.Lerp(charm, victim, .5),
/// not one color just overriding the other.
///
/// Gated to only affect entities carrying at least one of OUR buffs (checked per-buff, not
/// just "is this the right particle name") - not vanilla naturally-irradiated zombies that
/// happen to reuse the same particle prefab elsewhere.
///
/// GENERALIZED 2026-08-29 for buffNecroPortalChannel (BACKLOG.md item 6, user request:
/// dense green-blue particles while a portal stone channels, thick enough to partially
/// obscure the player) - was hard-gated to `EntityZombie` specifically (`_params.Self is
/// EntityZombie`) since the two original buffs are both zombie-facing; this new one targets
/// the PLAYER, so the check is now against the common `EntityAlive` base (where
/// `.Buffs`/`.emodel` actually live) instead. Also needed its own size/alpha/DENSITY numbers
/// separate from the zombie glow's - the first version reused the same SizeFactor/AlphaFactor
/// constants for all three buffs, which the user confirmed reads as "редкие-редкие" (way too
/// sparse) for a "should partly cover you" effect - see GlowConfig below, one per buff now
/// instead of two shared constants.
/// </summary>
[HarmonyPatch(typeof(MinEventActionAttachParticleEffectToEntity), "Execute")]
public static class Patch_AttachParticleEffectToEntity_ShrinkCharmGlow
{
public const string ParticleName = "RadiatedParticlesOnMesh";
public class GlowConfig
{
public Color Tint;
public float SizeFactor;
public float AlphaFactor;
/// <summary>Multiplies both the emission rate (particles/second) AND maxParticles by
/// this factor together - raising rate alone caps out silently once the system hits
/// its authored maxParticles ceiling, so both have to move together to actually get a
/// visibly denser cloud instead of the same particle count arriving faster.</summary>
public float DensityFactor;
}
/// <summary>Unchanged from the original 2026-08-28 tuning - the zombie-facing glow was
/// never asked to get denser/bigger, only the new portal-channel one was.</summary>
public static readonly GlowConfig CharmGlow = new GlowConfig { Tint = new Color(0.2f, 1f, 0.3f), SizeFactor = 0.2f, AlphaFactor = 0.5f, DensityFactor = 1f };
/// <summary>Purple, per user request 2026-08-28 ("подсвети бафнутого зомби... фиолетовым").</summary>
public static readonly GlowConfig VictimGlow = new GlowConfig { Tint = new Color(0.55f, 0.05f, 0.85f), SizeFactor = 0.2f, AlphaFactor = 0.5f, DensityFactor = 1f };
/// <summary>RE-TUNED 2026-08-29 (user: "частицы есть, но они редкие-редкие. А надо чтобы
/// прямо густо располагались... чтобы частично перекрывали внешний вид" + colour changed
/// from the first version's near-black to green-blue/teal, "зелёноголубые"). SizeFactor
/// bumped from a shrink (0.2, matching the mesh-hugging zombie glow) to just under full
/// size (0.9) - a swirl meant to partly obscure the player needs to actually be
/// body-sized, not a tight skin-hugging glow. AlphaFactor raised to near-opaque (0.9) for
/// the same "obscures the view" reason - the zombie glow's own 0.5 was deliberately subtle,
/// this one shouldn't be. DensityFactor=5 - the actual fix for "редкие-редкие", multiplies
/// both emission rate and maxParticles together (see GlowConfig's own doc on why both).</summary>
public static readonly GlowConfig PortalChannelGlow = new GlowConfig { Tint = new Color(0.1f, 0.85f, 0.8f), SizeFactor = 0.9f, AlphaFactor = 0.9f, DensityFactor = 5f };
public static void Postfix(MinEventActionAttachParticleEffectToEntity __instance, MinEventParams _params)
{
if (_params.Self == null || __instance.goToInstantiate == null)
{
return;
}
if (__instance.goToInstantiate.name != ParticleName)
{
return;
}
if (!(_params.Self is EntityAlive entity) || entity.Buffs == null)
{
return;
}
bool isPortalChannel = entity.Buffs.HasBuff(Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelBuffName);
bool isVictim = entity.Buffs.HasBuff(Patch_EntityAlive_dropItemOnDeath_VictimBag.VictimBuffName);
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
if (!isPortalChannel && !isVictim && !isCharm)
{
return;
}
// Portal channel is player-only and never coexists with the zombie-facing buffs
// below in practice, so it's kept as a simple separate branch rather than folded
// into the same Lerp blend those two use with each other.
GlowConfig config;
if (isPortalChannel)
{
config = PortalChannelGlow;
}
else if (isVictim && isCharm)
{
config = new GlowConfig { Tint = Color.Lerp(CharmGlow.Tint, VictimGlow.Tint, 0.5f), SizeFactor = CharmGlow.SizeFactor, AlphaFactor = CharmGlow.AlphaFactor, DensityFactor = 1f };
}
else
{
config = isVictim ? VictimGlow : CharmGlow;
}
Transform meshTransform = entity.emodel != null ? entity.emodel.meshTransform : null;
if (meshTransform == null)
{
return;
}
Transform particleTransform = meshTransform.Find("Ptl_" + ParticleName);
if (particleTransform == null)
{
return;
}
// Belt-and-suspenders for size: not every particle system's Scaling Mode respects
// transform scale, but startSizeMultiplier always does regardless of that setting.
particleTransform.localScale = Vector3.one * config.SizeFactor;
ParticleSystem[] systems = particleTransform.GetComponentsInChildren<ParticleSystem>(true);
foreach (ParticleSystem ps in systems)
{
ParticleSystem.MainModule main = ps.main;
main.startSizeMultiplier *= config.SizeFactor;
main.maxParticles = Mathf.Max(1, Mathf.RoundToInt(main.maxParticles * config.DensityFactor));
if (config.DensityFactor != 1f)
{
ParticleSystem.EmissionModule emission = ps.emission;
emission.rateOverTimeMultiplier *= config.DensityFactor;
emission.rateOverDistanceMultiplier *= config.DensityFactor;
}
ParticleSystem.MinMaxGradient startColor = main.startColor;
switch (startColor.mode)
{
case ParticleSystemGradientMode.Color:
{
Color c = config.Tint;
c.a = startColor.color.a * config.AlphaFactor;
startColor.color = c;
break;
}
case ParticleSystemGradientMode.TwoColors:
{
Color min = config.Tint;
Color max = config.Tint;
min.a = startColor.colorMin.a * config.AlphaFactor;
max.a = startColor.colorMax.a * config.AlphaFactor;
startColor.colorMin = min;
startColor.colorMax = max;
break;
}
// Gradient/TwoGradients modes bake color+alpha into the gradient asset itself -
// no generic way to override that from code, so those are left as-is.
}
main.startColor = startColor;
}
}
}
}