using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
///
/// Режим змеи, этап 2а (2026-09-28): камера, управление и прицел. Рывок на F и пояс «Press R» -
/// позже.
///
/// ПОЧЕМУ ДВИЖЕНИЕ СВОЁ, А НЕ ФИЗИКОЙ ИГРЫ. Змея - животное с RootMotion: скорость ей даёт
/// анимация, и 6-16 м/с из неё не выжать. Поэтому змея переведена в режим полёта без коллизий
/// (как `dm` у игрока - её собственный тик больше не тянет её вниз и не толкает), а положение
/// ведём сами: шаг по горизонтали не длиннее 0,25 м, стена - это два твёрдых блока друг над
/// другом (на один блок змея вползает), землю ищем лучом по слою коллизий ландшафта (65536,
/// как у самой игры для дронов и грифов), без земли под собой - падает с ускорением.
///
/// ПОЧЕМУ МЫШЬ ЧИТАЕМ САМИ. Пока тело игрока в рэгдолле, PlayerMoveController.Update выходит
/// в самом начале, и взгляд игры не обновляется. Берём оси мыши и чувствительность из тех же
/// мест, что и он (Input "Mouse X/Y", mouseLookSensitivity, invertMouse).
///
/// КАМЕРА. vp_FPCamera в третьем лице каждый LateUpdate ставит камеру за телом игрока; постфикс
/// к нему переставляет её за змею. Порядок гарантирован - постфикс идёт сразу после.
///
/// ГРАНИЦА. Змея не заходит туда, где у чанков нет коллизий: мир подгружается вокруг ТЕЛА, и
/// за его пределами она провалилась бы сквозь пустоту.
///
public static class SnakeControl
{
public const float WalkSpeed = 6f; // велосипед, vehicles.xml velocityMax
public const float RunSpeed = 16.5f; // мотоцикл турбо с нагнетателем
public const float Gravity = 20f;
/// Прыжок на Пробел (указание 28.09: «иначе не выбраться из ям»). 10 м/с при g = 20 -
/// подъём на 2,5 м: вместе с заползанием на блок хватает на стену в три блока.
public const float JumpSpeed = 10f;
public const float MaxStep = 0.25f;
/// Глаза змеи: над головой и чуть впереди, чтобы не смотреть изнутри своей морды.
/// Тест 28.09: «упирается в складки местности - камера уходит под текстуры» → глаза выше и
/// позади головы, плюс не пускаем камеру в землю (см. PlaceCamera).
/// Тест 28.09 (второй): «камеру чуть выше, чтобы видеть голову змеи» → глаза над телом,
/// позади головы: голова впереди и чуть ниже, в поле зрения.
public const float EyeUp = 0.5f;
public const float EyeForward = -1.2f;
/// Как часто змея проверяет радиацию края карты. Указание 02.10: без таймера, сразу
/// в тело; проверка раз в 2 с ради нагрузки (было 30 с отсчёта и проверка раз в 0.5 с).
public const float RadiationCheckPeriod = 2f;
/// Порог игры: buffRadiation03 вешается при _biomeradiation > 2.5
/// (buffs.xml, buffStatusCheck01), значение - biomeProvider.GetRadiationAt.
public const float RadiationThreshold = 2.5f;
public const float TargetRange = 30f; // дальность будущего рывка
public const float TargetRadius = 0.9f; // насколько мимо центра противника можно целиться
public const int TerrainMask = 65536;
/// Слои, о которые змея упирается: ландшафт и блоки (16) плюс модели блоков - двери,
/// люки, мебель (30). Та же маска, что у игры для лучей от головы игрока
/// (EntityPlayerLocal: 1073807360).
public const int WallMask = 1073807360;
/// Радиус проверочных сфер при шаге. Меньше полублока - в дверной проём и щель в
/// один блок змея пролезает.
public const float BodyRadius = 0.25f;
/// Точка тела для проверки «внутри текстуры»: над корнем змеи, ниже головы.
public const float BodyUp = 0.35f;
/// Как далеко вверх искать поверхность, под которой змея может быть зарыта.
public const float InsideProbe = 32f;
/// Полёт (указание 05.10): пока зажата правая кнопка. Скорость - вдвое ниже шага
/// зомби (MindControl.WalkSpeed), и в воздухе, и сквозь постройки; Shift не ускоряет.
/// Сквозь грунт (земля, песок, камень) - никак.
static bool _flying;
public const float FlySpeed = MindControl.WalkSpeed * 0.5f;
/// Не нашлось, куда выбросить змею из текстур, - пауза, а не поиск каждый кадр.
static float _ejectWait;
static Vector3[] _ejectOffsets;
/// Параметр Forward аниматора змеи при ползании и на Shift. Подобраны на глаз:
/// у животного с RootMotion полный ход даёт landMovementFactor * 2.5 = 0.25.
public static float AnimWalk = 0.25f;
public static float AnimRun = 0.6f;
/// Тревога у тела (указание 28.09): враг ближе 10 блоков к лежащему телу - прицел
/// мигает раз в секунду и надпись «Враг приближается к телу». Орды и прочие идут к телу, не к змее.
public const float BodyDangerRange = 10f;
public static bool BodyDanger;
static float _bodyCheck;
/// Рывок на правую кнопку мыши (указание 28.09: «там обычно атака»): по прямой к
/// цели в прицеле, сквозь препятствия, со звуком атаки змеи; цель убита чёрным разрывом.
public const float DashSpeed = 60f;
public const float DashStop = 0.6f;
public const string AttackSound = "snakeattack";
static EntityAlive _dashTarget;
/// После удара змея замирает (указание 28.09: «резко останавливалась, иначе
/// проскакивает частицы»), а частицы разрыва секунду едут вместе с ней.
public const float KillFreeze = 0.35f;
public const float BurstFollow = 1f;
static float _freeze;
/// Отброс взрывом Стирателя (02.10): горизонтальная скорость, гаснет сама.
static Vector3 _kick;
public const float KickSpeed = 14f;
public const float KickUp = 8f;
public const float KickDamping = 3f;
static int _killId = -1;
static Transform _burst;
static Vector3 _burstOffset;
static float _burstFollow;
static float _sleeperCheck;
/// Тёмное чутьё змеи (указание 28.09: «змее можно добавить тёмное чутьё, это логично»):
/// то же, что бафф ножа buffNecroDarkSense (buffs.xml), только от змеи - враги ближе 30 м
/// получают метку "zombie" на компасе и карте, дальше 60 м и мёртвые её теряют. Питомцы и
/// девианты не метятся (IsFoe). Бафф ножа считает от тела и тут не годится.
public const float SenseRange = 30f;
public const float SenseDropRange = 60f;
public const string SenseNavObject = "zombie";
static readonly System.Collections.Generic.HashSet _sensed = new System.Collections.Generic.HashSet();
static float _senseCheck;
/// Очередь испуга (указание 02.10: «пусть пугаются по очереди» - толпа, напуганная
/// разом, давала фризы): новые учуянные встают в очередь, пугается по одному раз в
/// FearInterval. Уже напуганным страх продлевается сразу, это дёшево.
static readonly System.Collections.Generic.Queue _fearQueue = new System.Collections.Generic.Queue();
static readonly System.Collections.Generic.HashSet _fearQueued = new System.Collections.Generic.HashSet();
static float _fearCheck;
static NecroMapMarker.Marker _mapNav;
public const float FearInterval = 0.25f;
/// Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
/// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.
public static readonly System.Collections.Generic.HashSet NoLoot = new System.Collections.Generic.HashSet();
/// Убитые змеёй: опыт за них - ноль (указание 28.09: «форма не для повышения уровней
/// или лута, она для повышения уровня Некромантии»). Снимается в патче AddKillXP.
public static readonly System.Collections.Generic.HashSet NoXp = new System.Collections.Generic.HashSet();
public const string ToolbeltWindow = "toolbelt";
public static bool Controlling;
public static float AnimForward;
static float _radiationCheck;
/// Свой наблюдатель чанков у змеи: мир подгружается вокруг наблюдателей, а у игрока
/// он стоит у тела (тест 28.09: «змея не может уйти далеко от тела»). Так же игра грузит
/// место под сброс припасов (AIAirDrop) - только здесь с видимой сеткой и дальностью
/// обзора из настроек.
static ChunkManager.ChunkObserver _observer;
/// Область тела сжата до Possession.BodyViewDim, как у одержимого (указание 05.10:
/// «делай как у одержимого»): две полные области - лишняя память. Полный радиус - для систем,
/// рисующих мир у змеи (SnakeAudioSwap). -1 - не сжата.
static int _bodyViewDim = -1;
public static int FullViewDim => _bodyViewDim;
public static EntityAlive Target;
static Vector3 _pos;
static float _vy;
static bool _airborne;
static float _yaw;
static float _pitch;
static float _snakeYaw;
static bool _loggedCamera;
/// Где голова относительно корня змеи (вперёд и вверх), замеряется один раз, пока
/// змея стоит. Камера держится этой точки на ровно едущем корне, а не самой головы: голова
/// при ползании ходит из стороны в сторону (тест 28.09: «камеру мотает, укачивает»).
static float _headForward = -1f;
static float _headUp;
public static void Begin(EntityPlayerLocal _player, EntityAlive _snake)
{
_pos = _snake.position;
_vy = 0f;
_airborne = false;
_yaw = _player.rotation.y;
_pitch = -15f;
_snakeYaw = _snake.rotation.y;
_snake.IsFlyMode.Value = true;
_snake.IsNoCollisionMode.Value = true;
_loggedCamera = false;
_headForward = -1f;
_dashTarget = null;
_freeze = 0f;
_kick = Vector3.zero;
_flying = false;
_ejectWait = 0f;
_killId = -1;
_burst = null;
_sleeperCheck = 0f;
_senseCheck = 0f;
_fearQueue.Clear();
_fearQueued.Clear();
_fearCheck = 0f;
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10. С 06.10 - своя
// метка по Transform (NecroMapMarker): через AddNavObject она так и не показалась.
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
_mapNav = NecroMapMarker.Show(_snake, NecroMapMarker.SnakeClass);
Target = null;
_radiationCheck = 0f;
BodyDanger = false;
_bodyCheck = 0f;
_observer = GameManager.Instance.AddChunkObserver(_pos, true, GameUtils.GetViewDistance(), -1);
ChunkManager.ChunkObserver bodyObserver = _player.ChunkObserver;
if (bodyObserver != null && bodyObserver.viewDim > Possession.BodyViewDim)
{
_bodyViewDim = bodyObserver.viewDim;
bodyObserver.viewDim = Possession.BodyViewDim;
}
Controlling = true;
// Пояс прячется на весь режим (указание 28.09); обратно его откроет сама игра в OnHUD.
_player.PlayerUI?.windowManager.Close(ToolbeltWindow);
LogScreenEffectsOnce(_player);
SnakeVision.On(_player);
Debug.Log("[NecromancerTome] SnakeControl: управление на змее, наблюдатель чанков " + _observer.id +
", дальность " + _observer.viewDim);
}
static bool _screenEffectsLogged;
/// Разведка под «зрение змеи» (28.09: красные тона, контраст, «как комикс»): все
/// экранные эффекты игры (Resources/ScreenEffects) с шейдером и цветовыми/числовыми
/// свойствами - из чего можно собрать своё. Раз за сессию.
static void LogScreenEffectsOnce(EntityPlayerLocal _player)
{
if (_screenEffectsLogged || _player.ScreenEffectManager == null)
{
return;
}
_screenEffectsLogged = true;
foreach (ScreenEffects.ScreenEffect e in _player.ScreenEffectManager.loadedEffects)
{
Material m = e?.Material;
if (m == null || m.shader == null)
{
continue;
}
System.Text.StringBuilder sb = new System.Text.StringBuilder();
Shader sh = m.shader;
for (int i = 0; i < sh.GetPropertyCount(); i++)
{
string name = sh.GetPropertyName(i);
switch (sh.GetPropertyType(i))
{
case UnityEngine.Rendering.ShaderPropertyType.Color:
sb.Append(' ').Append(name).Append('=').Append(m.GetColor(name).ToString("F2"));
break;
case UnityEngine.Rendering.ShaderPropertyType.Float:
case UnityEngine.Rendering.ShaderPropertyType.Range:
sb.Append(' ').Append(name).Append('=').Append(m.GetFloat(name).ToString("0.###"));
break;
case UnityEngine.Rendering.ShaderPropertyType.Texture:
sb.Append(' ').Append(name).Append("=tex");
break;
}
}
Debug.Log("[NecromancerTome] ScreenEffect '" + e.Name + "' shader '" + sh.name + "':" + sb);
}
}
public static void End()
{
ClearSense();
NecroMapMarker.Hide(ref _mapNav);
_fearQueue.Clear();
_fearQueued.Clear();
Controlling = false;
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
SnakeVision.Off(player);
if (_bodyViewDim > 0 && player?.ChunkObserver != null)
{
player.ChunkObserver.viewDim = _bodyViewDim;
}
_bodyViewDim = -1;
_dashTarget = null;
Target = null;
BodyDanger = false;
if (_observer != null)
{
if (GameManager.Instance != null && GameManager.Instance.World != null)
{
GameManager.Instance.RemoveChunkObserver(_observer);
}
_observer = null;
}
}
/// Каждый кадр, из SnakeMode.OnUnityUpdate. True - змея развеялась, пора в тело.
public static bool Update(EntityPlayerLocal _player, EntityAlive _snake)
{
if (!Controlling)
{
return false;
}
float dt = Time.deltaTime;
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
PlayerActionsLocal input = _player.playerInput;
Vector2 move = Vector2.zero;
bool run = false;
bool jump = false;
bool jumpHeld = false;
bool downHeld = false;
bool attack = false;
World world = GameManager.Instance.World;
if (_freeze > 0f)
{
_freeze -= dt;
}
FollowBurst(dt);
if (!gui && input != null)
{
Look();
}
if (!gui && input != null && _freeze <= 0f)
{
move = new Vector2(input.Move.X, input.Move.Y);
run = input.Run.IsPressed;
jump = input.Jump.WasPressed;
jumpHeld = input.Jump.IsPressed;
downHeld = input.Crouch.IsPressed || input.ToggleCrouch.IsPressed;
// Указание 28.09: атака на левую кнопку. 05.10: E - аварийный выход из текстур
// (E в игре на PermanentActions.Activate, см. MindControl).
attack = input.Primary.WasPressed;
if ((input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed) && _dashTarget == null)
{
Eject(world, false, 0.5f);
}
}
// Полёт - пока зажата правая кнопка (указание 05.10); отпустил - змея падает.
bool fly = !gui && input != null && input.Secondary.IsPressed && _dashTarget == null;
if (fly != _flying)
{
_flying = fly;
_vy = 0f;
_airborne = !fly;
Debug.Log("[NecromancerTome] SnakeControl: полёт " + (fly ? "вкл" : "выкл"));
}
if (_dashTarget == null && attack && Target != null)
{
_dashTarget = Target;
_vy = 0f;
_airborne = false;
Audio.Manager.BroadcastPlay(_snake, _snake.position, AttackSound);
Debug.Log("[NecromancerTome] SnakeControl: рывок к " + _dashTarget.EntityName + " (" + _dashTarget.entityId +
"), " + (_dashTarget.position - _pos).magnitude.ToString("F1") + " м");
}
if (_dashTarget != null)
{
Dash(_player);
if (_dashTarget == null)
{
KeepOut(world, dt);
}
_observer?.SetPosition(_pos);
_snake.SetPosition(_pos);
_snake.SetRotation(new Vector3(0f, _snakeYaw, 0f));
_snake.motion = Vector3.zero;
_snake.moveDirection = Vector3.zero;
AnimForward = AnimRun;
CheckBody(world, _player, _snake, dt);
return Radiation(world, dt);
}
Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
Vector3 dir = yawRot * Vector3.forward * move.y + yawRot * Vector3.right * move.x;
if (_flying)
{
// В полёте - куда смотрит камера, Пробел вверх, приседание вниз.
dir = Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * move.y + yawRot * Vector3.right * move.x +
Vector3.up * ((jumpHeld ? 1f : 0f) - (downHeld ? 1f : 0f));
}
if (dir.sqrMagnitude > 1f)
{
dir.Normalize();
}
bool moving = dir.sqrMagnitude > 0.01f;
if (moving)
{
float speed = _flying ? FlySpeed : run ? RunSpeed : WalkSpeed;
Vector3 delta = dir * speed * dt;
int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / MaxStep));
Vector3 d = delta / steps;
for (int i = 0; i < steps; i++)
{
// Упёрлись - пробуем скользить вдоль стены по одной из осей.
if (!TryStep(world, d) && !TryStep(world, new Vector3(d.x, 0f, 0f)) && !TryStep(world, new Vector3(0f, 0f, d.z)) &&
!(_flying && TryStep(world, new Vector3(0f, d.y, 0f))))
{
break;
}
}
}
if (_kick.sqrMagnitude > 0.01f)
{
Vector3 kd = _kick * dt;
int ks = Mathf.Max(1, Mathf.CeilToInt(kd.magnitude / MaxStep));
for (int i = 0; i < ks; i++)
{
if (!TryStep(world, kd / ks))
{
_kick = Vector3.zero;
break;
}
}
_kick *= Mathf.Exp(-KickDamping * dt);
}
// Вид от первого лица: змея смотрит туда же, куда игрок.
_snakeYaw = Mathf.LerpAngle(_snakeYaw, _yaw, dt * 12f);
if (!_flying)
{
Fall(world, dt, jump);
}
KeepOut(world, dt);
_observer?.SetPosition(_pos);
_snake.SetPosition(_pos);
_snake.SetRotation(new Vector3(0f, _snakeYaw, 0f));
_snake.motion = Vector3.zero;
// Анимацию ползания даёт патч AvatarAnimalController.LateUpdate ниже (AnimForward).
_snake.moveDirection = Vector3.zero;
AnimForward = moving ? (run ? AnimRun : AnimWalk) : 0f;
CheckBody(world, _player, _snake, dt);
Sleepers(world, _player, _pos, ref _sleeperCheck, dt);
Sense(world, _snake, dt);
FearQueue(world, _snake, dt);
return Radiation(world, dt);
}
static void FearQueue(World _world, EntityAlive _snake, float _dt)
{
_fearCheck -= _dt;
if (_fearCheck > 0f || _fearQueue.Count == 0)
{
return;
}
_fearCheck = FearInterval;
bool bloodMoon = _world.aiDirector?.BloodMoonComponent?.BloodMoonActive ?? false;
while (_fearQueue.Count > 0)
{
int id = _fearQueue.Dequeue();
_fearQueued.Remove(id);
EntityAlive e = _world.GetEntity(id) as EntityAlive;
// Ушёл из чутья, умер, стал своим, началась луна - пропускаем и берём следующего.
if (e == null || bloodMoon || _world.IsRemote() || !IsFoe(e, _snake) || !IsUndead(e) ||
(e.position - _pos).sqrMagnitude > SenseRange * SenseRange)
{
continue;
}
NecroFleeSnakeTask.Scare(e);
return;
}
}
/// Шаг рывка: по прямой к ногам цели, без земли и стен. Долетела - убийство: цель
/// помечается для чёрного разрыва (NecroBurst.Pending) и получает смертельный урон ОТ ИГРОКА -
/// тогда игра сама выдаёт опыт, а NecromancyKillCreditPatch засчитывает убийство в Некромантию.
static void Dash(EntityPlayerLocal _player)
{
EntityAlive target = _dashTarget;
if (target == null || target.IsDead() || target.IsMarkedForUnload())
{
_dashTarget = null;
return;
}
Vector3 to = target.position - _pos;
float dist = to.magnitude;
Vector3 flat = new Vector3(to.x, 0f, to.z);
if (flat.sqrMagnitude > 0.0001f)
{
_snakeYaw = Mathf.Atan2(flat.x, flat.z) * Mathf.Rad2Deg;
}
float step = DashSpeed * Time.deltaTime;
if (dist - DashStop > step)
{
_pos += to / dist * step;
return;
}
_pos = target.position - (flat.sqrMagnitude > 0.0001f ? flat.normalized * DashStop : Vector3.zero);
_dashTarget = null;
_freeze = KillFreeze;
_killId = target.entityId;
NoLoot.Add(target.entityId);
NoXp.Add(target.entityId);
NecroBurst.Pending.Add(target.entityId);
target.DamageEntity(new DamageSourceEntity(EnumDamageSource.External, EnumDamageTypes.Piercing, _player.entityId),
99999, false);
if (target.IsDead())
{
NecroChallenges.Award(NecroChallenges.SnakeKill);
}
else
{
NecroBurst.Pending.Remove(target.entityId);
NoLoot.Remove(target.entityId);
NoXp.Remove(target.entityId);
}
Debug.Log("[NecromancerTome] SnakeControl: удар по " + target.EntityName + ", " + (target.IsDead() ? "убит" : "ВЫЖИЛ"));
}
/// Из NecroBurst.OnBurst: наш разрыв - запоминаем частицы, чтобы вести их за змеёй.
public static void OnBurst(EntityAlive _victim, Transform _effect)
{
if (!Controlling || _victim == null || _victim.entityId != _killId || _effect == null)
{
return;
}
_killId = -1;
_burst = _effect;
_burstOffset = _effect.position - (_pos - Origin.position);
_burstFollow = BurstFollow;
}
static void FollowBurst(float _dt)
{
if (_burst == null)
{
return;
}
_burstFollow -= _dt;
if (_burstFollow <= 0f)
{
_burst = null;
return;
}
_burst.position = _pos - Origin.position + _burstOffset;
}
/// Комнаты со спящими в зданиях просыпаются, когда в них входит ИГРОК:
/// EntityPlayer.CheckSleeperTriggers каждый тик зовёт World.CheckSleeperVolumeTouching и
/// CheckTriggerVolumeTrigger, а те берут player.position (тест 28.09: «зомби в локациях не
/// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи
/// (и одержимого - Possession, 05.10).
public static void Sleepers(World _world, EntityPlayerLocal _player, Vector3 _at, ref float _timer, float _dt)
{
_timer -= _dt;
if (_timer > 0f || _world.IsRemote())
{
return;
}
_timer = 0.25f;
Vector3 body = _player.position;
_player.position = _at;
try
{
_world.CheckSleeperVolumeTouching(_player);
_world.CheckTriggerVolumeTrigger(_player);
}
finally
{
_player.position = body;
}
}
static void Sense(World _world, EntityAlive _snake, float _dt)
{
_senseCheck -= _dt;
if (_senseCheck > 0f)
{
return;
}
_senseCheck = 1f;
var senseWatch = System.Diagnostics.Stopwatch.StartNew();
// Леденящий ужас (02.10): учуянный зомби сразу бежит. ИИ живёт на сервере. В кровавую
// луну ужаса нет (указание 02.10: иначе змеёй распугивают орду).
bool scare = !_world.IsRemote() && !(_world.aiDirector?.BloodMoonComponent?.BloodMoonActive ?? false);
NecroFleeSnakeTask.SnakePos = _pos;
// Сетка поиска пути (AstarManager) строится только вокруг игроков (76 блоков) и отмеченных
// мест; тело далеко - у змеи сетки нет, и путь бегства не находился (тест 02.10, лог
// «путь нет»). Отмечаем место змеи, как игра отмечает точки орды (AddLocation(pos, 40) в
// AIDirectorBloodMoonParty). Отметка живёт 4 с и сливается с соседней - не копится.
if (!_world.IsRemote())
{
AstarManager.Instance?.AddLocation(_pos, 76);
}
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null)
{
continue;
}
float d2 = (e.position - _pos).sqrMagnitude;
// Чутьё и ужас - власть над мёртвыми (указание 06.10): только нежить, живых змея не чует и не пугает.
bool foe = IsFoe(e, _snake) && IsUndead(e);
if (foe && d2 < SenseRange * SenseRange)
{
// Только наземные: зомби и звери-зомби. Стервятникам пафайндер не нужен.
if (scare && (e is EntityZombie || e is EntityEnemyAnimal) && !NecroFleeSnakeTask.Refresh(e) &&
_fearQueued.Add(e.entityId))
{
_fearQueue.Enqueue(e.entityId);
}
if (_sensed.Add(e.entityId))
{
e.AddNavObject(SenseNavObject, null, null);
// Подсветка врагов (buffNecroSnakeSight) снята 28.09: фильтр зрения глушит её цвет.
// Бафф и его снятие оставлены - на случай возврата и для уже сохранённых зомби.
}
}
else if (_sensed.Contains(e.entityId) && (!foe || d2 > SenseDropRange * SenseDropRange))
{
e.RemoveNavObject(SenseNavObject);
e.Buffs?.RemoveBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName);
_sensed.Remove(e.entityId);
}
}
// Исчезнувшие (разорванные, выгруженные) - просто забываем: метку игра снимет сама.
_sensed.RemoveWhere(id => _world.GetEntity(id) == null);
if (senseWatch.Elapsed.TotalMilliseconds > 3.0)
{
Debug.Log("[NecromancerTome] SnakeControl: долгое чутьё/ужас - " + senseWatch.Elapsed.TotalMilliseconds.ToString("F1") + " мс");
}
}
static void ClearSense()
{
World world = GameManager.Instance?.World;
if (world != null)
{
foreach (int id in _sensed)
{
EntityAlive e = world.GetEntity(id) as EntityAlive;
e?.RemoveNavObject(SenseNavObject);
e?.Buffs?.RemoveBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName);
}
}
_sensed.Clear();
}
/// Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под
/// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это
/// не враг»). Общее для тревоги у тела и для прицела. Одержимый зомби - тоже не враг (13.10: «питомцы
/// нападают на одержимых»), охрана питомцев выбирает цель отсюда же.
public static bool IsFoe(EntityAlive _e, EntityAlive _snake)
{
return _e != null && _e != _snake && _e is EntityEnemy && !_e.IsDead() &&
!IsNecroEntity(_e) && !Possession.IsPossessed(_e) &&
!GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
}
/// Нежить - поднятые мертвецы: всё с меткой zombie (зомби-люди, зомби-собаки, -медведи,
/// -кабаны, стервятники, Стиратель). Живые звери, бандиты, игроки и торговцы - не нежить. Та же
/// метка, по которой считаются убийства в Некромантию (NecromancyKillCreditPatch.ZombieTag).
static readonly FastTags UndeadTag = FastTags.Parse("zombie");
public static bool IsUndead(EntityAlive _e) => _e != null && _e.HasAnyTags(UndeadTag);
/// Питомцы и змея мода - тоже EntityEnemy (зомби-основа), и без этой проверки
/// охрана выбирала целью самого питомца: он ближе всех к хозяину (тест 30.09, лог
/// «pet 3838 идёт на 3838»). Все наши классы сущностей начинаются с necro.
public static bool IsNecroEntity(EntityAlive _e)
{
EntityClass cls;
return EntityClass.list.TryGetValue(_e.entityClass, out cls) && cls != null &&
cls.entityClassName != null && cls.entityClassName.StartsWith("necro");
}
/// Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу игрока.
static void CheckBody(World _world, EntityPlayerLocal _player, EntityAlive _snake, float _dt)
{
_bodyCheck -= _dt;
if (_bodyCheck > 0f)
{
return;
}
_bodyCheck = 0.5f;
bool danger = false;
Vector3 body = _player.position;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (IsFoe(e, _snake) && (e.position - body).sqrMagnitude < BodyDangerRange * BodyDangerRange)
{
danger = true;
break;
}
}
if (danger != BodyDanger)
{
BodyDanger = danger;
Debug.Log("[NecromancerTome] SnakeControl: " + (danger ? "враг у тела" : "у тела чисто"));
}
}
/// Радиация края карты: змея в ней сразу развеивается, игрок в теле.
static bool Radiation(World _world, float _dt)
{
_radiationCheck -= _dt;
if (_radiationCheck > 0f)
{
return false;
}
_radiationCheck = RadiationCheckPeriod;
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
if (biomes == null || biomes.GetRadiationAt((int)_pos.x, (int)_pos.z) <= RadiationThreshold)
{
return false;
}
Debug.Log("[NecromancerTome] SnakeControl: змея развеялась в радиации");
return true;
}
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, 60f);
}
static bool TryStep(World _world, Vector3 _d)
{
if (_d.sqrMagnitude < 0.000001f)
{
return false;
}
Vector3 c = _pos + _d;
if (!_world.IsChunkAreaCollidersLoaded(c))
{
return false;
}
if (_flying)
{
// Сквозь постройки можно, в грунт - нет.
if (Inside(_world, c + Vector3.up * BodyUp, TerrainMask, out bool ground) && ground)
{
return false;
}
_pos = c;
return true;
}
// Один блок - ступенька, два - стена. Проверяем настоящую геометрию, а не тип блока:
// у блока есть только флаг «твёрдый», и открытая дверь для него такая же стена, как
// закрытая (тест 28.09: «змея не может пройти в открытые двери»).
if (Blocked(c + Vector3.up * 0.4f) && Blocked(c + Vector3.up * 1.35f))
{
return false;
}
// В полёте землю под собой не ищем - иначе прыжок обрывался бы на первом шаге.
// Вверх подтягиваем и в полёте: так змея цепляется за край уступа.
if (Ground(c + Vector3.up * 1.6f, 2.4f, out float y) && (!_airborne || y > c.y))
{
c.y = y;
if (_airborne && _vy <= 0f)
{
_airborne = false;
_vy = 0f;
}
}
// Ни в грунт, ни в блоки: обрыв выше подъёма или стена, которую шар изнутри не чует.
if (Inside(_world, c + Vector3.up * BodyUp, WallMask, out _))
{
return false;
}
_pos = c;
return true;
}
static void Fall(World _world, float _dt, bool _jump)
{
if (!Ground(_pos + Vector3.up * 0.3f, 300f, out float ground))
{
_vy = 0f;
_airborne = false;
return;
}
if (!_airborne && _jump && ground >= _pos.y - 0.05f)
{
_vy = JumpSpeed;
_airborne = true;
}
if (!_airborne && ground >= _pos.y - 0.02f)
{
_pos.y = ground;
_vy = 0f;
return;
}
_airborne = true;
_vy -= Gravity * _dt;
float dy = _vy * _dt;
// Потолок: удар головой гасит подъём.
if (dy > 0f && Physics.Raycast(_pos + Vector3.up * 0.3f - Origin.position, Vector3.up, dy + 0.3f, TerrainMask))
{
_vy = 0f;
dy = 0f;
}
// Прыжок не должен заносить тело в текстуры (указание 05.10).
if (dy > 0f && Inside(_world, _pos + Vector3.up * (dy + BodyUp), WallMask, out _))
{
_vy = 0f;
dy = 0f;
}
_pos.y += dy;
if (_pos.y <= ground)
{
_pos.y = ground;
_vy = 0f;
_airborne = false;
}
}
static bool Blocked(Vector3 _p)
{
return Physics.CheckSphere(_p - Origin.position, BodyRadius, WallMask, QueryTriggerInteraction.Ignore);
}
/// Каждый кадр: змея в грунте (в полёте) или в любой текстуре (на земле) -
/// выбрасываем на ближайшее свободное место (указание 05.10).
static void KeepOut(World _world, float _dt)
{
if (_ejectWait > 0f)
{
_ejectWait -= _dt;
return;
}
Vector3 body = _pos + Vector3.up * BodyUp;
if (!_world.IsChunkAreaCollidersLoaded(body))
{
return;
}
bool ground;
bool stuck = _flying ? Inside(_world, body, TerrainMask, out ground) && ground : Inside(_world, body, WallMask, out ground);
if (stuck && !Eject(_world, _flying, 0f))
{
_ejectWait = 1f;
}
}
/// Ближайшее свободное место: до 4 м вбок, до 1 м вниз и до 6 м вверх; вниз
/// дороже, чем вверх. _groundOnly - мешает только грунт (полёт). E зовёт с _minShift 0.5:
/// проверка может счесть место свободным, а змея не идёт (тест 28.09: «сдвиг 0.0 м»).
static bool Eject(World _world, bool _groundOnly, float _minShift)
{
if (_ejectOffsets == null)
{
System.Collections.Generic.List list = new System.Collections.Generic.List();
for (float r = 0f; r <= 4f; r += 0.5f)
{
int dirs = r == 0f ? 1 : 8;
for (float h = -1f; h <= 6f; h += 0.5f)
{
for (int i = 0; i < dirs; i++)
{
list.Add(Quaternion.Euler(0f, i * 45f, 0f) * Vector3.forward * r + Vector3.up * h);
}
}
}
list.Sort((x, y) => EjectCost(x).CompareTo(EjectCost(y)));
_ejectOffsets = list.ToArray();
}
for (int i = 0; i < _ejectOffsets.Length; i++)
{
Vector3 o = _ejectOffsets[i];
if (o.magnitude < _minShift || !Free(_world, _pos + o, _groundOnly))
{
continue;
}
Debug.Log("[NecromancerTome] SnakeControl: выброшена из текстур" + (_groundOnly ? " (грунт)" : "") + ", сдвиг " + o.magnitude.ToString("F1") + " м");
_pos += o;
_vy = 0f;
_airborne = !_flying;
return true;
}
Debug.Log("[NecromancerTome] SnakeControl: выбраться некуда");
return false;
}
static float EjectCost(Vector3 _o)
{
return _o.magnitude + (_o.y < 0f ? -_o.y : 0f);
}
public static bool Free(World _world, Vector3 _p, bool _groundOnly)
{
if (!_world.IsChunkAreaCollidersLoaded(_p))
{
return false;
}
bool ground;
if (_groundOnly)
{
return !(Inside(_world, _p + Vector3.up * BodyUp, TerrainMask, out ground) && ground);
}
return !Inside(_world, _p + Vector3.up * BodyUp, WallMask, out ground) &&
!Blocked(_p + Vector3.up * 0.4f) && !Blocked(_p + Vector3.up * 1f);
}
/// Точка внутри текстуры? Земля игры - поверхность, а не сплошная масса, и шар
/// (Blocked) изнутри её не чует. Поэтому так: луч вверх до первого потолка (поверхность
/// лицом вниз), потом луч от потолка вниз до точки. Попалась поверхность лицом вверх -
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
/// точке), а не постройка.
public static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
{
_ground = false;
Vector3 o = _p - Origin.position;
float top = InsideProbe;
if (Physics.Raycast(o, Vector3.up, out RaycastHit ceil, InsideProbe, _mask, QueryTriggerInteraction.Ignore))
{
top = ceil.distance;
}
if (top < 0.05f ||
!Physics.Raycast(o + Vector3.up * (top - 0.02f), Vector3.down, out RaycastHit floor, top - 0.02f, _mask, QueryTriggerInteraction.Ignore))
{
return false;
}
_ground = IsGround(_world, floor.point + Origin.position + Vector3.down * 0.05f) || IsGround(_world, _p);
return true;
}
static bool IsGround(World _world, Vector3 _p)
{
BlockValue b = _world.GetBlock(World.worldToBlockPos(_p));
return !b.isair && b.Block.shape.IsTerrain();
}
static bool Solid(World _world, Vector3 _p)
{
return _world.GetBlock(World.worldToBlockPos(_p)).Block.IsCollideMovement;
}
public static bool Ground(Vector3 _from, float _dist, out float _y)
{
if (Physics.Raycast(_from - Origin.position, Vector3.down, out RaycastHit hit, _dist, TerrainMask))
{
_y = hit.point.y + Origin.position.y;
return true;
}
_y = 0f;
return false;
}
/// Из патчей камеры: вид из глаз змеи и поиск цели под прицелом.
public static void PlaceCamera(Transform _camera)
{
World world = GameManager.Instance?.World;
EntityAlive snake = SnakeMode.Snake(world);
if (snake == null)
{
return;
}
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
Vector3 root = _pos - Origin.position;
Transform head = snake.emodel != null ? snake.emodel.GetHeadTransform() : null;
if (_headForward < 0f)
{
if (head != null)
{
Vector3 rel = head.position - (snake.position - Origin.position);
Vector3 snakeForward = Quaternion.Euler(0f, snake.rotation.y, 0f) * Vector3.forward;
_headForward = Mathf.Clamp(Vector3.Dot(rel, snakeForward), 0f, 1.5f);
_headUp = Mathf.Clamp(rel.y, 0f, 1f);
}
else
{
_headForward = 0.5f;
_headUp = 0.3f;
}
}
Vector3 eye = root + Vector3.up * (_headUp + EyeUp) + yawRot * Vector3.forward * (_headForward + EyeForward);
// Между телом змеи и глазом - земля? Камеру к телу, до препятствия.
Vector3 anchor = snake.position - Origin.position + Vector3.up * 0.5f;
Vector3 toEye = eye - anchor;
if (Physics.Raycast(anchor, toEye.normalized, out RaycastHit hit, toEye.magnitude + 0.15f, TerrainMask))
{
eye = anchor + toEye.normalized * Mathf.Max(0f, hit.distance - 0.15f);
}
// И не ниже поверхности прямо под глазом.
if (Physics.Raycast(eye + Vector3.up * 1.5f, Vector3.down, out hit, 1.5f + 0.2f, TerrainMask) && hit.point.y + 0.2f > eye.y)
{
eye.y = hit.point.y + 0.2f;
}
_camera.SetPositionAndRotation(eye, rot);
if (!_loggedCamera)
{
_loggedCamera = true;
Debug.Log("[NecromancerTome] SnakeControl: камера в глазах змеи" + (head != null ? " (голова " + head.name + ")" : " (головы нет)") +
", голова впереди " + _headForward.ToString("F2") + " м, выше " + _headUp.ToString("F2") + " м");
}
Target = FindTarget(world, snake, _camera.position, _camera.forward);
}
/// Ближайший живой противник на луче прицела не дальше TargetRange от змеи.
/// Сквозь стены - так же, как пойдёт рывок. Питомцы (призрачные) не цель.
static EntityAlive FindTarget(World _world, EntityAlive _snake, Vector3 _from, Vector3 _dir)
{
EntityAlive best = null;
float bestAlong = float.MaxValue;
Vector3 snakePos = _snake.position - Origin.position;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
// Атака духа змеи - только по нежити (указание 06.10): живые звери и люди ей не подвластны.
if (!IsFoe(e, _snake) || !IsUndead(e))
{
continue;
}
Vector3 p = e.GetChestTransformPosition();
if ((p - snakePos).sqrMagnitude > TargetRange * TargetRange)
{
continue;
}
float along = Vector3.Dot(p - _from, _dir);
if (along <= 0f || along >= bestAlong)
{
continue;
}
if (Vector3.Cross(p - _from, _dir).magnitude > TargetRadius)
{
continue;
}
best = e;
bestAlong = along;
}
return best;
}
}
/// Пока тело в рэгдолле, игра выключает vp_FPCamera и сама ведёт «камеру на себя»
/// (EntityPlayerLocal.OnRagdoll -> StartSelfCamera, каждый кадр SelfCameraFrameUpdate из Update).
/// Тест 28.09: «камера всё ещё у игрока» - постфикс к vp_FPCamera успел сработать один кадр,
/// до падения. Здесь её место занимает камера змеи.
[HarmonyPatch(typeof(EntityPlayerLocal), "SelfCameraFrameUpdate")]
public static class Patch_EntityPlayerLocal_SelfCamera_Snake
{
public static bool Prefix(EntityPlayerLocal __instance)
{
if (MindControl.Controlling)
{
MindControl.PlaceCamera(__instance.cameraTransform);
return false;
}
if (!SnakeControl.Controlling)
{
return true;
}
SnakeControl.PlaceCamera(__instance.cameraTransform);
return false;
}
}
/// Аниматор животного в режиме полёта получает скорость 0 (AvatarAnimalController.LateUpdate:
/// `if (!entity.IsFlyMode.Value) num = entity.speedForward`) - змея скользила неподвижной
/// (тест 28.09: «не извивается»). На время этого метода снимаем полёт и подставляем скорость.
[HarmonyPatch(typeof(AvatarAnimalController), "LateUpdate")]
public static class Patch_AvatarAnimalController_LateUpdate_Snake
{
public static void Prefix(AvatarAnimalController __instance, out bool __state)
{
__state = false;
EntityAlive e = __instance.entity;
if (!SnakeControl.Controlling || e == null || e.entityClass != EntityClass.FromString(SnakeMode.SnakeClassName) || !e.IsFlyMode.Value)
{
return;
}
__state = true;
e.IsFlyMode.Value = false;
e.speedForward = SnakeControl.AnimForward;
e.speedStrafe = 0f;
}
public static void Postfix(AvatarAnimalController __instance, bool __state)
{
if (__state)
{
__instance.entity.IsFlyMode.Value = true;
}
}
}
[HarmonyPatch(typeof(vp_FPCamera), "LateUpdate")]
public static class Patch_vpFPCamera_LateUpdate_Snake
{
public static void Postfix(vp_FPCamera __instance)
{
if (SnakeControl.Controlling)
{
SnakeControl.PlaceCamera(__instance.transform);
}
else if (Possession.Active)
{
Possession.PlaceCamera(__instance.transform);
}
else if (MindControl.Controlling)
{
MindControl.PlaceCamera(__instance.transform);
}
}
}
/// Прицел змеи: пиктограмма глаза из атласа игры посреди экрана, красная, когда в
/// прицеле противник. Рисуется через OnGUI, чтобы не заводить своё окно XUi.
public class SnakeCrosshair : MonoBehaviour
{
public const string Sprite = "ui_game_symbol_twitch_vision_effects";
static readonly Color Idle = new Color(0.85f, 0.95f, 1f, 0.8f);
static readonly Color OnTarget = new Color(1f, 0.15f, 0.1f, 1f);
/// Свой значок (указание 28.09: «вытащить из игры, чтобы я смог переделать»): если в
/// Resources мода лежит этот PNG, рисуется он, а не спрайт игры. Высота на экране та же,
/// ширина - по пропорциям картинки.
public const string CustomFile = "necrosnake_crosshair.png";
/// Куда один раз выгружается спрайт игры для перерисовки: закрытая папка рядом с
/// модом. У игроков её нет - тогда выгрузка просто не делается.
public const string ExportFile = "snake_crosshair_original.png";
INGUIAtlas _atlas;
UISpriteData _sprite;
Texture2D _custom;
bool _failed;
static string ModDir => System.IO.Path.GetDirectoryName(typeof(SnakeCrosshair).Assembly.Location);
public static void Create()
{
GameObject go = new GameObject("NecroSnakeCrosshair");
DontDestroyOnLoad(go);
go.AddComponent();
}
void OnGUI()
{
if (!SnakeControl.Controlling || GameManager.Instance == null || GameManager.Instance.World == null ||
Event.current.type != EventType.Repaint || !Resolve())
{
return;
}
if (SnakeControl.BodyDanger)
{
DrawBodyDanger();
}
Texture tex = _custom != null ? (Texture)_custom : _atlas.texture;
Rect uv = _custom != null
? new Rect(0f, 0f, 1f, 1f)
: new Rect((float)_sprite.x / tex.width, 1f - (float)(_sprite.y + _sprite.height) / tex.height,
(float)_sprite.width / tex.width, (float)_sprite.height / tex.height);
float aspect = _custom != null ? (float)_custom.width / _custom.height : (float)_sprite.width / _sprite.height;
float h = 32f * Screen.height / 1080f;
float w = h * aspect;
Rect rect = new Rect((Screen.width - w) * 0.5f, (Screen.height - h) * 0.5f, w, h);
Color old = GUI.color;
// Враг у тела не красит прицел, а заставляет его мигать: секунду виден, секунду нет
// (указание 28.09). Красный остаётся только за целью в прицеле.
if (SnakeControl.BodyDanger && Mathf.FloorToInt(Time.unscaledTime) % 2 == 1)
{
return;
}
GUI.color = SnakeControl.Target != null ? OnTarget : Idle;
GUI.DrawTextureWithTexCoords(rect, tex, uv);
GUI.color = old;
}
GUIStyle _dangerStyle;
/// Надпись под прицелом, пока враг у тела.
void DrawBodyDanger()
{
if (_dangerStyle == null)
{
_dangerStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.UpperCenter,
fontStyle = FontStyle.Bold
};
}
_dangerStyle.fontSize = Mathf.RoundToInt(26f * Screen.height / 1080f);
string text = Localization.Get("necroSnakeBodyDanger");
float top = Screen.height * 0.5f + 40f * Screen.height / 1080f;
Rect rect = new Rect(0f, top, Screen.width, 60f * Screen.height / 1080f);
Color old = GUI.color;
GUI.color = new Color(0f, 0f, 0f, 0.8f);
GUI.Label(new Rect(rect.x + 2f, rect.y + 2f, rect.width, rect.height), text, _dangerStyle);
GUI.color = OnTarget;
GUI.Label(rect, text, _dangerStyle);
GUI.color = old;
}
bool Resolve()
{
if (_sprite != null)
{
return true;
}
if (_failed)
{
return false;
}
XUi xui = LocalPlayerUI.GetUIForPrimaryPlayer()?.xui;
if (xui == null)
{
return false;
}
_atlas = xui.GetAtlasByName("UIAtlas", Sprite);
_sprite = _atlas?.GetSprite(Sprite);
if (_sprite == null || _atlas.texture == null)
{
_failed = true;
_sprite = null;
Debug.LogWarning("[NecromancerTome] SnakeCrosshair: спрайт '" + Sprite + "' не найден");
return false;
}
Debug.Log("[NecromancerTome] SnakeCrosshair: прицел '" + Sprite + "' " + _sprite.width + "x" + _sprite.height);
LoadCustom();
ExportOnce();
return true;
}
void LoadCustom()
{
string path = System.IO.Path.Combine(ModDir, "Resources", CustomFile);
if (!System.IO.File.Exists(path))
{
return;
}
Texture2D t = new Texture2D(2, 2, TextureFormat.RGBA32, false);
if (ImageConversion.LoadImage(t, System.IO.File.ReadAllBytes(path)))
{
t.filterMode = FilterMode.Bilinear;
_custom = t;
Debug.Log("[NecromancerTome] SnakeCrosshair: свой значок " + t.width + "x" + t.height);
}
else
{
Debug.LogWarning("[NecromancerTome] SnakeCrosshair: не прочитался " + path);
}
}
/// Вырезает спрайт из атласа в PNG. Атлас на видеокарте и нечитаем напрямую -
/// тот же приём, что GhostTraderPatch.Desaturate: копия в RenderTexture и ReadPixels.
/// У NGUI y спрайта считается сверху, у Unity - снизу.
void ExportOnce()
{
string dir = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(ModDir), "_NecromancerTome_private", "ForPainting");
string path = System.IO.Path.Combine(dir, ExportFile);
if (!System.IO.Directory.Exists(dir) || System.IO.File.Exists(path))
{
return;
}
Texture atlas = _atlas.texture;
RenderTexture previous = RenderTexture.active;
RenderTexture rt = RenderTexture.GetTemporary(atlas.width, atlas.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
try
{
Graphics.Blit(atlas, rt);
RenderTexture.active = rt;
Texture2D icon = new Texture2D(_sprite.width, _sprite.height, TextureFormat.RGBA32, false);
icon.ReadPixels(new Rect(_sprite.x, atlas.height - _sprite.y - _sprite.height, _sprite.width, _sprite.height), 0, 0);
icon.Apply();
System.IO.File.WriteAllBytes(path, ImageConversion.EncodeToPNG(icon));
Destroy(icon);
Debug.Log("[NecromancerTome] SnakeCrosshair: спрайт выгружен в " + path);
}
catch (System.Exception e)
{
Debug.LogWarning("[NecromancerTome] SnakeCrosshair: выгрузка не удалась: " + e.Message);
}
finally
{
RenderTexture.active = previous;
RenderTexture.ReleaseTemporary(rt);
}
}
}
/// Зомби вокруг змеи (тест 28.09: «вокруг змеи не спавнятся зомби»). Биомный спавн
/// работает в чанках у игрока: SpawnManagerBiomes.SpawnUpdate пропускает область, если ни один
/// игрок не стоит ближе 40 м, и ставит зомби не ближе 28 м от игроков и вне их взгляда. Чанки
/// вокруг змеи загружены её наблюдателем и перебираются миром, но эти проверки смотрят на тело.
/// На время вызова переносим позицию игрока к змее - все проверки считаются от неё.
[HarmonyPatch(typeof(SpawnManagerBiomes), "SpawnUpdate")]
public static class Patch_SpawnManagerBiomes_SpawnUpdate_Snake
{
public static void Prefix(out Vector3 __state)
{
__state = Vector3.zero;
World world = GameManager.Instance.World;
// И у одержимого зомби (03.10: «если одержимый отошёл подальше, новые не спавнятся возле него»).
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(world) : Possession.Active ? Possession.Zombie : null;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
if (player == null || snake == null)
{
return;
}
__state = player.position;
player.position = snake.position;
}
public static void Postfix(Vector3 __state)
{
if (__state == Vector3.zero)
{
return;
}
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
if (player != null)
{
player.position = __state;
}
}
}
/// Зомби у змеи не исчезают (тест 28.09: «зомби почему-то исчезают возле змеи»).
/// EntityAlive.CheckDespawn мерит расстояние до ближайшего ИГРОКА, то есть до тела: биомный
/// зомби дальше 128 м от него, которого давно не видели, удаляется. Если зомби не дальше 80 м от
/// змеи (тот же порог, при котором игра обнуляет счётчик «рядом нет игрока»), проверку
/// пропускаем и счётчик сбрасываем. Остальные - по правилам игры, от тела.
[HarmonyPatch(typeof(EntityAlive), "CheckDespawn")]
public static class Patch_EntityAlive_CheckDespawn_Snake
{
public static bool Prefix(EntityAlive __instance, ref int ___ticksNoPlayerAdjacent)
{
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(__instance.world as World) : Possession.Active ? Possession.Zombie : null;
if (snake == null)
{
return true;
}
// Одержимый сам не исчезает, как бы далеко от тела ни ушёл.
if (snake == __instance)
{
___ticksNoPlayerAdjacent = 0;
return !Possession.IsPossessed(__instance);
}
if ((__instance.position - snake.position).sqrMagnitude > 6400f)
{
return true;
}
___ticksNoPlayerAdjacent = 0;
return false;
}
}
/// Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»), так же в
/// чертогах и у одержимого питомца (06.10); от первого лица - только ячейки пояса (Possession.ShowPlayerBelt), не
/// вся группа: в ней же полоски здоровья и сытости персонажа. Игра каждый
/// кадр в EntityPlayerLocal.OnHUD открывает окно "toolbelt", если оно закрыто, - поэтому не
/// закрываем его раз за разом, а не даём открыться: все Open сходятся в openInternal.
[HarmonyPatch(typeof(GUIWindowManager), "openInternal")]
public static class Patch_GUIWindowManager_openInternal_SnakeToolbelt
{
public static bool Prefix(GUIWindow _w)
{
return !((SnakeControl.Controlling || MindControl.Controlling || (Possession.Active && Possession.IsPet)) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
}
}
/// Звуки у змеи слышны (тест 28.09: «звук при убийстве не срабатывает»). Audio.Manager
/// мерит расстояние до ИГРОКА (LocalPlayer().position - тело) и не создаёт звук дальше
/// maxDistance его источника (LoadAudio). Громкость потом считает сам Unity от слушателя - камеры
/// змеи. На время вызова переносим позицию игрока к змее.
public static class SnakeAudioSwap
{
/// Вложенность подмены: CheckLODs зовётся и изнутри WorldEnvironment.Update, где позиция уже
/// подменена. Подменяет и возвращает только внешний вызов, вложенные ничего не трогают.
static int _swapped, _swapFrame;
public static Vector3 Prefix()
{
if (_swapped > 0 && _swapFrame != Time.frameCount)
{
_swapped = 0; // подмена не вернулась (исключение посреди вызова) - не держим её дольше кадра
}
if (_swapped > 0)
{
_swapped++;
return Vector3.zero;
}
Vector3 body = PrefixOuter();
if (body != Vector3.zero)
{
_swapped = 1;
_swapFrame = Time.frameCount;
}
return body;
}
static Vector3 PrefixOuter()
{
if (MindControl.Controlling)
{
// Дух чертогов: звук, мир и компас - от него (MindControl.cs).
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p == null)
{
return Vector3.zero;
}
Vector3 bodyPos = p.position;
p.position = MindControl.FeetPos;
return bodyPos;
}
if (Possession.Active && Possession.Zombie != null)
{
// Одержимость (тест 03.10: «без звуков») - звуки мерятся от одержимого зомби, а не от тела.
EntityPlayerLocal pp = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pp == null)
{
return Vector3.zero;
}
Vector3 bodyAt = pp.position;
pp.position = Possession.Zombie.position;
// Область тела сжата (Possession.BodyViewDim) - этим системам радиус полный, они рисуют у зомби.
if (Possession.FullViewDim > 0 && pp.ChunkObserver != null)
{
pp.ChunkObserver.viewDim = Possession.FullViewDim;
}
return bodyAt;
}
if (!SnakeControl.Controlling)
{
return Vector3.zero;
}
World world = GameManager.Instance?.World;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
EntityAlive snake = SnakeMode.Snake(world);
if (player == null || snake == null)
{
return Vector3.zero;
}
Vector3 body = player.position;
player.position = snake.position;
if (SnakeControl.FullViewDim > 0 && player.ChunkObserver != null)
{
player.ChunkObserver.viewDim = SnakeControl.FullViewDim;
}
return body;
}
public static void Postfix(Vector3 _body)
{
if (_body == Vector3.zero)
{
if (_swapped > 1)
{
_swapped--;
}
return;
}
_swapped = 0;
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player != null)
{
player.position = _body;
if (((Possession.Active && Possession.FullViewDim > 0) || (SnakeControl.Controlling && SnakeControl.FullViewDim > 0)) &&
player.ChunkObserver != null)
{
player.ChunkObserver.viewDim = Possession.BodyViewDim;
}
}
}
}
/// Полная модель блоков (двери, люки, мебель) ближе 41 м, дальше - упрощённая замена:
/// ChunkGameObject.CheckLODs меряет расстояние до ИГРОКА - тела. Его зовёт не только WorldEnvironment.Update
/// (там позиция уже подменена), но и сам чанк при появлении - тест 03.10: «змея и одержимый видят вместо
/// дверей деревянные блоки». Подменяем позицию и здесь: змея, одержимый, дух чертогов.
[HarmonyPatch(typeof(ChunkGameObject), nameof(ChunkGameObject.CheckLODs))]
public static class Patch_ChunkGameObject_CheckLODs_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
/// Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела), и вокруг змеи (05.10: «текстуры вокруг неё отображаются коряво»).
[HarmonyPatch(typeof(PrefabLODManager), nameof(PrefabLODManager.FrameUpdate))]
public static class Patch_PrefabLODManager_FrameUpdate_Possession
{
public static void Prefix(out Vector3 __state) { __state = Possession.Active || SnakeControl.Controlling ? SnakeAudioSwap.Prefix() : Vector3.zero; }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
[HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Vector3), typeof(string), typeof(int), typeof(bool), typeof(float) })]
public static class Patch_AudioManager_PlayAt_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
[HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Entity), typeof(string), typeof(float), typeof(bool) })]
public static class Patch_AudioManager_PlayOn_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
/// Отрисовка мира вокруг змеи (тест 28.09: «есть блоки, которые прорисовываются, только
/// если рядом игрок»). Сами чанки у змеи грузит и строит её наблюдатель, но несколько систем
/// каждый кадр смотрят на позицию ИГРОКА - тела:
/// - WorldEnvironment.Update: дальний ландшафт (UnityDistantTerrainTest.FrameUpdate - вокруг
/// игрока в нём вырез под настоящие чанки) и ChunkGameObject.CheckLODs (декали ближе 41 м,
/// трава ближе 48-112 м);
/// - DynamicMeshManager.Update: упрощённые модели зданий вдали (Dynamic Mesh) - какие регионы
/// грузить и выгружать и где показывать настоящие чанки вместо упрощённых.
/// На время этих вызовов позиция игрока - у змеи, как и для звука (SnakeAudioSwap).
[HarmonyPatch(typeof(WorldEnvironment), "Update")]
public static class Patch_WorldEnvironment_Update_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
[HarmonyPatch(typeof(DynamicMeshManager), "Update")]
public static class Patch_DynamicMeshManager_Update_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
/// Мешок с добычей падает в EntityAlive.dropItemOnDeath (шанс LootDropProb). Для
/// убитых змеёй - не зовём вовсе.
[HarmonyPatch(typeof(EntityAlive), "dropItemOnDeath")]
public static class Patch_EntityAlive_dropItemOnDeath_Snake
{
public static bool Prefix(EntityAlive __instance)
{
return !SnakeControl.NoLoot.Remove(__instance.entityId);
}
}
/// Компас в режиме змеи: направление он и так берёт у камеры (то есть у змеи), а точку
/// отсчёта - у игрока, то есть у тела. На время его Update позиция игрока - у змеи, иначе
/// метки Тёмного чутья показывали бы направление от тела.
[HarmonyPatch(typeof(XUiC_CompassWindow), nameof(XUiC_CompassWindow.Update))]
public static class Patch_XUiC_CompassWindow_Update_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
/// Ноль опыта за убийство змеёй. EntityPlayer.AddKillXP - единственное место, где игра
/// начисляет опыт за убийство; префикс пропускает его целиком. Некромантия НЕ теряется: её счёт -
/// постфикс на том же методе (NecromancyKillCreditPatch), а Harmony вызывает постфиксы и тогда,
/// когда префикс отменил оригинал. Рост урона ножа идёт от того же счётчика.
[HarmonyPatch(typeof(EntityPlayer), "AddKillXP")]
public static class Patch_EntityPlayer_AddKillXP_SnakeNoXp
{
public static bool Prefix(EntityAlive killedEntity)
{
return killedEntity == null || !SnakeControl.NoXp.Remove(killedEntity.entityId);
}
}
}