using GUI_2; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Режим змеи Ножа некроманта (продиктовано 2026-09-28, BACKLOG «Замысел: режим змеи»). /// Зажать R с ножом в руке - меню, как у выбора патронов: человек (по умолчанию) и змея. Выбор /// змеи отнимает 95% здоровья (назад не возвращается), тело игрока падает и остаётся лежать /// беззащитным, рядом появляется призрачная змея в 1,4 раза больше обычной (было 2 - «удав получился», 28.09). Выход - снова R. /// Если тело убили, змея исчезает, и игрок видит обычную смерть. /// /// ЭТАП 1 (этот файл): меню, вход, выход, смерть тела. ЭТАП 2а (SnakeControl.cs): камера, /// управление, прицел. Рывок на F и скрытый пояс с надписью «Press R» - дальше. /// /// ПОЧЕМУ МЕНЮ ОТКРЫВАЕТСЯ. PlayerMoveController открывает радиальное меню на R для огнестрела /// И для DynamicMelee, если GetHoldingPrimary().HasRadial(). Наш нож - DynamicMelee /// (наследник костяного ножа), а ItemActionDynamic унаследован прямо от ItemAction и /// HasRadial/SetupRadial не переопределяет - значит, патчим пустые базовые методы ItemAction. /// Выбор в предмете НЕ хранится: меню служит только для входа, а выход идёт по R, пока тело /// лежит. Так сейв ножа не меняется вовсе. /// /// ПОЧЕМУ ТЕЛО - РЭГДОЛЛ. PlayerMoveController.Update первым делом проверяет /// emodel.IsRagdollActive и, если тело лежит, ничего не делает: ни ходьбы, ни E, ни R, ни меню. /// То есть упавшее тело само запрещает лутать и пользоваться поясом. Лежит оно, пока идёт /// ragdollDuration (ставим почти бесконечную); на выходе дотягиваем ragdollTime до конца, и /// игра сама поднимает персонажа. /// public static class SnakeMode { public const string KnifeName = "necroWpnBladeNecroKnife"; public const string SnakeClassName = "necroSnakeSpirit"; public const float HealthCost = 0.95f; /// Зазор между телом игрока и змеёй, метры (указание 28.09: «в 1 метре»). public const float Gap = 1f; public const float PlayerRadius = 0.4f; /// Сколько ждать после перехода на третье лицо, прежде чем ронять тело. public const float RagdollDelay = 0.25f; public static bool Active; static int _snakeId = -1; static float _sweepTimer; static float _ragdollAt; static bool _placeSnake; static Vector3 _snakeDir; static bool _waitStandUp; static float _standUpDeadline; public static void Init() { ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate); SnakeCrosshair.Create(); NecroBurst.OnBurst += SnakeControl.OnBurst; } /// Дух змеи - только ночью (указание 28.09): днём в меню вместо змеи циферблат и /// «Сейчас день», выбрать нельзя; наступило утро - змея развеивается, игрок в теле. «Ночь» - /// та же, что у игры (World.IsDark: часы из настроек длины дня, плюс вечный день/ночь). public const string DayIcon = "ui_game_symbol_clock"; public static bool IsNight() { World world = GameManager.Instance?.World; return world != null && world.IsDark(); } /// /// «Кровавый прилив» (указание 10.10, новая концепция ночи кровавой луны). Луна в этой фазе тянет из /// земли некротлен - прах умерших за годы эпидемии, - и плотность некроэнергии в воздухе скачком /// сравнивается с плотностью в теле некроманта. Дух выходит из тела по перепаду плотностей; перепада /// нет - выйти нечем. Поэтому до утра: нет духа змеи, чертогов разума, одержимости (и зомби, и своих /// питомцев), а кто был вне тела к началу - выброшен в тело. «Коза» (леденящий ужас) тонет в /// насыщенном воздухе: жест есть, эффекта нет. /// Время - ванильное GameUtils.IsBloodMoonTime (от заката дня луны до рассвета), оно считается по часам /// мира и одинаково у хоста и клиента; AIDirector с его BloodMoonActive есть только у сервера. /// public static bool IsBloodTide() { World world = GameManager.Instance?.World; return world != null && GameUtils.IsBloodMoonTime(world.worldTime, (world.DuskHour, world.DawnHour), GameStats.GetInt(EnumGameStats.BloodMoonDay)); } /// Надпись «кровавого прилива» - не чаще раза в BloodTideHintPeriod с. public const float BloodTideHintPeriod = 2f; static float _bloodTideHintAt = -100f; public static void BloodTideHint(EntityPlayerLocal _player) { if (_player == null || Time.time - _bloodTideHintAt < BloodTideHintPeriod) { return; } _bloodTideHintAt = Time.time; GameManager.ShowTooltip(_player, Localization.Get("necroBloodTide")); } public static bool IsKnife(ItemClass _item) { return _item != null && _item.Name == KnifeName; } public static void Enter(EntityPlayerLocal _player) { if (Active || MindPalace.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null) { return; } if (!IsNight()) { Debug.Log("[NecromancerTome] SnakeMode: днём дух змеи недоступен"); return; } if (IsBloodTide()) { BloodTideHint(_player); Debug.Log("[NecromancerTome] SnakeMode: кровавый прилив - дух не покидает тело"); return; } World world = GameManager.Instance.World; EntityClass snakeClass = EntityClass.list[EntityClass.FromString(SnakeClassName)]; if (snakeClass == null) { Debug.LogWarning("[NecromancerTome] SnakeMode: класс '" + SnakeClassName + "' не найден"); return; } int cost = Mathf.CeilToInt(_player.GetMaxHealth() * HealthCost); _player.Health = Mathf.Max(1, _player.Health - cost); Vector3 pos = FindFreeSpot(world, _player, out _snakeDir); float yaw = Quaternion.LookRotation(_snakeDir).eulerAngles.y; Entity snake = EntityFactory.CreateEntity(EntityClass.FromString(SnakeClassName), pos, new Vector3(0f, yaw, 0f)); world.SpawnEntityInWorld(snake); _snakeId = snake.entityId; _placeSnake = true; // Сначала третье лицо, потом падение. switchModelView меняет модель игрока, и рэгдолл, // включённый до переключения, остаётся на старой модели - видимое тело стоит (проверено // в игре 28.09: «персонаж остался стоять», хотя IsRagdollActive был true). _player.SetFirstPersonView(false, true); _ragdollAt = Time.time + RagdollDelay; _waitStandUp = false; Active = true; Debug.Log("[NecromancerTome] SnakeMode: вход, здоровье " + _player.Health + ", змея " + _snakeId); } /// Свободное место для змеи: по кругу, начиная с направления взгляда, шагами по /// 45 градусов; две клетки высоты без коллизии. Точное расстояние доводит PlaceSnake. static Vector3 FindFreeSpot(World _world, EntityPlayerLocal _player, out Vector3 _dir) { float yaw = _player.rotation.y; Vector3 origin = _player.position; for (int step = 0; step < 8; step++) { int k = (step + 1) / 2 * (step % 2 == 0 ? -1 : 1); Vector3 dir = Quaternion.Euler(0f, yaw + k * 45f, 0f) * Vector3.forward; for (float dist = 1.5f; dist <= 3.5f; dist += 1f) { Vector3 p = origin + dir * dist; if (IsFree(_world, p) && IsFree(_world, (origin + p) * 0.5f)) { _dir = dir; return p; } } } _dir = Quaternion.Euler(0f, yaw, 0f) * Vector3.forward; return origin + _dir * 1.5f; } static bool IsFree(World _world, Vector3 _pos) { Vector3i b = World.worldToBlockPos(_pos); return !_world.GetBlock(b).Block.IsCollideMovement && !_world.GetBlock(b + Vector3i.up).Block.IsCollideMovement; } /// Когда модель змеи уже есть, сдвигает её вдоль _snakeDir так, чтобы от тела /// игрока до ближайшей точки змеи было Gap метров (по горизонтали). static bool PlaceSnake(EntityPlayerLocal _player, EntityAlive _snake) { Transform model = _snake.emodel != null ? _snake.emodel.GetModelTransform() : null; Renderer[] rs = model != null ? model.GetComponentsInChildren() : null; if (rs == null || rs.Length == 0) { return false; } Bounds bounds = rs[0].bounds; for (int i = 1; i < rs.Length; i++) { bounds.Encapsulate(rs[i].bounds); } Vector3 center = _player.position - Origin.position; Vector3 closest = bounds.ClosestPoint(new Vector3(center.x, bounds.center.y, center.z)); float gap = new Vector2(closest.x - center.x, closest.z - center.z).magnitude - PlayerRadius; Vector3 target = _snake.position + _snakeDir * (Gap - gap); if (IsFree(GameManager.Instance.World, target)) { _snake.SetPosition(target); } Debug.Log("[NecromancerTome] SnakeMode: змея " + bounds.size.ToString("F2") + ", зазор был " + gap.ToString("F2") + " м, сдвиг " + (Gap - gap).ToString("F2") + " м"); return true; } public static void Exit(EntityPlayerLocal _player) { RemoveSnake(); Active = false; if (_player == null || _player.IsDead()) { return; } // Сначала встать, потом первое лицо: переключение вида меняет модель, и посреди // рэгдолла это оставило бы тело лежать. Переключаем в OnUnityUpdate, когда встал. _ragdollAt = 0f; _player.emodel.ragdollTime = _player.emodel.ragdollDuration; _waitStandUp = true; _standUpDeadline = Time.time + 5f; Debug.Log("[NecromancerTome] SnakeMode: выход"); } public static EntityAlive Snake(World _world) { return _snakeId != -1 && _world != null ? _world.GetEntity(_snakeId) as EntityAlive : null; } static void RemoveSnake() { SnakeControl.End(); World world = GameManager.Instance.World; if (_snakeId != -1 && world != null && world.GetEntity(_snakeId) != null) { world.RemoveEntity(_snakeId, EnumRemoveEntityReason.Despawned); } _snakeId = -1; } static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data) { World world = GameManager.Instance?.World; if (world == null) { // Выход в меню посреди режима (тест 28.09: «прицел оставался на экране»). Active = false; _snakeId = -1; SnakeControl.End(); return; } EntityPlayerLocal player = world.GetPrimaryPlayer(); if (!Active) { if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline)) { _waitStandUp = false; player.SetFirstPersonView(true, true); } SweepStraySnakes(world); return; } if (player == null) { return; } if (player.IsDead()) { // Тело убито: змея исчезает, камеру и смерть ведёт игра. RemoveSnake(); Active = false; Debug.Log("[NecromancerTome] SnakeMode: тело убито, режим снят"); return; } EntityAlive snake = world.GetEntity(_snakeId) as EntityAlive; if (snake == null) { Exit(player); return; } snake.Health = snake.GetMaxHealth(); if (!IsNight()) { Debug.Log("[NecromancerTome] SnakeMode: наступило утро - обратно в тело"); Exit(player); return; } if (IsBloodTide()) { BloodTideHint(player); Debug.Log("[NecromancerTome] SnakeMode: кровавый прилив - дух выброшен в тело"); Exit(player); return; } if (_placeSnake && PlaceSnake(player, snake)) { _placeSnake = false; SnakeControl.Begin(player, snake); } if (SnakeControl.Update(player, snake)) { // Развеялась в радиации - обратно в тело. Exit(player); return; } if (_ragdollAt > 0f) { if (Time.time >= _ragdollAt) { _ragdollAt = 0f; player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false); Debug.Log("[NecromancerTome] SnakeMode: тело упало, рэгдолл " + player.emodel.IsRagdollActive); } } else if (!player.emodel.IsRagdollActive) { player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false); } // Выход по R - только когда тело уже лежит. В первые RagdollDelay секунд игра ещё // обрабатывает ввод: можно взять ствол, и R разом перезарядило бы его и вывело из змеи. PlayerActionsLocal input = player.playerInput; if (_ragdollAt <= 0f && player.emodel.IsRagdollActive && input != null && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed)) { Exit(player); } } /// Змея не должна пережить сейв: после загрузки режима нет, а сущность в мире /// осталась бы. Раз в пару секунд убираем все такие, если режим не активен. static void SweepStraySnakes(World _world) { _sweepTimer += Time.deltaTime; if (_sweepTimer < 2f) { return; } _sweepTimer = 0f; if (!SingletonMonoBehaviour.Instance.IsServer) { return; } int classId = EntityClass.FromString(SnakeClassName); for (int i = _world.Entities.list.Count - 1; i >= 0; i--) { Entity e = _world.Entities.list[i]; // Подсветка зрения змеи, пережившая сейв: баффы зомби сохраняются. if (e is EntityAlive alive && alive.Buffs != null && alive.Buffs.HasBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName)) { alive.Buffs.RemoveBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName); } if (e != null && e.entityClass == classId) { _world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned); } } } public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context) { if (_commandIndex == 1) { Enter(_sender.xui.playerUI.entityPlayer); } else if (_commandIndex == 2) { MindPalace.Enter(_sender.xui.playerUI.entityPlayer); } } } [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))] public static class Patch_ItemAction_HasRadial_SnakeKnife { public static void Postfix(ItemAction __instance, ref bool __result) { if (!__result && !SnakeMode.Active && !MindPalace.Active && SnakeMode.IsKnife(__instance.item)) { __result = true; } } } [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))] public static class Patch_ItemAction_SetupRadial_SnakeKnife { /// Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается). public const string MindPalaceIcon = "ui_game_symbol_book"; public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl) { if (!SnakeMode.IsKnife(__instance.item)) { return true; } _xuiRadialWindow.ResetRadialEntries(); _xuiRadialWindow.CreateRadialEntry(0, "ui_game_symbol_player", "UIAtlas", "", Localization.Get("necroSnakeModeHuman"), _highlighted: true); if (SnakeMode.IsNight()) { _xuiRadialWindow.CreateRadialEntry(1, "ui_game_symbol_tracking_snake", "UIAtlas", "", Localization.Get("necroSnakeModeSnake"), _highlighted: false); } else { _xuiRadialWindow.CreateRadialEntry(1, SnakeMode.DayIcon, "UIAtlas", "", Localization.Get("necroSnakeModeDay"), _highlighted: false); } // Чертоги разума (указание 03.10: «вход тоже через нож») - третья строка, тоже только ночью. if (SnakeMode.IsNight()) { _xuiRadialWindow.CreateRadialEntry(2, MindPalaceIcon, "UIAtlas", "", Localization.Get("necroMindModePalace"), _highlighted: false); } else { _xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "", Localization.Get("necroSnakeModeDay"), _highlighted: false); } _xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload), SnakeMode.OnRadialCommand, null, 0); return false; } } }