using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Кровавый прилив и нож (указание 10.10): «раз некроэнергии становится много, она должна воздействовать не /// только на зомби и некроманта». Пока идёт прилив (SnakeMode.IsBloodTide - ночь кровавой луны): /// - на игроке бафф buffNecroBloodTide (buffs.xml): по нему нож (effect_group necroKnifeBloodTide, items.xml) бьёт /// вдвое сильнее и вдвое чаще, лечит вдвое, плата за силовую вдвое меньше, выносливость на удар вчетверо /// меньше, дальность - как у меча; с ножом в руке бег на 50% быстрее (сам бафф); /// - хват ножа - как у мачете, модель - нож в фиолетовом мече (ItemClass ножа подменяется на всю ночь, /// нож в руке пересоздаётся); /// и, только если нож в руке: /// - восстановление здоровья и выносливости вдвое (лишь положительное - кровотечение и прочий урон не трогаем); /// - полоски здоровья и выносливости пульсируют фиолетовым в такт сердцу; /// - фоном бьётся сердце, 120 ударов в минуту (синтез в коде: два низких удара синусом, «лаб» и «даб», /// с затуханием и спадом тона - по образцу, одобренному пользователем 10.10). /// Всё - у локального игрока (одиночная игра и хост). /// public static class BloodTide { public const string BuffName = "buffNecroBloodTide"; /// Хват мачете (items.xml игры, meleeWpnBladeT3Machete HoldType=47); у ножа - 36. public const int TideHoldType = 47; /// Модель меча на прилив (10.10): префаб в том же бандле necroknife (ItemsUnityProject, NecroTideSetup.cs) - /// путь берётся из пути обычного ножа (уже разобранного игрой, @modfolder) заменой имени префаба. public const string KnifePrefab = "necroKnifePrefab.prefab", TidePrefab = "necroKnifeTidePrefab.prefab"; static string TideMeshFile => _origMesh != null && _origMesh.Contains(KnifePrefab) ? _origMesh.Replace(KnifePrefab, TidePrefab) : null; public const float HeartBpm = 120f; public const float HeartVolume = 0.55f; /// Цвет пульса полосок - фиолетовый меча. public static readonly Color PulseColor = new Color(0.67f, 0.24f, 1f); /// Идёт прилив (кэш на кадр). public static bool Active; /// Прилив и нож в руке. public static bool Armed; static bool _swapped; static int _origHoldType = -1; static string _origMesh; static float _nextBuffCheck; static GameObject _heartGo; static AudioSource _heart; static AudioClip _heartClip; public static void Init() { ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate); } static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data) { World world = GameManager.Instance?.World; EntityPlayerLocal player = world?.GetPrimaryPlayer(); if (world == null || player == null) { Active = Armed = false; _swapped = false; // классы предметов игра грузит заново с миром _recreate = false; _origHoldType = -1; Heart(false); Patch_XUiC_HUDStatBar_Update_BloodTide.Forget(); Patch_Stat_Tick_BloodTide.Next = null; // не держать стат (а через него игрока) после выхода из мира return; } bool tide = SnakeMode.IsBloodTide(); if (tide != Active) { Active = tide; Debug.Log("[NecromancerTome] BloodTide: " + (tide ? "прилив начался" : "прилив схлынул")); } SwapKnife(player, Active); TryRecreate(player); if (Time.time >= _nextBuffCheck && !player.IsDead()) { _nextBuffCheck = Time.time + 0.5f; bool has = player.Buffs.HasBuff(BuffName); if (Active && !has) { player.Buffs.AddBuff(BuffName); } else if (!Active && has) { player.Buffs.RemoveBuff(BuffName); } } Armed = Active && !player.IsDead() && SnakeMode.IsKnife(player.inventory?.holdingItem); Heart(Armed && !GameManager.Instance.IsPaused()); } /// Хват и модель ножа на прилив и обратно. Подмена - в общем ItemClass, поэтому видна у любого /// ножа; нож в руке пересоздаём (снять и достать тот же слот), иначе он до смены оружия остался бы прежним. static void SwapKnife(EntityPlayerLocal _player, bool _tide) { if (_tide == _swapped) { return; } ItemClass knife = ItemClass.GetItemClass(SnakeMode.KnifeName); if (knife == null) { return; } if (_origHoldType < 0) { _origHoldType = knife.HoldType.Value; _origMesh = knife.MeshFile; } knife.HoldType.Value = _tide ? TideHoldType : _origHoldType; knife.MeshFile = _tide && !string.IsNullOrEmpty(TideMeshFile) ? TideMeshFile : _origMesh; _swapped = _tide; Hand hand = _player.Hand; // Ножи в других слотах пояса: «собранная» копия помечается устаревшей - соберутся заново (с новыми хватом и // моделью), когда их возьмут. Нож в руке - в TryRecreate, когда рука свободна. if (hand?.slotDatas != null) { foreach (ItemInventoryData d in hand.slotDatas) { if (d != null && d.item == knife && d != hand.equipped && d.physicalItemValue != null) { MarkStale(d); } } } _recreate = true; Debug.Log("[NecromancerTome] BloodTide: нож - хват " + knife.HoldType.Value); } /// Нож в руке ждёт пересоздания (сменились хват/модель). static bool _recreate; /// «Собранный» предмет перестаёт совпадать со слотом (NeedsRebuild): копия того же ножа с другим Seed. /// Не ItemValue.None (тест 10.10): Hand.Unequip читает класс предмета у собранного - NRE. static void MarkStale(ItemInventoryData _d) { ItemValue pv = (_d.physicalItemValue ?? _d.itemValue).Clone(); pv.Seed = (ushort)(pv.Seed + 1); _d.physicalItemValue = pv; } /// /// Пересоздать нож в руке: повторный выбор того же слота снимает его и собирает заново (Hand.SelectHoldingMode). /// Только когда рука свободна - не идёт смена предмета и удар; иначе ждём кадр. Тест 10.10: при сбое внутри /// SelectHoldingMode игра оставляла Hand.isSwitching = true навсегда, и рука переставала брать что-либо до /// перезапуска («после ошибки не получается переключиться на что-либо ещё»). Поэтому сбой перехватываем и /// возвращаем руку в рабочее состояние. /// static void TryRecreate(EntityPlayerLocal _player) { if (!_recreate) { return; } Inventory inv = _player.inventory; Hand hand = _player.Hand; ItemInventoryData held = inv?.holdingItemData; if (hand == null || held == null || !SnakeMode.IsKnife(held.item)) { _recreate = false; // ножа в руке нет - возьмут, тогда и соберётся (слоты помечены) return; } if (hand.isSwitching || inv.IsHoldingItemActionRunning() || _player.IsDead()) { return; } _recreate = false; ItemValue before = held.physicalItemValue; try { MarkStale(held); inv.SetHoldingItemIdx(inv.holdingItemIdx); Debug.Log("[NecromancerTome] BloodTide: нож в руке пересоздан, хват " + held.item.HoldType.Value); } catch (System.Exception ex) { Debug.LogWarning("[NecromancerTome] BloodTide: нож не пересоздан - " + ex.Message + "; рука восстановлена"); held.physicalItemValue = before; hand.isSwitching = false; try { hand.Reconcile(); } catch (System.Exception ex2) { Debug.LogWarning("[NecromancerTome] BloodTide: Reconcile - " + ex2.Message); } } } // ---------------------------------------------------------------- сердце static void Heart(bool _on) { if (!_on) { if (_heart != null && _heart.isPlaying) { _heart.Stop(); } return; } if (_heart == null) { _heartGo = new GameObject("NecroBloodTideHeart"); Object.DontDestroyOnLoad(_heartGo); _heart = _heartGo.AddComponent(); _heart.clip = _heartClip ?? (_heartClip = MakeHeartClip()); _heart.loop = true; _heart.spatialBlend = 0f; _heart.playOnAwake = false; } _heart.volume = HeartVolume * GamePrefs.GetFloat(EnumGamePrefs.OptionsOverallAudioVolumeLevel); if (!_heart.isPlaying) { _heart.Play(); } } /// Доля удара сердца для пульса полосок: 1 в момент «лаба», дальше спад; без звука - 0. public static float Beat() { if (_heart == null || !_heart.isPlaying) { return 0f; } float t = _heart.time; float dub = 0.17f; return Mathf.Max(Mathf.Exp(-9f * t), t >= dub ? 0.6f * Mathf.Exp(-12f * (t - dub)) : 0f); } /// Один цикл сердца (60/HeartBpm с): «лаб» 62 Гц и «даб» 52 Гц через 0.17 с, каждый - синус с /// второй гармоникой, атака 4 мс, экспоненциальное затухание, тон к концу падает на треть. static AudioClip MakeHeartClip() { const int sr = 44100; int n = Mathf.RoundToInt(sr * 60f / HeartBpm); float[] buf = new float[n]; Thump(buf, 0, 62f, 0.16f, 1f, 22f, sr); Thump(buf, Mathf.RoundToInt(sr * 0.17f), 52f, 0.12f, 0.6f, 28f, sr); float max = 0f; foreach (float v in buf) { max = Mathf.Max(max, Mathf.Abs(v)); } for (int i = 0; i < n; i++) { buf[i] = buf[i] / max * 0.9f; } AudioClip clip = AudioClip.Create("necroHeartbeat", n, 1, sr, false); clip.SetData(buf, 0); return clip; } static void Thump(float[] _buf, int _start, float _freq, float _dur, float _amp, float _decay, int _sr) { int n = Mathf.Min(Mathf.RoundToInt(_sr * _dur), _buf.Length - _start); double ph = 0; for (int i = 0; i < n; i++) { float t = (float)i / _sr; float env = Mathf.Min(t / 0.004f, 1f) * Mathf.Exp(-_decay * t); ph += 2 * Mathf.PI * _freq * (1f - 0.35f * t / _dur) / _sr; _buf[_start + i] += _amp * env * (float)(System.Math.Sin(ph) + 0.25 * System.Math.Sin(2 * ph)); } } } /// Восстановление вдвое (прилив и нож в руке). Игра сама задаёт RegenerationAmount и вызывает Stat.Tick в /// PlayerEntityStats.UpdatePlayerHealthOT/StaminaOT; удваиваем только свежее положительное восстановление этого /// вызова (у здоровья оно считается, лишь когда здоровье не полное и HealthChangeOT больше нуля - иначе в поле /// остаётся прошлая разница, её трогать нельзя). [HarmonyPatch(typeof(PlayerEntityStats), nameof(PlayerEntityStats.UpdatePlayerHealthOT))] public static class Patch_PlayerEntityStats_UpdatePlayerHealthOT_BloodTide { public static void Prefix(PlayerEntityStats __instance) { Patch_Stat_Tick_BloodTide.Next = null; if (BloodTide.Armed && __instance.m_entity is EntityPlayerLocal p && __instance.Health.ValuePercent < 1f && EffectManager.GetValue(PassiveEffects.HealthChangeOT, null, 0f, p) > 0f) { Patch_Stat_Tick_BloodTide.Next = __instance.Health; } } } [HarmonyPatch(typeof(PlayerEntityStats), nameof(PlayerEntityStats.UpdatePlayerStaminaOT))] public static class Patch_PlayerEntityStats_UpdatePlayerStaminaOT_BloodTide { public static void Prefix(PlayerEntityStats __instance) { Patch_Stat_Tick_BloodTide.Next = BloodTide.Armed && __instance.m_entity is EntityPlayerLocal ? __instance.Stamina : null; } } [HarmonyPatch(typeof(Stat), nameof(Stat.Tick))] public static class Patch_Stat_Tick_BloodTide { /// Стат, чей следующий Tick удвоить (ставят префиксы выше). public static Stat Next; public static void Prefix(Stat __instance) { if (Next == null || Next != __instance) { return; } Next = null; if (__instance.regenAmount > 0f) { __instance.regenAmount *= 2f; } } } /// /// Крит меча (указание 10.10: «шанс на критический удар бы тоже повысить»). Случайного крита у ближнего боя в игре /// нет: ItemActionDynamic помечает критическим каждый силовой удар (действие 1) и ни одного обычного, а /// CriticalChance уходит в параметр _criticalHitChanceOLD функции Hit, которую та не читает. Сам крит урон не /// поднимает - только чаще сбивает с ног и ставит на колено, вдвое быстрее копит урон по ногам, красит цифру урона. /// Наш крит: в прилив обычный удар ножом по существу с шансом CritChance - критический, урон x CritMultiplier. /// [HarmonyPatch(typeof(ItemActionAttack), nameof(ItemActionAttack.Hit))] public static class Patch_ItemActionAttack_Hit_BloodTideCrit { public const float CritChance = 0.25f; public const float CritMultiplier = 2f; public static void Prefix(WorldRayHitInfo hitInfo, int _attackerEntityId, ref float _entityDamage, ItemActionAttack.AttackHitInfo _attackDetails, ItemValue damagingItemValue, bool _isGrazingHit) { if (!BloodTide.Armed || _isGrazingHit || _attackDetails == null || _attackDetails.isCriticalHit || _entityDamage <= 0f || hitInfo?.tag == null || !hitInfo.tag.StartsWith("E_") || damagingItemValue?.ItemClass?.Name != SnakeMode.KnifeName) { return; } World world = GameManager.Instance?.World; if (world == null || world.GetPrimaryPlayer()?.entityId != _attackerEntityId || Random.value >= CritChance) { return; } _entityDamage *= CritMultiplier; _attackDetails.isCriticalHit = true; } } /// Пульс полосок здоровья и выносливости (прилив и нож в руке): цвет полосы (BarContent в HUDLeftStatBars /// игры, у здоровья 255,0,0,160, у выносливости 0,54,255,160) уходит в фиолетовый на каждом ударе сердца. [HarmonyPatch(typeof(XUiC_HUDStatBar), nameof(XUiC_HUDStatBar.Update))] public static class Patch_XUiC_HUDStatBar_Update_BloodTide { static readonly Dictionary _bars = new Dictionary(); public static void Forget() { _bars.Clear(); } public static void Postfix(XUiC_HUDStatBar __instance) { if (__instance.statType != HUDStatTypes.Health && __instance.statType != HUDStatTypes.Stamina) { return; } if (_bars.TryGetValue(__instance, out var e) && e.bar.UiTransform == null) { _bars.Remove(__instance); // окно пересобрано - старая полоса удалена } if (!_bars.TryGetValue(__instance, out e)) { if (!BloodTide.Armed || !(__instance.GetChildById("BarContent")?.ViewComponent is XUiV_Sprite s)) { return; } e = (s, s.Color); _bars[__instance] = e; } Color c = BloodTide.Armed ? Color.Lerp(e.orig, new Color(BloodTide.PulseColor.r, BloodTide.PulseColor.g, BloodTide.PulseColor.b, Mathf.Max(e.orig.a, 0.8f)), BloodTide.Beat()) : e.orig; if (e.bar.Color != c) { e.bar.Color = c; } } } }