using GamePath; using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Одержимость - заклинание хвата «пиф-паф» (решения пользователя 03.10, BACKLOG «Одержимость»). /// ЛКМ по зомби-гуманоиду (EntityZombie и наследники; не звери и не стервятники) - игрок перехватывает /// управление им: бегает, прыгает, бьёт. Тело игрока не падает, а цепенеет и стоит (аниматор на паузе, /// ввод снят) - его могут атаковать, это и есть цена. Любое время суток, и в кровавую луну. /// Остальные зомби, увидев одержимого, бросаются на него, как на игрока, - можно танковать. /// Выход - R, гибель одержимого или смерть тела. После выхода зомби пугается и убегает, как при /// «отрицании». Убийства одержимым игроку не засчитываются (урон от его имени), его собственная /// смерть - тоже (NecromancyKillCreditPatch). /// /// КАК ВЕДЁМ ЗОМБИ. Его ИИ выключен (EntityAlive.updateTasks пропущен), а движение - родное: /// SetMoveForwardWithModifiers, как делает сама игра при DebugStopEnemiesMoving и EntityMoveHelper; /// прыжок - Jumping + SetJumpDistance, как EntityMoveHelper.StartJump; удар - Attack(false/true), /// как EAIApproachAndAttackTarget. Походка, анимация и урон - самого зомби. /// /// КАК СТОИТ ТЕЛО. PlayerMoveController.Update пропущен (и сам вызывает stopMoving, как у тела в /// рэгдолле), вид - от третьего лица, аниматор модели на паузе. Камера - за головой зомби (постфикс /// к vp_FPCamera.LateUpdate, как у змеи). /// /// ПРИЁМЫ ЗОМБИ (указание 03.10: «у копа есть плевок и подрыв - чтобы такие спеллы становились доступны»): /// ПКМ - дальняя атака, если у зомби есть задача ИИ EAIRangedAttackTarget (Ранчер, Чак, коп, мутант): /// её параметры берутся из задачи захваченного зомби, а шаги повторяют её Start/Update/Reset - /// замах (StartAnimAction startAnimType+3000), отпуск (ContinueAnimAction +1), выстрел родным /// оружием (UseHoldingItem itemType), пока держишь кнопку и не вышло attackDuration. Направление - /// точка под прицелом камеры (SetLookPosition), как у ИИ - голова цели. /// Пробел - прыжок на собственную дальность зомби (jumpMaxDistance из JumpMaxDistance): у паука 7-9 м, /// как его EAILeap, у остальных их 1-4 м. /// ПКМ у крикуньи - её крик (SoundAlert): все зомби в ScreamRange бросаются на неё, даже не видя (её /// ванильный зов орды - часть разведки AIScoutHordeSpawner, напрямую не вызываем). /// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько /// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело. /// /// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время /// одержимости; на выходе и при выходе из мира всё обнуляется. /// public static class Possession { public static bool Active; public static EntityAlive Zombie; /// Камера - позади и выше головы зомби. public const float CamBack = 2.6f; public const float CamUp = 0.6f; /// Как далеко зомби замечают одержимого: зрение зомби - 30 м (entityclasses.xml SightRange). public const float TauntRange = 30f; public const float TauntPeriod = 1f; public const int TauntTicks = 1200; /// Спящие просыпаются от одержимого (тест 05.10: «не разбудил спящего»). Игра будит их /// только игроком - увидел или услышал (EAISetNearestEntityAsTarget.FindTargetPlayer), одержимый для /// неё просто зомби. Будим сами: видит одержимого ближе SleeperWakeRange (пригнувшегося - ближе /// SleeperWakeCrouched) или тот вплотную, ближе SleeperHearRange, хоть и за спиной. public const float SleeperWakeRange = 8f; public const float SleeperWakeCrouched = 3f; public const float SleeperHearRange = 2f; /// Приседание (указание 05.10): пока зажата кнопка приседания. Высота - доля роста зомби; /// ниже 0.7 игра включает ходьбу пригнувшись (walkType 8), как у её зомби под низким потолком. public static bool Crouching; /// Удар по прицелу (тест 05.10: «не попадаю по другим зомби»). Удар зомби - луч ~1.65 м из /// головы по взгляду (ItemActionMelee, EntityHuman.GetLookVector), а взгляд брался из наклона камеры, /// которая сзади и выше: под прицелом враг, а луч из головы уходит ниже и мимо. На время замаха /// взгляд зомби - на точку под прицелом, а если рядом враг почти по прицелу - на его грудь. public const float MeleeAssistRange = 2.5f; public const float MeleeAssistDeg = 25f; static float _meleeLookUntil; /// Ctrl в игре - приседание переключателем (ToggleCrouch), C - пока держишь (Crouch). static bool _crouchToggled; public const float CrouchHeight = 0.6f; /// Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить /// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора /// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков /// хватает, чтобы тело стояло на земле и зомби могли к нему подойти. public const int BodyViewDim = 3; static int _bodyViewDim = -1; /// Полный радиус области игрока (до сжатия) - для систем, рисующих мир у одержимого /// (SnakeAudioSwap: дальний ландшафт, Dynamic Mesh, детализация зданий). -1 - не сжата. public static int FullViewDim => _bodyViewDim; public const float ScreamRange = 50f; public const float ScreamCooldown = 10f; static float _nextScream; static EntityPlayerLocal _player; static float _yaw, _pitch; /// Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») - /// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера. static float _bodyYaw; public const float TurnSpeed = 540f; static float _nextAttack; static float _nextTaunt; static float _sleeperCheck; static ChunkManager.ChunkObserver _observer; static readonly List _frozen = new List(); static readonly List _frozenSpeed = new List(); static bool _loggedCamera; // Дальняя атака захваченного зомби (копия параметров его EAIRangedAttackTarget). static bool _hasRanged; static int _rItem, _rStartAnim; static float _rRelease, _rDuration, _rCooldown; static string _rSoundStart, _rSoundRelease; enum RState { Idle, StartAnim, Release, Fire } static RState _rState; static float _rTime, _rNext; static GameObject _hud; public static void Init() { ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate); if (_hud == null) { _hud = new GameObject("NecroPossessionHud"); Object.DontDestroyOnLoad(_hud); _hud.AddComponent(); } } public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId; /// Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»). public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e); /// True - вселились (для звука «пиф-пафа»). public static bool Enter(EntityPlayerLocal _player0, EntityAlive _zombie) { if (Active || SnakeMode.Active || MindPalace.Active || _player0 == null || _player0.IsDead() || _player0.AttachedToEntity != null || !CanPossess(_zombie) || _player0.world.IsRemote()) { return false; } Active = true; _player = _player0; Zombie = _zombie; _yaw = _player0.rotation.y; _bodyYaw = _zombie.rotation.y; _pitch = 0f; _nextAttack = 0f; _nextTaunt = 0f; _nextScream = 0f; _loggedCamera = false; // Зомби - в руки игрока: цели, путь и шаги ИИ сброшены. _zombie.SetAttackTarget(null, 0); _zombie.SetRevengeTarget(null); PathFinderThread.Instance.RemovePathsFor(_zombie.entityId); _zombie.navigator?.clearPath(); _zombie.moveHelper?.Stop(); _zombie.ConditionalTriggerSleeperWakeUp(); _zombie.SetRotation(new Vector3(0f, _yaw, 0f)); // Тело цепенеет: вид от третьего лица, аниматоры модели на паузе. _player0.SetFirstPersonView(false, true); FreezeBody(_player0, true); _observer = GameManager.Instance.AddChunkObserver(_zombie.position, true, GameUtils.GetViewDistance(), -1); ChunkManager.ChunkObserver body = _player0.ChunkObserver; if (body != null && body.viewDim > BodyViewDim) { _bodyViewDim = body.viewDim; body.viewDim = BodyViewDim; } ReadRanged(_zombie); Hint(_player0, _zombie); Debug.Log("[NecromancerTome] Possession: вселение в " + _zombie.EntityName + " (" + _zombie.entityId + ")"); return true; } public static void Exit(string _why) { if (!Active) { return; } Active = false; Crouching = false; _meleeLookUntil = 0f; _crouchToggled = false; EntityAlive z = Zombie; z?.SetLookPosition(Vector3.zero); EntityPlayerLocal player = _player; if (_rState != RState.Idle && z != null && !z.IsDead()) { StopRanged(z); } _rState = RState.Idle; _hasRanged = false; Zombie = null; _player = null; Debug.Log("[NecromancerTome] Possession: выход - " + _why); FreezeBody(player, false); if (_bodyViewDim > 0 && player?.ChunkObserver != null) { player.ChunkObserver.viewDim = _bodyViewDim; } _bodyViewDim = -1; if (player != null && !player.IsDead()) { player.SetFirstPersonView(true, true); } if (_observer != null && GameManager.Instance?.World != null) { GameManager.Instance.RemoveChunkObserver(_observer); } _observer = null; World world = GameManager.Instance?.World; if (world == null || z == null) { return; } // Кто гнался за одержимым - отпускает его. for (int i = 0; i < world.EntityAlives.Count; i++) { EntityAlive e = world.EntityAlives[i]; if (e != null && e != z && e.GetAttackTarget() == z) { e.SetAttackTarget(null, 0); } } // Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»). if (!z.IsDead() && player != null) { z.SetMoveForwardWithModifiers(0f, 0f, 0f, false); NecroFleeSnakeTask.ScareFrom(z, player, HandHolds.DenyMaxSeconds, HandHolds.DenySpeed, HandHolds.DenyFarDistance); } } static void FreezeBody(EntityPlayerLocal _p, bool _freeze) { if (_freeze) { _frozen.Clear(); _frozenSpeed.Clear(); Transform model = _p?.emodel?.GetModelTransform(); if (model == null) { return; } foreach (Animator a in model.GetComponentsInChildren(true)) { _frozen.Add(a); _frozenSpeed.Add(a.speed); a.speed = 0f; } return; } for (int i = 0; i < _frozen.Count; i++) { if (_frozen[i] != null) { _frozen[i].speed = _frozenSpeed[i]; } } _frozen.Clear(); _frozenSpeed.Clear(); } static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data) { World world = GameManager.Instance?.World; if (world == null) { if (Active || Zombie != null || _frozen.Count > 0) { Active = false; Zombie = null; _player = null; _observer = null; _bodyViewDim = -1; _frozen.Clear(); _frozenSpeed.Clear(); } return; } if (!Active) { return; } EntityAlive z = Zombie; if (_player == null || _player.IsDead()) { Exit("тело погибло"); return; } if (z == null || z.IsDead() || z.IsMarkedForUnload()) { Exit("одержимый погиб"); return; } PlayerActionsLocal input = _player.playerInput; bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen(); if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed)) { Exit("R"); return; } Vector2 move = Vector2.zero; bool run = false, jump = false, attack = false, ranged = false, rangedHeld = false, detonate = false; if (input != null && !gui) { Look(); move = new Vector2(input.Move.X, input.Move.Y); run = input.Run.IsPressed; jump = input.Jump.WasPressed; attack = input.Primary.WasPressed; ranged = input.Secondary.WasPressed; rangedHeld = input.Secondary.IsPressed; // E на клавиатуре - в PlayerActionsPermanent.Activate; у PlayerActionsLocal.Activate только // кнопка геймпада (тест 03.10: «подрыва нету»). detonate = input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed; } if (input != null && !gui && input.ToggleCrouch.WasPressed) { _crouchToggled = !_crouchToggled; } Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled); if (detonate && z is EntityZombieCop cop && !cop.isPrimed) { Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName); cop.HandlePrimingDetonator(); } Ranged(z, ranged, rangedHeld); bool moving = move.sqrMagnitude > 0.01f; float wantYaw = moving ? _yaw + Mathf.Atan2(move.x, move.y) * Mathf.Rad2Deg : _yaw; _bodyYaw = Mathf.MoveTowardsAngle(_bodyYaw, wantYaw, TurnSpeed * Time.deltaTime); z.SetRotation(new Vector3(0f, _bodyYaw, 0f)); z.rotation.x = -_pitch; // наклон головы - для луча удара if (moving) { z.SetMoveForwardWithModifiers(run ? z.GetMoveSpeedAggro() : z.GetMoveSpeed(), 1f, 0f, false); } else { z.SetMoveForwardWithModifiers(0f, 0f, 0f, false); } if (jump && z.onGround && !z.Jumping) { z.Jumping = true; z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f); } if (ranged && IsScreamer(z)) { Scream(world, z); } if (attack && Time.time >= _nextAttack) { z.SetLookPosition(MeleeAim(world, z)); if (z.Attack(false)) { z.Attack(true); float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f); _nextAttack = Time.time + timeout; _meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout); } } if (_meleeLookUntil > 0f && _rState == RState.Idle) { if (Time.time < _meleeLookUntil) { z.SetLookPosition(MeleeAim(world, z)); } else { _meleeLookUntil = 0f; z.SetLookPosition(Vector3.zero); } } _observer?.SetPosition(z.position); // Спящие в зданиях просыпаются от одержимого, как от змеи (указание 05.10). SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime); Taunt(world, z); } static void ReadRanged(EntityAlive _z) { _hasRanged = false; _rState = RState.Idle; _rNext = 0f; List tasks = _z.aiManager?.GetTasks(); if (tasks == null || tasks.Count == 0) { return; } EAIRangedAttackTarget t = tasks[0]; _hasRanged = true; _rItem = t.itemActionType; _rStartAnim = t.startAnimType; _rRelease = t.releaseDelay; _rDuration = t.attackDuration > 0f ? t.attackDuration : 3f; // Вдвое быстрее ИИ (тест 03.10: «перезаряд медленный»). _rCooldown = Mathf.Max(1f, t.baseCooldown * 0.5f); _rSoundStart = t.soundStartName; _rSoundRelease = t.soundReleaseName; Debug.Log("[NecromancerTome] Possession: дальняя атака - оружие " + _rItem + ", замах " + _rStartAnim + ", длит. " + _rDuration + " с, откат " + _rCooldown + " с"); } /// Подсказка при вселении: что умеет этот зомби. static void Hint(EntityPlayerLocal _p, EntityAlive _z) { string text = Localization.Get("necroPossessHintBase"); if (_hasRanged) { text += ", " + Localization.Get("necroPossessHintRanged"); } if (IsScreamer(_z)) { text += ", " + Localization.Get("necroPossessHintScream"); } if (_z.jumpMaxDistance >= 5f) { text += ", " + Localization.Get("necroPossessHintLeap"); } if (_z is EntityZombieCop) { text += ", " + Localization.Get("necroPossessHintDetonate"); } GameManager.ShowTooltip(_p, text); } /// Куда бить: грудь врага ближе MeleeAssistRange к голове и не дальше MeleeAssistDeg от /// направления на прицел (ближайший к прицелу), иначе - точка под прицелом. static Vector3 MeleeAim(World _world, EntityAlive _z) { Vector3 aim = AimPoint(); Vector3 head = _z.getHeadPosition(); Vector3 dir = (aim - head).normalized; EntityAlive best = null; float bestAngle = MeleeAssistDeg; for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (e == null || e == _z || e.IsDead() || e is EntityPlayer || SnakeControl.IsNecroEntity(e)) { continue; } Vector3 to = e.getChestPosition() - head; if (to.sqrMagnitude > MeleeAssistRange * MeleeAssistRange) { continue; } float angle = Vector3.Angle(dir, to); if (angle < bestAngle) { bestAngle = angle; best = e; } } return best != null ? best.getChestPosition() : aim; } /// Точка под прицелом камеры (в мировых координатах), до 50 м. static Vector3 AimPoint() { Transform cam = _player?.cameraTransform; if (cam == null) { return Zombie.position + Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * 30f; } if (Physics.Raycast(cam.position, cam.forward, out RaycastHit hit, 50f, -5, QueryTriggerInteraction.Ignore) && hit.distance > CamBack + 0.5f) { return hit.point + Origin.position; } return cam.position + Origin.position + cam.forward * 50f; } /// ПКМ - дальняя атака зомби по шагам ванильной EAIRangedAttackTarget. static void Ranged(EntityAlive _z, bool _pressed, bool _held) { if (!_hasRanged) { return; } float dt = Time.deltaTime; switch (_rState) { case RState.Idle: if (!_pressed) { 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; _rTime = 0f; if (!string.IsNullOrEmpty(_rSoundStart)) { Audio.Manager.BroadcastPlay(_z, _rSoundStart); } if (_rStartAnim >= 0) { _z.StartAnimAction(_rStartAnim + 3000); _rState = RState.StartAnim; } else { _rState = RState.Fire; } return; case RState.StartAnim: _z.SetLookPosition(AimPoint()); if (_z.GetAnimActionState() != AvatarController.ActionState.Ready) { return; } _z.ContinueAnimAction(_rStartAnim + 1 + 3000); if (!string.IsNullOrEmpty(_rSoundRelease)) { Audio.Manager.BroadcastPlay(_z, _rSoundRelease); } _rState = RState.Release; _rTime = 0f; return; case RState.Release: _z.SetLookPosition(AimPoint()); _rTime += dt; if (_rTime >= _rRelease) { _rState = RState.Fire; _rTime = 0f; } return; case RState.Fire: _z.SetLookPosition(AimPoint()); _rTime += dt; _z.UseHoldingItem(_rItem, false); // Доводим до конца по одному нажатию: у плевка сначала 1-3 предупреждения по 1.2 с // (ItemActionVomit), и с отпущенной кнопкой он сбрасывался (тест 03.10: «если зажать // ПКМ - плюёт»). Конец - оружие закончило или вышла длительность задачи. if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration) { StopRanged(_z); } return; } } /// Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»). /// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный /// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1). static void RearmRanged(EntityAlive _z) { ItemInventoryData inv = _z.inventory?.holdingItemData; if (inv == null || _rItem >= inv.actionData.Count) { return; } ItemValue iv = _z.inventory.holdingItemItemValue; string before = "патронов " + (iv != null ? iv.Meta.ToString() : "?"); if (inv.actionData[_rItem] is ItemActionRanged.ItemActionDataRanged d && _z.inventory.holdingItem.Actions[_rItem] is ItemActionRanged ranged) { before += ", очередь " + d.curBurstCount + ", перезарядка " + d.isReloading; ranged.ResetBurstShot(d); d.bReleased = true; d.bPressed = false; d.isReloading = false; d.isReloadRequested = false; } // Плевок: без лишних «позывов» - сразу последнее предупреждение (ItemActionVomit: 1-3 по 1.2 с). if (inv.actionData[_rItem] is ItemActionVomit.ItemActionDataVomit dv && _z.inventory.holdingItem.Actions[_rItem] is ItemActionVomit vomit) { dv.numWarningsPlayed = Mathf.Max(0, Traverse.Create(vomit).Field("warningMax").GetValue() - 1); dv.warningTime = 0f; } if (iv != null && iv.Meta < 1) { iv.Meta = 1; } Debug.Log("[NecromancerTome] Possession: дальняя атака - взвод (" + before + ")"); } static void StopRanged(EntityAlive _z) { _z.ShowHoldingItem(false); _z.UseHoldingItem(_rItem, true); _z.StartAnimAction(9999); _z.SetLookPosition(Vector3.zero); _z.emodel.avatarController.hitWeightMax = 1f; _rState = RState.Idle; _rNext = Time.time + _rCooldown; } static bool IsScreamer(EntityAlive _z) { return EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c) && c.entityClassName.StartsWith("zombieScreamer"); } /// Крик одержимой крикуньи: её звук тревоги и все зомби в ScreamRange - на неё (танкование). static void Scream(World _world, EntityAlive _z) { if (Time.time < _nextScream) { return; } _nextScream = Time.time + ScreamCooldown; string snd = _z.GetSoundAlert(); if (!string.IsNullOrEmpty(snd)) { Audio.Manager.BroadcastPlay(_z, snd); } int n = 0; for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _z.position).sqrMagnitude > ScreamRange * ScreamRange) { continue; } e.ConditionalTriggerSleeperWakeUp(); AllowZombieTarget(e); e.SetAttackTarget(_z, TauntTicks); n++; } Debug.Log("[NecromancerTome] Possession: крик крикуньи - на зов идут " + n); } static void Look() { PlayerMoveController pmc = PlayerMoveController.Instance; Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f; int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1; _yaw += Input.GetAxis("Mouse X") * sens.x; _pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f); } /// Раз в секунду: зомби, которые видят одержимого, бросаются на него (решение 03.10: /// «зомби начинают считать этого зомби ещё одним игроком»). Ванильный подход к цели принимает /// только свои классы (EntityPlayer...) - добавляем EntityZombie, как CharmPatch; это безопасно: /// цель-зомби им ставим только мы (поиск цели EAISetNearestEntityAsTarget не трогаем). static void Taunt(World _world, EntityAlive _z) { if (Time.time < _nextTaunt) { return; } _nextTaunt = Time.time + TauntPeriod; for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.GetAttackTarget() == _z || e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null) { continue; } float d2 = (e.position - _z.position).sqrMagnitude; if (d2 > TauntRange * TauntRange) { continue; } if (e.IsSleeping) { float wake = Crouching ? SleeperWakeCrouched : SleeperWakeRange; if (d2 > SleeperHearRange * SleeperHearRange && (d2 > wake * wake || !e.CanSee(_z))) { continue; } e.ConditionalTriggerSleeperWakeUp(); Debug.Log("[NecromancerTome] Possession: проснулся " + e.EntityName + " (" + e.entityId + "), " + Mathf.Sqrt(d2).ToString("F1") + " м"); } else if (!e.CanSee(_z)) { continue; } AllowZombieTarget(e); e.SetAttackTarget(_z, TauntTicks); } } static void AllowZombieTarget(EntityAlive _e) { List tasks = _e.aiManager.GetTasks(); if (tasks == null) { return; } foreach (EAIApproachAndAttackTarget t in tasks) { bool has = false; foreach (EAIApproachAndAttackTarget.TargetClass c in t.targetClasses) { has |= c.type == typeof(EntityZombie); } if (!has) { t.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = typeof(EntityZombie), chaseTimeMax = 0f }); } } } /// Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены. public static void PlaceCamera(Transform _camera) { EntityAlive z = Zombie; if (z == null) { return; } Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null; Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f; Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f); Vector3 eye = anchor + Vector3.up * CamUp - rot * Vector3.forward * CamBack; Vector3 to = eye - anchor; if (Physics.Raycast(anchor, to.normalized, out RaycastHit hit, to.magnitude + 0.2f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore)) { eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f); } _camera.SetPositionAndRotation(eye, rot); if (!_loggedCamera) { _loggedCamera = true; Debug.Log("[NecromancerTome] Possession: камера за " + (head != null ? "головой " + head.name : "корнем (головы нет)")); } } } /// Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы. [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))] public static class Patch_EntityAlive_updateTasks_Possession { public static bool Prefix(EntityAlive __instance) { return !Possession.IsPossessed(__instance); } } /// Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход). [HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))] public static class Patch_PlayerMoveController_Update_Possession { public static bool Prefix(PlayerMoveController __instance) { if (!Possession.Active) { return true; } __instance.stopMoving(); return false; } } /// Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») - /// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно). /// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi. 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 Fill = new Color(0.55f, 0.08f, 0.06f, 0.95f); static GUIStyle _style; void OnGUI() { EntityAlive z = Possession.Active ? Possession.Zombie : null; if (z == null || Event.current.type != EventType.Repaint) { return; } 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.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); } } /// Приседание одержимого: пока зажата кнопка, рост зомби опускается до CrouchHeight, как игра /// опускает его под низким потолком (EntityAlive.CrouchHeightFixedUpdate, её же шаги). Отпустил - дальше /// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой, /// а проём впереди чует через moveHelper, который у одержимого остановлен. [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.CrouchHeightFixedUpdate))] public static class Patch_EntityAlive_CrouchHeightFixedUpdate_Possession { public static bool Prefix(EntityAlive __instance) { if (!Possession.Crouching || !Possession.IsPossessed(__instance) || __instance.crouchType == 0 || __instance.physicsBaseHeight <= 1.3f) { return true; } float h = Utils.FastMoveTowards(__instance.physicsHeight, __instance.physicsBaseHeight * Possession.CrouchHeight, 0.099999994f); __instance.SetHeight(h); if (h <= __instance.physicsBaseHeight * 0.7f) { __instance.crouchBendPerTarget = 0f; if (__instance.walkType != 8 && __instance.walkType != 21) { int was = __instance.walkType; __instance.SetWalkType(8); __instance.walkTypeBeforeCrouch = was; } } __instance.crouchBendPer = Mathf.MoveTowards(__instance.crouchBendPer, __instance.crouchBendPerTarget, 0.099999994f); return false; } } }