В прилив: урон, скорость ударов и вампиризм ножа вдвое, плата за силовую вдвое меньше, выносливость вчетверо меньше, дальность 2.8, хват мачете (модели меча пока нет). С ножом в руке: бег +50%, восстановление здоровья и выносливости вдвое, полоски пульсируют фиолетовым, сердце 120 уд/мин. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
12 KiB
C#
284 lines
12 KiB
C#
using System.Collections.Generic;
|
||
using HarmonyLib;
|
||
using UnityEngine;
|
||
|
||
namespace NecromancerTome
|
||
{
|
||
/// <summary>
|
||
/// Кровавый прилив и нож (указание 10.10): «раз некроэнергии становится много, она должна воздействовать не
|
||
/// только на зомби и некроманта». Пока идёт прилив (SnakeMode.IsBloodTide - ночь кровавой луны):
|
||
/// - на игроке бафф buffNecroBloodTide (buffs.xml): по нему нож (effect_group necroKnifeBloodTide, items.xml) бьёт
|
||
/// вдвое сильнее и вдвое чаще, лечит вдвое, плата за силовую вдвое меньше, выносливость на удар вчетверо
|
||
/// меньше, дальность - как у меча; с ножом в руке бег на 50% быстрее (сам бафф);
|
||
/// - хват ножа - как у мачете, модель - нож в фиолетовом мече (ItemClass ножа подменяется на всю ночь,
|
||
/// нож в руке пересоздаётся);
|
||
/// и, только если нож в руке:
|
||
/// - восстановление здоровья и выносливости вдвое (лишь положительное - кровотечение и прочий урон не трогаем);
|
||
/// - полоски здоровья и выносливости пульсируют фиолетовым в такт сердцу;
|
||
/// - фоном бьётся сердце, 120 ударов в минуту (синтез в коде: два низких удара синусом, «лаб» и «даб»,
|
||
/// с затуханием и спадом тона - по образцу, одобренному пользователем 10.10).
|
||
/// Всё - у локального игрока (одиночная игра и хост).
|
||
/// </summary>
|
||
public static class BloodTide
|
||
{
|
||
public const string BuffName = "buffNecroBloodTide";
|
||
/// <summary>Хват мачете (items.xml игры, meleeWpnBladeT3Machete HoldType=47); у ножа - 36.</summary>
|
||
public const int TideHoldType = 47;
|
||
/// <summary>Модель меча на прилив. Пока не собрана - null, остаётся модель ножа (меняется только хват).</summary>
|
||
public static string TideMeshFile = null;
|
||
public const float HeartBpm = 120f;
|
||
public const float HeartVolume = 0.55f;
|
||
/// <summary>Цвет пульса полосок - фиолетовый меча.</summary>
|
||
public static readonly Color PulseColor = new Color(0.67f, 0.24f, 1f);
|
||
|
||
/// <summary>Идёт прилив (кэш на кадр).</summary>
|
||
public static bool Active;
|
||
/// <summary>Прилив и нож в руке.</summary>
|
||
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; // классы предметов игра грузит заново с миром
|
||
_origHoldType = -1;
|
||
Heart(false);
|
||
Patch_XUiC_HUDStatBar_Update_BloodTide.Forget();
|
||
return;
|
||
}
|
||
bool tide = SnakeMode.IsBloodTide();
|
||
if (tide != Active)
|
||
{
|
||
Active = tide;
|
||
Debug.Log("[NecromancerTome] BloodTide: " + (tide ? "прилив начался" : "прилив схлынул"));
|
||
}
|
||
SwapKnife(player, Active);
|
||
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());
|
||
}
|
||
|
||
/// <summary>Хват и модель ножа на прилив и обратно. Подмена - в общем ItemClass, поэтому видна у любого
|
||
/// ножа; нож в руке пересоздаём (снять и достать тот же слот), иначе он до смены оружия остался бы прежним.</summary>
|
||
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;
|
||
Inventory inv = _player.inventory;
|
||
ItemInventoryData held = inv?.holdingItemData;
|
||
if (held != null && held.item == knife)
|
||
{
|
||
// NeedsRebuild - «собранный» предмет не совпадает с тем, что в слоте: сбрасываем собранный, и
|
||
// повторный выбор того же слота снимает нож и собирает его заново (Hand.SelectHoldingMode).
|
||
held.physicalItemValue = ItemValue.None.Clone();
|
||
inv.SetHoldingItemIdx(inv.holdingItemIdx);
|
||
}
|
||
Debug.Log("[NecromancerTome] BloodTide: нож - хват " + knife.HoldType.Value + (held != null && held.item == knife ? ", в руке пересоздан" : ""));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- сердце
|
||
|
||
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<AudioSource>();
|
||
_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();
|
||
}
|
||
}
|
||
|
||
/// <summary>Доля удара сердца для пульса полосок: 1 в момент «лаба», дальше спад; без звука - 0.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Один цикл сердца (60/HeartBpm с): «лаб» 62 Гц и «даб» 52 Гц через 0.17 с, каждый - синус с
|
||
/// второй гармоникой, атака 4 мс, экспоненциальное затухание, тон к концу падает на треть.</summary>
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Восстановление вдвое (прилив и нож в руке). Игра сама задаёт RegenerationAmount и вызывает Stat.Tick в
|
||
/// PlayerEntityStats.UpdatePlayerHealthOT/StaminaOT; удваиваем только свежее положительное восстановление этого
|
||
/// вызова (у здоровья оно считается, лишь когда здоровье не полное и HealthChangeOT больше нуля - иначе в поле
|
||
/// остаётся прошлая разница, её трогать нельзя).</summary>
|
||
[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
|
||
{
|
||
/// <summary>Стат, чей следующий Tick удвоить (ставят префиксы выше).</summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Пульс полосок здоровья и выносливости (прилив и нож в руке): цвет полосы (BarContent в HUDLeftStatBars
|
||
/// игры, у здоровья 255,0,0,160, у выносливости 0,54,255,160) уходит в фиолетовый на каждом ударе сердца.</summary>
|
||
[HarmonyPatch(typeof(XUiC_HUDStatBar), nameof(XUiC_HUDStatBar.Update))]
|
||
public static class Patch_XUiC_HUDStatBar_Update_BloodTide
|
||
{
|
||
static readonly Dictionary<XUiC_HUDStatBar, (XUiV_Sprite bar, Color orig)> _bars = new Dictionary<XUiC_HUDStatBar, (XUiV_Sprite, Color)>();
|
||
|
||
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))
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|