Files
necromants-tome-7d2d-3-2/HarmonySrc/SnakeMode.cs
T
AlexCubeandClaude Opus 5.5 85d8036e81 Урны: череп-ретранслятор, прилив прячет духов, меры нагрузки; проверки без мыши
- Череп-ретранслятор: нить урны проходит через черепа-декор (цепочка), каждый пролёт до
  15 м; черепа хранятся у нити (запись урны v5, старые - без черепов). ПКМ ножом по черепу
  - провести нить, ЛКМ - снять нити через него; убрали череп - нить снимается.
- Прилив: ночью кровавой луны духи всех урн исчезают, работа стоит, утром - сама.
- Нагрузка: общий бюджет дел духов на кадр (3), анимируются только 16 ближайших духов;
  лимит урн сборщика смягчён - одна на 200 убийств (было 500), тексты на 13 языках.
- Описание логиста (кадило, два вида, маленький дух, мигание), черепа-декора (передаёт
  нить), подсказки ножа - на 13 языках.
- Разработка: necrogod, necrofps [uncap], necrotide, necroshot серией, necroclick по клетке,
  necrobox fill, necrourn с черепами на пути.

Проверено в игре (test8): мигание, нить 28 м через череп и её разрыв, прилив, 63 урны
сборщика: 13.0 мс на кадр против 11.8 мс без работы урн.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:42:52 +03:00

463 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using GUI_2;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Режим змеи Ножа некроманта (продиктовано 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 до конца, и
/// игра сама поднимает персонажа.
/// </summary>
public static class SnakeMode
{
public const string KnifeName = "necroWpnBladeNecroKnife";
public const string SnakeClassName = "necroSnakeSpirit";
public const float HealthCost = 0.95f;
/// <summary>Зазор между телом игрока и змеёй, метры (указание 28.09: «в 1 метре»).</summary>
public const float Gap = 1f;
public const float PlayerRadius = 0.4f;
/// <summary>Сколько ждать после перехода на третье лицо, прежде чем ронять тело.</summary>
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;
}
/// <summary>Дух змеи - только ночью (указание 28.09): днём в меню вместо змеи циферблат и
/// «Сейчас день», выбрать нельзя; наступило утро - змея развеивается, игрок в теле. «Ночь» -
/// та же, что у игры (World.IsDark: часы из настроек длины дня, плюс вечный день/ночь).</summary>
public const string DayIcon = "ui_game_symbol_clock";
public static bool IsNight()
{
World world = GameManager.Instance?.World;
return world != null && world.IsDark();
}
/// <summary>
/// «Кровавый прилив» (указание 10.10, новая концепция ночи кровавой луны). Луна в этой фазе тянет из
/// земли некротлен - прах умерших за годы эпидемии, - и плотность некроэнергии в воздухе скачком
/// сравнивается с плотностью в теле некроманта. Дух выходит из тела по перепаду плотностей; перепада
/// нет - выйти нечем. Поэтому до утра: нет духа змеи, чертогов разума, одержимости (и зомби, и своих
/// питомцев), а кто был вне тела к началу - выброшен в тело. «Коза» (леденящий ужас) тонет в
/// насыщенном воздухе: жест есть, эффекта нет.
/// Время - ванильное GameUtils.IsBloodMoonTime (от заката дня луны до рассвета), оно считается по часам
/// мира и одинаково у хоста и клиента; AIDirector с его BloodMoonActive есть только у сервера.
/// </summary>
/// <summary>Прилив для проверок (necrotide on|off|auto): null - по часам мира.</summary>
public static bool? ForceTide;
public static bool IsBloodTide()
{
if (ForceTide.HasValue)
{
return ForceTide.Value;
}
World world = GameManager.Instance?.World;
return world != null && GameUtils.IsBloodMoonTime(world.worldTime, (world.DuskHour, world.DawnHour),
GameStats.GetInt(EnumGameStats.BloodMoonDay));
}
/// <summary>Надпись «кровавого прилива» - не чаще раза в BloodTideHintPeriod с.</summary>
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);
}
/// <summary>Свободное место для змеи: по кругу, начиная с направления взгляда, шагами по
/// 45 градусов; две клетки высоты без коллизии. Точное расстояние доводит PlaceSnake.</summary>
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;
}
/// <summary>Когда модель змеи уже есть, сдвигает её вдоль _snakeDir так, чтобы от тела
/// игрока до ближайшей точки змеи было Gap метров (по горизонтали).</summary>
static bool PlaceSnake(EntityPlayerLocal _player, EntityAlive _snake)
{
Transform model = _snake.emodel != null ? _snake.emodel.GetModelTransform() : null;
Renderer[] rs = model != null ? model.GetComponentsInChildren<Renderer>() : 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);
}
}
/// <summary>Змея не должна пережить сейв: после загрузки режима нет, а сущность в мире
/// осталась бы. Раз в пару секунд убираем все такие, если режим не активен.</summary>
static void SweepStraySnakes(World _world)
{
_sweepTimer += Time.deltaTime;
if (_sweepTimer < 2f)
{
return;
}
_sweepTimer = 0f;
if (!SingletonMonoBehaviour<ConnectionManager>.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 const int GripThreadIdx = 3;
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
EntityPlayerLocal pl = _sender.xui.playerUI.entityPlayer;
// Хват ножа (16.10, нити цинерария): 3 - хват для нитей; 0 у ножа-для-нитей - обычный хват.
if (_commandIndex == GripThreadIdx)
{
CinerariumThreads.Convert(pl, CinerariumThreads.ThreadKnifeName);
return;
}
if (_commandIndex == 0 && CinerariumThreads.IsThreadKnife(pl?.inventory?.holdingItem))
{
CinerariumThreads.Convert(pl, CinerariumThreads.KnifeName);
return;
}
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) || CinerariumThreads.IsThreadKnife(__instance.item)))
{
__result = true;
}
}
}
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_SnakeKnife
{
/// <summary>Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается).</summary>
public const string MindPalaceIcon = "ui_game_symbol_book";
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (CinerariumThreads.IsThreadKnife(__instance.item))
{
// Нож хватом для нитей: вернуть обычный хват (0) или остаться (3).
_xuiRadialWindow.ResetRadialEntries();
_xuiRadialWindow.CreateRadialEntry(0, "NecromancersKnife", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripNormal"), _highlighted: false);
_xuiRadialWindow.CreateRadialEntry(SnakeMode.GripThreadIdx, "resourceSewingKit", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripThread"), _highlighted: true);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, SnakeMode.GripThreadIdx);
return false;
}
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);
}
// Хват для нитей (16.10: «Есть в игре пиктограмма ниток, катушки?» - набор для шитья) - в любое время суток.
_xuiRadialWindow.CreateRadialEntry(SnakeMode.GripThreadIdx, "resourceSewingKit", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripThread"), _highlighted: false);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0);
return false;
}
}
}