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;
}
}
}
}