- Прицел всех одержимых - всегда перекрестие (свой guiDrawCrosshair: точка бралась из проверки камеры от 3-го лица у тела); краснеет на враге: у стервятника - цель рывка, у наземных - враг на луче прицела до 30 м, не за стеной - Падаль: отхил вдвое меньше (EatHealFrac 0.125); ест на F стервятник, медведь, собака, волк и одержимые зомби - E - действие: стервятник - клич (был F), крыса/волк - обыск, медведь - на дыбы (враги до 3.5 м сбиты с ног и отброшены, откат 6 с) - Рёв медведя: надписи «на медведя идут N» и откат, мимо очереди подсказок - Крыса и волк обыскивают мешки с зомби (EntityLootContainer), волк берёт в сумку - Багажник волка: окно сумки как у браслета, без рюкзака; выброс - у волка - Отзыв волка: мешок на месте и метка «Сумка волка» на карте, снимается с мешком - Тексты на 13 языках Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
356 lines
15 KiB
C#
356 lines
15 KiB
C#
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;
|
||
/// <summary>Звук «козы» (05.10: «нужен короткий звук» - вместо twitch_celebrate): бросок змеи - тот же,
|
||
/// что у рывка духа змеи (SnakeControl.AttackSound): «раз зомби боятся духа змеи, некромант может
|
||
/// пугать им и в телесной форме».</summary>
|
||
public const string DenySound = SnakeControl.AttackSound;
|
||
/// <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;
|
||
/// <summary>Вселение - на пике толчка руки (05.10): иначе вид сразу уходит к зомби и толчка не видно.
|
||
/// Цель берётся в момент щелчка.</summary>
|
||
static EntityAlive _pifPafVictim;
|
||
static float _pifPafAt = -1f;
|
||
|
||
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;
|
||
_pifPafVictim = null;
|
||
_pifPafAt = -1f;
|
||
HandPose.ResetAll();
|
||
return;
|
||
}
|
||
if (_pifPafAt >= 0f && Time.time >= _pifPafAt)
|
||
{
|
||
PifPafShot(player);
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
static void PifPafShot(EntityPlayerLocal _player)
|
||
{
|
||
EntityAlive victim = _pifPafVictim;
|
||
_pifPafVictim = null;
|
||
_pifPafAt = -1f;
|
||
if (Current(_player) != PifPaf)
|
||
{
|
||
return; // за полтика сменил хват или руку - выстрела нет
|
||
}
|
||
if (victim != null && !victim.IsDead() && Possession.CanPossess(victim) && Possession.Enter(_player, victim))
|
||
{
|
||
_player.PlayOneShot(PifPafPossessSound);
|
||
}
|
||
else
|
||
{
|
||
_player.PlayOneShot(PifPafFailSound);
|
||
Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид"));
|
||
}
|
||
}
|
||
|
||
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): «пиф-паф» по зомби-гуманоиду - управление им.
|
||
HandPose.Jab();
|
||
_pifPafVictim = Target(_player);
|
||
_pifPafAt = Time.time + HandPose.JabTime * 0.5f;
|
||
return;
|
||
}
|
||
HandPose.Jab(); // толчок к прицелу, как у «пиф-пафа» (05.10)
|
||
_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;
|
||
// В одержимости прицел красит Possession.AimTint по цели одержимого, а не хват тела.
|
||
if (Possession.Active)
|
||
{
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
}
|