- Радиация: проверка раз в 2 с, в поясе змея сразу развеивается (таймер 30 с убран). - Леденящий ужас: учуянные змеёй зомби (30 м) бегут, ломая препятствия; очередь испуга, отметка места змеи для поиска пути, выключен в кровавую луну. «Шум» (зомби шли к змее) удалён. Диагностика бегства в логе пока оставлена. - Стиратель (zombieEraser): в орде игрока-змеи с шансом 0.5; взрывается на любой атаке и при разрыве змеёй, отбрасывает змею; тело - чёрный разрыв. Вид: своя раскраска на модели гавайца (Resources/Eraser), свечение маджента, звуки спайдера. - Фиолетовая змея на карте (necroSnakeSpirit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1288 lines
55 KiB
C#
1288 lines
55 KiB
C#
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 > 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>Параметр 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;
|
||
public const string SnakeMapNavObject = "necroSnakeSpirit";
|
||
static EntityAlive _mapSnake;
|
||
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;
|
||
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;
|
||
_killId = -1;
|
||
_burst = null;
|
||
_sleeperCheck = 0f;
|
||
_senseCheck = 0f;
|
||
_fearQueue.Clear();
|
||
_fearQueued.Clear();
|
||
_fearCheck = 0f;
|
||
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10.
|
||
_snake.AddNavObject(SnakeMapNavObject, null, null);
|
||
_mapSnake = _snake;
|
||
Target = null;
|
||
_radiationCheck = 0f;
|
||
BodyDanger = false;
|
||
_bodyCheck = 0f;
|
||
_observer = GameManager.Instance.AddChunkObserver(_pos, true, GameUtils.GetViewDistance(), -1);
|
||
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();
|
||
if (_mapSnake != null)
|
||
{
|
||
_mapSnake.RemoveNavObject(SnakeMapNavObject);
|
||
_mapSnake = null;
|
||
}
|
||
_fearQueue.Clear();
|
||
_fearQueued.Clear();
|
||
Controlling = false;
|
||
SnakeVision.Off(GameManager.Instance?.World?.GetPrimaryPlayer());
|
||
_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 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;
|
||
// Указание 28.09: атака на левую кнопку, правая - выбраться из застревания.
|
||
attack = input.Primary.WasPressed;
|
||
if (input.Secondary.WasPressed)
|
||
{
|
||
Unstick(world);
|
||
}
|
||
}
|
||
|
||
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);
|
||
_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 (dir.sqrMagnitude > 1f)
|
||
{
|
||
dir.Normalize();
|
||
}
|
||
bool moving = dir.sqrMagnitude > 0.01f;
|
||
if (moving)
|
||
{
|
||
Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * 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)))
|
||
{
|
||
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);
|
||
Fall(dt, jump);
|
||
_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, 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) ||
|
||
(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 с зовём их же, подставив позицию змеи.</summary>
|
||
static void Sleepers(World _world, EntityPlayerLocal _player, float _dt)
|
||
{
|
||
_sleeperCheck -= _dt;
|
||
if (_sleeperCheck > 0f || _world.IsRemote())
|
||
{
|
||
return;
|
||
}
|
||
_sleeperCheck = 0.25f;
|
||
Vector3 body = _player.position;
|
||
_player.position = _pos;
|
||
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;
|
||
bool foe = IsFoe(e, _snake);
|
||
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>Питомцы и змея мода - тоже 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;
|
||
}
|
||
// Один блок - ступенька, два - стена. Проверяем настоящую геометрию, а не тип блока:
|
||
// у блока есть только флаг «твёрдый», и открытая дверь для него такая же стена, как
|
||
// закрытая (тест 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;
|
||
}
|
||
}
|
||
_pos = c;
|
||
return true;
|
||
}
|
||
|
||
static void Fall(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;
|
||
}
|
||
_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>Правая кнопка: выбраться из текстур. Ближайшее свободное место - сначала выше
|
||
/// на месте, потом кольцами до 3 м и до 4 м вверх; там, где тело змеи ни во что не упирается.
|
||
/// Дальше змея сама падает на землю.</summary>
|
||
static void Unstick(World _world)
|
||
{
|
||
if (_dashTarget != null)
|
||
{
|
||
return;
|
||
}
|
||
for (float r = 0f; r <= 3f; r += 1f)
|
||
{
|
||
int dirs = r == 0f ? 1 : 8;
|
||
// На месте - минимум на полметра вверх: проверка может счесть место свободным, а
|
||
// змея всё равно не идёт (тест 28.09: дважды «выбралась, сдвиг 0.0 м»).
|
||
for (float h = r == 0f ? 0.5f : 0f; h <= 4f; h += 0.5f)
|
||
{
|
||
for (int i = 0; i < dirs; i++)
|
||
{
|
||
Vector3 p = _pos + Quaternion.Euler(0f, _yaw + i * 45f, 0f) * Vector3.forward * r + Vector3.up * h;
|
||
if (!_world.IsChunkAreaCollidersLoaded(p) || Blocked(p + Vector3.up * 0.4f) || Blocked(p + Vector3.up * 1f))
|
||
{
|
||
continue;
|
||
}
|
||
Debug.Log("[NecromancerTome] SnakeControl: выбралась, сдвиг " + (p - _pos).magnitude.ToString("F1") + " м");
|
||
_pos = p;
|
||
_vy = 0f;
|
||
_airborne = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
Debug.Log("[NecromancerTome] SnakeControl: выбраться некуда");
|
||
}
|
||
|
||
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];
|
||
if (!IsFoe(e, _snake))
|
||
{
|
||
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 (!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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
if (!SnakeControl.Controlling)
|
||
{
|
||
return;
|
||
}
|
||
World world = GameManager.Instance.World;
|
||
EntityPlayerLocal player = world?.GetPrimaryPlayer();
|
||
EntityAlive snake = SnakeMode.Snake(world);
|
||
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)
|
||
{
|
||
if (!SnakeControl.Controlling)
|
||
{
|
||
return true;
|
||
}
|
||
EntityAlive snake = SnakeMode.Snake(__instance.world as World);
|
||
if (snake == null || snake == __instance || (__instance.position - snake.position).sqrMagnitude > 6400f)
|
||
{
|
||
return true;
|
||
}
|
||
___ticksNoPlayerAdjacent = 0;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»). Игра каждый
|
||
/// кадр в 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 && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
|
||
}
|
||
}
|
||
|
||
/// <summary>Звуки у змеи слышны (тест 28.09: «звук при убийстве не срабатывает»). Audio.Manager
|
||
/// мерит расстояние до ИГРОКА (LocalPlayer().position - тело) и не создаёт звук дальше
|
||
/// maxDistance его источника (LoadAudio). Громкость потом считает сам Unity от слушателя - камеры
|
||
/// змеи. На время вызова переносим позицию игрока к змее.</summary>
|
||
public static class SnakeAudioSwap
|
||
{
|
||
public static Vector3 Prefix()
|
||
{
|
||
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;
|
||
return body;
|
||
}
|
||
|
||
public static void Postfix(Vector3 _body)
|
||
{
|
||
if (_body == Vector3.zero)
|
||
{
|
||
return;
|
||
}
|
||
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||
if (player != null)
|
||
{
|
||
player.position = _body;
|
||
}
|
||
}
|
||
}
|
||
|
||
[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);
|
||
}
|
||
}
|
||
}
|