Files
necromants-tome-7d2d-3-2/HarmonySrc/SnakeMode.cs
T
AlexCubeandClaude Opus 5.5 b737dd43d0 Режим змеи: выход по R только когда тело уже лежит
В первые RagdollDelay секунд после входа игра ещё обрабатывает ввод:
можно было взять ствол, и R разом перезаряжало его и выводило из змеи.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:05:31 +03:00

364 lines
15 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();
}
public static bool IsKnife(ItemClass _item)
{
return _item != null && _item.Name == KnifeName;
}
public static void Enter(EntityPlayerLocal _player)
{
if (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);
}
/// <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 (_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);
}
}
}
[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 && SnakeMode.IsKnife(__instance.item))
{
__result = true;
}
}
}
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_SnakeKnife
{
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);
}
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0);
return false;
}
}
}