Одержимость: приёмы Чака, ранчера и крикуньи на 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
+4 -2
View File
@@ -202,7 +202,8 @@ namespace NecromancerTome
{
return;
}
if (!NecroCharmSide.IsCharmed(_e))
// Одержимый зомби - тоже «свой» (13.10: орда крикуньи под девиацией появляется вокруг неё самой).
if (!NecroCharmSide.IsCharmed(_e) && !Possession.IsPossessed(_e))
{
return;
}
@@ -219,7 +220,8 @@ namespace NecromancerTome
{
return;
}
if (!NecroCharmSide.IsCharmed(__instance) || !NecroCharmSide.IsCharmed(_attackTarget))
if (!NecroCharmSide.IsCharmed(__instance) ||
!NecroCharmSide.IsCharmed(_attackTarget) && !Possession.IsPossessed(_attackTarget))
{
return;
}
+365 -39
View File
@@ -32,8 +32,7 @@ namespace NecromancerTome
/// точка под прицелом камеры (SetLookPosition), как у ИИ - голова цели.
/// Пробел - прыжок на собственную дальность зомби (jumpMaxDistance из JumpMaxDistance): у паука 7-9 м,
/// как его EAILeap, у остальных их 1-4 м.
/// ПКМ у крикуньи - её крик (SoundAlert): все зомби в ScreamRange бросаются на неё, даже не видя (её
/// ванильный зов орды - часть разведки AIScoutHordeSpawner, напрямую не вызываем).
/// Чак, ранчер, крикунья - свои приёмы на E и ПКМ (указание 13.10), см. PossessionPowers.
/// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько
/// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело.
///
@@ -206,9 +205,15 @@ namespace NecromancerTome
static int _rItem, _rStartAnim;
static float _rRelease, _rDuration, _rCooldown;
static string _rSoundStart, _rSoundRelease;
enum RState { Idle, StartAnim, Release, Fire }
// Held - валун Чака поднят и ждёт ПКМ.
enum RState { Idle, StartAnim, Held, Release, Fire }
static RState _rState;
static float _rTime, _rNext;
/// <summary>Сколько ждать позы готовности после замаха; не дождались - валун всё равно считается поднятым,
/// а в лог - что показывал аниматор (тест 12.10: замах не начинался, кнопки больше не отвечали).</summary>
public const float WindupTimeout = 2.5f;
/// <summary>Куда летит валун Чака - выбрано в момент броска (Patch_ItemActionVomit_GetActionEffectsValues_Possession).</summary>
public static Vector3 ThrowAim;
static GameObject _hud;
/// <summary>Метка одержимого на карте (указание 06.10, как у змеи) - NecroMapMarker.</summary>
@@ -311,6 +316,7 @@ namespace NecromancerTome
_bodyViewDim = body.viewDim;
body.viewDim = BodyViewDim;
}
PossessionPowers.Begin(_zombie, _pet);
ReadRanged(_zombie);
Hint(_player0, _zombie);
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
@@ -366,6 +372,7 @@ namespace NecromancerTome
}
_rState = RState.Idle;
_hasRanged = false;
PossessionPowers.End(z);
Zombie = null;
_player = null;
Debug.Log("[NecromancerTome] Possession: выход - " + _why);
@@ -486,10 +493,13 @@ namespace NecromancerTome
_eating = false;
_eatCorpse = null;
}
PossessionPowers.Clear();
PossessionHud.Forget();
// Цвет прицела - общая таблица игры, переживает выход из мира: вернуть всегда.
AimTint(false);
return;
}
PossessionPowers.SpawnTick(world); // орда крикуньи досыпается и после выхода из неё
if (!Active)
{
return;
@@ -558,7 +568,33 @@ namespace NecromancerTome
cop.HandlePrimingDetonator();
NecroChallenges.Award(NecroChallenges.LivingBomb);
}
Ranged(z, ranged, rangedHeld);
switch (PossessionPowers.Current)
{
case PossessionPowers.Kind.Thrower:
Throw(world, z, detonate, ranged); // E - замах, ПКМ - бросок
break;
case PossessionPowers.Kind.Rancher:
if (detonate)
{
PossessionPowers.StartInvis(z);
}
if (ranged && _rState == RState.Idle && Time.time >= _rNext)
{
PossessionPowers.BreakInvis(z, "плевок");
}
Ranged(z, ranged, rangedHeld); // ПКМ - Рой фараона (Patch_ItemActionSpawnEntity_Spawn_Possession)
break;
case PossessionPowers.Kind.Screamer:
if (detonate)
{
PossessionPowers.Scream(_player, z);
}
break;
default:
Ranged(z, ranged, rangedHeld);
break;
}
PossessionPowers.Tick(world, z);
if (Flying)
{
@@ -703,12 +739,9 @@ namespace NecromancerTome
// Нюх (указание 09.10): все существа рядом с собакой - на компасе и карте.
DogSense.Tick(world, z, Time.deltaTime);
}
if (ranged && IsScreamer(z))
{
Scream(world, z);
}
if (attack && Time.time >= _nextAttack)
{
PossessionPowers.BreakInvis(z, "удар");
z.SetLookPosition(MeleeAim(world, z));
if (z.Attack(false))
{
@@ -736,8 +769,12 @@ namespace NecromancerTome
}
_observer?.SetPosition(z.position);
// Спящие в зданиях просыпаются от одержимого, как от змеи (указание 05.10).
SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime);
Taunt(world, z);
if (!PossessionPowers.Invisible)
{
// Невидимого ранчера никто не замечает и спящие от него не просыпаются.
SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime);
Taunt(world, z);
}
CheckBody(world, z, Time.deltaTime);
}
@@ -1356,6 +1393,12 @@ namespace NecromancerTome
_rDuration = t.attackDuration > 0f ? t.attackDuration : 3f;
// Вдвое быстрее ИИ (тест 03.10: «перезаряд медленный»).
_rCooldown = Mathf.Max(1f, t.baseCooldown * 0.5f);
if (PossessionPowers.Current == PossessionPowers.Kind.Rancher)
{
// Рой фараона - раз в 30 с (решение 13.10); откат не сбрасывается перевселением.
_rCooldown = PossessionPowers.SwarmCooldown;
_rNext = PossessionPowers.SwarmReadyAt;
}
_rSoundStart = t.soundStartName;
_rSoundRelease = t.soundReleaseName;
Debug.Log("[NecromancerTome] Possession: дальняя атака - оружие " + _rItem + ", замах " + _rStartAnim + ", длит. " + _rDuration + " с, откат " + _rCooldown + " с");
@@ -1369,13 +1412,23 @@ namespace NecromancerTome
{
text += ", " + Localization.Get("necroPossessHintFly");
}
if (_hasRanged)
switch (PossessionPowers.Current)
{
text += ", " + Localization.Get("necroPossessHintRanged");
}
if (IsScreamer(_z))
{
text += ", " + Localization.Get("necroPossessHintScream");
case PossessionPowers.Kind.Thrower:
text += ", " + Localization.Get("necroPossessHintThrow");
break;
case PossessionPowers.Kind.Rancher:
text += ", " + Localization.Get("necroPossessHintSwarm") + ", " + Localization.Get("necroPossessHintInvis");
break;
case PossessionPowers.Kind.Screamer:
text += ", " + Localization.Get("necroPossessHintScream");
break;
default:
if (_hasRanged)
{
text += ", " + Localization.Get("necroPossessHintRanged");
}
break;
}
if (Flying)
{
@@ -1538,7 +1591,9 @@ namespace NecromancerTome
// Доводим до конца по одному нажатию: у плевка сначала 1-3 предупреждения по 1.2 с
// (ItemActionVomit), и с отпущенной кнопкой он сбрасывался (тест 03.10: «если зажать
// ПКМ - плюёт»). Конец - оружие закончило или вышла длительность задачи.
if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration)
// Рой ранчера: действие «появления» само не кончается, пока держат кнопку, - вылетел, и хватит.
if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration ||
PossessionPowers.SwarmSpawned && _rTime > 0.5f)
{
StopRanged(_z);
}
@@ -1546,6 +1601,79 @@ namespace NecromancerTome
}
}
/// <summary>Валун Чака (указание 13.10): E - замах, поднять валун над собой (шаг StartAnim ванильной задачи);
/// ПКМ - бросок (ContinueAnimAction, отпуск, выстрел - общие шаги Ranged) в точку под прицелом или в существо
/// у прицела. Замах - прямо через avatarController.StartAction: EntityAlive.StartAnimAction молча ничего не
/// делает, пока аниматор считает, что идёт другое действие (IsActionActive), - так, похоже, и было 12.10.</summary>
static void Throw(World _world, EntityAlive _z, bool _windup, bool _throw)
{
if (!_hasRanged || _rStartAnim < 0)
{
Ranged(_z, _throw, _throw);
return;
}
switch (_rState)
{
case RState.Idle:
if (!_windup)
{
if (_throw)
{
Debug.Log("[NecromancerTome] Possession: броска нет - валун не поднят (сначала E)");
}
return;
}
if (Time.time < _rNext || !_z.IsAttackValid())
{
Debug.Log("[NecromancerTome] Possession: замах не готов - откат " + Mathf.Max(0f, _rNext - Time.time).ToString("F1") + " с, атака " + (_z.IsAttackValid() ? "можно" : "нельзя"));
return;
}
RearmRanged(_z);
_z.emodel.avatarController.hitWeightMax = 0.5f;
if (!string.IsNullOrEmpty(_rSoundStart))
{
Audio.Manager.BroadcastPlay(_z, _rSoundStart);
}
Debug.Log("[NecromancerTome] Possession: замах Чака, аниматор до - " + _z.GetAnimActionState());
_z.emodel.avatarController.StartAction(_rStartAnim + 3000);
_rState = RState.StartAnim;
_rTime = 0f;
return;
case RState.StartAnim:
_z.SetLookPosition(AimPoint());
_rTime += Time.deltaTime;
AvatarController.ActionState s = _z.GetAnimActionState();
if (s != AvatarController.ActionState.Ready && _rTime < WindupTimeout)
{
return;
}
Debug.Log(s == AvatarController.ActionState.Ready
? "[NecromancerTome] Possession: валун поднят за " + _rTime.ToString("F2") + " с"
: "[NecromancerTome] Possession: замах не дошёл до готовности за " + WindupTimeout + " с (аниматор - " + s + "), бросок всё равно разрешён");
_rState = RState.Held;
return;
case RState.Held:
_z.SetLookPosition(AimPoint());
if (!_throw)
{
return;
}
ThrowAim = PossessionPowers.ThrowTarget(_world, _z, AimPoint());
_z.ContinueAnimAction(_rStartAnim + 1 + 3000);
if (!string.IsNullOrEmpty(_rSoundRelease))
{
Audio.Manager.BroadcastPlay(_z, _rSoundRelease);
}
Debug.Log("[NecromancerTome] Possession: бросок валуна, до цели " + (ThrowAim - _z.position).magnitude.ToString("F1") + " м");
_rState = RState.Release;
_rTime = 0f;
return;
default:
Ranged(_z, false, false); // отпуск и выстрел - как у ИИ
return;
}
}
/// <summary>Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
/// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
/// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).</summary>
@@ -1589,15 +1717,57 @@ namespace NecromancerTome
_z.emodel.avatarController.hitWeightMax = 1f;
_rState = RState.Idle;
_rNext = Time.time + _rCooldown;
if (PossessionPowers.Current == PossessionPowers.Kind.Rancher)
{
PossessionPowers.SwarmReadyAt = _rNext;
}
PossessionPowers.SwarmSpawned = false;
}
static bool IsScreamer(EntityAlive _z)
/// <summary>Откат приёма для PossessionHud: Rmb - кнопка ПКМ (иначе E), Left/Total - секунды; Active - приём идёт
/// (невидимость), Left - сколько ему осталось.</summary>
public struct Cooldown
{
return EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c) && c.entityClassName.StartsWith("zombieScreamer");
public bool Rmb;
public float Left, Total;
public bool Active;
}
/// <summary>Крик одержимой крикуньи (и клич одержимого стервятника): его звук тревоги и все зомби в
/// ScreamRange - на него (танкование, приманка).</summary>
/// <summary>Откаты одержимого (до двух) - сколько записано в _out.</summary>
public static int Cooldowns(Cooldown[] _out)
{
if (!Active || Zombie == null)
{
return 0;
}
float now = Time.time;
int n = 0;
switch (PossessionPowers.Current)
{
case PossessionPowers.Kind.Thrower:
_out[n++] = new Cooldown { Rmb = false, Left = Mathf.Max(0f, _rNext - now), Total = _rCooldown };
break;
case PossessionPowers.Kind.Rancher:
_out[n++] = PossessionPowers.Invisible
? new Cooldown { Rmb = false, Left = PossessionPowers.InvisLeft, Total = PossessionPowers.InvisTime, Active = true }
: new Cooldown { Rmb = false, Left = PossessionPowers.InvisCooldownLeft, Total = PossessionPowers.InvisCooldown };
_out[n++] = new Cooldown { Rmb = true, Left = Mathf.Max(0f, _rNext - now), Total = _rCooldown };
break;
case PossessionPowers.Kind.Screamer:
_out[n++] = new Cooldown { Rmb = false, Left = PossessionPowers.HordeCooldownLeft, Total = PossessionPowers.HordeCooldown };
break;
default:
if (_hasRanged)
{
_out[n++] = new Cooldown { Rmb = true, Left = Mathf.Max(0f, _rNext - now), Total = _rCooldown };
}
break;
}
return n;
}
/// <summary>Клич одержимого стервятника (у крикуньи с 13.10 - орда, PossessionPowers.Scream): звук и все
/// зомби в ScreamRange - на него (приманка).</summary>
static void Scream(World _world, EntityAlive _z)
{
if (Time.time < _nextScream)
@@ -1913,19 +2083,96 @@ namespace NecromancerTome
}
}
/// <summary>Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
/// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
/// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.</summary>
/// <summary>Полоски одержимого (указание 13.10): здоровье - поверх полоски здоровья игрока, откаты приёмов -
/// поверх полоски выносливости (HUDLeftStatBars игры). Место берётся у самих полосок игры, на экране
/// (их UiTransform через камеру NGUI): так оно совпадает при любом масштабе интерфейса. Если полоски игры
/// сейчас не видно - последнее известное место, а до него - внизу по центру, как было с 03.10.
/// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.
///
/// ПАМЯТЬ. Ссылки на две полоски игры (их контроллеры) держим только до выхода из мира (Forget из
/// Possession) или до пересборки окна (UiTransform == null). Строки подписей собираются, только когда
/// меняется число, - не каждый кадр.</summary>
public class PossessionHud : MonoBehaviour
{
const float Width = 420f, Height = 16f, Bottom = 150f;
static readonly Color Back = new Color(0f, 0f, 0f, 0.6f);
static readonly Color Back = new Color(0f, 0f, 0f, 0.85f);
static readonly Color Fill = new Color(0.55f, 0.08f, 0.06f, 0.95f);
static readonly Color CdReady = new Color(0.75f, 0.62f, 0.95f, 0.95f);
static readonly Color CdCharging = new Color(0.35f, 0.3f, 0.45f, 0.95f);
static readonly Color CdActive = new Color(0.55f, 0.2f, 0.85f, 0.95f);
static GUIStyle _style;
static readonly Color Danger = new Color(1f, 0.15f, 0.1f, 1f);
static GUIStyle _dangerStyle;
static XUiC_HUDStatBar _healthBar, _staminaBar;
static Rect _healthRect, _staminaRect;
static bool _healthKnown, _staminaKnown;
static readonly Possession.Cooldown[] _cd = new Possession.Cooldown[2];
static readonly string[] _cdText = new string[2];
static readonly int[] _cdSec = { -1, -1 };
static readonly bool[] _cdRmb = new bool[2];
static string _hpText;
static int _hpId = -1, _hpCur = -1, _hpMax = -1;
/// <summary>Полоска игры обновилась - запомнить её (Patch_XUiC_HUDStatBar_Update_PossessionHud).</summary>
public static void Remember(XUiC_HUDStatBar _bar)
{
if (_bar.statType == HUDStatTypes.Health)
{
_healthBar = _bar;
}
else if (_bar.statType == HUDStatTypes.Stamina)
{
_staminaBar = _bar;
}
}
public static void Forget()
{
_healthBar = _staminaBar = null;
_healthKnown = _staminaKnown = false;
_hpText = null;
_hpId = -1;
for (int i = 0; i < _cdText.Length; i++)
{
_cdText[i] = null;
_cdSec[i] = -1;
}
}
/// <summary>Где на экране полоска игры (координаты GUI: y сверху).</summary>
static bool ScreenRect(ref XUiC_HUDStatBar _bar, ref Rect _rect, ref bool _known)
{
Transform t = _bar?.ViewComponent?.UiTransform;
if (t == null)
{
_bar = null; // окно пересобрано или мир закрыт
return _known;
}
if (!t.gameObject.activeInHierarchy)
{
return _known;
}
Camera cam = NGUITools.FindCameraForLayer(t.gameObject.layer);
if (cam == null)
{
return _known;
}
Vector2i size = _bar.ViewComponent.Size;
Vector3 a = cam.WorldToScreenPoint(t.position);
Vector3 b = cam.WorldToScreenPoint(t.TransformPoint(new Vector3(size.x, -size.y, 0f)));
Rect r = Rect.MinMaxRect(Mathf.Min(a.x, b.x), Screen.height - Mathf.Max(a.y, b.y), Mathf.Max(a.x, b.x), Screen.height - Mathf.Min(a.y, b.y));
if (r.width < 8f || r.height < 4f)
{
return _known;
}
_rect = r;
_known = true;
return true;
}
void OnGUI()
{
EntityAlive z = Possession.Active ? Possession.Zombie : null;
@@ -1938,23 +2185,89 @@ namespace NecromancerTome
DrawBodyDanger();
}
float scale = Screen.height / 1080f;
float w = Width * scale, h = Height * scale;
Rect r = new Rect((Screen.width - w) / 2f, Screen.height - Bottom * scale - h, w, h);
float frac = Mathf.Clamp01(z.Health / (float)Mathf.Max(1, z.GetMaxHealth()));
Color prev = GUI.color;
GUI.color = Back;
GUI.DrawTexture(r, Texture2D.whiteTexture);
GUI.color = Fill;
GUI.DrawTexture(new Rect(r.x + 2f, r.y + 2f, (r.width - 4f) * frac, r.height - 4f), Texture2D.whiteTexture);
GUI.color = prev;
if (_style == null)
{
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.LowerCenter };
_style.normal.textColor = new Color(0.9f, 0.85f, 0.8f);
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleLeft, clipping = TextClipping.Clip };
_style.normal.textColor = new Color(0.95f, 0.9f, 0.85f);
}
_style.fontSize = Mathf.RoundToInt(18f * scale);
string name = Localization.Get(z.EntityName);
GUI.Label(new Rect(r.x, r.y - 26f * scale, r.width, 24f * scale), name + " " + z.Health + " / " + z.GetMaxHealth(), _style);
Rect hr;
if (ScreenRect(ref _healthBar, ref _healthRect, ref _healthKnown))
{
hr = _healthRect;
}
else
{
float w = Width * scale, h = Height * scale * 1.6f;
hr = new Rect((Screen.width - w) / 2f, Screen.height - Bottom * scale - h, w, h);
}
int hp = z.Health, max = z.GetMaxHealth();
if (_hpText == null || _hpId != z.entityId || _hpCur != hp || _hpMax != max)
{
_hpId = z.entityId;
_hpCur = hp;
_hpMax = max;
_hpText = Localization.Get(z.EntityName) + " " + hp + "/" + max;
}
DrawBar(hr, Mathf.Clamp01(hp / (float)Mathf.Max(1, max)), Fill, _hpText);
int n = Possession.Cooldowns(_cd);
if (n == 0)
{
return;
}
Rect sr;
if (ScreenRect(ref _staminaBar, ref _staminaRect, ref _staminaKnown))
{
sr = _staminaRect;
}
else
{
sr = new Rect(hr.x, hr.y - hr.height - 4f * scale, hr.width, hr.height);
}
float gap = 4f * scale;
float cw = (sr.width - gap * (n - 1)) / n;
for (int i = 0; i < n; i++)
{
Possession.Cooldown c = _cd[i];
int sec = c.Left > 0f ? Mathf.CeilToInt(c.Left) : 0;
if (_cdText[i] == null || _cdSec[i] != sec || _cdRmb[i] != c.Rmb)
{
_cdSec[i] = sec;
_cdRmb[i] = c.Rmb;
string key = c.Rmb ? Localization.Get("necroPossessKeyRmb") : "E";
_cdText[i] = sec > 0 ? key + " " + sec : key;
}
float frac;
Color col;
if (c.Active)
{
frac = c.Total > 0f ? Mathf.Clamp01(c.Left / c.Total) : 0f;
col = CdActive;
}
else if (c.Left > 0f)
{
frac = c.Total > 0f ? 1f - Mathf.Clamp01(c.Left / c.Total) : 0f;
col = CdCharging;
}
else
{
frac = 1f;
col = CdReady;
}
DrawBar(new Rect(sr.x + i * (cw + gap), sr.y, cw, sr.height), frac, col, _cdText[i]);
}
}
static void DrawBar(Rect _r, float _frac, Color _fill, string _text)
{
Color prev = GUI.color;
GUI.color = Back;
GUI.DrawTexture(_r, Texture2D.whiteTexture);
GUI.color = _fill;
GUI.DrawTexture(new Rect(_r.x + 2f, _r.y + 2f, (_r.width - 4f) * _frac, _r.height - 4f), Texture2D.whiteTexture);
GUI.color = prev;
_style.fontSize = Mathf.Max(8, Mathf.RoundToInt(_r.height * 0.62f));
GUI.Label(new Rect(_r.x + 6f, _r.y, _r.width - 8f, _r.height), _text, _style);
}
/// <summary>Надпись «враг у тела» - та же, что у змеи (SnakeCrosshair.DrawBodyDanger): под прицелом,
@@ -1978,6 +2291,19 @@ namespace NecromancerTome
}
}
/// <summary>Запомнить полоски здоровья и выносливости игры - на их место PossessionHud кладёт свои.</summary>
[HarmonyPatch(typeof(XUiC_HUDStatBar), nameof(XUiC_HUDStatBar.Update))]
public static class Patch_XUiC_HUDStatBar_Update_PossessionHud
{
public static void Postfix(XUiC_HUDStatBar __instance)
{
if (__instance.statType == HUDStatTypes.Health || __instance.statType == HUDStatTypes.Stamina)
{
PossessionHud.Remember(__instance);
}
}
}
/// <summary>Приседание одержимого: пока зажата кнопка, рост зомби опускается до CrouchHeight, как игра
/// опускает его под низким потолком (EntityAlive.CrouchHeightFixedUpdate, её же шаги). Отпустил - дальше
/// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой,
+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;
}
}
}
}
+2 -1
View File
@@ -184,7 +184,8 @@ namespace NecromancerTome
float bestDistSq = float.MaxValue;
foreach (Entity entity in nearby)
{
if (!(entity is EntityAlive zombie) || zombie.IsDead())
// Одержимого (13.10: ранчер плюёт этим роем) не жалить - он свой.
if (!(entity is EntityAlive zombie) || zombie.IsDead() || Possession.IsPossessed(zombie))
{
continue;
}