Нет змеи, чертогов, одержимости зомби и питомцев; кто вне тела - выброшен в тело; «коза» без эффекта. Подмешивание Стирателей в орду отключено. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
18 KiB
C#
426 lines
18 KiB
C#
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>
|
||
public static bool IsBloodTide()
|
||
{
|
||
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 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
|
||
{
|
||
/// <summary>Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается).</summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|