- AimPoint пропускает тело одержимого: у крупных зомби луч камеры упирался в свою спину - прицел не краснел, удары и выстрелы шли в себя. - Плевок мутанта (E): скорость снарядов пересчитывается навесом в цель под прицелом (до 15 м), вместо ванильного веера под ноги; подсказка на 13 языках. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
15 KiB
C#
380 lines
15 KiB
C#
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).
|
||
/// Мутант: E - его плевок веером вокруг себя (был на ПКМ). Подрыва у него нет: он EntityZombieCop, но в игре
|
||
/// не взрывается (ExplodeHealthThreshold 0), а E подрывал его по ошибке.
|
||
/// Откаты видны в PossessionHud (там, где полоска выносливости игрока).
|
||
///
|
||
/// ПАМЯТЬ. Только числа и очередь id классов орды; ссылок на сущности нет. Очередь чистится при выходе из мира.
|
||
/// </summary>
|
||
public static class PossessionPowers
|
||
{
|
||
public enum Kind { None, Thrower, Rancher, Screamer, Mutant }
|
||
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;
|
||
}
|
||
if (n.StartsWith("zombieMutated"))
|
||
{
|
||
return Kind.Mutant;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Плевок одержимого мутанта - в прицел (указание 13.10: «у мутанта атака не целится»). Ванильный
|
||
/// ItemActionVomit мутанта бросает снаряды веером под ноги (SpreadVerticalOffset -50, разброс 90° вбок) со
|
||
/// скоростью 4.5 м/с - ИИ так поливает того, кто вплотную. Здесь после вылета скорость каждого снаряда
|
||
/// пересчитывается навесом в Possession.SpitTarget, как ProjectileMoveScript.Fire делает для _isBallistic, с
|
||
/// небольшим разбросом между снарядами очереди.</summary>
|
||
[HarmonyPatch(typeof(ProjectileMoveScript), nameof(ProjectileMoveScript.Fire))]
|
||
public static class Patch_ProjectileMoveScript_Fire_PossessedSpit
|
||
{
|
||
public static void Postfix(ProjectileMoveScript __instance, Vector3 _idealStartPos, Entity _firingEntity)
|
||
{
|
||
if (!Possession.IsPossessed(_firingEntity) || PossessionPowers.Current != PossessionPowers.Kind.Mutant ||
|
||
__instance.itemActionProjectile == null || __instance.itemActionProjectile.FlyTime < 0f)
|
||
{
|
||
return;
|
||
}
|
||
Vector3 from = _idealStartPos.y != 0f ? _idealStartPos : __instance.transform.position + Origin.position;
|
||
Vector3 d = Possession.SpitTarget(from) + Random.insideUnitSphere * 0.4f - from;
|
||
float t = Mathf.Clamp(d.magnitude / 12f, 0.2f, 1.2f);
|
||
__instance.velocity = d / t;
|
||
__instance.velocity.y -= __instance.gravity * 0.5f * t;
|
||
__instance.flyDirection = __instance.velocity.normalized;
|
||
}
|
||
}
|
||
}
|