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();
}
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;
}
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 (_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;
}
}
}