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