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 м. /// Чак, ранчер, крикунья - свои приёмы на E и ПКМ (указание 13.10), см. PossessionPowers. /// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько /// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело. /// /// ОДЕРЖИМОСТЬ ПИТОМЦА (указание 06.10): ПКМ с книгой призыва - вселение в своего питомца (все, включая /// стервятника и Духа крысы), тем же механизмом. Отличия: питомец не пугается на выходе, а возвращается /// к хозяину своим ИИ (охрана, поводок - PetFollowPatch пропускает одержимого); стервятник летает /// (EntityVulture ведёт полёт сам в своём updateTasks - его мы подменяем на FlyTick). Удаляться от тела /// можно на любое расстояние, область тела сжата так же, тело так же уязвимо. /// /// ВРАГ У ТЕЛА (указание 06.10, как у змеи): раз в полсекунды - есть ли враг ближе /// SnakeControl.BodyDangerRange к телу; надпись necroSnakeBodyDanger под прицелом (PossessionHud). /// Для одержимого зомби - тоже. /// /// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время /// одержимости; на выходе и при выходе из мира всё обнуляется. /// public static class Possession { public static bool Active; public static EntityAlive Zombie; /// Вселились в своего питомца (книга призыва), а не в чужого зомби. public static bool IsPet; /// Питомец-стервятник: летает (FlyTick из его updateTasks). public static bool Flying; /// Враг у тела (CheckBody) - для надписи в PossessionHud. public static bool BodyDanger; static float _bodyCheck; // Ввод полёта - кадр пишет, тик стервятника (20 в секунду) читает. static Vector2 _flyMove; static bool _flyUp, _flyDown, _flyRun; /// Разгон полёта - как у ванильного стервятника (EntityVulture.updateTasks: 0.55 обычный, /// 0.95 пикирование); на бегу - пикирующий. public const float FlyAccel = 0.55f, FlyAccelRun = 0.95f; /// Удар стервятника - его же пауза между клевками (cAttackDelay 18 тиков). public const float FlyAttackDelay = 0.9f; /// Рывок стервятника на ЛКМ (указание 06.10: «как у змеи, но с 5 метров и на простой /// удар» - иначе в полёте никого не достать). Цель - на луче прицела не дальше FlyDashRange от /// стервятника; летит к её голове FlyDashSpeed м за тик (20 тиков в секунду), долетел ближе /// FlyDashHit (у ванильного клевка 0.9 м) - обычный клевок. Не долетел за FlyDashTime - отбой. public const float FlyDashRange = 10f; // было 5 м; 10 - указание 06.10 (с высоты полёта 5 м не хватало) /// Насколько луч прицела может пройти мимо тела цели (у змеи 0.9 м от груди - с высоты мало). public const float FlyDashAimRadius = 1.5f; public const float FlyDashSpeed = 0.6f; public const float FlyDashHit = 0.9f; public const float FlyDashTime = 1f; // 10 м при 12 м/с - 0.85 с /// Урон, если оружие стервятника не прочиталось (у грифа-питомца - 20, items.xml). public const float FlyDashDamage = 20f; /// Бафф кровотечения от клевка грифа-питомца (necroMeleeHandZombieGriffin). public const string FlyDashBleedBuff = "buffNecroRatBleed"; /// Звук попадания рывка - ванильный удар рукой зомби по телу (sounds.xml). public const string FlyDashHitSound = "organichitorganic"; static EntityAlive _flyDash; /// Ноша стервятника (указание 08.10): рывок на курицу или кролика (EntityAnimalRabbit - только /// они; злая курица - другой класс) не клюёт, а хватает. Несёт под лапами, его ИИ стоит; повторная ЛКМ - /// отпускает (падает сам; у земли - стоит на ней). Курицу так приносят в загон, где игрок её ловит /// для курятника (wildChicken). static EntityAlive _carry; /// «Зоркий глаз» (указание 08.10): пока держишь ПКМ, камера уходит к голове, а угол обзора сужается в ZoomFactor раз /// за ZoomTime, как у прицела; мышь во столько же раз медленнее. Свой угол игра ставит только на /// своём приближении (vp_FPCamera), поэтому исходный запоминаем и возвращаем сами. public const float ZoomFactor = 8f; // x8 - указание 08.10 (было x4) public const float ZoomTime = 0.2f; /// Камера в приближении - на столько впереди головы, м (вне собственного клюва). public const float ZoomEyeForward = 0.3f; static bool _flyZoomHeld; static float _zoom; static float _zoomBaseFov = -1f; static Camera _zoomCam; /// Угол обзора «зоркого глаза», -1 - нет. Ставит Patch_EntityPlayerLocal_LateUpdate_Possession: /// игра каждый кадр задаёт свой угол в EntityPlayerLocal.LateUpdate (тест 08.10: «x8 не работает» - /// угол из PlaceCamera затирался). public static float ZoomFov = -1f; /// Зазор между когтями стервятника и спиной ноши, м; минус - когти впиваются в спину /// (тест 08.10: «курица висит под стервятником, а не в его когтях»). public const float CarryGap = -0.06f; /// Цель рывка под прицелом сейчас (каждый кадр) - обычный прицел игры краснеет (AimTint). public static EntityAlive FlyAim; static float _flyDashUntil; /// Сел на землю без ввода - поза спящего стервятника (указание 06.10), как у ванильного /// sleeperVultureAnimal (поза 0, sleeperSit). Только анимация: IsSleeping не трогаем - его читают /// ИИ и звуки. Взлёт - поза «проснулся» (-1). public const int VultureSitPose = 0; public const float FlySitGround = 0.3f; /// Остановился - скорость меньше этой, м за тик (0.02 = 0.4 м/с). public const float FlySitSpeed = 0.02f; /// Толчок вверх при взлёте с земли на пробел, м за тик. public const float FlyTakeOff = 0.3f; static bool _flySitting; /// Питание падалью (указание 06.10; у медведя и собаки - 09.10): сидя или стоя у трупа, E - EatTime секунд клюёт его и лечится на /// EatHealFrac здоровья. У трупа запас мяса - его полное здоровье при жизни; поедание отнимает /// столько же, сколько лечит (и на полном здоровье тоже). Запас кончился - труп исчезает (указание /// 06.10), вместе с содержимым, если это красный труп-добыча. Сдвинулся, взлетел, ударил - еда /// прервана, вылеченное и съеденное остаются; к трупу можно вернуться. public const float EatRange = 2.5f; public const float EatTime = 3f; public const float EatHealFrac = 0.125f; // было 0.25 - указание 09.10: «лечится слишком быстро, вдвое» static EntityAlive _eatCorpse; static float _eatLeft, _eatHeal, _eatBite; static int _eatCount; static readonly Dictionary _carrion = new Dictionary(); static readonly Dictionary _carrionBlocks = new Dictionary(); static readonly Dictionary _isCarrion = new Dictionary(); static bool _eating; static Vector3i _eatBlock; static int _eatBlockType; /// Признак блока-падали: среди его добычи есть гнилая плоть. public const string CarrionItem = "foodRottingFlesh"; /// Камера - позади и выше головы зомби. public const float CamBack = 2.6f; /// Вид от первого лица (указание 13.10): клавиша ~/ё - как переключение вида у персонажа в игре /// (QuickMenu, по умолчанию Backquote; и CameraChange геймпада), каждое вселение начинается от третьего лица. Камера - в голове, тело - только тень /// (HideBody), положение глаз сглажено (FpSmooth) - голова зомби на ходу мотается. public static bool FirstPerson; /// Пояс одержимого (13.10): 2 ячейки, пока пустые; выбор 1/2 или колесом, только от первого лица. public const int BeltSlots = 2; public static int BeltSlot; public const float FpForward = 0.12f, FpSmooth = 12f; static Vector3 _fpEye; static readonly List _hiddenRenderers = new List(); static readonly List _hiddenModes = new List(); static EntityAlive _hiddenBody; 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; /// Потолок роста пригнувшегося (тест 13.10: «зомби в приседе не проходят в 1 блок»): 0.6 роста - это /// ~1.08 м, больше блока. Игра под низким потолком сжимает до высоты проёма минус 0.21 - под блок ~0.8 м. public const float CrouchMaxHeight = 0.7f; /// Куда идёт одержимый (по земле), иначе ноль - для выправления движения анимации /// (Patch_EntityAlive_MoveEntityHeaded_Possession). public static Vector3 MoveDir; /// Лестница: 1 - вверх (шаг вперёд у лестницы), -1 - вниз (приседание), 0 - нет. Сам подъём - в /// Patch_EntityAlive_MoveEntityHeaded_Possession: признак _climb у игры вверх не поднимает (поднимает её навигатор). public static int Ladder; public const float LadderSpeed = 0.1f; // за тик (20 в секунду) - 2 м/с /// Сколько после последней ступени ещё помогать перешагнуть через край (Ladder 2). public const float LadderTopTime = 0.6f; static float _ladderTop; /// Сколько чанков держать вокруг тела, пока игрок в зомби (указание 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; /// Клич грифа - крик паникующей курицы (указание 06.10; сначала был chickenattack), ванильный /// sounds.xml, 6 вариантов. Перезарядка - полсекунды, а не 10 с крикуньи (тест 06.10: «у звука КД»). public const string FlyScreamSound = "chickenstressmax"; public const float FlyScreamCooldown = 0.5f; static float _nextScream; static EntityPlayerLocal _player; /// Игрок, который сейчас в одержимом (тело). public static EntityPlayerLocal Player => _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; // 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. static NecroMapMarker.Marker _mapNav; /// Разведка стервятника (указание 08.10: «карта следует за ним как за игроком»; только он, не /// другие одержимые): туман карты открывается вокруг него так же, как вокруг игрока (EntityPlayer.OnUpdateLive: при смене чанка /// ChunkObserver.mapDatabase.Add - 4 чанка во все стороны), а карта открывается на нём /// (Patch_XUiC_MapArea_*_Possession). Это обычная карта игрока - сохраняется как у него. static Vector3i _mapChunk; static bool _mapChunkSet; 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; /// Несёт ли стервятник это существо (его ИИ стоит). public static bool IsCarried(Entity _e) => _carry != null && _e != null && _e.entityId == _carry.entityId; static bool Carryable(EntityAlive _e) => _e is EntityAnimalRabbit; /// Только зомби-гуманоиды (решение 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) { return CanPossess(_zombie) && Begin(_player0, _zombie, false); } /// Вселение в своего питомца (ПКМ с книгой призыва, SummonPatch). public static bool EnterPet(EntityPlayerLocal _player0, EntityAlive _pet) { return _pet != null && !_pet.IsDead() && Begin(_player0, _pet, true); } static bool Begin(EntityPlayerLocal _player0, EntityAlive _zombie, bool _pet) { if (Active || SnakeMode.Active || MindPalace.Active || _player0 == null || _player0.IsDead() || _player0.AttachedToEntity != null || _player0.world.IsRemote()) { return false; } // Кровавый прилив (указание 10.10): дух не выходит из тела - ни в зомби, ни в своего питомца. if (SnakeMode.IsBloodTide()) { SnakeMode.BloodTideHint(_player0); Debug.Log("[NecromancerTome] Possession: кровавый прилив - дух не покидает тело"); return false; } Active = true; IsPet = _pet; Flying = _zombie is EntityVulture; BodyDanger = false; _bodyCheck = 0f; _flyMove = Vector2.zero; _flyUp = _flyDown = _flyRun = false; _flyDash = null; _flySitting = false; _player = _player0; Zombie = _zombie; _yaw = _player0.rotation.y; _bodyYaw = _zombie.rotation.y; _pitch = 0f; _nextAttack = 0f; _nextTaunt = 0f; _nextScream = 0f; _loggedCamera = false; FirstPerson = false; BeltSlot = 0; if (ZombieBag.Has(_zombie, _pet)) { ZombieBag.Begin(_player0, _zombie); // сумка зомби на это вселение (13.10) } // Пояс одержимому питомцу ни к чему (указание 06.10); открыться заново не даёт // Patch_GUIWindowManager_openInternal_SnakeToolbelt, после выхода игра откроет его сама. if (_pet) { _player0.PlayerUI?.windowManager.Close(SnakeControl.ToolbeltWindow); } // Зомби - в руки игрока: цели, путь и шаги ИИ сброшены. _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; } PossessionPowers.Begin(_zombie, _pet); ReadRanged(_zombie); Hint(_player0, _zombie); NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша _mapNav = NecroMapMarker.Show(_zombie, NecroMapMarker.PossessedClass); _mapChunkSet = false; if (Flying) { _zombie.motion = Vector3.zero; } Debug.Log("[NecromancerTome] Possession: вселение в " + (_pet ? "питомца " : "") + _zombie.EntityName + " (" + _zombie.entityId + ")" + (Flying ? ", полёт" : "")); NecroChallenges.Award(_pet ? NecroChallenges.PossessPet : NecroChallenges.PossessZombie); return true; } public static void Exit(string _why) { if (!Active) { return; } Active = false; ForceImpact = false; FirstPerson = false; HideBody(null); FpArms.Hide(_player); ShowPlayerBelt(true); RatLoot.CloseIfViewing(); WolfBag.CloseWindow(); ZombieBag.End(Zombie); // сумка - мешком на месте зомби (13.10), погиб - с меткой RatPhase.Stop(GameManager.Instance?.World, Zombie, "выход из одержимости"); DogSense.Clear(); WolfControl.Clear(); NecroMapMarker.Hide(ref _mapNav); bool pet = IsPet; IsPet = false; Flying = false; BodyDanger = false; Crouching = false; MoveDir = Vector3.zero; Ladder = 0; _meleeLookUntil = 0f; _crouchToggled = false; EntityAlive z = Zombie; z?.SetLookPosition(Vector3.zero); _flyDash = null; FlyAim = null; AimTint(false); DropCarry("выход из стервятника"); ZoomReset(); StopEat(z, null); VultureNeck.Reset(); if (_flySitting) { _flySitting = false; SetSitPose(z, false); } EntityPlayerLocal player = _player; if (_rState != RState.Idle && z != null && !z.IsDead()) { StopRanged(z); } _rState = RState.Idle; _hasRanged = false; PossessionPowers.End(z); 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); } } // Питомец просто отпущен: его ИИ (следование, охрана, поводок) сам вернёт его к хозяину. if (pet) { z.SetMoveForwardWithModifiers(0f, 0f, 0f, false); z.SetAttackTarget(null, 0); return; } // Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»). // В кровавый прилив леденящий ужас не действует (10.10) - выброшенный дух зомби не пугает. if (!z.IsDead() && player != null && !SnakeMode.IsBloodTide()) { 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; // Замер утечки сущностей (09.10) - не связан с одержимостью, просто ежекадровый крючок мода. if (world == null) { EntityLeakProbe.Reset(); } else { EntityLeakProbe.Tick(); } if (world == null) { if (Active || Zombie != null || _frozen.Count > 0) { Active = false; IsPet = false; Flying = false; BodyDanger = false; _mapNav = null; Zombie = null; _player = null; _observer = null; _bodyViewDim = -1; _frozen.Clear(); _frozenSpeed.Clear(); _flyDash = null; FlyAim = null; _carry = null; CarryVisual.Clear(); RatPhase.On = false; DogSense.Clear(); WolfControl.Clear(); WolfBag.Forget(); ZombieBag.Forget(); _zoomCam = null; _zoomBaseFov = -1f; _zoom = 0f; ZoomFov = -1f; _eating = false; _eatCorpse = null; } PossessionPowers.Clear(); PossessionHud.Forget(); // Цвет прицела - общая таблица игры, переживает выход из мира: вернуть всегда. AimTint(false); return; } PossessionPowers.SpawnTick(world); // орда крикуньи досыпается и после выхода из неё if (!Active) { return; } EntityAlive z = Zombie; if (_player == null || _player.IsDead()) { Exit("тело погибло"); return; } if (z == null || z.IsDead() || z.IsMarkedForUnload()) { Exit("одержимый погиб"); return; } if (SnakeMode.IsBloodTide()) { SnakeMode.BloodTideHint(_player); Exit("кровавый прилив - дух выброшен в тело"); return; } if (Flying) { RevealMap(world, z); // разведка - только стервятник (указание 08.10) } // Подсказка тела (тест 08.10: «стоял перед дверью - подсказка осталась в стервятнике»): её гасит // PlayerMoveController.Update, а он на время одержимости выключен. Прочие сообщения (голод и т.п.) - // другие окна, их не трогаем. LocalPlayerUI ui = _player.PlayerUI; if (ui != null && ui.windowManager.IsWindowOpen(XUiC_InteractionPrompt.ID)) { XUiC_InteractionPrompt.SetText(ui, null); } 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; } // E на дверь или выключатель - как у персонажа (указание 13.10: одержимым управляет ГГ - «ему хватает ума не // ломать дверь, которую можно открыть»); у волка и крысы - нет. Прицел не на двери - E как раньше. if (detonate && !IsPet && UseDoorOrSwitch(world, z)) { detonate = false; } // Только гуманоиды обычной комплекции (указание 13.10, FpArms.Allows) - у остальных ~/ё ничего не делает. if (input != null && !gui && FpArms.Allows(z) && (input.CameraChange.WasPressed || input.QuickMenu.WasPressed || input.PermanentActions.QuickMenu.WasPressed)) { FirstPerson = !FirstPerson; _fpEye = Vector3.zero; if (FirstPerson) { FpArms.Show(_player, z); } else { HideBody(null); FpArms.Hide(_player); ShowPlayerBelt(true); } Debug.Log("[NecromancerTome] Possession: вид от " + (FirstPerson ? "первого" : "третьего") + " лица"); } if (FirstPerson && input != null && !gui) { int was = BeltSlot; if (input.InventorySlot1.WasPressed) { BeltSlot = 0; } else if (input.InventorySlot2.WasPressed) { BeltSlot = 1; } else if (input.ScrollUp.WasPressed || input.ScrollDown.WasPressed) { BeltSlot = (BeltSlot + 1) % BeltSlots; } if (BeltSlot != was) { Debug.Log("[NecromancerTome] Possession: пояс - ячейка " + (BeltSlot + 1)); } } if (input != null && !gui && input.ToggleCrouch.WasPressed) { _crouchToggled = !_crouchToggled; } Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled); // E у питомца больше не отзыв (указание 08.10: «R - всегда вернуться в тело у всех одержимых, // E - для других целей»). Отозвать питомца - E с книгой в руке, уже из тела. // Мутант тоже EntityZombieCop, но в игре не взрывается (ExplodeHealthThreshold 0) - у него E плевок. if (detonate && z is EntityZombieCop cop && !cop.isPrimed && PossessionPowers.Current != PossessionPowers.Kind.Mutant) { Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName); cop.HandlePrimingDetonator(); NecroChallenges.Award(NecroChallenges.LivingBomb); } 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; case PossessionPowers.Kind.Mutant: Ranged(z, detonate, detonate); // E - плевок (указание 13.10), в прицел - Patch_ProjectileMoveScript_Fire_PossessedSpit break; default: Ranged(z, ranged, rangedHeld); break; } PossessionPowers.Tick(world, z); if (Flying) { // Полёт: направление - камера, пробел - вверх, присесть - вниз; сам шаг - в FlyTick. _flyMove = move; _flyRun = run; _flyUp = input != null && !gui && input.Jump.IsPressed; // Только C (пока держишь): Ctrl-переключатель у стервятника не используется (указание 09.10: // «снижается, пока не прожмёшь Ctrl снова», а индикатора, как у приседания, нет). _flyDown = input != null && !gui && input.Crouch.IsPressed; _crouchToggled = false; Crouching = false; if (attack && _carry != null) { // Повторная ЛКМ с ношей - отпустить (не клевок и не новый рывок). DropCarry("ЛКМ"); attack = false; _nextAttack = Time.time + FlyAttackDelay; } HoldCarry(z); FlyAim = _flyDash == null && _carry == null ? FlyDashTarget(world, z) : null; AimTint(FlyAim != null); if (attack && Time.time >= _nextAttack && FlyAim is EntityAlive dashTo) { // Рывок к цели под прицелом; клевок - по прилёте, в FlyTick. _flyDash = dashTo; _flyDashUntil = Time.time + FlyDashTime; _nextAttack = Time.time + FlyDashTime + FlyAttackDelay; Debug.Log("[NecromancerTome] Possession: рывок стервятника к " + dashTo.EntityName + " (" + dashTo.entityId + "), " + (dashTo.getHeadPosition() - z.position).magnitude.ToString("F1") + " м"); } else if (attack && Time.time >= _nextAttack && _flyDash == null) { if (_aimMissNear != null) { Debug.Log("[NecromancerTome] Possession: рывка нет - ближайший " + _aimMissNear.EntityName + " в " + _aimMissDist.ToString("F1") + " м (нужно до " + FlyDashRange + "), мимо прицела на " + _aimMissOff.ToString("F1") + " м (нужно до " + FlyDashAimRadius + ")"); } // Клевок стервятника - его оружие (UseHoldingItem 0), как EntityVulture.AttackAndAdjust; // куда клюёт - GetLookVector стервятника берёт lookAtPosition. z.SetLookPosition(MeleeAim(world, z)); if (z.UseHoldingItem(0, false)) { z.UseHoldingItem(0, true); } _nextAttack = Time.time + FlyAttackDelay; _meleeLookUntil = Time.time + FlyAttackDelay; } // E - клич-приманка (указание 06.10; кнопка - 08.10 F, была ПКМ; 09.10 - E: «на E действие, у стервятника крик»): // крик крикуньи, только голосом паникующей курицы - зомби в ScreamRange идут на него; с воздуха уводит // толпу от базы или от тела. detonate - это и есть нажатие E. bool scream = detonate; if (scream) { Scream(world, z); } // ПКМ (держать) - «зоркий глаз» (указание 08.10: «приближение как у прицела»), в PlaceCamera. _flyZoomHeld = rangedHeld; // F - питаться падалью (указание 06.10; кнопка 08.10 - E, 09.10 - F у всех одержимых питомцев). // F у игрока - фонарик, но его читает только выключенный PlayerMoveController.Update. bool eat = FKey(input, gui); // Есть можно сидя или просто остановившись рядом (зависнув над падалью): без ввода и почти без скорости. bool still = _flySitting || _flyMove.sqrMagnitude < 0.01f && !_flyUp && new Vector2(z.motion.x, z.motion.z).sqrMagnitude < FlySitSpeed * FlySitSpeed; Eat(world, z, eat, attack || scream || _flyMove.sqrMagnitude > 0.01f || _flyUp || !_flySitting, still); if (_meleeLookUntil > 0f && Time.time >= _meleeLookUntil) { _meleeLookUntil = 0f; z.SetLookPosition(Vector3.zero); } _observer?.SetPosition(z.position); SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime); Taunt(world, z); CheckBody(world, z, Time.deltaTime); return; } 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; // наклон головы - для луча удара MoveDir = moving ? Quaternion.Euler(0f, _bodyYaw, 0f) * Vector3.forward : Vector3.zero; Ladder = z.bCanClimbLadders && z.IsInElevator() && Crouching ? -1 : 0; if (moving) { // Бег - ночная скорость ярости (moveSpeedAggroMax) в любое время: днём GetMoveSpeedAggro отдаёт // дневную, а у многих зомби она равна шагу (13.10: «не все зомби бегают по Shift»). // Лестница (тест 13.10: «одержимые не могут лазить по лестнице»): у игры лезет навигатор - в клетке с // лестницей (IsInElevator) и цель выше - _climb; у нас - у лестницы и шаг вперёд. bool climb = z.bCanClimbLadders && z.IsInElevator() && move.y > 0.1f; if (climb && !Crouching) { Ladder = 1; _ladderTop = Time.time + LadderTopTime; } else if (Ladder == 0 && move.y > 0.1f && !z.IsInElevator() && Time.time < _ladderTop) { // Верх лестницы (тест 13.10: «лезет, но не может с неё сойти»): ступени под ногами кончились, а до // пола ещё не хватает - ещё немного вверх и вперёд, как перешагнуть через край. Ladder = 2; } z.SetMoveForwardWithModifiers(run ? Mathf.Max(z.GetMoveSpeedAggro(), EffectManager.GetValue(PassiveEffects.RunSpeed, null, z.moveSpeedAggroMax, z)) : z.GetMoveSpeed(), 1f, 0f, climb); } else { z.SetMoveForwardWithModifiers(0f, 0f, 0f, false); } // E у прыгунов (паук, jumpMaxDistance от 5 м) - тот же дальний прыжок (13.10: «спайдер не прыгает по E»). if (detonate && z.jumpMaxDistance >= 5f) { jump = true; } if (jump && z.onGround && !z.Jumping) { z.Jumping = true; z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f); } if (IsPet && RatModel.Is(z)) { // E - осмотреть контейнер без права взять (указание 08.10: крыса - разведчик). RatLoot.Tick(world, _player, z, detonate); // ПКМ (держать) - сквозь постройки, без полёта (BACKLOG, «Могильная крыса» п.4). RatPhase.Tick(world, z, rangedHeld); } if (ZombieBag.Active) { // Tab в зомби - окно его сумки, а не рюкзак персонажа (указание 13.10), как у волка. GUIWindowManager zwm = LocalPlayerUI.GetUIForPlayer(_player)?.windowManager; if (zwm != null && zwm.IsWindowOpen("windowpaging") && !ZombieBag.WindowOpen && !RatLoot.Viewing) { zwm.CloseAllOpenModalWindows(); zwm.Close("windowpaging"); ZombieBag.OpenWindow(_player, z); } // E: на ящик или мешок с зомби - обыск с переносом в сумку, на предмет на земле - подобрать (13.10, как у волка). bool zlooted = RatLoot.Tick(world, _player, z, detonate, true); if (detonate && !zlooted && !RatLoot.Viewing) { WolfBag.TryPickup(world, _player, z); } } if (IsPet && WolfControl.Is(z)) { // Tab в волке - окно его сумки, а не рюкзак персонажа (тест 10.10: «в волке Tab - вижу сумку игрока»). GUIWindowManager wm = LocalPlayerUI.GetUIForPlayer(_player)?.windowManager; if (wm != null && wm.IsWindowOpen("windowpaging") && !WolfBag.WindowOpen && !RatLoot.Viewing) { wm.CloseAllOpenModalWindows(); wm.Close("windowpaging"); WolfBag.OpenWindow(_player, z); } // ПКМ - бросок на цель, след крови раненых (решено 09.10). WolfControl.Tick(world, _player, z, ranged, ref _bodyYaw); // E: на контейнер или мешок с зомби - лутать в сумку (3 ячейки); у тела - выгрузить сумку в рюкзак; // иначе - «багажник», окно сумки (указание 09.10). bool looted = RatLoot.Tick(world, _player, z, detonate, true); // E на предмет на земле - в сумку (тест 10.10: «не могу подбирать выброшенные предметы»). if (detonate && !looted && !RatLoot.Viewing && !WolfBag.TryPickup(world, _player, z) && !WolfBag.TryUnload(_player, z)) { WolfBag.OpenWindow(_player, z); } } if (ranged && IsPet && BearControl.Is(z)) { BearControl.Roar(world, z); // ПКМ - рёв-провокация (решено 09.10) } if (detonate && IsPet && BearControl.Is(z)) { BearControl.RearUp(world, z); // E - на дыбы, враги рядом падают (указание 09.10) } if (EatsCarrion(z)) { // F - есть падаль, как стервятник (указание 09.10: «поедание падали на F одержимым питомцам, на E - // действие»; и одержимым зомби - их ИИ в игре и так ест трупы), стоя на месте рядом с трупом. Eat(world, z, FKey(input, gui), moving || jump || attack || ranged, !moving && z.onGround); } AimTint(GroundAimTarget(world, z) != null); if (IsPet && DogSense.Is(z)) { // Нюх (указание 09.10): все существа рядом с собакой - на компасе и карте. DogSense.Tick(world, z, Time.deltaTime); } if (attack && Time.time >= _nextAttack && FpArms.Active) { // От первого лица - как у персонажа (указание 13.10): ЛКМ - удар рук, урон зомби - когда их анимация дошла // до точки удара (FpArms -> FpImpact), а не по его собственному замаху. PossessionPowers.BreakInvis(z, "удар"); FpArms.Punch(); _nextAttack = Time.time + Mathf.Max(FpArms.SwingTime, z.GetAttackTimeoutTicks() / 20f); } else if (attack && Time.time >= _nextAttack) { PossessionPowers.BreakInvis(z, "удар"); z.SetLookPosition(MeleeAim(world, z)); if (z.Attack(false)) { z.Attack(true); if (IsPet && BearControl.Is(z)) { BearControl.Smash(world, _player, z); // удар лапой и по блоку (решено 09.10) } 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). if (!PossessionPowers.Invisible) { // Невидимого ранчера никто не замечает и спящие от него не просыпаются. SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime); Taunt(world, z); } CheckBody(world, z, Time.deltaTime); } /// Тик полёта одержимого стервятника (из его updateTasks, 20 в секунду) - формула его же /// полёта: motion = motion * 0.9 + направление * (разгон * 0.1), разгон умножен на скорость класса. /// Без ввода гасим скорость - зависает: у EntityFlying нет гравитации. public static void FlyTick(EntityVulture _v) { if (_flyDash != null) { FlyDashTick(_v); return; } Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f); Vector3 dir = rot * Vector3.forward * _flyMove.y + Quaternion.Euler(0f, _yaw, 0f) * Vector3.right * _flyMove.x; if (_flyUp) { dir += Vector3.up; } if (_flyDown) { dir += Vector3.down; } bool moving = dir.sqrMagnitude > 0.01f; float accel = _flyRun ? FlyAccelRun * _v.moveSpeedAggro : FlyAccel * _v.moveSpeed; if (moving) { _v.motion = _v.motion * 0.9f + dir.normalized * (accel * 0.1f); } else { _v.motion *= 0.8f; } Vector3 flat = new Vector3(_v.motion.x, 0f, _v.motion.z); float wantYaw = flat.sqrMagnitude > 0.0004f ? Mathf.Atan2(flat.x, flat.z) * Mathf.Rad2Deg : _yaw; _bodyYaw = Mathf.MoveTowardsAngle(_bodyYaw, wantYaw, 20f); _v.SetRotation(new Vector3(0f, _bodyYaw, 0f)); if (_v.emodel != null && _v.emodel.avatarController != null) { _v.glidingCurrentPercent = Mathf.MoveTowards(_v.glidingCurrentPercent, _flyRun && moving ? 1f : 0f, 0.06f); _v.emodel.avatarController.UpdateFloat("Gliding", _v.glidingCurrentPercent); } // «Вниз» на земле посадке не мешает: Ctrl - переключатель, и с ним стервятник не сел бы никогда. // Сесть = на земле И остановился (указание 06.10: «опуститься на землю - это ещё и остановиться»): // без ввода скорость гаснет за несколько тиков, поза - когда она почти ноль. // За едой сидит всегда - даже там, где опору не видно (тест 06.10: мешок с трупом на прицепе - // модель машины, луч вниз её не находит, и «стервятник не сидит»). bool sit = _eating || _flyMove.sqrMagnitude < 0.01f && !_flyUp && _meleeLookUntil <= 0f && (_flySitting || flat.sqrMagnitude < FlySitSpeed * FlySitSpeed) && OnGround(_v); if (sit) { _v.motion = Vector3.zero; } if (sit != _flySitting) { if (!sit && _flyUp) { // Взлёт с земли на пробел (указание 06.10) - толчок вверх, а не медленный отрыв. _v.motion.y = Mathf.Max(_v.motion.y, FlyTakeOff); } _flySitting = sit; SetSitPose(_v, sit); } } /// Едят падаль на F, кроме стервятника (указание 09.10): чужие одержимые зомби и питомцы медведь, /// собака, волк. Крыса - дух, не ест. static bool EatsCarrion(EntityAlive _z) => !IsPet || BearControl.Is(_z) || DogSense.Is(_z) || WolfControl.Is(_z); /// F на клавиатуре или геймпаде (у игрока - фонарик). static bool FKey(PlayerActionsLocal _input, bool _gui) => _input != null && !_gui && (_input.PermanentActions.ToggleFlashlight.WasPressed || _input.ToggleFlashlight.WasPressed); /// _moved - сдвинулся или что-то сделал (еда прерывается), _still - стоит/сидит, можно начать. static void Eat(World _world, EntityAlive _v, bool _pressed, bool _moved, bool _still) { if (_eating) { bool moved = _moved; bool gone = _eatCorpse != null ? _eatCorpse.IsMarkedForUnload() : _world.GetBlock(_eatBlock).type != _eatBlockType; if (moved || gone) { StopEat(_v, gone ? "падаль пропала" : "прервано"); return; } // Как едят зомби в игре (EAIApproachAndAttackTarget, isEating): раз в 1.25-1.75 с брызги крови // blood_eat у головы, через раз - звук eat_player. _eatBite -= Time.deltaTime; if (_eatBite <= 0f) { _eatBite = 1.25f + Random.value * 0.5f; if ((_eatCount & 1) == 0) { _v.PlayOneShot("eat_player"); } ParticleEffect pe = new ParticleEffect("blood_eat", new Vector3(0f, 0.04f, 0.08f), 1f, Color.white, null, _v.entityId, ParticleEffect.Attachment.Head); GameManager.Instance.SpawnParticleEffectServer(pe, _v.entityId); _eatCount++; } float dt = Mathf.Min(Time.deltaTime, _eatLeft); _eatLeft -= dt; float bite = _v.GetMaxHealth() * EatHealFrac * dt / EatTime; _eatHeal += bite; float meat = EatMeat(-bite); int add = Mathf.Min((int)_eatHeal, _v.GetMaxHealth() - _v.Health); if (add > 0) { _v.AddHealth(add); } _eatHeal -= (int)_eatHeal; // Огрызок меньше одного подхода доедается сразу (тест 13.10: у трупа осталось 5 из 111 при подходе ~17 - // с виду целый труп «не исчезает»). if (meat <= 0f || _eatLeft <= 0f && meat < _v.GetMaxHealth() * EatHealFrac) { string what = EatName(); EntityAlive corpse = _eatCorpse; Vector3i pos = _eatBlock; StopEat(_v, "доедено"); if (corpse != null) { _carrion.Remove(corpse.entityId); _world.RemoveEntity(corpse.entityId, EnumRemoveEntityReason.Despawned); } else { _carrionBlocks.Remove(pos); _world.SetBlocksRPC(new List { new BlockChangeInfo(pos, BlockValue.Air, MarchingCubes.DensityAir) }); LogCarrionLeft(_world, pos); } Debug.Log("[NecromancerTome] Possession: падаль " + what + " исчезла"); } else if (_eatLeft <= 0f) { StopEat(_v, "кусок съеден, у падали осталось " + Mathf.CeilToInt(meat)); } return; } if (!_pressed) { return; } if (!_still || !FindCarrion(_world, _v)) { GameManager.ShowTooltip(_player, Localization.Get(Flying ? "necroPossessNoCarrion" : "necroPossessNoCarrionGround")); Debug.Log("[NecromancerTome] Possession: падали нет" + (_still ? "" : " - " + _v.EntityName + " не остановился")); return; } _eating = true; if (Flying && !_flySitting) { // Сел за еду сразу, не дожидаясь тика полёта: иначе проверка «не сидит» тут же прервала бы еду. _flySitting = true; _v.motion = Vector3.zero; SetSitPose(_v, true); } _eatLeft = EatTime; _eatHeal = 0f; _eatBite = 0f; _eatCount = 0; _v.SetLookPosition(_eatCorpse != null ? _eatCorpse.getChestPosition() : _eatBlock.ToVector3() + new Vector3(0.5f, 0.3f, 0.5f)); _v.IsEating = true; // как у зомби: анимация поедания и синхронизация по сети Debug.Log("[NecromancerTome] Possession: " + _v.EntityName + " ест " + EatName() + ", здоровье " + _v.Health + "/" + _v.GetMaxHealth() + ", мяса у падали " + Mathf.CeilToInt(EatMeat(0f))); } /// Диагностика (тест 13.10: «останки визуально не исчезли» после «доедено»): что стоит на месте съеденного /// блока сразу после замены и какие блоки падали остались в 2 блоках вокруг - одна куча может быть из нескольких. static void LogCarrionLeft(World _world, Vector3i _pos) { string near = ""; for (int x = -2; x <= 2; x++) for (int y = -2; y <= 2; y++) for (int z = -2; z <= 2; z++) { Vector3i p = new Vector3i(_pos.x + x, _pos.y + y, _pos.z + z); BlockValue bv = _world.GetBlock(p); if (!bv.isair && IsCarrionBlock(bv.type)) { near += " " + bv.Block.GetBlockName() + (bv.ischild ? "(часть)" : "") + "@" + p; } } Debug.Log("[NecromancerTome] Possession: после еды на " + _pos + " - " + _world.GetBlock(_pos).Block.GetBlockName() + ", падаль рядом:" + (near.Length > 0 ? near : " нет")); } /// Запас мяса текущей падали плюс _delta; первый укус - запас с нуля: у трупа - полное /// здоровье при жизни, у блока - его прочность (MaxDamage минус уже нанесённый урон). static float EatMeat(float _delta) { float meat; if (_eatCorpse != null) { if (!_carrion.TryGetValue(_eatCorpse.entityId, out meat)) { meat = Mathf.Max(1, _eatCorpse.GetMaxHealth()); } meat += _delta; _carrion[_eatCorpse.entityId] = meat; return meat; } if (!_carrionBlocks.TryGetValue(_eatBlock, out meat)) { BlockValue bv = GameManager.Instance.World.GetBlock(_eatBlock); meat = Mathf.Max(1, bv.Block.MaxDamage - bv.damage); } meat += _delta; _carrionBlocks[_eatBlock] = meat; return meat; } static string EatName() { return _eatCorpse != null ? _eatCorpse.EntityName + " (" + _eatCorpse.entityId + ")" : Block.list[_eatBlockType].GetBlockName() + " " + _eatBlock; } static void StopEat(EntityAlive _v, string _why) { if (!_eating) { return; } _eating = false; _eatCorpse = null; if (_v != null && !_v.IsDead()) { _v.IsEating = false; _v.SetLookPosition(Vector3.zero); } if (_why != null) { Debug.Log("[NecromancerTome] Possession: еда - " + _why + ", здоровье " + (_v != null ? _v.Health + "/" + _v.GetMaxHealth() : "?")); } } /// Ближайшая падаль ближе EatRange. Сначала трупы - любое мёртвое существо, кроме игроков и /// существ мода (питомцы, рой, змея); нет трупа - блок-падаль (указание 06.10: «в игре кроме трупов /// зомби много другой падали»): тела людей, останки животных, мешки с трупами - всё, с чего при /// разделке падает гнилая плоть (IsCarrionBlock). У составного блока берём его главный блок. static bool FindCarrion(World _world, EntityAlive _v) { _eatCorpse = null; float bestD = EatRange * EatRange; List list = _world.Entities.list; for (int i = 0; i < list.Count; i++) { if (!(list[i] is EntityAlive e) || e == _v || !e.IsDead() || e is EntityPlayer || SnakeControl.IsNecroEntity(e) || e.IsMarkedForUnload()) { continue; } float d = (e.position - _v.position).sqrMagnitude; if (d < bestD) { bestD = d; _eatCorpse = e; } } if (_eatCorpse != null) { return true; } bool found = false; Vector3i c = World.worldToBlockPos(_v.position); int r = Mathf.CeilToInt(EatRange); for (int x = -r; x <= r; x++) { for (int y = -r; y <= r; y++) { for (int z = -r; z <= r; z++) { Vector3i p = new Vector3i(c.x + x, c.y + y, c.z + z); BlockValue bv = _world.GetBlock(p); if (bv.isair || !IsCarrionBlock(bv.type)) { continue; } float d = (p.ToVector3() + new Vector3(0.5f, 0.5f, 0.5f) - _v.position).sqrMagnitude; if (d >= bestD) { continue; } if (bv.ischild) { p += bv.parent; bv = _world.GetBlock(p); if (bv.isair || !IsCarrionBlock(bv.type)) { continue; } } bestD = d; _eatBlock = p; _eatBlockType = bv.type; found = true; } } } return found; } /// Блок - падаль, если хоть одна его добыча - гнилая плоть (foodRottingFlesh). static bool IsCarrionBlock(int _type) { if (_isCarrion.TryGetValue(_type, out bool yes)) { return yes; } Block b = Block.list[_type]; if (b?.itemsToDrop != null) { foreach (KeyValuePair> drops in b.itemsToDrop) { for (int i = 0; drops.Value != null && i < drops.Value.Count && !yes; i++) { yes = drops.Value[i].name == CarrionItem; } } } _isCarrion[_type] = yes; return yes; } /// Прицел краснеет на цели рывка (указание 06.10: прицел обычный, не змеиный). Игра каждый кадр /// красит прицел цветом crosshairColors[OptionsCrosshairColor] (EntityPlayerLocal.guiDrawCrosshair) - /// пока цель под прицелом, этот цвет в таблице красный, без цели и на выходе - прежний. (Первая версия - /// подмена на время рисования патчем guiDrawCrosshair - в игре не сработала, тест 06.10.) static void AimTint(bool _on) { List colors = EntityPlayerLocal.crosshairColors; if (_on == (_tintIndex >= 0) || colors == null || colors.Count == 0) { return; } if (_on) { int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor); _tintIndex = i >= 0 && i < colors.Count ? i : 0; _tintSaved = colors[_tintIndex]; colors[_tintIndex] = AimOnTarget; } else { colors[_tintIndex] = _tintSaved; _tintIndex = -1; } } static readonly Color AimOnTarget = new Color(1f, 0.15f, 0.1f, 1f); static int _tintIndex = -1; static Color _tintSaved; /// Стоит ли стервятник на земле: флаг движка или опора ближе FlySitGround под ногами /// (у EntityFlying нет гравитации - сам он на землю не «падает» и onGround бывает не всегда). static bool OnGround(EntityAlive _v) { if (_v.onGround) { return true; } Vector3 from = _v.position - Origin.position + Vector3.up * 0.1f; return Physics.Raycast(from, Vector3.down, FlySitGround + 0.1f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore); } static void SetSitPose(EntityAlive _v, bool _sit) { if (_v?.emodel?.avatarController == null || _v.IsDead()) { return; } _v.emodel.avatarController.TriggerSleeperPose(_sit ? VultureSitPose : AvatarController.cSleeperPoseAwake); Debug.Log("[NecromancerTome] Possession: стервятник " + (_sit ? "сел" : "взлетел")); } /// Тик рывка: прямо к голове цели; долетел - клевок, как ванильный AttackAndAdjust. static void FlyDashTick(EntityVulture _v) { EntityAlive t = _flyDash; if (_flySitting) { _flySitting = false; SetSitPose(_v, false); } if (t == null || t.IsDead() || t.IsMarkedForUnload() || Time.time > _flyDashUntil) { _flyDash = null; _v.motion *= 0.5f; Debug.Log("[NecromancerTome] Possession: рывок стервятника сорван"); return; } Vector3 head = t.getHeadPosition(); Vector3 to = head - _v.position; Vector3 flat = new Vector3(to.x, 0f, to.z); if (flat.sqrMagnitude > 0.0001f) { _bodyYaw = Mathf.Atan2(flat.x, flat.z) * Mathf.Rad2Deg; _v.SetRotation(new Vector3(0f, _bodyYaw, 0f)); } float dist = to.magnitude; if (dist > FlyDashHit) { _v.motion = to / dist * Mathf.Min(FlyDashSpeed, dist - FlyDashHit * 0.5f); return; } // Удар - прямым уроном его оружия (тест 06.10: «рывок вообще никак»). Клевок игры бьёт лучом из // головы через ~0.5 с после нажатия, а стервятник к тому времени пролетал мимо - ни одного // попадания. Анимация и звук клевка - его же; урон - от стервятника, убийство засчитает // PetKillCreditPatch (питомец) хозяину. _flyDash = null; _v.motion = Vector3.zero; _v.SetLookPosition(head); _v.emodel?.avatarController?.StartAnimationAttack(); if (Carryable(t)) { // Курица или кролик - не клевок, а захват: ИИ встаёт (updateTasks пропущен), путь сброшен. _carry = t; t.SetAttackTarget(null, 0); t.SetRevengeTarget(null); PathFinderThread.Instance.RemovePathsFor(t.entityId); t.navigator?.clearPath(); t.moveHelper?.Stop(); t.SetRotation(new Vector3(0f, _bodyYaw, 0f)); t.transform.rotation = Quaternion.Euler(0f, _bodyYaw, 0f); CarryVisual.Begin(t); HoldCarry(_v); Debug.Log("[NecromancerTome] Possession: стервятник схватил " + t.EntityName + " (" + t.entityId + ")"); return; } string snd = _v.GetSoundAttack(); if (!string.IsNullOrEmpty(snd)) { Audio.Manager.BroadcastPlay(_v, snd); } float dmg = FlyDashDamage; if (_v.inventory?.holdingItem?.Actions != null && _v.inventory.holdingItem.Actions.Length > 0 && _v.inventory.holdingItem.Actions[0] is ItemActionAttack melee) { dmg = melee.GetDamageEntity(_v.inventory.holdingItemItemValue, _v, 0); } int dealt = t.DamageEntity(new DamageSourceEntity(EnumDamageSource.External, EnumDamageTypes.Piercing, _v.entityId), Mathf.Max(1, Mathf.RoundToInt(dmg)), false); // Звук попадания (тест 06.10: «нет звука в момент удара - дезориентирует»): прямой урон звуков // удара не даёт, их играет только клевок игры - удар органики об органику, как у руки зомби. Audio.Manager.BroadcastPlay(t, FlyDashHitSound); // Кровотечение - как у клевка грифа (items.xml, onSelfAttackedOther; указание 06.10). if (!t.IsDead() && t.Buffs != null) { t.Buffs.AddBuff(FlyDashBleedBuff, _v.entityId); } _meleeLookUntil = Time.time + FlyAttackDelay; Debug.Log("[NecromancerTome] Possession: клевок после рывка по " + t.EntityName + " - урон " + dealt + " из " + dmg.ToString("F0") + (t.IsDead() ? ", убит" : ", здоровье " + t.Health)); } /// Ноша - под лапами стервятника, каждый кадр: верх слепка ноши на CarryGap ниже его ног /// (CarryVisual). Ниже уже земля (схватил у земли, сел) - ноша стоит на земле под ним, но не отпущена. static void HoldCarry(EntityAlive _v) { EntityAlive c = _carry; if (c == null) { return; } if (c.IsDead() || c.IsMarkedForUnload()) { _carry = null; CarryVisual.End(c); Debug.Log("[NecromancerTome] Possession: ноша погибла"); return; } CarryVisual.Place(_v, c, CarryGap); c.SetRotation(new Vector3(0f, _bodyYaw, 0f)); c.motion = Vector3.zero; c.fallDistance = 0f; } /// Отпустить ношу: ИИ снова идёт, падает она сама (с высоты - может разбиться). static void DropCarry(string _why) { EntityAlive c = _carry; if (c == null) { return; } _carry = null; CarryVisual.End(c); if (!c.IsDead()) { c.motion = Vector3.zero; } Debug.Log("[NecromancerTome] Possession: стервятник отпустил " + c.EntityName + " - " + _why); } /// Цель рывка: ближайшая к стервятнику по лучу прицела, не дальше FlyDashRange от него и /// не дальше TargetRadius змеи от луча. Свои (питомцы, игроки, зачарованные) - не цель. static EntityAlive FlyDashTarget(World _world, EntityAlive _v) { Transform cam = _player?.cameraTransform; if (cam == null) { return null; } Vector3 from = cam.position + Origin.position; Vector3 dir = cam.forward; EntityAlive best = null; float bestAlong = float.MaxValue; _aimMissNear = null; _aimMissDist = _aimMissOff = float.MaxValue; for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (e == null || e == _v || e.IsDead() || e is EntityPlayer || SnakeControl.IsNecroEntity(e) || NecroCharmSide.IsCharmed(e) || GhostTraderPatch.ShouldBeGhost(e)) { continue; } // Тело цели - отрезок от ног до головы (тест 06.10: «прицел не краснеет, рывок не срабатывает» - // по одной точке груди с высоты почти не попасть). Берём точку отрезка ближе всех к лучу прицела; // дальность - от стервятника до ближайшей точки тела. Vector3 feet = e.position, head = e.getHeadPosition(); float dist = float.MaxValue, off = float.MaxValue, along = 0f; for (int k = 0; k <= 4; k++) { Vector3 p = Vector3.Lerp(feet, head, k / 4f); dist = Mathf.Min(dist, (p - _v.position).magnitude); float a = Vector3.Dot(p - from, dir); float o = Vector3.Cross(p - from, dir).magnitude; if (a > 0f && o < off) { off = o; along = a; } } if (dist < FlyDashRange + 5f && dist < _aimMissDist) { _aimMissNear = e; _aimMissDist = dist; _aimMissOff = off; } if (dist > FlyDashRange || off > FlyDashAimRadius || along >= bestAlong) { continue; } best = e; bestAlong = along; } return best; } // Для лога промаха: ближайший к стервятнику враг (до FlyDashRange + 5 м), его дальность и отступ от луча прицела. static EntityAlive _aimMissNear; static float _aimMissDist, _aimMissOff; /// Враг под прицелом наземного одержимого (указание 09.10: прицел краснеет на противнике у /// всех одержимых): тело врага - отрезок от ног до головы - не дальше GroundAimRadius от луча /// камеры и не дальше GroundAimRange от одержимого, и не за стеной - не дальше первого попадания /// луча (AimPoint). static EntityAlive GroundAimTarget(World _world, EntityAlive _z) { Transform cam = _player?.cameraTransform; if (cam == null) { return null; } Vector3 from = cam.position + Origin.position; Vector3 dir = cam.forward; float wall = Vector3.Dot(AimPoint() - from, dir) + 0.5f; EntityAlive best = null; float bestAlong = float.MaxValue; for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (!SnakeControl.IsFoe(e, _z) || (e.position - _z.position).sqrMagnitude > GroundAimRange * GroundAimRange) { continue; } Vector3 feet = e.position, head = e.getHeadPosition(); for (int k = 0; k <= 4; k++) { Vector3 p = Vector3.Lerp(feet, head, k / 4f); float a = Vector3.Dot(p - from, dir); if (a > 0f && a < wall && a < bestAlong && Vector3.Cross(p - from, dir).magnitude < GroundAimRadius) { best = e; bestAlong = a; } } } return best; } public const float GroundAimRange = 30f; // зрение зомби, как TauntRange public const float GroundAimRadius = 0.6f; /// Девианты и питомцы мода не бьют одержимого (13.10: «девианты не должны нападать на /// одержимого»). Новую цель им гасят заплатки на выборе и присвоении (CharmPatch), но зомби, на которого /// девиант или питомец уже шёл до вселения, оставался его целью - SetAttackTarget больше не звался. /// Такую цель снимаем здесь, раз в полсекунды (из CheckBody). static void DropAllyTargets(World _world, EntityAlive _z) { for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (e == null || e == _z || e.IsDead() || e.GetAttackTarget() != _z && e.GetRevengeTarget() != _z || !NecroCharmSide.IsCharmed(e) && !SnakeControl.IsNecroEntity(e)) { continue; } e.SetRevengeTarget(null); e.SetAttackTarget(null, 0); Debug.Log("[NecromancerTome] Possession: " + e.EntityName + " (" + e.entityId + ") больше не нападает на одержимого"); } } /// Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу (как у змеи). static void CheckBody(World _world, EntityAlive _z, float _dt) { _bodyCheck -= _dt; if (_bodyCheck > 0f) { return; } _bodyCheck = 0.5f; DropAllyTargets(_world, _z); bool danger = false; Vector3 body = _player.position; float r2 = SnakeControl.BodyDangerRange * SnakeControl.BodyDangerRange; for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (SnakeControl.IsFoe(e, _z) && (e.position - body).sqrMagnitude < r2) { danger = true; break; } } if (danger != BodyDanger) { BodyDanger = danger; Debug.Log("[NecromancerTome] Possession: " + (danger ? "враг у тела" : "у тела чисто")); } } 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); 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 + " с"); } /// Подсказка при вселении: что умеет этот зомби. static void Hint(EntityPlayerLocal _p, EntityAlive _z) { string text = Localization.Get("necroPossessHintBase"); if (FpArms.Allows(_z)) { text += ", " + Localization.Get("necroPossessHintView"); } if (ZombieBag.Has(_z, IsPet)) { text += ", " + Localization.Get("necroPossessHintZombieBag"); } if (!IsPet) { text += ", " + Localization.Get("necroPossessHintDoor"); // двери и выключатели (13.10) } if (Flying) { text += ", " + Localization.Get("necroPossessHintFly"); } switch (PossessionPowers.Current) { 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; case PossessionPowers.Kind.Mutant: text += ", " + Localization.Get("necroPossessHintSpitE"); break; default: if (_hasRanged) { text += ", " + Localization.Get("necroPossessHintRanged"); } break; } if (Flying) { text += ", " + Localization.Get("necroPossessHintZoom") + ", " + Localization.Get("necroPossessHintScreamE"); } if (Flying) { text += ", " + Localization.Get("necroPossessHintEatF"); } if (IsPet && RatModel.Is(_z)) { text += ", " + Localization.Get("necroPossessHintLook") + ", " + Localization.Get("necroPossessHintPhase"); } if (IsPet && DogSense.Is(_z)) { text += ", " + Localization.Get("necroPossessHintSmell"); } if (IsPet && WolfControl.Is(_z)) { text += ", " + Localization.Get("necroPossessHintPounce") + ", " + Localization.Get("necroPossessHintBlood") + ", " + Localization.Get("necroPossessHintWolfBag"); } if (IsPet && BearControl.Is(_z)) { text += ", " + Localization.Get("necroPossessHintRoar") + ", " + Localization.Get("necroPossessHintRear") + ", " + Localization.Get("necroPossessHintSmash") + ", " + Localization.Get("necroPossessHintSkin"); } if (EatsCarrion(_z)) { text += ", " + Localization.Get("necroPossessHintEatGroundF"); } if (_z.jumpMaxDistance >= 5f) { text += ", " + Localization.Get("necroPossessHintLeap"); } if (_z is EntityZombieCop && PossessionPowers.Current != PossessionPowers.Kind.Mutant) { text += ", " + Localization.Get("necroPossessHintDetonate"); } if (!IsPet) { // Решение 03.10, подтверждено 09.10: после выхода бывший одержимый в леденящем ужасе бежит от тела. text += ", " + Localization.Get("necroPossessHintAfterFear"); } 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 м. Тело самого одержимого луч /// пропускает (13.10: «у всех зомби должен краснеть прицел», «у мутанта атака не целится»): камера /// за головой, и у крупных зомби луч упирался в собственную спину - точка прицела была на себе. static Vector3 AimPoint() { Transform cam = _player?.cameraTransform; if (cam == null) { return Zombie.position + Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * 30f; } int n = Physics.RaycastNonAlloc(cam.position, cam.forward, _aimHits, 50f, -5, QueryTriggerInteraction.Ignore); Transform self = Zombie != null ? Zombie.transform : null; float best = float.MaxValue; Vector3 point = Vector3.zero; for (int i = 0; i < n; i++) { RaycastHit hit = _aimHits[i]; if (hit.distance <= (FirstPerson ? 0.1f : CamBack + 0.5f) || hit.distance >= best || self != null && (hit.transform == self || hit.transform.IsChildOf(self))) { continue; } best = hit.distance; point = hit.point; } if (best < float.MaxValue) { return point + Origin.position; } return cam.position + Origin.position + cam.forward * 50f; } static readonly RaycastHit[] _aimHits = new RaycastHit[32]; /// Куда летит плевок мутанта (указание 13.10: «у мутанта атака не целится»): грудь врага под /// прицелом, иначе точка под прицелом; дальше SpitRange - точка на луче к ней. public static Vector3 SpitTarget(Vector3 _from) { EntityAlive foe = Zombie != null && Zombie.world != null ? GroundAimTarget(Zombie.world, Zombie) : null; Vector3 to = foe != null ? foe.getChestPosition() : AimPoint(); Vector3 d = to - _from; return d.magnitude > SpitRange ? _from + d.normalized * SpitRange : to; } public const float SpitRange = 15f; /// ПКМ - дальняя атака зомби по шагам ванильной 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 || PossessionPowers.SwarmSpawned && _rTime > 0.5f) { StopRanged(_z); } return; } } /// Валун Чака (указание 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). 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; if (PossessionPowers.Current == PossessionPowers.Kind.Rancher) { PossessionPowers.SwarmReadyAt = _rNext; } PossessionPowers.SwarmSpawned = false; } /// Откат приёма для PossessionHud: Rmb - кнопка ПКМ (иначе E), Left/Total - секунды; Active - приём идёт /// (невидимость), Left - сколько ему осталось. public struct Cooldown { public bool Rmb; public float Left, Total; public bool Active; } /// Откаты одержимого (до двух) - сколько записано в _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; case PossessionPowers.Kind.Mutant: _out[n++] = new Cooldown { Rmb = false, Left = Mathf.Max(0f, _rNext - now), Total = _rCooldown }; 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) { return; } _nextScream = Time.time + (Flying ? FlyScreamCooldown : ScreamCooldown); string snd = Flying ? FlyScreamSound : _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, _z); e.SetAttackTarget(_z, TauntTicks); n++; } Debug.Log("[NecromancerTome] Possession: крик " + _z.EntityName + " - на зов идут " + 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; if (_zoomCam != null && _zoomBaseFov > 0f) { sens *= _zoomCam.fieldOfView / _zoomBaseFov; // в «зорком глазе» мышь медленнее, как в прицеле } _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, _z); e.SetAttackTarget(_z, TauntTicks); } } /// Разрешить зомби подход к цели класса одержимого: EntityZombie, у питомцев - их класс /// (EntityZombieDog, EntityVulture). internal static void AllowZombieTarget(EntityAlive _e, EntityAlive _target) { System.Type type = _target is EntityZombie ? typeof(EntityZombie) : _target.GetType(); 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 == type; } if (!has) { t.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = type, chaseTimeMax = 0f }); } } } /// Открыть карту вокруг одержимого - как игра открывает её вокруг игрока, при смене чанка. static void RevealMap(World _world, EntityAlive _z) { IMapChunkDatabase map = _player.ChunkObserver?.mapDatabase; if (map == null || !_z.IsSpawned() || _mapChunkSet && _z.chunkPosAddedEntityTo == _mapChunk) { return; } _mapChunk = _z.chunkPosAddedEntityTo; _mapChunkSet = true; map.Add(_mapChunk, _world); } /// Шаг «зоркого глаза» за кадр; отпустил - угол плавно возвращается и восстанавливается точно. static void Zoom(bool _held) { Camera cam = _player != null ? _player.playerCamera : null; if (_held && cam != null && _zoomBaseFov < 0f) { _zoomCam = cam; _zoomBaseFov = cam.fieldOfView; } if (_zoomBaseFov < 0f) { return; } _zoom = Mathf.MoveTowards(_zoom, _held ? 1f : 0f, Time.deltaTime / ZoomTime); if (_zoom <= 0f) { ZoomReset(); return; } ZoomFov = Mathf.Lerp(_zoomBaseFov, _zoomBaseFov / ZoomFactor, Mathf.SmoothStep(0f, 1f, _zoom)); } static void ZoomReset() { if (_zoomCam != null && _zoomBaseFov > 0f) { _zoomCam.fieldOfView = _zoomBaseFov; } _zoomCam = null; _zoomBaseFov = -1f; _zoom = 0f; ZoomFov = -1f; _flyZoomHeld = false; } /// Команды блока, которые одержимый выполняет по E (как первая доступная команда у персонажа): двери /// (TEFeatureDoor - open/close), выключатели и лампы (light), рычаги квестов (activate). Без take, lock, edit, /// trigger. Запертую чужую дверь не откроет - замок проверяет сама дверь. static readonly string[] UseCommands = { "open", "close", "light", "activate" }; public const float UseReach = 3f; static bool UseDoorOrSwitch(World _world, EntityAlive _z) { Transform cam = _player?.cameraTransform; if (cam == null || _world == null) { return false; } int n = Physics.RaycastNonAlloc(cam.position, cam.forward, _aimHits, 50f, -5, QueryTriggerInteraction.Ignore); Transform self = _z.transform; float best = float.MaxValue; RaycastHit hit = default; for (int i = 0; i < n; i++) { RaycastHit h = _aimHits[i]; if (h.distance >= best || h.transform == self || h.transform.IsChildOf(self)) { continue; } best = h.distance; hit = h; } if (best == float.MaxValue) { return false; } Vector3 at = hit.point + Origin.position; if ((at - _z.getHeadPosition()).sqrMagnitude > UseReach * UseReach) { return false; } Vector3i pos = World.worldToBlockPos(at + cam.forward * 0.05f); BlockValue bv = _world.GetBlock(pos); if (bv.isair) { pos = World.worldToBlockPos(at - cam.forward * 0.05f); bv = _world.GetBlock(pos); } if (bv.ischild) { pos += bv.parent; bv = _world.GetBlock(pos); } Block b = bv.Block; if (bv.isair || b == null) { return false; } BlockActivationCommand[] cmds = b.GetBlockActivationCommands(_world, bv, pos, _player); for (int i = 0; cmds != null && i < cmds.Length; i++) { if (!cmds[i].enabled || System.Array.IndexOf(UseCommands, cmds[i].text) < 0) { continue; } b.OnBlockActivated(cmds[i].text, _world, pos, bv, _player); Debug.Log("[NecromancerTome] Possession: " + _z.EntityName + " - " + cmds[i].text + " " + b.GetBlockName() + " " + pos); return true; } return false; } /// Миг удара рук от первого лица (FpArms): зомби бьёт сразу - его удар начат и сразу «дошёл до точки /// удара» (ForceImpact в EntityAlive.IsAttackImpact, задержка луча ItemActionMelee снята). Урон, дальность, /// цель - его, взгляд - на прицел (MeleeAim). public static bool ForceImpact; public static void FpImpact() { EntityAlive z = Zombie; if (!Active || z == null || z.IsDead()) { return; } z.SetLookPosition(MeleeAim(z.world, z)); _meleeLookUntil = Time.time + 0.6f; if (!z.Attack(false)) { Debug.Log("[NecromancerTome] Possession: удар рук - зомби не может бить сейчас"); return; } z.Attack(true); ForceImpact = true; if (z.inventory?.holdingItemData?.actionData != null && z.inventory.holdingItemData.actionData.Count > 0 && z.inventory.holdingItemData.actionData[0] is ItemActionMelee.InventoryDataMelee d) { d.lastUseTime -= 10f; } } /// Ячейки пояса персонажа (окно windowToolbelt) - скрыты в виде от первого лица (указание 13.10: «пояс игрока /// нужно прятать», у зомби свой пояс). Только это окно: группа toolbelt держит и полоски здоровья и сытости персонажа /// (тест 13.10 - пропали вместе с поясом). Ставится каждый кадр - игра может показать окно снова. static void ShowPlayerBelt(bool _show) { XUiV_Window w = _player?.PlayerUI?.xui?.GetWindow("windowToolbelt"); if (w == null) { if (!_show && !_beltMissingLogged) { _beltMissingLogged = true; Debug.LogWarning("[NecromancerTome] Possession: окно windowToolbelt не найдено - пояс персонажа не скрыть"); } return; } // Прозрачность окна, а не IsVisible (тест 13.10: с IsVisible пояс оставался виден - окно показывают снова). // ForceVisible(0) держит его прозрачным до следующего открытия окна. float a = _show ? 1f : 0f; if (w.PanelAlpha != a) { w.ForceVisible(a); } } static bool _beltMissingLogged; /// Тело одержимого в виде от первого лица - только тень (тест 13.10: изнутри видно тело, у паука на /// бегу лапы и череп в кадре; сжатие одной головы не спасало). Видимость - дело своего экрана: другие игроки /// рисуют этого зомби сами и видят его как обычно; так же игра прячет тело персонажа от первого лица. /// Рендеры собираются один раз на вход в вид; null - вернуть каждому прежний режим тени. /// /// ПАМЯТЬ. Ссылки на рендеры держим только пока вид от первого лица (null чистит список). static void HideBody(EntityAlive _z) { if (_hiddenBody == _z) { return; } for (int i = 0; i < _hiddenRenderers.Count; i++) { if (_hiddenRenderers[i] != null) { _hiddenRenderers[i].shadowCastingMode = _hiddenModes[i]; } } _hiddenRenderers.Clear(); _hiddenModes.Clear(); _hiddenBody = _z; if (_z == null || _z.emodel == null) { return; } _z.emodel.GetComponentsInChildren(true, _hiddenRenderers); for (int i = 0; i < _hiddenRenderers.Count; i++) { _hiddenModes.Add(_hiddenRenderers[i].shadowCastingMode); _hiddenRenderers[i].shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly; } Debug.Log("[NecromancerTome] Possession: тело " + _z.EntityName + " - только тень, рендеров " + _hiddenRenderers.Count); } /// Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены. public static void PlaceCamera(Transform _camera) { EntityAlive z = Zombie; if (z == null) { return; } if (Flying) { // Шея - до камеры: та берёт положение головы. VultureNeck.Apply(z, _flySitting, !_eating); } Zoom(Flying && _flyZoomHeld); 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); if (FirstPerson) { // Положение головы - уже после аниматора (мы в LateUpdate камеры). Vector3 want = anchor + rot * Vector3.forward * FpForward; _fpEye = _fpEye == Vector3.zero || (want - _fpEye).sqrMagnitude > 4f ? want : Vector3.Lerp(_fpEye, want, 1f - Mathf.Exp(-FpSmooth * Time.deltaTime)); HideBody(z); _camera.SetPositionAndRotation(_fpEye, rot); FpArms.Tick(_player?.playerCamera); ShowPlayerBelt(false); return; } 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); } if (_zoom > 0f) { // В «зорком глазе» камера уходит к голове (чуть впереди неё): из-за спины при x8 весь кадр // закрыла бы сама птица. eye = Vector3.Lerp(eye, anchor + rot * Vector3.forward * ZoomEyeForward, Mathf.SmoothStep(0f, 1f, _zoom)); } _camera.SetPositionAndRotation(eye, rot); if (!_loggedCamera) { _loggedCamera = true; Debug.Log("[NecromancerTome] Possession: камера за " + (head != null ? "головой " + head.name : "корнем (головы нет)")); } } } /// Тело не вертится за мышью (тест 13.10: «персонаж поворачивается синхронно с зомби»). Камера игры /// читает мышь сама (vp_FPInput - InputSmoothLook - vp_FPCamera.UpdateInput, m_Yaw) и в LateUpdate /// доворачивает своего родителя - тело игрока. Взгляд одержимого мы считаем сами (Possession.Look), так что /// на время одержимости ввод камеры тела пропускаем: после выхода тело смотрит, куда смотрело до вселения. [HarmonyPatch(typeof(vp_FPCamera), nameof(vp_FPCamera.UpdateInput))] public static class Patch_vpFPCamera_UpdateInput_Possession { public static bool Prefix() => !Possession.Active; } /// То же для вида от третьего лица, в котором тело стоит всю одержимость (тест 13.10: правка выше не /// помогла - «персонаж всё ещё поворачивается синхронно с зомби»). vp_FPCamera.Update3rdPerson каждый кадр /// берёт поворот камеры (а его ставим мы - взгляд зомби) как targetRotation3P, и тело доворачивается к нему. /// Камеру одержимости целиком ставит PlaceCamera, так что этот шаг на время одержимости пропускаем. [HarmonyPatch(typeof(vp_FPCamera), nameof(vp_FPCamera.Update3rdPerson))] public static class Patch_vpFPCamera_Update3rdPerson_Possession { public static bool Prefix() => !Possession.Active; } /// Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы. [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))] public static class Patch_EntityAlive_updateTasks_Possession { public static bool Prefix(EntityAlive __instance) { return !Possession.IsPossessed(__instance) && !Possession.IsCarried(__instance); } } /// Одержимый стервятник: его собственный полёт-ИИ (EntityVulture.updateTasks - своё, без /// вызова базового) заменён полётом по вводу игрока. [HarmonyPatch(typeof(EntityVulture), nameof(EntityVulture.updateTasks))] public static class Patch_EntityVulture_updateTasks_Possession { public static bool Prefix(EntityVulture __instance) { if (!Possession.IsPossessed(__instance)) { return true; } Possession.FlyTick(__instance); return false; } } /// Карта в одержимом стервятнике открывается на нём, а не на теле (указание 08.10: разведка - /// только у стервятника). [HarmonyPatch(typeof(XUiC_MapArea), nameof(XUiC_MapArea.OnOpen))] public static class Patch_XUiC_MapArea_OnOpen_Possession { public static void Postfix(XUiC_MapArea __instance) { if (Possession.Active && Possession.Flying && Possession.Zombie != null) { __instance.PositionMapAt(Possession.Zombie.GetPosition()); } } } /// Кнопка «к игроку» на карте при одержимости - к одержимому. [HarmonyPatch(typeof(XUiC_MapArea), nameof(XUiC_MapArea.onPlayerIconPressed))] public static class Patch_XUiC_MapArea_onPlayerIconPressed_Possession { public static bool Prefix(XUiC_MapArea __instance) { if (!Possession.Active || !Possession.Flying || Possession.Zombie == null) { return true; } __instance.PositionMapAt(Possession.Zombie.GetPosition()); return false; } } /// «Зоркий глаз» стервятника: свой угол обзора - после того, как игра поставила свой. [HarmonyPatch(typeof(EntityPlayerLocal), "LateUpdate")] public static class Patch_EntityPlayerLocal_LateUpdate_Possession { public static void Postfix(EntityPlayerLocal __instance) { if (Possession.ZoomFov > 0f && __instance == Possession.Player && __instance.playerCamera != null) { __instance.playerCamera.fieldOfView = Possession.ZoomFov; } } } /// Прицел одержимого - всегда перекрестие (указание 09.10: «то перекрестие, то точка»). Вид прицела /// игра берёт у предмета в руке тела, а у книги призыва и пустой руки он зависит от проверки камеры от /// третьего лица (ItemAction.GetCrosshairType - EntityPlayerLocal.CharacterCameraAngleValid): камера дальше /// 20° от того, куда повёрнуто тело, - прицела нет, и игра рисует точку. В одержимости камера - глаза /// одержимого, тело стоит, так что вид менялся от поворота мыши. Рисуем сами то же, что ветка Plus /// guiDrawCrosshair; цвет - из таблицы игры, его красит Possession.AimTint. [HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))] public static class Patch_EntityPlayerLocal_guiDrawCrosshair_Possession { public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen) { if (!Possession.Active || __instance != Possession.Player) { return true; } if (!_guiInGame.showCrosshair || Event.current.type != EventType.Repaint || __instance.IsDead() || !GamePrefs.GetBool(EnumGamePrefs.OptionsCrosshairEnabled) || bModalWindowOpen || _guiInGame.CrosshairTexture == null) { return false; } int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor); if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count) { i = 0; } Color c = EntityPlayerLocal.crosshairColors[i]; Vector2 center = __instance.GetCrosshairPosition2D(); center.y = Screen.height - center.y; float size = Screen.height * 0.059f * GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairScale); Color was = GUI.color; GUI.color = new Color(c.r, c.g, c.b, GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairOpacity)); GUI.DrawTexture(new Rect(center.x - size / 2f, center.y - size / 2f, size, size), _guiInGame.CrosshairTexture, ScaleMode.StretchToFill); GUI.color = was; return false; } } /// Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - 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; } } /// Полоски одержимого (указание 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.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; if (z == null || Event.current.type != EventType.Repaint) { return; } if (Possession.BodyDanger) { DrawBodyDanger(); } float scale = Screen.height / 1080f; if (_style == null) { _style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleLeft, clipping = TextClipping.Clip }; _style.normal.textColor = new Color(0.95f, 0.9f, 0.85f); } if (Possession.FirstPerson) { DrawBelt(scale); } Rect hr; // Справа от полосок игрока, а не поверх (указание 13.10: «можем вывести где-нибудь и здоровье игрока»): // над ними значки баффов, справа до панели предметов пусто. float shift = 0f; if (ScreenRect(ref _healthBar, ref _healthRect, ref _healthKnown)) { shift = _healthRect.width + 8f * scale; hr = _healthRect; hr.x += shift; } 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; // Только числа: имя зомби в полоске не помещалось и переносилось (скриншоты 13.10: «название зомби // в индикаторе здоровья - плохая идея»). _hpText = 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; sr.x += shift; } 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]); } } /// Пояс одержимого от первого лица (указание 13.10: «собственный пояс на 2 ячейки», пока пустой) - на месте /// пояса персонажа, внизу по центру; выбранная ячейка в рамке. Ячейки пустые - бьёт кулаками. static void DrawBelt(float _scale) { float size = 64f * _scale, gap = 6f * _scale, frame = 3f * _scale; float x0 = (Screen.width - (size * Possession.BeltSlots + gap * (Possession.BeltSlots - 1))) / 2f; float y = Screen.height - 14f * _scale - size; Color prev = GUI.color; for (int i = 0; i < Possession.BeltSlots; i++) { Rect r = new Rect(x0 + i * (size + gap), y, size, size); if (i == Possession.BeltSlot) { GUI.color = CdReady; GUI.DrawTexture(new Rect(r.x - frame, r.y - frame, r.width + frame * 2f, r.height + frame * 2f), Texture2D.whiteTexture); } GUI.color = Back; GUI.DrawTexture(r, Texture2D.whiteTexture); GUI.color = prev; GUI.Label(new Rect(r.x + 4f * _scale, r.y + 2f * _scale, r.width, 18f * _scale), (i + 1).ToString(), _style); } GUI.color = prev; } 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): под прицелом, /// красная с тенью. static void DrawBodyDanger() { if (_dangerStyle == null) { _dangerStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.UpperCenter, fontStyle = FontStyle.Bold }; } _dangerStyle.fontSize = Mathf.RoundToInt(26f * Screen.height / 1080f); string text = Localization.Get("necroSnakeBodyDanger"); float top = Screen.height * 0.5f + 40f * Screen.height / 1080f; Rect rect = new Rect(0f, top, Screen.width, 60f * Screen.height / 1080f); Color old = GUI.color; GUI.color = new Color(0f, 0f, 0f, 0.8f); GUI.Label(new Rect(rect.x + 2f, rect.y + 2f, rect.width, rect.height), text, _dangerStyle); GUI.color = Danger; GUI.Label(rect, text, _dangerStyle); GUI.color = old; } } /// Запомнить полоски здоровья и выносливости игры - на их место 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, её же шаги). Отпустил - дальше /// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой, /// а проём впереди чует через moveHelper, который у одержимого остановлен. /// Движение анимации - туда, куда идёт одержимый (тест 13.10: «паук не может бежать вперёд, его сносит /// влево»). У зомби с RootMotion (паук) шаг задаёт сама анимация, а у бега паука в ней уклон вбок; в игре его /// каждый тик выправляет EntityMoveHelper - MakeMotionMoveToward к цели, скорость анимации та же. У одержимого /// moveHelper стоит, поэтому то же самое делаем здесь, прямо перед тем, как MoveEntityHeaded превратит /// накопленное движение анимации в шаг. [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.MoveEntityHeaded))] public static class Patch_EntityAlive_MoveEntityHeaded_Possession { public static void Prefix(EntityAlive __instance) { if (Possession.Ladder == 2 && Possession.IsPossessed(__instance)) { __instance.motion.y = Mathf.Max(__instance.motion.y, Possession.LadderSpeed); __instance.motion += Possession.MoveDir * 0.05f; } else if (Possession.Ladder != 0 && Possession.IsPossessed(__instance) && __instance.IsInElevator()) { __instance.motion.y = Possession.Ladder * Possession.LadderSpeed; } Vector3 d = Possession.MoveDir; if (d == Vector3.zero || !__instance.RootMotion || !Possession.IsPossessed(__instance) || __instance.Jumping) { return; } // Длина 10 - больше любого шага: MakeMotionMoveToward укоротит направление до длины движения анимации. __instance.MakeMotionMoveToward(d.x * 10f, d.z * 10f, 0.02f, 1f); } } [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 target = Mathf.Min(__instance.physicsBaseHeight * Possession.CrouchHeight, Possession.CrouchMaxHeight); float h = Utils.FastMoveTowards(__instance.physicsHeight, target, 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; } } }