diff --git a/Config/Localization.csv b/Config/Localization.csv
index 6d36cb7..97cdf2d 100644
--- a/Config/Localization.csv
+++ b/Config/Localization.csv
@@ -223,8 +223,12 @@ necroHoldPifPaf,items,Item,,,,Bang-bang,,Peng-peng,Pum-pum,Pan-pan,Bang-bang,バ
necroPossessHintBase,items,Item,,,,"LMB - strike, R - leave the body",,"LMT - Schlag, R - Körper verlassen","LMB: golpe, R: salir del cuerpo","Clic gauche : frapper, R : quitter le corps","LMB - colpo, R - lascia il corpo",左クリック: 攻撃、R: 体から出る,"좌클릭 - 공격, R - 몸에서 나가기","LPM - cios, R - opuść ciało","LMB - golpe, R - sair do corpo","ЛКМ - удар, R - выйти из тела","Sol tık - vuruş, R - bedenden çık",左键 - 攻击,R - 离开身体,左鍵 - 攻擊,R - 離開身體
necroPossessHintRanged,items,Item,,,,RMB - ranged attack,,RMT - Fernangriff,RMB: ataque a distancia,Clic droit : attaque à distance,RMB - attacco a distanza,右クリック: 遠距離攻撃,우클릭 - 원거리 공격,PPM - atak dystansowy,RMB - ataque à distância,ПКМ - дальняя атака,Sağ tık - menzilli saldırı,右键 - 远程攻击,右鍵 - 遠程攻擊
necroPossessHintDetonate,items,Item,,,,E - detonate,,E - Sprengen,E: detonar,E : exploser,E - detonare,E: 起爆,E - 폭발,E - detonacja,E - detonar,E - подрыв,E - patlat,E - 引爆,E - 引爆
-necroPossessHintScream,items,Item,,,,RMB - scream (calls the dead to you),,RMT - Schrei (ruft die Toten herbei),RMB: grito (llama a los muertos),Clic droit : cri (appelle les morts),RMB - urlo (richiama i morti),右クリック: 叫び(死者を呼ぶ),우클릭 - 비명 (죽은 자를 부름),PPM - krzyk (wzywa umarłych),RMB - grito (chama os mortos),ПКМ - крик (созвать мертвецов),Sağ tık - çığlık (ölüleri çağırır),右键 - 尖叫(召唤亡者),右鍵 - 尖叫(召喚亡者)
+necroPossessHintScream,items,Item,,,,E - scream (summons a horde under Deviation),,E - Schrei (ruft eine Horde unter Abweichung),E: grito (invoca una horda bajo desviación),E : cri (appelle une horde sous déviation),E - urlo (evoca un'orda sotto deviazione),E: 叫び(逸脱したホードを呼ぶ),E - 비명 (일탈한 무리를 부름),E - krzyk (wzywa hordę pod dewiacją),E - grito (invoca uma horda sob desvio),E - крик (созвать орду под девиацией),E - çığlık (sapkın bir sürü çağırır),E - 尖叫(召唤逸脱的尸潮),E - 尖叫(召喚逸脫的屍潮)
necroPossessHintScreamE,items,Item,,,,E - scream (calls the dead to you),,E - Schrei (ruft die Toten herbei),E: grito (llama a los muertos),E : cri (appelle les morts),E - urlo (richiama i morti),E: 叫び(死者を呼ぶ),E - 비명 (죽은 자를 부름),E - krzyk (wzywa umarłych),E - grito (chama os mortos),E - крик (созвать мертвецов),E - çığlık (ölüleri çağırır),E - 尖叫(召唤亡者),E - 尖叫(召喚亡者)
+necroPossessHintThrow,items,Item,,,,"E - lift a boulder, RMB - throw it",,"E - Felsbrocken heben, RMT - werfen","E: levantar una roca, RMB: lanzarla","E : soulever un rocher, clic droit : le lancer","E - solleva un masso, RMB - lancialo",E: 岩を持ち上げる、右クリック: 投げる,"E - 바위 들기, 우클릭 - 던지기","E - podnieś głaz, PPM - rzuć","E - erguer uma pedra, RMB - arremessar","E - поднять валун, ПКМ - метнуть","E - kaya kaldır, sağ tık - fırlat",E - 举起巨石,右键 - 投掷,E - 舉起巨石,右鍵 - 投擲
+necroPossessHintSwarm,items,Item,,,,RMB - spew Pharaoh's beetles,,RMT - Käfer des Pharao ausspeien,RMB: escupir escarabajos del faraón,Clic droit : cracher les scarabées du pharaon,RMB - sputa gli scarabei del faraone,右クリック: ファラオの甲虫を吐く,우클릭 - 파라오의 딱정벌레 뱉기,PPM - wypluj żuki faraona,RMB - cuspir besouros do faraó,ПКМ - изрыгнуть жуков фараона,Sağ tık - firavun böceklerini kus,右键 - 喷出法老甲虫,右鍵 - 噴出法老甲蟲
+necroPossessHintInvis,items,Item,,,,E - invisibility (a strike breaks it),,E - Unsichtbarkeit (ein Schlag bricht sie),E: invisibilidad (un golpe la rompe),E : invisibilité (un coup la rompt),E - invisibilità (un colpo la spezza),E: 透明化(攻撃すると解ける),E - 투명화 (공격하면 풀림),E - niewidzialność (cios ją przerywa),E - invisibilidade (um golpe a quebra),E - невидимость (удар снимает),E - görünmezlik (vuruş bozar),E - 隐身(攻击会解除),E - 隱身(攻擊會解除)
+necroPossessKeyRmb,items,Item,,,,RMB,,RMT,RMB,Clic droit,RMB,右クリック,우클릭,PPM,RMB,ПКМ,Sağ tık,右键,右鍵
necroPossessHintZoom,items,Item,,,,RMB (hold) - keen eye: zoom like a scope,,RMT (halten) - scharfes Auge: Zoom wie ein Zielfernrohr,"RMB (mantener): ojo avizor, zoom como una mira","Clic droit (maintenir) : œil perçant, zoom comme une lunette",RMB (tieni premuto) - occhio acuto: zoom come un mirino,右クリック(長押し): 鋭い目・スコープのようにズーム,우클릭(누르기) - 매의 눈: 조준경처럼 확대,PPM (przytrzymaj) - bystre oko: przybliżenie jak luneta,RMB (segurar) - olho aguçado: zoom como uma luneta,"ПКМ (удерживать) - зоркий глаз: приближение, как у прицела",Sağ tık (basılı tut) - keskin göz: dürbün gibi yakınlaştır,右键(按住)- 锐眼:像瞄准镜一样放大,右鍵(按住)- 銳眼:像瞄準鏡一樣放大
necroPossessHintLeap,items,Item,,,,Space - long leap,,Leertaste - weiter Sprung,Espacio: salto largo,Espace : grand bond,Spazio - balzo lungo,スペース: 大ジャンプ,스페이스 - 멀리뛰기,Spacja - daleki skok,Espaço - salto longo,Пробел - дальний прыжок,Boşluk - uzun sıçrayış,空格 - 远跳,空白鍵 - 遠跳
necroPetRecallHint,items,Item,,,,Recall pet - E,,Diener zurückrufen - E,Devolver al sirviente - E,Rappeler le serviteur - E,Richiama il servitore - E,僕を呼び戻す - E,종복 불러들이기 - E,Odwołaj sługę - E,Chamar o servo de volta - E,Отозвать питомца - E,Hizmetkârı geri çağır - E,召回仆从 - E,召回僕從 - E
diff --git a/HarmonySrc/CharmPatch.cs b/HarmonySrc/CharmPatch.cs
index 2bb8007..82a19a1 100644
--- a/HarmonySrc/CharmPatch.cs
+++ b/HarmonySrc/CharmPatch.cs
@@ -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;
}
diff --git a/HarmonySrc/Possession.cs b/HarmonySrc/Possession.cs
index 533b6a0..6e5f6d3 100644
--- a/HarmonySrc/Possession.cs
+++ b/HarmonySrc/Possession.cs
@@ -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;
+ /// Сколько ждать позы готовности после замаха; не дождались - валун всё равно считается поднятым,
+ /// а в лог - что показывал аниматор (тест 12.10: замах не начинался, кнопки больше не отвечали).
+ public const float WindupTimeout = 2.5f;
+ /// Куда летит валун Чака - выбрано в момент броска (Patch_ItemActionVomit_GetActionEffectsValues_Possession).
+ public static Vector3 ThrowAim;
static GameObject _hud;
/// Метка одержимого на карте (указание 06.10, как у змеи) - NecroMapMarker.
@@ -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
}
}
+ /// Валун Чака (указание 13.10): E - замах, поднять валун над собой (шаг StartAnim ванильной задачи);
+ /// ПКМ - бросок (ContinueAnimAction, отпуск, выстрел - общие шаги Ranged) в точку под прицелом или в существо
+ /// у прицела. Замах - прямо через avatarController.StartAction: EntityAlive.StartAnimAction молча ничего не
+ /// делает, пока аниматор считает, что идёт другое действие (IsActionActive), - так, похоже, и было 12.10.
+ 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;
+ }
+ }
+
/// Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
/// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
/// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).
@@ -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)
+ /// Откат приёма для PossessionHud: Rmb - кнопка ПКМ (иначе E), Left/Total - секунды; Active - приём идёт
+ /// (невидимость), Left - сколько ему осталось.
+ 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;
}
- /// Крик одержимой крикуньи (и клич одержимого стервятника): его звук тревоги и все зомби в
- /// ScreamRange - на него (танкование, приманка).
+ /// Откаты одержимого (до двух) - сколько записано в _out.
+ 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;
+ }
+
+ /// Клич одержимого стервятника (у крикуньи с 13.10 - орда, PossessionPowers.Scream): звук и все
+ /// зомби в ScreamRange - на него (приманка).
static void Scream(World _world, EntityAlive _z)
{
if (Time.time < _nextScream)
@@ -1913,19 +2083,96 @@ namespace NecromancerTome
}
}
- /// Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
- /// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
- /// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.
+ /// Полоски одержимого (указание 13.10): здоровье - поверх полоски здоровья игрока, откаты приёмов -
+ /// поверх полоски выносливости (HUDLeftStatBars игры). Место берётся у самих полосок игры, на экране
+ /// (их UiTransform через камеру NGUI): так оно совпадает при любом масштабе интерфейса. Если полоски игры
+ /// сейчас не видно - последнее известное место, а до него - внизу по центру, как было с 03.10.
+ /// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.
+ ///
+ /// ПАМЯТЬ. Ссылки на две полоски игры (их контроллеры) держим только до выхода из мира (Forget из
+ /// Possession) или до пересборки окна (UiTransform == null). Строки подписей собираются, только когда
+ /// меняется число, - не каждый кадр.
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;
+
+ /// Полоска игры обновилась - запомнить её (Patch_XUiC_HUDStatBar_Update_PossessionHud).
+ 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;
+ }
+ }
+
+ /// Где на экране полоска игры (координаты GUI: y сверху).
+ 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);
}
/// Надпись «враг у тела» - та же, что у змеи (SnakeCrosshair.DrawBodyDanger): под прицелом,
@@ -1978,6 +2291,19 @@ namespace NecromancerTome
}
}
+ /// Запомнить полоски здоровья и выносливости игры - на их место PossessionHud кладёт свои.
+ [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);
+ }
+ }
+ }
+
/// Приседание одержимого: пока зажата кнопка, рост зомби опускается до CrouchHeight, как игра
/// опускает его под низким потолком (EntityAlive.CrouchHeightFixedUpdate, её же шаги). Отпустил - дальше
/// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой,
diff --git a/HarmonySrc/PossessionPowers.cs b/HarmonySrc/PossessionPowers.cs
new file mode 100644
index 0000000..83b7dc0
--- /dev/null
+++ b/HarmonySrc/PossessionPowers.cs
@@ -0,0 +1,349 @@
+using System.Collections.Generic;
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Сверхспособности одержимых особых зомби (указание 13.10: «проверь ванильные способности и переставь их на E»).
+ /// Чак (и Морозный коготь): E - замах, поднять валун над собой; ПКМ - метнуть его в точку под прицелом или
+ /// в существо у прицела (тест 12.10: «так и не бросил камень» - замах ждал позы готовности, а она не
+ /// наступала, и все следующие нажатия молча тонули).
+ /// Ранчер (и Чумной плевун): ПКМ - выплюнуть Рой фараона (necroInsectSwarm, наш рой из свитка, обращает
+ /// ужаленных зомби) вместо ванильных мух, откат SwarmCooldown; E - невидимость на InvisTime: зомби его
+ /// не видят и бросают погоню, модель полупрозрачна; удар или плевок её снимают.
+ /// Крикунья: E - крик зовёт орду разведчиков по стадии игры (как в игре, ScoutGSList), и вся орда сразу под
+ /// девиацией. Чужих зомби крик больше не стягивает (решение 13.10).
+ /// Откаты видны в PossessionHud (там, где полоска выносливости игрока).
+ ///
+ /// ПАМЯТЬ. Только числа и очередь id классов орды; ссылок на сущности нет. Очередь чистится при выходе из мира.
+ ///
+ public static class PossessionPowers
+ {
+ public enum Kind { None, Thrower, Rancher, Screamer }
+ public static Kind Current;
+
+ public const float InvisTime = 15f, InvisCooldown = 30f;
+ /// Прозрачность невидимого ранчера - родной _Fade шейдера персонажей (EntityAlive.renderFadeMax).
+ 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";
+ /// Не больше стольких за крик (у игры на стадии 52 - 26).
+ public const int HordeMax = 40;
+ /// Орда появляется по одному зомби раз в столько секунд - без рывка кадра.
+ 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 _hordeQueue = new List();
+ static Vector3 _hordeAt;
+ static int _hordeFails;
+ /// Рой фараона уже вылетел в этом плевке - дальняя атака ранчера закончена.
+ public static bool SwarmSpawned;
+ /// Когда ранчер снова сможет плюнуть роем (Time.time); переживает перевселение.
+ 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;
+ }
+
+ /// Выход из мира: всё в ноль (откаты тоже - Time.time в новом мире продолжается, но держать их незачем).
+ 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 + " с");
+ }
+
+ /// Удар или плевок - невидимость снята, откат от этой минуты.
+ 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;
+ }
+
+ /// Каждый кадр одержимости: пока невидим - кто гнался за ранчером, теряет его.
+ 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 + ")");
+ }
+
+ /// Каждый кадр (и после выхода из одержимости): орда появляется по одному.
+ 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;
+ /// Насколько луч прицела может пройти мимо груди существа, чтобы метнуть в него.
+ public const float ThrowAimRadius = 1.5f;
+
+ /// Куда лететь валуну: грудь существа у луча прицела (ближайшее по лучу), иначе точка под прицелом.
+ 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;
+ }
+ }
+
+ /// Валун одержимого Чака - по дуге в точку под прицелом (или в существо): позиция вместо направления
+ /// и 1 - «баллистика», как у ИИ, когда цель есть. Без цели игра кидает прямо по взгляду - камень падал рядом.
+ [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;
+ }
+ }
+
+ /// Плевок одержимого ранчера - Рой фараона вместо ванильных мух (указание 13.10).
+ [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;
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/SwarmTargetPatch.cs b/HarmonySrc/SwarmTargetPatch.cs
index 7fac787..7295d4d 100644
--- a/HarmonySrc/SwarmTargetPatch.cs
+++ b/HarmonySrc/SwarmTargetPatch.cs
@@ -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;
}
diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll
index 158d08f..d5f64ac 100644
Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ
diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb
index 17b9337..6ff9d19 100644
Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ