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