- Змея: проверка «внутри текстуры» лучами (земля игры - поверхность), каждый кадр выброс на ближайшее свободное место (на земле - из любых текстур, в полёте - из грунта); шаг и прыжок в текстуры не пускают. Полёт пока зажата ПКМ: 1.1 м/с (половина шага зомби), сквозь постройки можно, в грунт нельзя; Пробел вверх, C/Ctrl вниз. E - аварийный выход. Детализация зданий и область тела - как у одержимого. - Одержимый: будит спящих (комнаты зданий и сам вид одержимого), приседание на C и Ctrl, удар по точке под прицелом с наведением на грудь ближнего врага. - Хваты: толчок руки к прицелу на ЛКМ; «пиф-паф» вселяет на пике толчка. «Отрицание» стало «Козой» (13 языков): средний и безымянный поджаты, большой прижат; звук - бросок змеи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
819 lines
34 KiB
C#
819 lines
34 KiB
C#
using GamePath;
|
||
using System.Collections.Generic;
|
||
using HarmonyLib;
|
||
using UnityEngine;
|
||
|
||
namespace NecromancerTome
|
||
{
|
||
/// <summary>
|
||
/// Одержимость - заклинание хвата «пиф-паф» (решения пользователя 03.10, BACKLOG «Одержимость»).
|
||
/// ЛКМ по зомби-гуманоиду (EntityZombie и наследники; не звери и не стервятники) - игрок перехватывает
|
||
/// управление им: бегает, прыгает, бьёт. Тело игрока не падает, а цепенеет и стоит (аниматор на паузе,
|
||
/// ввод снят) - его могут атаковать, это и есть цена. Любое время суток, и в кровавую луну.
|
||
/// Остальные зомби, увидев одержимого, бросаются на него, как на игрока, - можно танковать.
|
||
/// Выход - R, гибель одержимого или смерть тела. После выхода зомби пугается и убегает, как при
|
||
/// «отрицании». Убийства одержимым игроку не засчитываются (урон от его имени), его собственная
|
||
/// смерть - тоже (NecromancyKillCreditPatch).
|
||
///
|
||
/// КАК ВЕДЁМ ЗОМБИ. Его ИИ выключен (EntityAlive.updateTasks пропущен), а движение - родное:
|
||
/// SetMoveForwardWithModifiers, как делает сама игра при DebugStopEnemiesMoving и EntityMoveHelper;
|
||
/// прыжок - Jumping + SetJumpDistance, как EntityMoveHelper.StartJump; удар - Attack(false/true),
|
||
/// как EAIApproachAndAttackTarget. Походка, анимация и урон - самого зомби.
|
||
///
|
||
/// КАК СТОИТ ТЕЛО. PlayerMoveController.Update пропущен (и сам вызывает stopMoving, как у тела в
|
||
/// рэгдолле), вид - от третьего лица, аниматор модели на паузе. Камера - за головой зомби (постфикс
|
||
/// к vp_FPCamera.LateUpdate, как у змеи).
|
||
///
|
||
/// ПРИЁМЫ ЗОМБИ (указание 03.10: «у копа есть плевок и подрыв - чтобы такие спеллы становились доступны»):
|
||
/// ПКМ - дальняя атака, если у зомби есть задача ИИ EAIRangedAttackTarget (Ранчер, Чак, коп, мутант):
|
||
/// её параметры берутся из задачи захваченного зомби, а шаги повторяют её Start/Update/Reset -
|
||
/// замах (StartAnimAction startAnimType+3000), отпуск (ContinueAnimAction +1), выстрел родным
|
||
/// оружием (UseHoldingItem itemType), пока держишь кнопку и не вышло attackDuration. Направление -
|
||
/// точка под прицелом камеры (SetLookPosition), как у ИИ - голова цели.
|
||
/// Пробел - прыжок на собственную дальность зомби (jumpMaxDistance из JumpMaxDistance): у паука 7-9 м,
|
||
/// как его EAILeap, у остальных их 1-4 м.
|
||
/// ПКМ у крикуньи - её крик (SoundAlert): все зомби в ScreamRange бросаются на неё, даже не видя (её
|
||
/// ванильный зов орды - часть разведки AIScoutHordeSpawner, напрямую не вызываем).
|
||
/// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько
|
||
/// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело.
|
||
///
|
||
/// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время
|
||
/// одержимости; на выходе и при выходе из мира всё обнуляется.
|
||
/// </summary>
|
||
public static class Possession
|
||
{
|
||
public static bool Active;
|
||
public static EntityAlive Zombie;
|
||
|
||
/// <summary>Камера - позади и выше головы зомби.</summary>
|
||
public const float CamBack = 2.6f;
|
||
public const float CamUp = 0.6f;
|
||
/// <summary>Как далеко зомби замечают одержимого: зрение зомби - 30 м (entityclasses.xml SightRange).</summary>
|
||
public const float TauntRange = 30f;
|
||
public const float TauntPeriod = 1f;
|
||
public const int TauntTicks = 1200;
|
||
/// <summary>Спящие просыпаются от одержимого (тест 05.10: «не разбудил спящего»). Игра будит их
|
||
/// только игроком - увидел или услышал (EAISetNearestEntityAsTarget.FindTargetPlayer), одержимый для
|
||
/// неё просто зомби. Будим сами: видит одержимого ближе SleeperWakeRange (пригнувшегося - ближе
|
||
/// SleeperWakeCrouched) или тот вплотную, ближе SleeperHearRange, хоть и за спиной.</summary>
|
||
public const float SleeperWakeRange = 8f;
|
||
public const float SleeperWakeCrouched = 3f;
|
||
public const float SleeperHearRange = 2f;
|
||
/// <summary>Приседание (указание 05.10): пока зажата кнопка приседания. Высота - доля роста зомби;
|
||
/// ниже 0.7 игра включает ходьбу пригнувшись (walkType 8), как у её зомби под низким потолком.</summary>
|
||
public static bool Crouching;
|
||
/// <summary>Удар по прицелу (тест 05.10: «не попадаю по другим зомби»). Удар зомби - луч ~1.65 м из
|
||
/// головы по взгляду (ItemActionMelee, EntityHuman.GetLookVector), а взгляд брался из наклона камеры,
|
||
/// которая сзади и выше: под прицелом враг, а луч из головы уходит ниже и мимо. На время замаха
|
||
/// взгляд зомби - на точку под прицелом, а если рядом враг почти по прицелу - на его грудь.</summary>
|
||
public const float MeleeAssistRange = 2.5f;
|
||
public const float MeleeAssistDeg = 25f;
|
||
static float _meleeLookUntil;
|
||
/// <summary>Ctrl в игре - приседание переключателем (ToggleCrouch), C - пока держишь (Crouch).</summary>
|
||
static bool _crouchToggled;
|
||
public const float CrouchHeight = 0.6f;
|
||
/// <summary>Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить
|
||
/// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора
|
||
/// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков
|
||
/// хватает, чтобы тело стояло на земле и зомби могли к нему подойти.</summary>
|
||
public const int BodyViewDim = 3;
|
||
static int _bodyViewDim = -1;
|
||
/// <summary>Полный радиус области игрока (до сжатия) - для систем, рисующих мир у одержимого
|
||
/// (SnakeAudioSwap: дальний ландшафт, Dynamic Mesh, детализация зданий). -1 - не сжата.</summary>
|
||
public static int FullViewDim => _bodyViewDim;
|
||
public const float ScreamRange = 50f;
|
||
public const float ScreamCooldown = 10f;
|
||
static float _nextScream;
|
||
|
||
static EntityPlayerLocal _player;
|
||
static float _yaw, _pitch;
|
||
/// <summary>Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») -
|
||
/// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера.</summary>
|
||
static float _bodyYaw;
|
||
public const float TurnSpeed = 540f;
|
||
static float _nextAttack;
|
||
static float _nextTaunt;
|
||
static float _sleeperCheck;
|
||
static ChunkManager.ChunkObserver _observer;
|
||
static readonly List<Animator> _frozen = new List<Animator>();
|
||
static readonly List<float> _frozenSpeed = new List<float>();
|
||
static bool _loggedCamera;
|
||
// Дальняя атака захваченного зомби (копия параметров его EAIRangedAttackTarget).
|
||
static bool _hasRanged;
|
||
static int _rItem, _rStartAnim;
|
||
static float _rRelease, _rDuration, _rCooldown;
|
||
static string _rSoundStart, _rSoundRelease;
|
||
enum RState { Idle, StartAnim, Release, Fire }
|
||
static RState _rState;
|
||
static float _rTime, _rNext;
|
||
|
||
static GameObject _hud;
|
||
|
||
public static void Init()
|
||
{
|
||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||
if (_hud == null)
|
||
{
|
||
_hud = new GameObject("NecroPossessionHud");
|
||
Object.DontDestroyOnLoad(_hud);
|
||
_hud.AddComponent<PossessionHud>();
|
||
}
|
||
}
|
||
|
||
public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
|
||
|
||
/// <summary>Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).</summary>
|
||
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
|
||
|
||
/// <summary>True - вселились (для звука «пиф-пафа»).</summary>
|
||
public static bool Enter(EntityPlayerLocal _player0, EntityAlive _zombie)
|
||
{
|
||
if (Active || SnakeMode.Active || MindPalace.Active || _player0 == null || _player0.IsDead() ||
|
||
_player0.AttachedToEntity != null || !CanPossess(_zombie) || _player0.world.IsRemote())
|
||
{
|
||
return false;
|
||
}
|
||
Active = true;
|
||
_player = _player0;
|
||
Zombie = _zombie;
|
||
_yaw = _player0.rotation.y;
|
||
_bodyYaw = _zombie.rotation.y;
|
||
_pitch = 0f;
|
||
_nextAttack = 0f;
|
||
_nextTaunt = 0f;
|
||
_nextScream = 0f;
|
||
_loggedCamera = false;
|
||
|
||
// Зомби - в руки игрока: цели, путь и шаги ИИ сброшены.
|
||
_zombie.SetAttackTarget(null, 0);
|
||
_zombie.SetRevengeTarget(null);
|
||
PathFinderThread.Instance.RemovePathsFor(_zombie.entityId);
|
||
_zombie.navigator?.clearPath();
|
||
_zombie.moveHelper?.Stop();
|
||
_zombie.ConditionalTriggerSleeperWakeUp();
|
||
_zombie.SetRotation(new Vector3(0f, _yaw, 0f));
|
||
|
||
// Тело цепенеет: вид от третьего лица, аниматоры модели на паузе.
|
||
_player0.SetFirstPersonView(false, true);
|
||
FreezeBody(_player0, true);
|
||
_observer = GameManager.Instance.AddChunkObserver(_zombie.position, true, GameUtils.GetViewDistance(), -1);
|
||
ChunkManager.ChunkObserver body = _player0.ChunkObserver;
|
||
if (body != null && body.viewDim > BodyViewDim)
|
||
{
|
||
_bodyViewDim = body.viewDim;
|
||
body.viewDim = BodyViewDim;
|
||
}
|
||
ReadRanged(_zombie);
|
||
Hint(_player0, _zombie);
|
||
Debug.Log("[NecromancerTome] Possession: вселение в " + _zombie.EntityName + " (" + _zombie.entityId + ")");
|
||
return true;
|
||
}
|
||
|
||
public static void Exit(string _why)
|
||
{
|
||
if (!Active)
|
||
{
|
||
return;
|
||
}
|
||
Active = false;
|
||
Crouching = false;
|
||
_meleeLookUntil = 0f;
|
||
_crouchToggled = false;
|
||
EntityAlive z = Zombie;
|
||
z?.SetLookPosition(Vector3.zero);
|
||
EntityPlayerLocal player = _player;
|
||
if (_rState != RState.Idle && z != null && !z.IsDead())
|
||
{
|
||
StopRanged(z);
|
||
}
|
||
_rState = RState.Idle;
|
||
_hasRanged = false;
|
||
Zombie = null;
|
||
_player = null;
|
||
Debug.Log("[NecromancerTome] Possession: выход - " + _why);
|
||
FreezeBody(player, false);
|
||
if (_bodyViewDim > 0 && player?.ChunkObserver != null)
|
||
{
|
||
player.ChunkObserver.viewDim = _bodyViewDim;
|
||
}
|
||
_bodyViewDim = -1;
|
||
if (player != null && !player.IsDead())
|
||
{
|
||
player.SetFirstPersonView(true, true);
|
||
}
|
||
if (_observer != null && GameManager.Instance?.World != null)
|
||
{
|
||
GameManager.Instance.RemoveChunkObserver(_observer);
|
||
}
|
||
_observer = null;
|
||
World world = GameManager.Instance?.World;
|
||
if (world == null || z == null)
|
||
{
|
||
return;
|
||
}
|
||
// Кто гнался за одержимым - отпускает его.
|
||
for (int i = 0; i < world.EntityAlives.Count; i++)
|
||
{
|
||
EntityAlive e = world.EntityAlives[i];
|
||
if (e != null && e != z && e.GetAttackTarget() == z)
|
||
{
|
||
e.SetAttackTarget(null, 0);
|
||
}
|
||
}
|
||
// Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»).
|
||
if (!z.IsDead() && player != null)
|
||
{
|
||
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
|
||
NecroFleeSnakeTask.ScareFrom(z, player, HandHolds.DenyMaxSeconds, HandHolds.DenySpeed, HandHolds.DenyFarDistance);
|
||
}
|
||
}
|
||
|
||
static void FreezeBody(EntityPlayerLocal _p, bool _freeze)
|
||
{
|
||
if (_freeze)
|
||
{
|
||
_frozen.Clear();
|
||
_frozenSpeed.Clear();
|
||
Transform model = _p?.emodel?.GetModelTransform();
|
||
if (model == null)
|
||
{
|
||
return;
|
||
}
|
||
foreach (Animator a in model.GetComponentsInChildren<Animator>(true))
|
||
{
|
||
_frozen.Add(a);
|
||
_frozenSpeed.Add(a.speed);
|
||
a.speed = 0f;
|
||
}
|
||
return;
|
||
}
|
||
for (int i = 0; i < _frozen.Count; i++)
|
||
{
|
||
if (_frozen[i] != null)
|
||
{
|
||
_frozen[i].speed = _frozenSpeed[i];
|
||
}
|
||
}
|
||
_frozen.Clear();
|
||
_frozenSpeed.Clear();
|
||
}
|
||
|
||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||
{
|
||
World world = GameManager.Instance?.World;
|
||
if (world == null)
|
||
{
|
||
if (Active || Zombie != null || _frozen.Count > 0)
|
||
{
|
||
Active = false;
|
||
Zombie = null;
|
||
_player = null;
|
||
_observer = null;
|
||
_bodyViewDim = -1;
|
||
_frozen.Clear();
|
||
_frozenSpeed.Clear();
|
||
}
|
||
return;
|
||
}
|
||
if (!Active)
|
||
{
|
||
return;
|
||
}
|
||
EntityAlive z = Zombie;
|
||
if (_player == null || _player.IsDead())
|
||
{
|
||
Exit("тело погибло");
|
||
return;
|
||
}
|
||
if (z == null || z.IsDead() || z.IsMarkedForUnload())
|
||
{
|
||
Exit("одержимый погиб");
|
||
return;
|
||
}
|
||
PlayerActionsLocal input = _player.playerInput;
|
||
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
|
||
if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
|
||
{
|
||
Exit("R");
|
||
return;
|
||
}
|
||
Vector2 move = Vector2.zero;
|
||
bool run = false, jump = false, attack = false, ranged = false, rangedHeld = false, detonate = false;
|
||
if (input != null && !gui)
|
||
{
|
||
Look();
|
||
move = new Vector2(input.Move.X, input.Move.Y);
|
||
run = input.Run.IsPressed;
|
||
jump = input.Jump.WasPressed;
|
||
attack = input.Primary.WasPressed;
|
||
ranged = input.Secondary.WasPressed;
|
||
rangedHeld = input.Secondary.IsPressed;
|
||
// E на клавиатуре - в PlayerActionsPermanent.Activate; у PlayerActionsLocal.Activate только
|
||
// кнопка геймпада (тест 03.10: «подрыва нету»).
|
||
detonate = input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed;
|
||
}
|
||
if (input != null && !gui && input.ToggleCrouch.WasPressed)
|
||
{
|
||
_crouchToggled = !_crouchToggled;
|
||
}
|
||
Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
|
||
if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
|
||
{
|
||
Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
|
||
cop.HandlePrimingDetonator();
|
||
}
|
||
Ranged(z, ranged, rangedHeld);
|
||
|
||
bool moving = move.sqrMagnitude > 0.01f;
|
||
float wantYaw = moving ? _yaw + Mathf.Atan2(move.x, move.y) * Mathf.Rad2Deg : _yaw;
|
||
_bodyYaw = Mathf.MoveTowardsAngle(_bodyYaw, wantYaw, TurnSpeed * Time.deltaTime);
|
||
z.SetRotation(new Vector3(0f, _bodyYaw, 0f));
|
||
z.rotation.x = -_pitch; // наклон головы - для луча удара
|
||
if (moving)
|
||
{
|
||
z.SetMoveForwardWithModifiers(run ? z.GetMoveSpeedAggro() : z.GetMoveSpeed(), 1f, 0f, false);
|
||
}
|
||
else
|
||
{
|
||
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
|
||
}
|
||
if (jump && z.onGround && !z.Jumping)
|
||
{
|
||
z.Jumping = true;
|
||
z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f);
|
||
}
|
||
if (ranged && IsScreamer(z))
|
||
{
|
||
Scream(world, z);
|
||
}
|
||
if (attack && Time.time >= _nextAttack)
|
||
{
|
||
z.SetLookPosition(MeleeAim(world, z));
|
||
if (z.Attack(false))
|
||
{
|
||
z.Attack(true);
|
||
float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
|
||
_nextAttack = Time.time + timeout;
|
||
_meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout);
|
||
}
|
||
}
|
||
if (_meleeLookUntil > 0f && _rState == RState.Idle)
|
||
{
|
||
if (Time.time < _meleeLookUntil)
|
||
{
|
||
z.SetLookPosition(MeleeAim(world, z));
|
||
}
|
||
else
|
||
{
|
||
_meleeLookUntil = 0f;
|
||
z.SetLookPosition(Vector3.zero);
|
||
}
|
||
}
|
||
_observer?.SetPosition(z.position);
|
||
// Спящие в зданиях просыпаются от одержимого, как от змеи (указание 05.10).
|
||
SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime);
|
||
Taunt(world, z);
|
||
}
|
||
|
||
static void ReadRanged(EntityAlive _z)
|
||
{
|
||
_hasRanged = false;
|
||
_rState = RState.Idle;
|
||
_rNext = 0f;
|
||
List<EAIRangedAttackTarget> tasks = _z.aiManager?.GetTasks<EAIRangedAttackTarget>();
|
||
if (tasks == null || tasks.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
EAIRangedAttackTarget t = tasks[0];
|
||
_hasRanged = true;
|
||
_rItem = t.itemActionType;
|
||
_rStartAnim = t.startAnimType;
|
||
_rRelease = t.releaseDelay;
|
||
_rDuration = t.attackDuration > 0f ? t.attackDuration : 3f;
|
||
// Вдвое быстрее ИИ (тест 03.10: «перезаряд медленный»).
|
||
_rCooldown = Mathf.Max(1f, t.baseCooldown * 0.5f);
|
||
_rSoundStart = t.soundStartName;
|
||
_rSoundRelease = t.soundReleaseName;
|
||
Debug.Log("[NecromancerTome] Possession: дальняя атака - оружие " + _rItem + ", замах " + _rStartAnim + ", длит. " + _rDuration + " с, откат " + _rCooldown + " с");
|
||
}
|
||
|
||
/// <summary>Подсказка при вселении: что умеет этот зомби.</summary>
|
||
static void Hint(EntityPlayerLocal _p, EntityAlive _z)
|
||
{
|
||
string text = Localization.Get("necroPossessHintBase");
|
||
if (_hasRanged)
|
||
{
|
||
text += ", " + Localization.Get("necroPossessHintRanged");
|
||
}
|
||
if (IsScreamer(_z))
|
||
{
|
||
text += ", " + Localization.Get("necroPossessHintScream");
|
||
}
|
||
if (_z.jumpMaxDistance >= 5f)
|
||
{
|
||
text += ", " + Localization.Get("necroPossessHintLeap");
|
||
}
|
||
if (_z is EntityZombieCop)
|
||
{
|
||
text += ", " + Localization.Get("necroPossessHintDetonate");
|
||
}
|
||
GameManager.ShowTooltip(_p, text);
|
||
}
|
||
|
||
/// <summary>Куда бить: грудь врага ближе MeleeAssistRange к голове и не дальше MeleeAssistDeg от
|
||
/// направления на прицел (ближайший к прицелу), иначе - точка под прицелом.</summary>
|
||
static Vector3 MeleeAim(World _world, EntityAlive _z)
|
||
{
|
||
Vector3 aim = AimPoint();
|
||
Vector3 head = _z.getHeadPosition();
|
||
Vector3 dir = (aim - head).normalized;
|
||
EntityAlive best = null;
|
||
float bestAngle = MeleeAssistDeg;
|
||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||
{
|
||
EntityAlive e = _world.EntityAlives[i];
|
||
if (e == null || e == _z || e.IsDead() || e is EntityPlayer || SnakeControl.IsNecroEntity(e))
|
||
{
|
||
continue;
|
||
}
|
||
Vector3 to = e.getChestPosition() - head;
|
||
if (to.sqrMagnitude > MeleeAssistRange * MeleeAssistRange)
|
||
{
|
||
continue;
|
||
}
|
||
float angle = Vector3.Angle(dir, to);
|
||
if (angle < bestAngle)
|
||
{
|
||
bestAngle = angle;
|
||
best = e;
|
||
}
|
||
}
|
||
return best != null ? best.getChestPosition() : aim;
|
||
}
|
||
|
||
/// <summary>Точка под прицелом камеры (в мировых координатах), до 50 м.</summary>
|
||
static Vector3 AimPoint()
|
||
{
|
||
Transform cam = _player?.cameraTransform;
|
||
if (cam == null)
|
||
{
|
||
return Zombie.position + Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * 30f;
|
||
}
|
||
if (Physics.Raycast(cam.position, cam.forward, out RaycastHit hit, 50f, -5, QueryTriggerInteraction.Ignore) &&
|
||
hit.distance > CamBack + 0.5f)
|
||
{
|
||
return hit.point + Origin.position;
|
||
}
|
||
return cam.position + Origin.position + cam.forward * 50f;
|
||
}
|
||
|
||
/// <summary>ПКМ - дальняя атака зомби по шагам ванильной EAIRangedAttackTarget.</summary>
|
||
static void Ranged(EntityAlive _z, bool _pressed, bool _held)
|
||
{
|
||
if (!_hasRanged)
|
||
{
|
||
return;
|
||
}
|
||
float dt = Time.deltaTime;
|
||
switch (_rState)
|
||
{
|
||
case RState.Idle:
|
||
if (!_pressed)
|
||
{
|
||
return;
|
||
}
|
||
if (Time.time < _rNext || !_z.IsAttackValid())
|
||
{
|
||
Debug.Log("[NecromancerTome] Possession: дальняя атака не готова - откат " + Mathf.Max(0f, _rNext - Time.time).ToString("F1") + " с, атака " + (_z.IsAttackValid() ? "можно" : "нельзя"));
|
||
return;
|
||
}
|
||
RearmRanged(_z);
|
||
_z.emodel.avatarController.hitWeightMax = 0.5f;
|
||
_rTime = 0f;
|
||
if (!string.IsNullOrEmpty(_rSoundStart))
|
||
{
|
||
Audio.Manager.BroadcastPlay(_z, _rSoundStart);
|
||
}
|
||
if (_rStartAnim >= 0)
|
||
{
|
||
_z.StartAnimAction(_rStartAnim + 3000);
|
||
_rState = RState.StartAnim;
|
||
}
|
||
else
|
||
{
|
||
_rState = RState.Fire;
|
||
}
|
||
return;
|
||
case RState.StartAnim:
|
||
_z.SetLookPosition(AimPoint());
|
||
if (_z.GetAnimActionState() != AvatarController.ActionState.Ready)
|
||
{
|
||
return;
|
||
}
|
||
_z.ContinueAnimAction(_rStartAnim + 1 + 3000);
|
||
if (!string.IsNullOrEmpty(_rSoundRelease))
|
||
{
|
||
Audio.Manager.BroadcastPlay(_z, _rSoundRelease);
|
||
}
|
||
_rState = RState.Release;
|
||
_rTime = 0f;
|
||
return;
|
||
case RState.Release:
|
||
_z.SetLookPosition(AimPoint());
|
||
_rTime += dt;
|
||
if (_rTime >= _rRelease)
|
||
{
|
||
_rState = RState.Fire;
|
||
_rTime = 0f;
|
||
}
|
||
return;
|
||
case RState.Fire:
|
||
_z.SetLookPosition(AimPoint());
|
||
_rTime += dt;
|
||
_z.UseHoldingItem(_rItem, false);
|
||
// Доводим до конца по одному нажатию: у плевка сначала 1-3 предупреждения по 1.2 с
|
||
// (ItemActionVomit), и с отпущенной кнопкой он сбрасывался (тест 03.10: «если зажать
|
||
// ПКМ - плюёт»). Конец - оружие закончило или вышла длительность задачи.
|
||
if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration)
|
||
{
|
||
StopRanged(_z);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
/// <summary>Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
|
||
/// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
|
||
/// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).</summary>
|
||
static void RearmRanged(EntityAlive _z)
|
||
{
|
||
ItemInventoryData inv = _z.inventory?.holdingItemData;
|
||
if (inv == null || _rItem >= inv.actionData.Count)
|
||
{
|
||
return;
|
||
}
|
||
ItemValue iv = _z.inventory.holdingItemItemValue;
|
||
string before = "патронов " + (iv != null ? iv.Meta.ToString() : "?");
|
||
if (inv.actionData[_rItem] is ItemActionRanged.ItemActionDataRanged d && _z.inventory.holdingItem.Actions[_rItem] is ItemActionRanged ranged)
|
||
{
|
||
before += ", очередь " + d.curBurstCount + ", перезарядка " + d.isReloading;
|
||
ranged.ResetBurstShot(d);
|
||
d.bReleased = true;
|
||
d.bPressed = false;
|
||
d.isReloading = false;
|
||
d.isReloadRequested = false;
|
||
}
|
||
// Плевок: без лишних «позывов» - сразу последнее предупреждение (ItemActionVomit: 1-3 по 1.2 с).
|
||
if (inv.actionData[_rItem] is ItemActionVomit.ItemActionDataVomit dv && _z.inventory.holdingItem.Actions[_rItem] is ItemActionVomit vomit)
|
||
{
|
||
dv.numWarningsPlayed = Mathf.Max(0, Traverse.Create(vomit).Field("warningMax").GetValue<int>() - 1);
|
||
dv.warningTime = 0f;
|
||
}
|
||
if (iv != null && iv.Meta < 1)
|
||
{
|
||
iv.Meta = 1;
|
||
}
|
||
Debug.Log("[NecromancerTome] Possession: дальняя атака - взвод (" + before + ")");
|
||
}
|
||
|
||
static void StopRanged(EntityAlive _z)
|
||
{
|
||
_z.ShowHoldingItem(false);
|
||
_z.UseHoldingItem(_rItem, true);
|
||
_z.StartAnimAction(9999);
|
||
_z.SetLookPosition(Vector3.zero);
|
||
_z.emodel.avatarController.hitWeightMax = 1f;
|
||
_rState = RState.Idle;
|
||
_rNext = Time.time + _rCooldown;
|
||
}
|
||
|
||
static bool IsScreamer(EntityAlive _z)
|
||
{
|
||
return EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c) && c.entityClassName.StartsWith("zombieScreamer");
|
||
}
|
||
|
||
/// <summary>Крик одержимой крикуньи: её звук тревоги и все зомби в ScreamRange - на неё (танкование).</summary>
|
||
static void Scream(World _world, EntityAlive _z)
|
||
{
|
||
if (Time.time < _nextScream)
|
||
{
|
||
return;
|
||
}
|
||
_nextScream = Time.time + ScreamCooldown;
|
||
string snd = _z.GetSoundAlert();
|
||
if (!string.IsNullOrEmpty(snd))
|
||
{
|
||
Audio.Manager.BroadcastPlay(_z, snd);
|
||
}
|
||
int n = 0;
|
||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||
{
|
||
EntityAlive e = _world.EntityAlives[i];
|
||
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
|
||
PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _z.position).sqrMagnitude > ScreamRange * ScreamRange)
|
||
{
|
||
continue;
|
||
}
|
||
e.ConditionalTriggerSleeperWakeUp();
|
||
AllowZombieTarget(e);
|
||
e.SetAttackTarget(_z, TauntTicks);
|
||
n++;
|
||
}
|
||
Debug.Log("[NecromancerTome] Possession: крик крикуньи - на зов идут " + n);
|
||
}
|
||
|
||
static void Look()
|
||
{
|
||
PlayerMoveController pmc = PlayerMoveController.Instance;
|
||
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
|
||
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
|
||
_yaw += Input.GetAxis("Mouse X") * sens.x;
|
||
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f);
|
||
}
|
||
|
||
/// <summary>Раз в секунду: зомби, которые видят одержимого, бросаются на него (решение 03.10:
|
||
/// «зомби начинают считать этого зомби ещё одним игроком»). Ванильный подход к цели принимает
|
||
/// только свои классы (EntityPlayer...) - добавляем EntityZombie, как CharmPatch; это безопасно:
|
||
/// цель-зомби им ставим только мы (поиск цели EAISetNearestEntityAsTarget не трогаем).</summary>
|
||
static void Taunt(World _world, EntityAlive _z)
|
||
{
|
||
if (Time.time < _nextTaunt)
|
||
{
|
||
return;
|
||
}
|
||
_nextTaunt = Time.time + TauntPeriod;
|
||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||
{
|
||
EntityAlive e = _world.EntityAlives[i];
|
||
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.GetAttackTarget() == _z ||
|
||
e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null)
|
||
{
|
||
continue;
|
||
}
|
||
float d2 = (e.position - _z.position).sqrMagnitude;
|
||
if (d2 > TauntRange * TauntRange)
|
||
{
|
||
continue;
|
||
}
|
||
if (e.IsSleeping)
|
||
{
|
||
float wake = Crouching ? SleeperWakeCrouched : SleeperWakeRange;
|
||
if (d2 > SleeperHearRange * SleeperHearRange && (d2 > wake * wake || !e.CanSee(_z)))
|
||
{
|
||
continue;
|
||
}
|
||
e.ConditionalTriggerSleeperWakeUp();
|
||
Debug.Log("[NecromancerTome] Possession: проснулся " + e.EntityName + " (" + e.entityId + "), " + Mathf.Sqrt(d2).ToString("F1") + " м");
|
||
}
|
||
else if (!e.CanSee(_z))
|
||
{
|
||
continue;
|
||
}
|
||
AllowZombieTarget(e);
|
||
e.SetAttackTarget(_z, TauntTicks);
|
||
}
|
||
}
|
||
|
||
static void AllowZombieTarget(EntityAlive _e)
|
||
{
|
||
List<EAIApproachAndAttackTarget> tasks = _e.aiManager.GetTasks<EAIApproachAndAttackTarget>();
|
||
if (tasks == null)
|
||
{
|
||
return;
|
||
}
|
||
foreach (EAIApproachAndAttackTarget t in tasks)
|
||
{
|
||
bool has = false;
|
||
foreach (EAIApproachAndAttackTarget.TargetClass c in t.targetClasses)
|
||
{
|
||
has |= c.type == typeof(EntityZombie);
|
||
}
|
||
if (!has)
|
||
{
|
||
t.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = typeof(EntityZombie), chaseTimeMax = 0f });
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.</summary>
|
||
public static void PlaceCamera(Transform _camera)
|
||
{
|
||
EntityAlive z = Zombie;
|
||
if (z == null)
|
||
{
|
||
return;
|
||
}
|
||
Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null;
|
||
Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f;
|
||
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
|
||
Vector3 eye = anchor + Vector3.up * CamUp - rot * Vector3.forward * CamBack;
|
||
Vector3 to = eye - anchor;
|
||
if (Physics.Raycast(anchor, to.normalized, out RaycastHit hit, to.magnitude + 0.2f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
|
||
{
|
||
eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f);
|
||
}
|
||
_camera.SetPositionAndRotation(eye, rot);
|
||
if (!_loggedCamera)
|
||
{
|
||
_loggedCamera = true;
|
||
Debug.Log("[NecromancerTome] Possession: камера за " + (head != null ? "головой " + head.name : "корнем (головы нет)"));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы.</summary>
|
||
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))]
|
||
public static class Patch_EntityAlive_updateTasks_Possession
|
||
{
|
||
public static bool Prefix(EntityAlive __instance)
|
||
{
|
||
return !Possession.IsPossessed(__instance);
|
||
}
|
||
}
|
||
|
||
/// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary>
|
||
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
|
||
public static class Patch_PlayerMoveController_Update_Possession
|
||
{
|
||
public static bool Prefix(PlayerMoveController __instance)
|
||
{
|
||
if (!Possession.Active)
|
||
{
|
||
return true;
|
||
}
|
||
__instance.stopMoving();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
|
||
/// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
|
||
/// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.</summary>
|
||
public class PossessionHud : MonoBehaviour
|
||
{
|
||
const float Width = 420f, Height = 16f, Bottom = 150f;
|
||
static readonly Color Back = new Color(0f, 0f, 0f, 0.6f);
|
||
static readonly Color Fill = new Color(0.55f, 0.08f, 0.06f, 0.95f);
|
||
static GUIStyle _style;
|
||
|
||
void OnGUI()
|
||
{
|
||
EntityAlive z = Possession.Active ? Possession.Zombie : null;
|
||
if (z == null || Event.current.type != EventType.Repaint)
|
||
{
|
||
return;
|
||
}
|
||
float scale = Screen.height / 1080f;
|
||
float w = Width * scale, h = Height * scale;
|
||
Rect r = new Rect((Screen.width - w) / 2f, Screen.height - Bottom * scale - h, w, h);
|
||
float frac = Mathf.Clamp01(z.Health / (float)Mathf.Max(1, z.GetMaxHealth()));
|
||
Color prev = GUI.color;
|
||
GUI.color = Back;
|
||
GUI.DrawTexture(r, Texture2D.whiteTexture);
|
||
GUI.color = Fill;
|
||
GUI.DrawTexture(new Rect(r.x + 2f, r.y + 2f, (r.width - 4f) * frac, r.height - 4f), Texture2D.whiteTexture);
|
||
GUI.color = prev;
|
||
if (_style == null)
|
||
{
|
||
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.LowerCenter };
|
||
_style.normal.textColor = new Color(0.9f, 0.85f, 0.8f);
|
||
}
|
||
_style.fontSize = Mathf.RoundToInt(18f * scale);
|
||
string name = Localization.Get(z.EntityName);
|
||
GUI.Label(new Rect(r.x, r.y - 26f * scale, r.width, 24f * scale), name + " " + z.Health + " / " + z.GetMaxHealth(), _style);
|
||
}
|
||
}
|
||
|
||
/// <summary>Приседание одержимого: пока зажата кнопка, рост зомби опускается до CrouchHeight, как игра
|
||
/// опускает его под низким потолком (EntityAlive.CrouchHeightFixedUpdate, её же шаги). Отпустил - дальше
|
||
/// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой,
|
||
/// а проём впереди чует через moveHelper, который у одержимого остановлен.</summary>
|
||
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.CrouchHeightFixedUpdate))]
|
||
public static class Patch_EntityAlive_CrouchHeightFixedUpdate_Possession
|
||
{
|
||
public static bool Prefix(EntityAlive __instance)
|
||
{
|
||
if (!Possession.Crouching || !Possession.IsPossessed(__instance) || __instance.crouchType == 0 ||
|
||
__instance.physicsBaseHeight <= 1.3f)
|
||
{
|
||
return true;
|
||
}
|
||
float h = Utils.FastMoveTowards(__instance.physicsHeight, __instance.physicsBaseHeight * Possession.CrouchHeight, 0.099999994f);
|
||
__instance.SetHeight(h);
|
||
if (h <= __instance.physicsBaseHeight * 0.7f)
|
||
{
|
||
__instance.crouchBendPerTarget = 0f;
|
||
if (__instance.walkType != 8 && __instance.walkType != 21)
|
||
{
|
||
int was = __instance.walkType;
|
||
__instance.SetWalkType(8);
|
||
__instance.walkTypeBeforeCrouch = was;
|
||
}
|
||
}
|
||
__instance.crouchBendPer = Mathf.MoveTowards(__instance.crouchBendPer, __instance.crouchBendPerTarget, 0.099999994f);
|
||
return false;
|
||
}
|
||
}
|
||
}
|