using GUI_2; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Хваты пустой руки (03.10, BACKLOG «Заклинания с руки и жесты»): на R - меню из трёх строк: /// обычная рука, «отрицание», «пиф-паф». Пустая рука - предмет meleeHandPlayer с DynamicMelee, /// меню на R игра открывает ей так же, как ножу (PlayerMoveController: HasRadial). /// /// Выбор НЕ сохраняется (указание 03.10: «после перезахода будет снова обычная рука») - просто поле /// в памяти. Ни cvar, ни предмет не трогаем. На выходе из мира (нет мира или сменился игрок) всё /// сбрасывается, и ссылки на кости, снимок заморозки и цель отпускаются (HandPose.ResetAll) - чтобы /// за сессию ничего не копилось (указание: «не допустить утечку памяти на сохранение состояний»). /// В хвате рука стоит позой (HandPose: кости после аниматора + заморозка остальных), а кнопки мыши /// не бьют: ExecuteAction у DynamicMelee пустой руки перехвачен. /// ЛКМ в «отрицании» - зомби под прицелом в леденящем ужасе: бежит от игрока с ускорением и теряет /// его как цель (NecroFleeSnakeTask.ScareFrom). Звук - как у празднующего убийства Twitch. /// ЛКМ в «пиф-пафе» - одержимость зомби-гуманоида под прицелом (Possession.cs). /// ПКМ в хватах - ничего. /// public static class HandHolds { public const string HandItem = "meleeHandPlayer"; public const int None = 0, Deny = 1, PifPaf = 2, Thread = 3; /// Хват для нитей - не сразу: снимок замороженных костей должен быть в покое хвата 68, после доставания. const float ThreadDelay = 0.6f; static float _threadSince = -1f; /// Дальность и конус выбора зомби для «отрицания». public const float DenyRange = 25f; public const float DenyConeDeg = 12f; /// Бежит, пока игрок не дальше этого: зрение зомби до 30 м, у Чака 40 (entityclasses.xml, /// SightRange) - указание 03.10: «убегать он должен дальше 40 м, чтобы терять из прицела». public const float DenyFarDistance = 40f; /// Предел бегства на случай, если застрял и не может уйти на 40 м. public const float DenyMaxSeconds = 25f; /// Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»). public const float DenySpeed = 2.2f; public const float Cooldown = 0.6f; /// Звук «козы» (05.10: «нужен короткий звук» - вместо twitch_celebrate): бросок змеи - тот же, /// что у рывка духа змеи (SnakeControl.AttackSound): «раз зомби боятся духа змеи, некромант может /// пугать им и в телесной форме». public const string DenySound = SnakeControl.AttackSound; /// «Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок /// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success). public const string PifPafFailSound = "glassdestroy"; public const string PifPafPossessSound = "lockpick_success"; /// Выбранный хват - только на время сессии в мире. public static int Hold; static int _applied = -1; static int _playerId = -1; static float _nextCast; /// Вселение - на пике толчка руки (05.10): иначе вид сразу уходит к зомби и толчка не видно. /// Цель берётся в момент щелчка. static EntityAlive _pifPafVictim; static float _pifPafAt = -1f; public static bool IsHand(ItemClass _item) => _item != null && _item.Name == HandItem; public static int Current(EntityPlayerLocal _player) { // Только от первого лица (16.10: «В виде от третьего лица что-то страшное»): поза и перенос ножа - для рук первого // лица; от третьего лица модель в руке - у тела, и нож улетал к невидимой руке первого лица. if (_player != null && _player.inventory?.holdingItem?.Name == CinerariumThreads.ThreadKnifeName && (_player.emodel as EModelSDCS)?.IsFPV == true && !SnakeMode.Active && !MindPalace.Active && !Possession.Active && !_player.IsDead()) { if (_threadSince < 0f) { _threadSince = Time.time; } bool ready = Time.time - _threadSince >= ThreadDelay && !(_player.Hand != null && _player.Hand.isSwitching); return ready ? Thread : None; } _threadSince = -1f; if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) || SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead()) { return None; } return Hold; } /// Из HandPoseDriver.LateUpdate - до HandPose.Apply. Позу трогаем только при смене /// хвата: ручная настройка командой necropose держится до следующей смены. public static void Update() { EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer(); int id = player != null ? player.entityId : -1; if (id != _playerId) { // Вышли из мира, зашли в другой или перезашли - обычная рука и пустые ссылки. _playerId = id; Hold = None; _applied = None; _target = null; _targetFrame = -1; _pifPafVictim = null; _pifPafAt = -1f; HandPose.ResetAll(); return; } if (_pifPafAt >= 0f && Time.time >= _pifPafAt) { PifPafShot(player); } int want = Current(player); if (want == _applied) { return; } _applied = want; if (want == Deny) { HandPose.PresetDeny(); } else if (want == PifPaf) { HandPose.PresetPifPaf(); } else if (want == Thread) { HandPose.PresetThread(); } else { HandPose.Enabled = false; HandPose.KnifeToLeft = false; } } static void PifPafShot(EntityPlayerLocal _player) { EntityAlive victim = _pifPafVictim; _pifPafVictim = null; _pifPafAt = -1f; if (Current(_player) != PifPaf) { return; // за полтика сменил хват или руку - выстрела нет } if (victim != null && !victim.IsDead() && Possession.CanPossess(victim) && Possession.Enter(_player, victim)) { _player.PlayOneShot(PifPafPossessSound); } else { _player.PlayOneShot(PifPafFailSound); Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид")); } } public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context) { if (_commandIndex >= None && _commandIndex <= PifPaf) { Hold = _commandIndex; } } /// ЛКМ в хвате. True - нажатие наше, удар не выполнять. public static void Cast(EntityPlayerLocal _player, int _hold) { if (Time.time < _nextCast) { return; } _nextCast = Time.time + Cooldown; if (_hold == PifPaf) { // Одержимость (решение 03.10): «пиф-паф» по зомби-гуманоиду - управление им. HandPose.Jab(); _pifPafVictim = Target(_player); _pifPafAt = Time.time + HandPose.JabTime * 0.5f; return; } HandPose.Jab(); // толчок к прицелу, как у «пиф-пафа» (05.10) _player.PlayOneShot(DenySound); World world = _player.world; if (SnakeMode.IsBloodTide()) { // Кровавый прилив (10.10): леденящий ужас тонет в насыщенном воздухе - жест и звук есть, бегства нет. SnakeMode.BloodTideHint(_player); return; } if (world.IsRemote()) { return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно } EntityAlive target = Target(_player); if (target != null) { if (target.HasAnyTags(FlyerTag)) { NecroFleeSnakeTask.ForgetNow(target, _player); // летает - наземного бегства нет, только теряет цель Debug.Log("[NecromancerTome] HandHolds: стервятник " + target.entityId + " отрицание - потерял цель"); } else { NecroFleeSnakeTask.ScareFrom(target, _player, DenyMaxSeconds, DenySpeed, DenyFarDistance); } } } static readonly FastTags ZombieTag = FastTags.Parse("zombie"); static readonly FastTags FlyerTag = FastTags.Parse("vulture"); /// Только зомби (указание 03.10: «кабаны и змеи хоть и противник, но не зомби, кроме /// зомбокабана»; «стервятники тоже зомби, обычных в игре нету») - метка zombie в entityclasses.xml: /// люди-зомби, зомби-собаки, лютые волки, зомби-медведи, зомби-кабаны, стервятники. public static bool IsZombie(EntityAlive _e) { return _e != null && _e.HasAnyTags(ZombieTag); } static int _targetFrame = -1; static EntityAlive _target; /// Зомби под прицелом (раз за кадр): и для заклинания, и для красного прицела. public static EntityAlive Target(EntityPlayerLocal _player) { if (_targetFrame != Time.frameCount) { _targetFrame = Time.frameCount; _target = FindTarget(_player.world, _player); } return _target; } /// Прямая видимость своим лучом: между глазом и точкой нет блоков и земли (сущности на луче /// не мешают). Не EntityAlive.CanSee: у игрока нет ИИ, и его кэш видимости (seeCache) не обновляется - /// тест 03.10: Арлин захватывалась, зомби-коп нет. static bool Visible(Vector3 _eye, Vector3 _to) { Vector3 d = _to - _eye; return !Physics.Raycast(_eye - Origin.position, d.normalized, d.magnitude - 0.3f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore); } /// Зомби ближе всех к линии прицела в конусе DenyConeDeg, не дальше DenyRange, /// с прямой видимостью. static EntityAlive FindTarget(World _world, EntityPlayerLocal _player) { Transform cam = _player.cameraTransform; if (cam == null) { return null; } Vector3 eye = cam.position + Origin.position; Vector3 fwd = cam.forward; EntityAlive best = null; float bestAngle = DenyConeDeg; for (int i = 0; i < _world.EntityAlives.Count; i++) { EntityAlive e = _world.EntityAlives[i]; if (e == null || e.IsDead() || !IsZombie(e)) { continue; } Vector3 to = e.getChestPosition() - eye; float d = to.magnitude; if (d > DenyRange || d < 0.1f) { continue; } float angle = Vector3.Angle(fwd, to); if (angle < bestAngle && Visible(eye, e.getChestPosition())) { bestAngle = angle; best = e; } } return best; } } /// Меню на R у пустой руки - как у ножа (Patch_ItemAction_HasRadial_SnakeKnife). [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))] public static class Patch_ItemAction_HasRadial_HandHolds { public static void Postfix(ItemAction __instance, ref bool __result) { if (!__result && !SnakeMode.Active && !MindPalace.Active && HandHolds.IsHand(__instance.item)) { __result = true; } } } [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))] public static class Patch_ItemAction_SetupRadial_HandHolds { public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl) { if (!HandHolds.IsHand(__instance.item)) { return true; } int cur = HandHolds.Hold; _xuiRadialWindow.ResetRadialEntries(); _xuiRadialWindow.CreateRadialEntry(HandHolds.None, "ui_game_symbol_hand", "UIAtlas", "", Localization.Get("necroHoldNone"), cur == HandHolds.None); _xuiRadialWindow.CreateRadialEntry(HandHolds.Deny, "ui_game_symbol_run", "UIAtlas", "", Localization.Get("necroHoldDeny"), cur == HandHolds.Deny); _xuiRadialWindow.CreateRadialEntry(HandHolds.PifPaf, "ui_game_symbol_pistol", "UIAtlas", "", Localization.Get("necroHoldPifPaf"), cur == HandHolds.PifPaf); _xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload), HandHolds.OnRadialCommand, null, cur); return false; } } /// В хвате кнопки мыши не бьют: ЛКМ - заклинание хвата, ПКМ - ничего (03.10: «руки /// деформируются, пытаясь совершить старую анимацию»). [HarmonyPatch(typeof(ItemActionDynamicMelee), nameof(ItemActionDynamicMelee.ExecuteAction))] public static class Patch_ItemActionDynamicMelee_ExecuteAction_HandHolds { public static bool Prefix(ItemActionData _actionData, bool _bReleased) { if (!(_actionData?.invData?.holdingEntity is EntityPlayerLocal player)) { return true; } int hold = HandHolds.Current(player); if (hold == HandHolds.None || !HandHolds.IsHand(_actionData.invData.item)) { return true; } if (!_bReleased && _actionData.indexInEntityOfAction == 0) { HandHolds.Cast(player, hold); } return false; } } /// Прицел краснеет, когда в нём зомби, в обоих хватах (указание 03.10). Цвет прицела игра /// берёт из EntityPlayerLocal.crosshairColors[настройка] - на время рисования подменяем его красным. [HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))] public static class Patch_EntityPlayerLocal_guiDrawCrosshair_HandHolds { static readonly Color Red = new Color(1f, 0.15f, 0.1f); public static void Prefix(EntityPlayerLocal __instance, out Color? __state) { __state = null; // В одержимости прицел красит Possession.AimTint по цели одержимого, а не хват тела. if (Possession.Active) { return; } int hold = HandHolds.Current(__instance); EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance); // «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет. if (target == null || hold == HandHolds.PifPaf && !Possession.CanPossess(target)) { return; } int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor); if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count) { i = 0; } __state = EntityPlayerLocal.crosshairColors[i]; EntityPlayerLocal.crosshairColors[i] = Red; } public static void Postfix(Color? __state) { if (__state == null) { return; } int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor); if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count) { i = 0; } EntityPlayerLocal.crosshairColors[i] = __state.Value; } } }