Одержимость: приёмы Чака, ранчера и крикуньи на 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
+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, её же шаги). Отпустил - дальше
/// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой,