Files
necromants-tome-7d2d-3-2/HarmonySrc/HandHolds.cs
T
AlexCubeandClaude Opus 5.5 66a0dcf403 Чертоги разума, хваты пустой руки, одержимость, книга с оглавлением
- Чертоги разума: дух в префабе chertogi под землёй, книга памяти, нож и плакат;
  кровать префаба - мебель necroPalaceBed (иначе становилась точкой возрождения);
  охрана выбрасывает и игроков (их нет в EntityAlives).
- Хваты пустой руки по R: «отрицание» и «пиф-паф» - поза рук костями поверх аниматора,
  остальные кости заморожены, ЛКМ - заклинание, прицел краснеет на зомби.
- «Отрицание»: зомби в ужасе бежит дальше 40 м и теряет игрока как цель.
- «Пиф-паф»: одержимость зомби-гуманоидом - бег, прыжок, удар, дальняя атака,
  подрыв, крик крикуньи; тело цепенеет, зомби танкуют одержимого; область
  вокруг тела сжата; полоска здоровья; спавн и прорисовка у одержимого.
- Книга с оглавлением (окно necroTome), глава «Некроэнергия».
- Прорисовка дверей у змеи и одержимого (CheckLODs от камеры, а не от тела).
- Почтовый ящик консоли и команды necrobones/necropose/necroshot для разработки.

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

324 lines
14 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>
/// Хваты пустой руки (03.10, BACKLOG «Заклинания с руки и жесты»): на R - меню из трёх строк:
/// обычная рука, «отрицание», «пиф-паф». Пустая рука - предмет meleeHandPlayer с DynamicMelee,
/// меню на R игра открывает ей так же, как ножу (PlayerMoveController: HasRadial).
///
/// Выбор НЕ сохраняется (указание 03.10: «после перезахода будет снова обычная рука») - просто поле
/// в памяти. Ни cvar, ни предмет не трогаем. На выходе из мира (нет мира или сменился игрок) всё
/// сбрасывается, и ссылки на кости, снимок заморозки и цель отпускаются (HandPose.ResetAll) - чтобы
/// за сессию ничего не копилось (указание: «не допустить утечку памяти на сохранение состояний»).
/// В хвате рука стоит позой (HandPose: кости после аниматора + заморозка остальных), а кнопки мыши
/// не бьют: ExecuteAction у DynamicMelee пустой руки перехвачен.
/// ЛКМ в «отрицании» - зомби под прицелом в леденящем ужасе: бежит от игрока с ускорением и теряет
/// его как цель (NecroFleeSnakeTask.ScareFrom). Звук - как у празднующего убийства Twitch.
/// ЛКМ в «пиф-пафе» - одержимость зомби-гуманоида под прицелом (Possession.cs).
/// ПКМ в хватах - ничего.
/// </summary>
public static class HandHolds
{
public const string HandItem = "meleeHandPlayer";
public const int None = 0, Deny = 1, PifPaf = 2;
/// <summary>Дальность и конус выбора зомби для «отрицания».</summary>
public const float DenyRange = 25f;
public const float DenyConeDeg = 12f;
/// <summary>Бежит, пока игрок не дальше этого: зрение зомби до 30 м, у Чака 40 (entityclasses.xml,
/// SightRange) - указание 03.10: «убегать он должен дальше 40 м, чтобы терять из прицела».</summary>
public const float DenyFarDistance = 40f;
/// <summary>Предел бегства на случай, если застрял и не может уйти на 40 м.</summary>
public const float DenyMaxSeconds = 25f;
/// <summary>Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»).</summary>
public const float DenySpeed = 2.2f;
public const float Cooldown = 0.6f;
public const string DenySound = "twitch_celebrate";
/// <summary>«Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок
/// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success).</summary>
public const string PifPafFailSound = "glassdestroy";
public const string PifPafPossessSound = "lockpick_success";
/// <summary>Выбранный хват - только на время сессии в мире.</summary>
public static int Hold;
static int _applied = -1;
static int _playerId = -1;
static float _nextCast;
public static bool IsHand(ItemClass _item) => _item != null && _item.Name == HandItem;
public static int Current(EntityPlayerLocal _player)
{
if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) ||
SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead())
{
return None;
}
return Hold;
}
/// <summary>Из HandPoseDriver.LateUpdate - до HandPose.Apply. Позу трогаем только при смене
/// хвата: ручная настройка командой necropose держится до следующей смены.</summary>
public static void Update()
{
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
int id = player != null ? player.entityId : -1;
if (id != _playerId)
{
// Вышли из мира, зашли в другой или перезашли - обычная рука и пустые ссылки.
_playerId = id;
Hold = None;
_applied = None;
_target = null;
_targetFrame = -1;
HandPose.ResetAll();
return;
}
int want = Current(player);
if (want == _applied)
{
return;
}
_applied = want;
if (want == Deny)
{
HandPose.PresetDeny();
}
else if (want == PifPaf)
{
HandPose.PresetPifPaf();
}
else
{
HandPose.Enabled = false;
}
}
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
if (_commandIndex >= None && _commandIndex <= PifPaf)
{
Hold = _commandIndex;
}
}
/// <summary>ЛКМ в хвате. True - нажатие наше, удар не выполнять.</summary>
public static void Cast(EntityPlayerLocal _player, int _hold)
{
if (Time.time < _nextCast)
{
return;
}
_nextCast = Time.time + Cooldown;
if (_hold == PifPaf)
{
// Одержимость (решение 03.10): «пиф-паф» по зомби-гуманоиду - управление им.
EntityAlive victim = Target(_player);
if (Possession.CanPossess(victim) && Possession.Enter(_player, victim))
{
_player.PlayOneShot(PifPafPossessSound);
}
else
{
_player.PlayOneShot(PifPafFailSound);
Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид"));
}
return;
}
_player.PlayOneShot(DenySound);
World world = _player.world;
if (world.IsRemote())
{
return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно
}
EntityAlive target = Target(_player);
if (target != null)
{
if (target.HasAnyTags(FlyerTag))
{
NecroFleeSnakeTask.ForgetNow(target, _player); // летает - наземного бегства нет, только теряет цель
Debug.Log("[NecromancerTome] HandHolds: стервятник " + target.entityId + " отрицание - потерял цель");
}
else
{
NecroFleeSnakeTask.ScareFrom(target, _player, DenyMaxSeconds, DenySpeed, DenyFarDistance);
}
}
}
static readonly FastTags<TagGroup.Global> ZombieTag = FastTags<TagGroup.Global>.Parse("zombie");
static readonly FastTags<TagGroup.Global> FlyerTag = FastTags<TagGroup.Global>.Parse("vulture");
/// <summary>Только зомби (указание 03.10: «кабаны и змеи хоть и противник, но не зомби, кроме
/// зомбокабана»; «стервятники тоже зомби, обычных в игре нету») - метка zombie в entityclasses.xml:
/// люди-зомби, зомби-собаки, лютые волки, зомби-медведи, зомби-кабаны, стервятники.</summary>
public static bool IsZombie(EntityAlive _e)
{
return _e != null && _e.HasAnyTags(ZombieTag);
}
static int _targetFrame = -1;
static EntityAlive _target;
/// <summary>Зомби под прицелом (раз за кадр): и для заклинания, и для красного прицела.</summary>
public static EntityAlive Target(EntityPlayerLocal _player)
{
if (_targetFrame != Time.frameCount)
{
_targetFrame = Time.frameCount;
_target = FindTarget(_player.world, _player);
}
return _target;
}
/// <summary>Прямая видимость своим лучом: между глазом и точкой нет блоков и земли (сущности на луче
/// не мешают). Не EntityAlive.CanSee: у игрока нет ИИ, и его кэш видимости (seeCache) не обновляется -
/// тест 03.10: Арлин захватывалась, зомби-коп нет.</summary>
static bool Visible(Vector3 _eye, Vector3 _to)
{
Vector3 d = _to - _eye;
return !Physics.Raycast(_eye - Origin.position, d.normalized, d.magnitude - 0.3f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
/// <summary>Зомби ближе всех к линии прицела в конусе DenyConeDeg, не дальше DenyRange,
/// с прямой видимостью.</summary>
static EntityAlive FindTarget(World _world, EntityPlayerLocal _player)
{
Transform cam = _player.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 eye = cam.position + Origin.position;
Vector3 fwd = cam.forward;
EntityAlive best = null;
float bestAngle = DenyConeDeg;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e.IsDead() || !IsZombie(e))
{
continue;
}
Vector3 to = e.getChestPosition() - eye;
float d = to.magnitude;
if (d > DenyRange || d < 0.1f)
{
continue;
}
float angle = Vector3.Angle(fwd, to);
if (angle < bestAngle && Visible(eye, e.getChestPosition()))
{
bestAngle = angle;
best = e;
}
}
return best;
}
}
/// <summary>Меню на R у пустой руки - как у ножа (Patch_ItemAction_HasRadial_SnakeKnife).</summary>
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))]
public static class Patch_ItemAction_HasRadial_HandHolds
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
if (!__result && !SnakeMode.Active && !MindPalace.Active && HandHolds.IsHand(__instance.item))
{
__result = true;
}
}
}
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_HandHolds
{
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (!HandHolds.IsHand(__instance.item))
{
return true;
}
int cur = HandHolds.Hold;
_xuiRadialWindow.ResetRadialEntries();
_xuiRadialWindow.CreateRadialEntry(HandHolds.None, "ui_game_symbol_hand", "UIAtlas", "",
Localization.Get("necroHoldNone"), cur == HandHolds.None);
_xuiRadialWindow.CreateRadialEntry(HandHolds.Deny, "ui_game_symbol_run", "UIAtlas", "",
Localization.Get("necroHoldDeny"), cur == HandHolds.Deny);
_xuiRadialWindow.CreateRadialEntry(HandHolds.PifPaf, "ui_game_symbol_pistol", "UIAtlas", "",
Localization.Get("necroHoldPifPaf"), cur == HandHolds.PifPaf);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
HandHolds.OnRadialCommand, null, cur);
return false;
}
}
/// <summary>В хвате кнопки мыши не бьют: ЛКМ - заклинание хвата, ПКМ - ничего (03.10: «руки
/// деформируются, пытаясь совершить старую анимацию»).</summary>
[HarmonyPatch(typeof(ItemActionDynamicMelee), nameof(ItemActionDynamicMelee.ExecuteAction))]
public static class Patch_ItemActionDynamicMelee_ExecuteAction_HandHolds
{
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
{
if (!(_actionData?.invData?.holdingEntity is EntityPlayerLocal player))
{
return true;
}
int hold = HandHolds.Current(player);
if (hold == HandHolds.None || !HandHolds.IsHand(_actionData.invData.item))
{
return true;
}
if (!_bReleased && _actionData.indexInEntityOfAction == 0)
{
HandHolds.Cast(player, hold);
}
return false;
}
}
/// <summary>Прицел краснеет, когда в нём зомби, в обоих хватах (указание 03.10). Цвет прицела игра
/// берёт из EntityPlayerLocal.crosshairColors[настройка] - на время рисования подменяем его красным.</summary>
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))]
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_HandHolds
{
static readonly Color Red = new Color(1f, 0.15f, 0.1f);
public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
{
__state = null;
int hold = HandHolds.Current(__instance);
EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
// «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
if (target == null || hold == HandHolds.PifPaf && !Possession.CanPossess(target))
{
return;
}
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
{
i = 0;
}
__state = EntityPlayerLocal.crosshairColors[i];
EntityPlayerLocal.crosshairColors[i] = Red;
}
public static void Postfix(Color? __state)
{
if (__state == null)
{
return;
}
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
{
i = 0;
}
EntityPlayerLocal.crosshairColors[i] = __state.Value;
}
}
}