Files
necromants-tome-7d2d-3-2/HarmonySrc/SnakeControl.cs
T
AlexCubeandClaude Opus 5.5 beab1640b0 Одержимость питомцев, стервятник-разведчик, метки на карте, череп, глава письма
- Одержимость питомца: ПКМ с книгой - вселиться, E - отозвать (и изнутри питомца),
  пояс скрыт, надпись о враге у тела.
- Одержимый стервятник: полёт, рывок на ЛКМ с 10 м с прямым уроном, звуком и
  кровотечением, обычный прицел краснеет на цели; посадка в позу 0 и взлёт на пробел;
  ПКМ - клич-приманка (паникующая курица, откат 0.5 с); F - питаться трупами и
  блоками-падалью, доеденное исчезает.
- Дух змеи атакует только нежить; фиолетовая метка на карте у змеи и одержимого.
- Череп из красного трупа (этап 1 цинерария); глава книги «Письмо архимага» с видео.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:09:28 +03:00

1556 lines
68 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Режим змеи, этап 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 ставит камеру за телом игрока; постфикс
/// к нему переставляет её за змею. Порядок гарантирован - постфикс идёт сразу после.
///
/// ГРАНИЦА. Змея не заходит туда, где у чанков нет коллизий: мир подгружается вокруг ТЕЛА, и
/// за его пределами она провалилась бы сквозь пустоту.
/// </summary>
public static class SnakeControl
{
public const float WalkSpeed = 6f; // велосипед, vehicles.xml velocityMax
public const float RunSpeed = 16.5f; // мотоцикл турбо с нагнетателем
public const float Gravity = 20f;
/// <summary>Прыжок на Пробел (указание 28.09: «иначе не выбраться из ям»). 10 м/с при g = 20 -
/// подъём на 2,5 м: вместе с заползанием на блок хватает на стену в три блока.</summary>
public const float JumpSpeed = 10f;
public const float MaxStep = 0.25f;
/// <summary>Глаза змеи: над головой и чуть впереди, чтобы не смотреть изнутри своей морды.</summary>
/// Тест 28.09: «упирается в складки местности - камера уходит под текстуры» → глаза выше и
/// позади головы, плюс не пускаем камеру в землю (см. PlaceCamera).
/// Тест 28.09 (второй): «камеру чуть выше, чтобы видеть голову змеи» → глаза над телом,
/// позади головы: голова впереди и чуть ниже, в поле зрения.
public const float EyeUp = 0.5f;
public const float EyeForward = -1.2f;
/// <summary>Как часто змея проверяет радиацию края карты. Указание 02.10: без таймера, сразу
/// в тело; проверка раз в 2 с ради нагрузки (было 30 с отсчёта и проверка раз в 0.5 с).</summary>
public const float RadiationCheckPeriod = 2f;
/// <summary>Порог игры: buffRadiation03 вешается при _biomeradiation &gt; 2.5
/// (buffs.xml, buffStatusCheck01), значение - biomeProvider.GetRadiationAt.</summary>
public const float RadiationThreshold = 2.5f;
public const float TargetRange = 30f; // дальность будущего рывка
public const float TargetRadius = 0.9f; // насколько мимо центра противника можно целиться
public const int TerrainMask = 65536;
/// <summary>Слои, о которые змея упирается: ландшафт и блоки (16) плюс модели блоков - двери,
/// люки, мебель (30). Та же маска, что у игры для лучей от головы игрока
/// (EntityPlayerLocal: 1073807360).</summary>
public const int WallMask = 1073807360;
/// <summary>Радиус проверочных сфер при шаге. Меньше полублока - в дверной проём и щель в
/// один блок змея пролезает.</summary>
public const float BodyRadius = 0.25f;
/// <summary>Точка тела для проверки «внутри текстуры»: над корнем змеи, ниже головы.</summary>
public const float BodyUp = 0.35f;
/// <summary>Как далеко вверх искать поверхность, под которой змея может быть зарыта.</summary>
public const float InsideProbe = 32f;
/// <summary>Полёт (указание 05.10): пока зажата правая кнопка. Скорость - вдвое ниже шага
/// зомби (MindControl.WalkSpeed), и в воздухе, и сквозь постройки; Shift не ускоряет.
/// Сквозь грунт (земля, песок, камень) - никак.</summary>
static bool _flying;
public const float FlySpeed = MindControl.WalkSpeed * 0.5f;
/// <summary>Не нашлось, куда выбросить змею из текстур, - пауза, а не поиск каждый кадр.</summary>
static float _ejectWait;
static Vector3[] _ejectOffsets;
/// <summary>Параметр Forward аниматора змеи при ползании и на Shift. Подобраны на глаз:
/// у животного с RootMotion полный ход даёт landMovementFactor * 2.5 = 0.25.</summary>
public static float AnimWalk = 0.25f;
public static float AnimRun = 0.6f;
/// <summary>Тревога у тела (указание 28.09): враг ближе 10 блоков к лежащему телу - прицел
/// мигает раз в секунду и надпись «Враг приближается к телу». Орды и прочие идут к телу, не к змее.</summary>
public const float BodyDangerRange = 10f;
public static bool BodyDanger;
static float _bodyCheck;
/// <summary>Рывок на правую кнопку мыши (указание 28.09: «там обычно атака»): по прямой к
/// цели в прицеле, сквозь препятствия, со звуком атаки змеи; цель убита чёрным разрывом.</summary>
public const float DashSpeed = 60f;
public const float DashStop = 0.6f;
public const string AttackSound = "snakeattack";
static EntityAlive _dashTarget;
/// <summary>После удара змея замирает (указание 28.09: «резко останавливалась, иначе
/// проскакивает частицы»), а частицы разрыва секунду едут вместе с ней.</summary>
public const float KillFreeze = 0.35f;
public const float BurstFollow = 1f;
static float _freeze;
/// <summary>Отброс взрывом Стирателя (02.10): горизонтальная скорость, гаснет сама.</summary>
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;
/// <summary>Тёмное чутьё змеи (указание 28.09: «змее можно добавить тёмное чутьё, это логично»):
/// то же, что бафф ножа buffNecroDarkSense (buffs.xml), только от змеи - враги ближе 30 м
/// получают метку "zombie" на компасе и карте, дальше 60 м и мёртвые её теряют. Питомцы и
/// девианты не метятся (IsFoe). Бафф ножа считает от тела и тут не годится.</summary>
public const float SenseRange = 30f;
public const float SenseDropRange = 60f;
public const string SenseNavObject = "zombie";
static readonly System.Collections.Generic.HashSet<int> _sensed = new System.Collections.Generic.HashSet<int>();
static float _senseCheck;
/// <summary>Очередь испуга (указание 02.10: «пусть пугаются по очереди» - толпа, напуганная
/// разом, давала фризы): новые учуянные встают в очередь, пугается по одному раз в
/// FearInterval. Уже напуганным страх продлевается сразу, это дёшево.</summary>
static readonly System.Collections.Generic.Queue<int> _fearQueue = new System.Collections.Generic.Queue<int>();
static readonly System.Collections.Generic.HashSet<int> _fearQueued = new System.Collections.Generic.HashSet<int>();
static float _fearCheck;
static NecroMapMarker.Marker _mapNav;
public const float FearInterval = 0.25f;
/// <summary>Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
/// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.</summary>
public static readonly System.Collections.Generic.HashSet<int> NoLoot = new System.Collections.Generic.HashSet<int>();
/// <summary>Убитые змеёй: опыт за них - ноль (указание 28.09: «форма не для повышения уровней
/// или лута, она для повышения уровня Некромантии»). Снимается в патче AddKillXP.</summary>
public static readonly System.Collections.Generic.HashSet<int> NoXp = new System.Collections.Generic.HashSet<int>();
public const string ToolbeltWindow = "toolbelt";
public static bool Controlling;
public static float AnimForward;
static float _radiationCheck;
/// <summary>Свой наблюдатель чанков у змеи: мир подгружается вокруг наблюдателей, а у игрока
/// он стоит у тела (тест 28.09: «змея не может уйти далеко от тела»). Так же игра грузит
/// место под сброс припасов (AIAirDrop) - только здесь с видимой сеткой и дальностью
/// обзора из настроек.</summary>
static ChunkManager.ChunkObserver _observer;
/// <summary>Область тела сжата до Possession.BodyViewDim, как у одержимого (указание 05.10:
/// «делай как у одержимого»): две полные области - лишняя память. Полный радиус - для систем,
/// рисующих мир у змеи (SnakeAudioSwap). -1 - не сжата.</summary>
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;
/// <summary>Где голова относительно корня змеи (вперёд и вверх), замеряется один раз, пока
/// змея стоит. Камера держится этой точки на ровно едущем корне, а не самой головы: голова
/// при ползании ходит из стороны в сторону (тест 28.09: «камеру мотает, укачивает»).</summary>
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, "ui_game_symbol_tracking_snake");
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;
/// <summary>Разведка под «зрение змеи» (28.09: красные тона, контраст, «как комикс»): все
/// экранные эффекты игры (Resources/ScreenEffects) с шейдером и цветовыми/числовыми
/// свойствами - из чего можно собрать своё. Раз за сессию.</summary>
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;
}
}
/// <summary>Каждый кадр, из SnakeMode.OnUnityUpdate. True - змея развеялась, пора в тело.</summary>
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;
}
}
/// <summary>Шаг рывка: по прямой к ногам цели, без земли и стен. Долетела - убийство: цель
/// помечается для чёрного разрыва (NecroBurst.Pending) и получает смертельный урон ОТ ИГРОКА -
/// тогда игра сама выдаёт опыт, а NecromancyKillCreditPatch засчитывает убийство в Некромантию.</summary>
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;
// Стиратель от разрыва взрывается; отброс змеи - из EraserPatch.Explode.
if (EraserPatch.IsEraser(target) && EraserPatch.Explode(target))
{
return;
}
_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())
{
NecroBurst.Pending.Remove(target.entityId);
NoLoot.Remove(target.entityId);
NoXp.Remove(target.entityId);
}
Debug.Log("[NecromancerTome] SnakeControl: удар по " + target.EntityName + ", " + (target.IsDead() ? "убит" : "ВЫЖИЛ"));
}
/// <summary>Из EraserPatch.Explode: змею ближе _range от взрыва отбрасывает прочь и вверх.
/// Не ранит - змея бессмертна.</summary>
public static void Knockback(Vector3 _from, float _range)
{
if (!Controlling)
{
return;
}
Vector3 away = _pos - _from;
if (away.sqrMagnitude > _range * _range)
{
return;
}
away.y = 0f;
if (away.sqrMagnitude < 0.01f)
{
away = Quaternion.Euler(0f, _yaw, 0f) * Vector3.back;
}
_dashTarget = null;
_kick = away.normalized * KickSpeed;
_vy = KickUp;
_airborne = true;
_freeze = 0.6f;
Debug.Log("[NecromancerTome] SnakeControl: отброшена взрывом Стирателя");
}
/// <summary>Из NecroBurst.OnBurst: наш разрыв - запоминаем частицы, чтобы вести их за змеёй.</summary>
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;
}
/// <summary>Комнаты со спящими в зданиях просыпаются, когда в них входит ИГРОК:
/// EntityPlayer.CheckSleeperTriggers каждый тик зовёт World.CheckSleeperVolumeTouching и
/// CheckTriggerVolumeTrigger, а те берут player.position (тест 28.09: «зомби в локациях не
/// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи
/// (и одержимого - Possession, 05.10).</summary>
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();
}
/// <summary>Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под
/// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это
/// не враг»). Общее для тревоги у тела и для прицела.</summary>
public static bool IsFoe(EntityAlive _e, EntityAlive _snake)
{
return _e != null && _e != _snake && _e is EntityEnemy && !_e.IsDead() &&
!IsNecroEntity(_e) &&
!GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
}
/// <summary>Нежить - поднятые мертвецы: всё с меткой zombie (зомби-люди, зомби-собаки, -медведи,
/// -кабаны, стервятники, Стиратель). Живые звери, бандиты, игроки и торговцы - не нежить. Та же
/// метка, по которой считаются убийства в Некромантию (NecromancyKillCreditPatch.ZombieTag).</summary>
static readonly FastTags<TagGroup.Global> UndeadTag = FastTags<TagGroup.Global>.Parse("zombie");
public static bool IsUndead(EntityAlive _e) => _e != null && _e.HasAnyTags(UndeadTag);
/// <summary>Питомцы и змея мода - тоже EntityEnemy (зомби-основа), и без этой проверки
/// охрана выбирала целью самого питомца: он ближе всех к хозяину (тест 30.09, лог
/// «pet 3838 идёт на 3838»). Все наши классы сущностей начинаются с necro.</summary>
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");
}
/// <summary>Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу игрока.</summary>
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 ? "враг у тела" : "у тела чисто"));
}
}
/// <summary>Радиация края карты: змея в ней сразу развеивается, игрок в теле.</summary>
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);
}
/// <summary>Каждый кадр: змея в грунте (в полёте) или в любой текстуре (на земле) -
/// выбрасываем на ближайшее свободное место (указание 05.10).</summary>
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;
}
}
/// <summary>Ближайшее свободное место: до 4 м вбок, до 1 м вниз и до 6 м вверх; вниз
/// дороже, чем вверх. _groundOnly - мешает только грунт (полёт). E зовёт с _minShift 0.5:
/// проверка может счесть место свободным, а змея не идёт (тест 28.09: «сдвиг 0.0 м»).</summary>
static bool Eject(World _world, bool _groundOnly, float _minShift)
{
if (_ejectOffsets == null)
{
System.Collections.Generic.List<Vector3> list = new System.Collections.Generic.List<Vector3>();
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);
}
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);
}
/// <summary>Точка внутри текстуры? Земля игры - поверхность, а не сплошная масса, и шар
/// (Blocked) изнутри её не чует. Поэтому так: луч вверх до первого потолка (поверхность
/// лицом вниз), потом луч от потолка вниз до точки. Попалась поверхность лицом вверх -
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
/// точке), а не постройка.</summary>
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;
}
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;
}
/// <summary>Из патчей камеры: вид из глаз змеи и поиск цели под прицелом.</summary>
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);
}
/// <summary>Ближайший живой противник на луче прицела не дальше TargetRange от змеи.
/// Сквозь стены - так же, как пойдёт рывок. Питомцы (призрачные) не цель.</summary>
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;
}
}
/// <summary>Пока тело в рэгдолле, игра выключает vp_FPCamera и сама ведёт «камеру на себя»
/// (EntityPlayerLocal.OnRagdoll -> StartSelfCamera, каждый кадр SelfCameraFrameUpdate из Update).
/// Тест 28.09: «камера всё ещё у игрока» - постфикс к vp_FPCamera успел сработать один кадр,
/// до падения. Здесь её место занимает камера змеи.</summary>
[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;
}
}
/// <summary>Аниматор животного в режиме полёта получает скорость 0 (AvatarAnimalController.LateUpdate:
/// `if (!entity.IsFlyMode.Value) num = entity.speedForward`) - змея скользила неподвижной
/// (тест 28.09: «не извивается»). На время этого метода снимаем полёт и подставляем скорость.</summary>
[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);
}
}
}
/// <summary>Прицел змеи: пиктограмма глаза из атласа игры посреди экрана, красная, когда в
/// прицеле противник. Рисуется через OnGUI, чтобы не заводить своё окно XUi.</summary>
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);
/// <summary>Свой значок (указание 28.09: «вытащить из игры, чтобы я смог переделать»): если в
/// Resources мода лежит этот PNG, рисуется он, а не спрайт игры. Высота на экране та же,
/// ширина - по пропорциям картинки.</summary>
public const string CustomFile = "necrosnake_crosshair.png";
/// <summary>Куда один раз выгружается спрайт игры для перерисовки: закрытая папка рядом с
/// модом. У игроков её нет - тогда выгрузка просто не делается.</summary>
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<SnakeCrosshair>();
}
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;
/// <summary>Надпись под прицелом, пока враг у тела.</summary>
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);
}
}
/// <summary>Вырезает спрайт из атласа в PNG. Атлас на видеокарте и нечитаем напрямую -
/// тот же приём, что GhostTraderPatch.Desaturate: копия в RenderTexture и ReadPixels.
/// У NGUI y спрайта считается сверху, у Unity - снизу.</summary>
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);
}
}
}
/// <summary>Зомби вокруг змеи (тест 28.09: «вокруг змеи не спавнятся зомби»). Биомный спавн
/// работает в чанках у игрока: SpawnManagerBiomes.SpawnUpdate пропускает область, если ни один
/// игрок не стоит ближе 40 м, и ставит зомби не ближе 28 м от игроков и вне их взгляда. Чанки
/// вокруг змеи загружены её наблюдателем и перебираются миром, но эти проверки смотрят на тело.
/// На время вызова переносим позицию игрока к змее - все проверки считаются от неё.</summary>
[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;
}
}
}
/// <summary>Зомби у змеи не исчезают (тест 28.09: «зомби почему-то исчезают возле змеи»).
/// EntityAlive.CheckDespawn мерит расстояние до ближайшего ИГРОКА, то есть до тела: биомный
/// зомби дальше 128 м от него, которого давно не видели, удаляется. Если зомби не дальше 80 м от
/// змеи (тот же порог, при котором игра обнуляет счётчик «рядом нет игрока»), проверку
/// пропускаем и счётчик сбрасываем. Остальные - по правилам игры, от тела.</summary>
[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;
}
}
/// <summary>Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»), так же в
/// чертогах и у одержимого питомца (06.10). Игра каждый
/// кадр в EntityPlayerLocal.OnHUD открывает окно "toolbelt", если оно закрыто, - поэтому не
/// закрываем его раз за разом, а не даём открыться: все Open сходятся в openInternal.</summary>
[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);
}
}
/// <summary>Звуки у змеи слышны (тест 28.09: «звук при убийстве не срабатывает»). Audio.Manager
/// мерит расстояние до ИГРОКА (LocalPlayer().position - тело) и не создаёт звук дальше
/// maxDistance его источника (LoadAudio). Громкость потом считает сам Unity от слушателя - камеры
/// змеи. На время вызова переносим позицию игрока к змее.</summary>
public static class SnakeAudioSwap
{
/// <summary>Вложенность подмены: CheckLODs зовётся и изнутри WorldEnvironment.Update, где позиция уже
/// подменена. Подменяет и возвращает только внешний вызов, вложенные ничего не трогают.</summary>
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;
}
}
}
}
/// <summary>Полная модель блоков (двери, люки, мебель) ближе 41 м, дальше - упрощённая замена:
/// ChunkGameObject.CheckLODs меряет расстояние до ИГРОКА - тела. Его зовёт не только WorldEnvironment.Update
/// (там позиция уже подменена), но и сам чанк при появлении - тест 03.10: «змея и одержимый видят вместо
/// дверей деревянные блоки». Подменяем позицию и здесь: змея, одержимый, дух чертогов.</summary>
[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); }
}
/// <summary>Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела), и вокруг змеи (05.10: «текстуры вокруг неё отображаются коряво»).</summary>
[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); }
}
/// <summary>Отрисовка мира вокруг змеи (тест 28.09: «есть блоки, которые прорисовываются, только
/// если рядом игрок»). Сами чанки у змеи грузит и строит её наблюдатель, но несколько систем
/// каждый кадр смотрят на позицию ИГРОКА - тела:
/// - WorldEnvironment.Update: дальний ландшафт (UnityDistantTerrainTest.FrameUpdate - вокруг
/// игрока в нём вырез под настоящие чанки) и ChunkGameObject.CheckLODs (декали ближе 41 м,
/// трава ближе 48-112 м);
/// - DynamicMeshManager.Update: упрощённые модели зданий вдали (Dynamic Mesh) - какие регионы
/// грузить и выгружать и где показывать настоящие чанки вместо упрощённых.
/// На время этих вызовов позиция игрока - у змеи, как и для звука (SnakeAudioSwap).</summary>
[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); }
}
/// <summary>Мешок с добычей падает в EntityAlive.dropItemOnDeath (шанс LootDropProb). Для
/// убитых змеёй - не зовём вовсе.</summary>
[HarmonyPatch(typeof(EntityAlive), "dropItemOnDeath")]
public static class Patch_EntityAlive_dropItemOnDeath_Snake
{
public static bool Prefix(EntityAlive __instance)
{
return !SnakeControl.NoLoot.Remove(__instance.entityId);
}
}
/// <summary>Компас в режиме змеи: направление он и так берёт у камеры (то есть у змеи), а точку
/// отсчёта - у игрока, то есть у тела. На время его Update позиция игрока - у змеи, иначе
/// метки Тёмного чутья показывали бы направление от тела.</summary>
[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); }
}
/// <summary>Ноль опыта за убийство змеёй. EntityPlayer.AddKillXP - единственное место, где игра
/// начисляет опыт за убийство; префикс пропускает его целиком. Некромантия НЕ теряется: её счёт -
/// постфикс на том же методе (NecromancyKillCreditPatch), а Harmony вызывает постфиксы и тогда,
/// когда префикс отменил оригинал. Рост урона ножа идёт от того же счётчика.</summary>
[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);
}
}
}