Одержимость: приёмы Чака, ранчера и крикуньи на E, откаты в HUD

Чак: E - замах, ПКМ - бросок валуна в прицел или в существо.
Ранчер: ПКМ - Рой фараона, E - невидимость. Крикунья: E - орда
под девиацией. Здоровье одержимого и откаты - на месте полосок
здоровья и выносливости игрока.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-13 11:49:02 +03:00
co-authored by Claude Opus 5.5
parent cb2a4f19b4
commit 37ad57f1bc
7 changed files with 725 additions and 43 deletions
+349
View File
@@ -0,0 +1,349 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Сверхспособности одержимых особых зомби (указание 13.10: «проверь ванильные способности и переставь их на E»).
/// Чак (и Морозный коготь): E - замах, поднять валун над собой; ПКМ - метнуть его в точку под прицелом или
/// в существо у прицела (тест 12.10: «так и не бросил камень» - замах ждал позы готовности, а она не
/// наступала, и все следующие нажатия молча тонули).
/// Ранчер (и Чумной плевун): ПКМ - выплюнуть Рой фараона (necroInsectSwarm, наш рой из свитка, обращает
/// ужаленных зомби) вместо ванильных мух, откат SwarmCooldown; E - невидимость на InvisTime: зомби его
/// не видят и бросают погоню, модель полупрозрачна; удар или плевок её снимают.
/// Крикунья: E - крик зовёт орду разведчиков по стадии игры (как в игре, ScoutGSList), и вся орда сразу под
/// девиацией. Чужих зомби крик больше не стягивает (решение 13.10).
/// Откаты видны в PossessionHud (там, где полоска выносливости игрока).
///
/// ПАМЯТЬ. Только числа и очередь id классов орды; ссылок на сущности нет. Очередь чистится при выходе из мира.
/// </summary>
public static class PossessionPowers
{
public enum Kind { None, Thrower, Rancher, Screamer }
public static Kind Current;
public const float InvisTime = 15f, InvisCooldown = 30f;
/// <summary>Прозрачность невидимого ранчера - родной _Fade шейдера персонажей (EntityAlive.renderFadeMax).</summary>
public const float InvisFade = 0.25f;
public const float InvisClearPeriod = 0.25f;
public const float SwarmCooldown = 30f;
public const string SwarmEntity = "necroInsectSwarm";
public const float HordeCooldown = 60f;
public const string HordeSpawner = "ScoutGSList";
/// <summary>Не больше стольких за крик (у игры на стадии 52 - 26).</summary>
public const int HordeMax = 40;
/// <summary>Орда появляется по одному зомби раз в столько секунд - без рывка кадра.</summary>
public const float HordeSpawnStep = 0.2f;
public const int HordeMinRange = 4, HordeMaxRange = 14;
static float _invisUntil, _invisNext, _invisClear;
static float _hordeNext, _hordeStep;
static readonly List<int> _hordeQueue = new List<int>();
static Vector3 _hordeAt;
static int _hordeFails;
/// <summary>Рой фараона уже вылетел в этом плевке - дальняя атака ранчера закончена.</summary>
public static bool SwarmSpawned;
/// <summary>Когда ранчер снова сможет плюнуть роем (Time.time); переживает перевселение.</summary>
public static float SwarmReadyAt;
public static bool Invisible => Current == Kind.Rancher && Time.time < _invisUntil;
public static Kind KindOf(EntityAlive _z)
{
if (_z == null || !EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c))
{
return Kind.None;
}
string n = c.entityClassName;
if (n.StartsWith("zombieChuck") || n.StartsWith("zombieFrostclaw"))
{
return Kind.Thrower;
}
if (n.StartsWith("zombieRancher") || n.StartsWith("zombiePlagueSpitter"))
{
return Kind.Rancher;
}
if (n.StartsWith("zombieScreamer"))
{
return Kind.Screamer;
}
return Kind.None;
}
public static void Begin(EntityAlive _z, bool _pet)
{
Current = _pet ? Kind.None : KindOf(_z);
_invisUntil = 0f;
SwarmSpawned = false;
}
public static void End(EntityAlive _z)
{
if (Invisible)
{
SetFade(_z, 1f);
_invisNext = Time.time + InvisCooldown;
}
_invisUntil = 0f;
Current = Kind.None;
}
/// <summary>Выход из мира: всё в ноль (откаты тоже - Time.time в новом мире продолжается, но держать их незачем).</summary>
public static void Clear()
{
Current = Kind.None;
_invisUntil = _invisNext = _hordeNext = SwarmReadyAt = 0f;
_hordeQueue.Clear();
SwarmSpawned = false;
}
// ---------- Невидимость ранчера ----------
public static void StartInvis(EntityAlive _z)
{
if (Invisible)
{
return;
}
if (Time.time < _invisNext)
{
Debug.Log("[NecromancerTome] Possession: невидимость не готова - откат " + (_invisNext - Time.time).ToString("F1") + " с");
return;
}
_invisUntil = Time.time + InvisTime;
_invisNext = _invisUntil + InvisCooldown;
_invisClear = 0f;
SetFade(_z, InvisFade);
Audio.Manager.BroadcastPlay(_z, "plaguesense");
Debug.Log("[NecromancerTome] Possession: невидимость ранчера на " + InvisTime + " с");
}
/// <summary>Удар или плевок - невидимость снята, откат от этой минуты.</summary>
public static void BreakInvis(EntityAlive _z, string _why)
{
if (!Invisible)
{
return;
}
_invisUntil = 0f;
_invisNext = Time.time + InvisCooldown;
SetFade(_z, 1f);
Debug.Log("[NecromancerTome] Possession: невидимость снята - " + _why);
}
static void SetFade(EntityAlive _z, float _fade)
{
if (_z == null)
{
return;
}
_z.renderFadeMax = _fade;
_z.renderFadeTarget = _fade;
}
/// <summary>Каждый кадр одержимости: пока невидим - кто гнался за ранчером, теряет его.</summary>
public static void Tick(World _world, EntityAlive _z)
{
if (Current != Kind.Rancher || _invisUntil <= 0f)
{
return;
}
if (Time.time >= _invisUntil)
{
_invisUntil = 0f;
SetFade(_z, 1f);
Debug.Log("[NecromancerTome] Possession: невидимость кончилась");
return;
}
_z.renderFadeMax = InvisFade; // игра поднимает его только на смерти, но держим явно
if (Time.time < _invisClear)
{
return;
}
_invisClear = Time.time + InvisClearPeriod;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _z || e.IsDead())
{
continue;
}
if (e.GetAttackTarget() == _z)
{
e.SetAttackTarget(null, 0);
}
if (e.GetRevengeTarget() == _z)
{
e.SetRevengeTarget(null);
}
}
}
public static float InvisLeft => Invisible ? _invisUntil - Time.time : 0f;
public static float InvisCooldownLeft => Invisible ? 0f : Mathf.Max(0f, _invisNext - Time.time);
// ---------- Орда крикуньи ----------
public static float HordeCooldownLeft => Mathf.Max(0f, _hordeNext - Time.time);
public static void Scream(EntityPlayerLocal _player, EntityAlive _z)
{
if (Time.time < _hordeNext)
{
Debug.Log("[NecromancerTome] Possession: крик не готов - откат " + (_hordeNext - Time.time).ToString("F1") + " с");
return;
}
_hordeNext = Time.time + HordeCooldown;
string snd = _z.GetSoundAlert();
if (!string.IsNullOrEmpty(snd))
{
Audio.Manager.BroadcastPlay(_z, snd);
}
if (!GameStageDefinition.TryGetGameStage(HordeSpawner, out GameStageDefinition def) || def.stages.Count == 0)
{
Debug.Log("[NecromancerTome] Possession: орды нет - нет спавнера " + HordeSpawner);
return;
}
int gs = GameStageDefinition.CalcGameStageAround(_player);
GameStageDefinition.Stage stage = def.GetStage(gs) ?? def.stages[0];
int last = -1;
int added = 0;
for (int g = 0; g < stage.Count; g++)
{
GameStageDefinition.SpawnGroup grp = stage.GetSpawnGroup(g);
// У игры на первых стадиях num не задан (1) - даём хотя бы двоих.
int n = Mathf.Max(2, (int)grp.spawnCount);
for (int k = 0; k < n && _hordeQueue.Count < HordeMax; k++)
{
int id = EntityGroups.GetRandomFromGroup(grp.groupName, ref last);
if (id != -1 && id != 0)
{
_hordeQueue.Add(id);
added++;
}
}
}
_hordeAt = _z.position;
_hordeStep = 0f;
_hordeFails = 0;
Debug.Log("[NecromancerTome] Possession: крик крикуньи - орда " + added + " (стадия " + gs + ", ступень " + stage.stageNum + ")");
}
/// <summary>Каждый кадр (и после выхода из одержимости): орда появляется по одному.</summary>
public static void SpawnTick(World _world)
{
if (_hordeQueue.Count == 0 || Time.time < _hordeStep)
{
return;
}
_hordeStep = Time.time + HordeSpawnStep;
if (!_world.GetMobRandomSpawnPosWithWater(_hordeAt, HordeMinRange, HordeMaxRange, 0, false, out Vector3 pos))
{
if (++_hordeFails > 20)
{
Debug.Log("[NecromancerTome] Possession: орде негде появиться, осталось " + _hordeQueue.Count + " - отбой");
_hordeQueue.Clear();
}
return;
}
int id = _hordeQueue[_hordeQueue.Count - 1];
_hordeQueue.RemoveAt(_hordeQueue.Count - 1);
Entity e = EntityFactory.CreateEntity(id, pos, new Vector3(0f, Random.Range(0f, 360f), 0f));
e.SetSpawnerSource(EnumSpawnerSource.Dynamic);
_world.SpawnEntityInWorld(e);
if (e is EntityAlive a && a.Buffs != null)
{
a.Buffs.AddBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
}
if (_hordeQueue.Count == 0)
{
Debug.Log("[NecromancerTome] Possession: орда крикуньи вся на месте");
}
}
// ---------- Бросок Чака ----------
public const float ThrowAimRange = 100f;
/// <summary>Насколько луч прицела может пройти мимо груди существа, чтобы метнуть в него.</summary>
public const float ThrowAimRadius = 1.5f;
/// <summary>Куда лететь валуну: грудь существа у луча прицела (ближайшее по лучу), иначе точка под прицелом.</summary>
public static Vector3 ThrowTarget(World _world, EntityAlive _z, Vector3 _aimPoint)
{
Transform cam = Possession.Player?.cameraTransform;
if (cam == null)
{
return _aimPoint;
}
Vector3 from = cam.position + Origin.position;
Vector3 dir = cam.forward;
EntityAlive best = null;
float bestAlong = ThrowAimRange;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _z || e.IsDead() || e == Possession.Player)
{
continue;
}
Vector3 to = e.getChestPosition() - from;
float along = Vector3.Dot(to, dir);
if (along < Possession.CamBack + 0.5f || along > bestAlong)
{
continue;
}
if ((to - dir * along).sqrMagnitude > ThrowAimRadius * ThrowAimRadius)
{
continue;
}
bestAlong = along;
best = e;
}
return best != null ? best.getChestPosition() : _aimPoint;
}
}
/// <summary>Валун одержимого Чака - по дуге в точку под прицелом (или в существо): позиция вместо направления
/// и 1 - «баллистика», как у ИИ, когда цель есть. Без цели игра кидает прямо по взгляду - камень падал рядом.</summary>
[HarmonyPatch(typeof(ItemActionVomit), "GetActionEffectsValues")]
public static class Patch_ItemActionVomit_GetActionEffectsValues_Possession
{
public static void Postfix(ItemActionData _actionData, ref Vector3 _direction, ref int __result)
{
EntityAlive holder = _actionData?.invData?.holdingEntity;
if (!Possession.IsPossessed(holder) || PossessionPowers.Current != PossessionPowers.Kind.Thrower)
{
return;
}
_direction = Possession.ThrowAim;
__result = 1;
}
}
/// <summary>Плевок одержимого ранчера - Рой фараона вместо ванильных мух (указание 13.10).</summary>
[HarmonyPatch(typeof(ItemActionSpawnEntity), "Spawn")]
public static class Patch_ItemActionSpawnEntity_Spawn_Possession
{
public static void Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData, ref string __state)
{
EntityAlive holder = _actionData?.invData?.holdingEntity;
if (!Possession.IsPossessed(holder) || PossessionPowers.Current != PossessionPowers.Kind.Rancher)
{
return;
}
__state = __instance.entityToSpawn;
__instance.entityToSpawn = PossessionPowers.SwarmEntity;
PossessionPowers.SwarmSpawned = true;
Debug.Log("[NecromancerTome] Possession: ранчер выплюнул Рой фараона");
}
public static void Finalizer(ItemActionSpawnEntity __instance, string __state)
{
if (__state != null)
{
__instance.entityToSpawn = __state;
}
}
}
}