diff --git a/Config/Localization.csv b/Config/Localization.csv index 3003601..87e2f55 100644 --- a/Config/Localization.csv +++ b/Config/Localization.csv @@ -190,7 +190,7 @@ necroSnakeModeSnake,items,Item,,,,Snake,,Schlange,Serpiente,Serpent,Serpente,蛇 necroSnakeBodyDanger,UI,Label,,,,Enemy approaching your body,,Ein Feind nähert sich deinem Körper,Un enemigo se acerca a tu cuerpo,Un ennemi approche de ton corps,Un nemico si avvicina al tuo corpo,敵が体に近づいている,적이 몸에 다가오고 있다,Wróg zbliża się do twojego ciała,Um inimigo se aproxima do seu corpo,Враг приближается к телу,Bir düşman bedenine yaklaşıyor,敌人正在接近你的身体,敵人正在接近你的身體 necroSnakeModeDay,items,Item,,,,It is daytime,,Es ist Tag,Es de día,Il fait jour,È giorno,今は昼だ,지금은 낮이다,Jest dzień,Agora é dia,Сейчас день,Şu an gündüz,现在是白天,現在是白天 necroHoldNone,items,Item,,,,Bare hand,,Bloße Hand,Mano desnuda,Main nue,Mano nuda,素手,맨손,Goła ręka,Mão nua,Обычная рука,Çıplak el,空手,空手 -necroHoldDeny,items,Item,,,,Denial,,Verleugnung,Negación,Déni,Negazione,拒絶,거부,Zaprzeczenie,Negação,Отрицание,İnkâr,否认,否認 +necroHoldDeny,items,Item,,,,Horns,,Pommesgabel,Cuernos,Cornes,Corna,メロイックサイン,악마의 뿔,Rogi,Chifres,Коза,Boynuzlar,恶魔角,惡魔角 necroHoldPifPaf,items,Item,,,,Bang-bang,,Peng-peng,Pum-pum,Pan-pan,Bang-bang,バンバン,빵야빵야,Pif-paf,Pou-pou,Пиф-паф,Bang-bang,砰砰,砰砰 necroPossessHintBase,items,Item,,,,"LMB - strike, R - leave the body",,"LMT - Schlag, R - Körper verlassen","LMB: golpe, R: salir del cuerpo","Clic gauche : frapper, R : quitter le corps","LMB - colpo, R - lascia il corpo",左クリック: 攻撃、R: 体から出る,"좌클릭 - 공격, R - 몸에서 나가기","LPM - cios, R - opuść ciało","LMB - golpe, R - sair do corpo","ЛКМ - удар, R - выйти из тела","Sol tık - vuruş, R - bedenden çık",左键 - 攻击,R - 离开身体,左鍵 - 攻擊,R - 離開身體 necroPossessHintRanged,items,Item,,,,RMB - ranged attack,,RMT - Fernangriff,RMB: ataque a distancia,Clic droit : attaque à distance,RMB - attacco a distanza,右クリック: 遠距離攻撃,우클릭 - 원거리 공격,PPM - atak dystansowy,RMB - ataque à distância,ПКМ - дальняя атака,Sağ tık - menzilli saldırı,右键 - 远程攻击,右鍵 - 遠程攻擊 diff --git a/HarmonySrc/HandHolds.cs b/HarmonySrc/HandHolds.cs index 75dc622..bea312e 100644 --- a/HarmonySrc/HandHolds.cs +++ b/HarmonySrc/HandHolds.cs @@ -36,7 +36,10 @@ namespace NecromancerTome /// Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»). public const float DenySpeed = 2.2f; public const float Cooldown = 0.6f; - public const string DenySound = "twitch_celebrate"; + /// Звук «козы» (05.10: «нужен короткий звук» - вместо twitch_celebrate): бросок змеи - тот же, + /// что у рывка духа змеи (SnakeControl.AttackSound): «раз зомби боятся духа змеи, некромант может + /// пугать им и в телесной форме». + public const string DenySound = SnakeControl.AttackSound; /// «Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок /// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success). public const string PifPafFailSound = "glassdestroy"; @@ -47,6 +50,10 @@ namespace NecromancerTome static int _applied = -1; static int _playerId = -1; static float _nextCast; + /// Вселение - на пике толчка руки (05.10): иначе вид сразу уходит к зомби и толчка не видно. + /// Цель берётся в момент щелчка. + static EntityAlive _pifPafVictim; + static float _pifPafAt = -1f; public static bool IsHand(ItemClass _item) => _item != null && _item.Name == HandItem; @@ -74,9 +81,15 @@ namespace NecromancerTome _applied = None; _target = null; _targetFrame = -1; + _pifPafVictim = null; + _pifPafAt = -1f; HandPose.ResetAll(); return; } + if (_pifPafAt >= 0f && Time.time >= _pifPafAt) + { + PifPafShot(player); + } int want = Current(player); if (want == _applied) { @@ -97,6 +110,26 @@ namespace NecromancerTome } } + static void PifPafShot(EntityPlayerLocal _player) + { + EntityAlive victim = _pifPafVictim; + _pifPafVictim = null; + _pifPafAt = -1f; + if (Current(_player) != PifPaf) + { + return; // за полтика сменил хват или руку - выстрела нет + } + if (victim != null && !victim.IsDead() && Possession.CanPossess(victim) && Possession.Enter(_player, victim)) + { + _player.PlayOneShot(PifPafPossessSound); + } + else + { + _player.PlayOneShot(PifPafFailSound); + Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид")); + } + } + public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context) { if (_commandIndex >= None && _commandIndex <= PifPaf) @@ -116,18 +149,12 @@ namespace NecromancerTome if (_hold == PifPaf) { // Одержимость (решение 03.10): «пиф-паф» по зомби-гуманоиду - управление им. - EntityAlive victim = Target(_player); - if (Possession.CanPossess(victim) && Possession.Enter(_player, victim)) - { - _player.PlayOneShot(PifPafPossessSound); - } - else - { - _player.PlayOneShot(PifPafFailSound); - Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид")); - } + HandPose.Jab(); + _pifPafVictim = Target(_player); + _pifPafAt = Time.time + HandPose.JabTime * 0.5f; return; } + HandPose.Jab(); // толчок к прицелу, как у «пиф-пафа» (05.10) _player.PlayOneShot(DenySound); World world = _player.world; if (world.IsRemote()) diff --git a/HarmonySrc/HandPosePose.cs b/HarmonySrc/HandPosePose.cs index 1254faa..631104e 100644 --- a/HarmonySrc/HandPosePose.cs +++ b/HarmonySrc/HandPosePose.cs @@ -88,6 +88,7 @@ namespace NecromancerTome Enabled = false; Overrides.Clear(); CamOverrides.Clear(); + _jabStart = -1f; Unfreeze(); _bones.Clear(); _rig = null; @@ -159,7 +160,8 @@ namespace NecromancerTome b.localRotation = Quaternion.Euler(kv.Value); } } - Transform cam = CamOverrides.Count > 0 ? Camera() : null; + Transform cam = CamOverrides.Count > 0 || _jabStart >= 0f ? Camera() : null; + ApplyJab(cam); if (cam != null) { foreach (var kv in CamOverrides) @@ -173,6 +175,43 @@ namespace NecromancerTome } } + /// Толчок руки к прицелу (указание 05.10, «пиф-паф» на ЛКМ: «чуть сдвигает руку в сторону + /// прицела и возвращает»). Плечо доворачивается так, чтобы кисть прошла JabAmount пути до линии + /// взгляда и вернулась - по синусу за JabTime. Ставится до CamOverrides: кисть сохраняет поворот + /// относительно камеры, сдвигается только её место. + public const float JabTime = 0.25f; + public const float JabAmount = 0.35f; + static float _jabStart = -1f; + + public static void Jab() + { + _jabStart = Time.time; + } + + static void ApplyJab(Transform _cam) + { + if (_jabStart < 0f) + { + return; + } + float t = (Time.time - _jabStart) / JabTime; + if (t >= 1f) + { + _jabStart = -1f; + return; + } + Transform arm = Bone("RightArm"); + Transform hand = Bone("RightHand"); + if (_cam == null || arm == null || hand == null) + { + return; + } + Vector3 toHand = hand.position - arm.position; + Vector3 aim = _cam.position + _cam.forward * Vector3.Dot(hand.position - _cam.position, _cam.forward); + Quaternion full = Quaternion.FromToRotation(toHand, aim - arm.position); + arm.rotation = Quaternion.Slerp(Quaternion.identity, full, Mathf.Sin(t * Mathf.PI) * JabAmount) * arm.rotation; + } + /// Кисть в осях камеры. Оси кисти правой руки (лог necrobones 03.10): пальцы - локальная +X /// (суставы пальцев лежат по +X), ладонь - +Y (сгиб пальцев +Z ведёт +X к +Y), +Z - к мизинцу, /// большой палец - к -Z. Нулевая поза: пальцы вверх, ладонь от игрока, тыльная сторона к камере, @@ -221,8 +260,9 @@ namespace NecromancerTome Overrides["LeftArm"] = new Vector3(30f, 13.6f, 57.3f); } - /// Хват «отрицание» (утверждён пользователем 03.10): рука вперёд, как у браслета, ниже прицела, - /// кисть в линию с предплечьем, пальцы раскрыты. + /// Хват «коза» (до 05.10 - «отрицание»; утверждён пользователем 03.10): рука вперёд, как у браслета, ниже прицела, + /// кисть в линию с предплечьем, пальцы раскрыты. 05.10: средний и безымянный поджаты, как в кулаке + /// (сгиб - как у поджатых пальцев «пиф-пафа»), указательный и мизинец прямые. public static void PresetDeny() { Overrides.Clear(); @@ -232,9 +272,16 @@ namespace NecromancerTome Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f); Overrides["RightHand"] = new Vector3(45f, 0f, 0f); SetFingers("Right", 3f, 12f); - Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 40.6f); - Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f); - Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f); + for (int j = 1; j <= 3; j++) + { + float curl = j == 3 ? 55f : 80f; + Overrides["RightHandMiddle" + j] = new Vector3(0f, 0f, curl); + Overrides["RightHandRing" + j] = new Vector3(0f, 0f, curl); + } + // Большой палец прижимает поджатые (05.10: «полноценная коза»). Подбор на глаз - уточнять necropose. + Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 75f); + Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 40f); + Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 30f); LeftArmDown(); Unfreeze(); Enabled = true; diff --git a/HarmonySrc/Possession.cs b/HarmonySrc/Possession.cs index c2f1438..a5f6ffe 100644 --- a/HarmonySrc/Possession.cs +++ b/HarmonySrc/Possession.cs @@ -52,6 +52,26 @@ namespace NecromancerTome public const float TauntRange = 30f; public const float TauntPeriod = 1f; public const int TauntTicks = 1200; + /// Спящие просыпаются от одержимого (тест 05.10: «не разбудил спящего»). Игра будит их + /// только игроком - увидел или услышал (EAISetNearestEntityAsTarget.FindTargetPlayer), одержимый для + /// неё просто зомби. Будим сами: видит одержимого ближе SleeperWakeRange (пригнувшегося - ближе + /// SleeperWakeCrouched) или тот вплотную, ближе SleeperHearRange, хоть и за спиной. + public const float SleeperWakeRange = 8f; + public const float SleeperWakeCrouched = 3f; + public const float SleeperHearRange = 2f; + /// Приседание (указание 05.10): пока зажата кнопка приседания. Высота - доля роста зомби; + /// ниже 0.7 игра включает ходьбу пригнувшись (walkType 8), как у её зомби под низким потолком. + public static bool Crouching; + /// Удар по прицелу (тест 05.10: «не попадаю по другим зомби»). Удар зомби - луч ~1.65 м из + /// головы по взгляду (ItemActionMelee, EntityHuman.GetLookVector), а взгляд брался из наклона камеры, + /// которая сзади и выше: под прицелом враг, а луч из головы уходит ниже и мимо. На время замаха + /// взгляд зомби - на точку под прицелом, а если рядом враг почти по прицелу - на его грудь. + public const float MeleeAssistRange = 2.5f; + public const float MeleeAssistDeg = 25f; + static float _meleeLookUntil; + /// Ctrl в игре - приседание переключателем (ToggleCrouch), C - пока держишь (Crouch). + static bool _crouchToggled; + public const float CrouchHeight = 0.6f; /// Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить /// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора /// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков @@ -73,6 +93,7 @@ namespace NecromancerTome public const float TurnSpeed = 540f; static float _nextAttack; static float _nextTaunt; + static float _sleeperCheck; static ChunkManager.ChunkObserver _observer; static readonly List _frozen = new List(); static readonly List _frozenSpeed = new List(); @@ -155,7 +176,11 @@ namespace NecromancerTome return; } Active = false; + Crouching = false; + _meleeLookUntil = 0f; + _crouchToggled = false; EntityAlive z = Zombie; + z?.SetLookPosition(Vector3.zero); EntityPlayerLocal player = _player; if (_rState != RState.Idle && z != null && !z.IsDead()) { @@ -287,6 +312,11 @@ namespace NecromancerTome // кнопка геймпада (тест 03.10: «подрыва нету»). detonate = input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed; } + if (input != null && !gui && input.ToggleCrouch.WasPressed) + { + _crouchToggled = !_crouchToggled; + } + Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled); if (detonate && z is EntityZombieCop cop && !cop.isPrimed) { Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName); @@ -316,12 +346,32 @@ namespace NecromancerTome { Scream(world, z); } - if (attack && Time.time >= _nextAttack && z.Attack(false)) + if (attack && Time.time >= _nextAttack) { - z.Attack(true); - _nextAttack = Time.time + Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f); + z.SetLookPosition(MeleeAim(world, z)); + if (z.Attack(false)) + { + z.Attack(true); + float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f); + _nextAttack = Time.time + timeout; + _meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout); + } + } + if (_meleeLookUntil > 0f && _rState == RState.Idle) + { + if (Time.time < _meleeLookUntil) + { + z.SetLookPosition(MeleeAim(world, z)); + } + else + { + _meleeLookUntil = 0f; + z.SetLookPosition(Vector3.zero); + } } _observer?.SetPosition(z.position); + // Спящие в зданиях просыпаются от одержимого, как от змеи (указание 05.10). + SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime); Taunt(world, z); } @@ -371,6 +421,37 @@ namespace NecromancerTome GameManager.ShowTooltip(_p, text); } + /// Куда бить: грудь врага ближе MeleeAssistRange к голове и не дальше MeleeAssistDeg от + /// направления на прицел (ближайший к прицелу), иначе - точка под прицелом. + static Vector3 MeleeAim(World _world, EntityAlive _z) + { + Vector3 aim = AimPoint(); + Vector3 head = _z.getHeadPosition(); + Vector3 dir = (aim - head).normalized; + EntityAlive best = null; + float bestAngle = MeleeAssistDeg; + for (int i = 0; i < _world.EntityAlives.Count; i++) + { + EntityAlive e = _world.EntityAlives[i]; + if (e == null || e == _z || e.IsDead() || e is EntityPlayer || SnakeControl.IsNecroEntity(e)) + { + continue; + } + Vector3 to = e.getChestPosition() - head; + if (to.sqrMagnitude > MeleeAssistRange * MeleeAssistRange) + { + continue; + } + float angle = Vector3.Angle(dir, to); + if (angle < bestAngle) + { + bestAngle = angle; + best = e; + } + } + return best != null ? best.getChestPosition() : aim; + } + /// Точка под прицелом камеры (в мировых координатах), до 50 м. static Vector3 AimPoint() { @@ -566,8 +647,26 @@ namespace NecromancerTome { EntityAlive e = _world.EntityAlives[i]; if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.GetAttackTarget() == _z || - e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null || - (e.position - _z.position).sqrMagnitude > TauntRange * TauntRange || !e.CanSee(_z)) + e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null) + { + continue; + } + float d2 = (e.position - _z.position).sqrMagnitude; + if (d2 > TauntRange * TauntRange) + { + continue; + } + if (e.IsSleeping) + { + float wake = Crouching ? SleeperWakeCrouched : SleeperWakeRange; + if (d2 > SleeperHearRange * SleeperHearRange && (d2 > wake * wake || !e.CanSee(_z))) + { + continue; + } + e.ConditionalTriggerSleeperWakeUp(); + Debug.Log("[NecromancerTome] Possession: проснулся " + e.EntityName + " (" + e.entityId + "), " + Mathf.Sqrt(d2).ToString("F1") + " м"); + } + else if (!e.CanSee(_z)) { continue; } @@ -685,4 +784,35 @@ namespace NecromancerTome GUI.Label(new Rect(r.x, r.y - 26f * scale, r.width, 24f * scale), name + " " + z.Health + " / " + z.GetMaxHealth(), _style); } } + + /// Приседание одержимого: пока зажата кнопка, рост зомби опускается до CrouchHeight, как игра + /// опускает его под низким потолком (EntityAlive.CrouchHeightFixedUpdate, её же шаги). Отпустил - дальше + /// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой, + /// а проём впереди чует через moveHelper, который у одержимого остановлен. + [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.CrouchHeightFixedUpdate))] + public static class Patch_EntityAlive_CrouchHeightFixedUpdate_Possession + { + public static bool Prefix(EntityAlive __instance) + { + if (!Possession.Crouching || !Possession.IsPossessed(__instance) || __instance.crouchType == 0 || + __instance.physicsBaseHeight <= 1.3f) + { + return true; + } + float h = Utils.FastMoveTowards(__instance.physicsHeight, __instance.physicsBaseHeight * Possession.CrouchHeight, 0.099999994f); + __instance.SetHeight(h); + if (h <= __instance.physicsBaseHeight * 0.7f) + { + __instance.crouchBendPerTarget = 0f; + if (__instance.walkType != 8 && __instance.walkType != 21) + { + int was = __instance.walkType; + __instance.SetWalkType(8); + __instance.walkTypeBeforeCrouch = was; + } + } + __instance.crouchBendPer = Mathf.MoveTowards(__instance.crouchBendPer, __instance.crouchBendPerTarget, 0.099999994f); + return false; + } + } } diff --git a/HarmonySrc/SnakeControl.cs b/HarmonySrc/SnakeControl.cs index 6adfe0a..dcfde03 100644 --- a/HarmonySrc/SnakeControl.cs +++ b/HarmonySrc/SnakeControl.cs @@ -56,6 +56,18 @@ namespace NecromancerTome /// Радиус проверочных сфер при шаге. Меньше полублока - в дверной проём и щель в /// один блок змея пролезает. 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. @@ -125,6 +137,11 @@ namespace NecromancerTome /// место под сброс припасов (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; @@ -154,6 +171,8 @@ namespace NecromancerTome _dashTarget = null; _freeze = 0f; _kick = Vector3.zero; + _flying = false; + _ejectWait = 0f; _killId = -1; _burst = null; _sleeperCheck = 0f; @@ -169,6 +188,12 @@ namespace NecromancerTome 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); @@ -231,7 +256,13 @@ namespace NecromancerTome _fearQueue.Clear(); _fearQueued.Clear(); Controlling = false; - SnakeVision.Off(GameManager.Instance?.World?.GetPrimaryPlayer()); + 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; @@ -258,6 +289,8 @@ namespace NecromancerTome 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) @@ -274,13 +307,25 @@ namespace NecromancerTome move = new Vector2(input.Move.X, input.Move.Y); run = input.Run.IsPressed; jump = input.Jump.WasPressed; - // Указание 28.09: атака на левую кнопку, правая - выбраться из застревания. + 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.Secondary.WasPressed) + if ((input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed) && _dashTarget == null) { - Unstick(world); + 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) { @@ -294,6 +339,10 @@ namespace NecromancerTome if (_dashTarget != null) { Dash(_player); + if (_dashTarget == null) + { + KeepOut(world, dt); + } _observer?.SetPosition(_pos); _snake.SetPosition(_pos); _snake.SetRotation(new Vector3(0f, _snakeYaw, 0f)); @@ -305,6 +354,12 @@ namespace NecromancerTome } 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(); @@ -312,13 +367,15 @@ namespace NecromancerTome bool moving = dir.sqrMagnitude > 0.01f; if (moving) { - Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt; + 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))) + 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; } @@ -340,7 +397,11 @@ namespace NecromancerTome } // Вид от первого лица: змея смотрит туда же, куда игрок. _snakeYaw = Mathf.LerpAngle(_snakeYaw, _yaw, dt * 12f); - Fall(dt, jump); + if (!_flying) + { + Fall(world, dt, jump); + } + KeepOut(world, dt); _observer?.SetPosition(_pos); _snake.SetPosition(_pos); @@ -350,7 +411,7 @@ namespace NecromancerTome _snake.moveDirection = Vector3.zero; AnimForward = moving ? (run ? AnimRun : AnimWalk) : 0f; CheckBody(world, _player, _snake, dt); - Sleepers(world, _player, dt); + Sleepers(world, _player, _pos, ref _sleeperCheck, dt); Sense(world, _snake, dt); FearQueue(world, _snake, dt); return Radiation(world, dt); @@ -485,17 +546,18 @@ namespace NecromancerTome /// Комнаты со спящими в зданиях просыпаются, когда в них входит ИГРОК: /// EntityPlayer.CheckSleeperTriggers каждый тик зовёт World.CheckSleeperVolumeTouching и /// CheckTriggerVolumeTrigger, а те берут player.position (тест 28.09: «зомби в локациях не - /// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи. - static void Sleepers(World _world, EntityPlayerLocal _player, float _dt) + /// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи + /// (и одержимого - Possession, 05.10). + public static void Sleepers(World _world, EntityPlayerLocal _player, Vector3 _at, ref float _timer, float _dt) { - _sleeperCheck -= _dt; - if (_sleeperCheck > 0f || _world.IsRemote()) + _timer -= _dt; + if (_timer > 0f || _world.IsRemote()) { return; } - _sleeperCheck = 0.25f; + _timer = 0.25f; Vector3 body = _player.position; - _player.position = _pos; + _player.position = _at; try { _world.CheckSleeperVolumeTouching(_player); @@ -667,6 +729,16 @@ namespace NecromancerTome { return false; } + if (_flying) + { + // Сквозь постройки можно, в грунт - нет. + if (Inside(_world, c + Vector3.up * BodyUp, TerrainMask, out bool ground) && ground) + { + return false; + } + _pos = c; + return true; + } // Один блок - ступенька, два - стена. Проверяем настоящую геометрию, а не тип блока: // у блока есть только флаг «твёрдый», и открытая дверь для него такая же стена, как // закрытая (тест 28.09: «змея не может пройти в открытые двери»). @@ -685,11 +757,16 @@ namespace NecromancerTome _vy = 0f; } } + // Ни в грунт, ни в блоки: обрыв выше подъёма или стена, которую шар изнутри не чует. + if (Inside(_world, c + Vector3.up * BodyUp, WallMask, out _)) + { + return false; + } _pos = c; return true; } - static void Fall(float _dt, bool _jump) + static void Fall(World _world, float _dt, bool _jump) { if (!Ground(_pos + Vector3.up * 0.3f, 300f, out float ground)) { @@ -717,6 +794,12 @@ namespace NecromancerTome _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) { @@ -731,38 +814,115 @@ namespace NecromancerTome return Physics.CheckSphere(_p - Origin.position, BodyRadius, WallMask, QueryTriggerInteraction.Ignore); } - /// Правая кнопка: выбраться из текстур. Ближайшее свободное место - сначала выше - /// на месте, потом кольцами до 3 м и до 4 м вверх; там, где тело змеи ни во что не упирается. - /// Дальше змея сама падает на землю. - static void Unstick(World _world) + /// Каждый кадр: змея в грунте (в полёте) или в любой текстуре (на земле) - + /// выбрасываем на ближайшее свободное место (указание 05.10). + static void KeepOut(World _world, float _dt) { - if (_dashTarget != null) + if (_ejectWait > 0f) + { + _ejectWait -= _dt; + return; + } + Vector3 body = _pos + Vector3.up * BodyUp; + if (!_world.IsChunkAreaCollidersLoaded(body)) { return; } - for (float r = 0f; r <= 3f; r += 1f) + bool ground; + bool stuck = _flying ? Inside(_world, body, TerrainMask, out ground) && ground : Inside(_world, body, WallMask, out ground); + if (stuck && !Eject(_world, _flying, 0f)) { - int dirs = r == 0f ? 1 : 8; - // На месте - минимум на полметра вверх: проверка может счесть место свободным, а - // змея всё равно не идёт (тест 28.09: дважды «выбралась, сдвиг 0.0 м»). - for (float h = r == 0f ? 0.5f : 0f; h <= 4f; h += 0.5f) + _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) { - for (int i = 0; i < dirs; i++) + int dirs = r == 0f ? 1 : 8; + for (float h = -1f; h <= 6f; h += 0.5f) { - 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)) + for (int i = 0; i < dirs; i++) { - continue; + list.Add(Quaternion.Euler(0f, i * 45f, 0f) * Vector3.forward * r + Vector3.up * h); } - Debug.Log("[NecromancerTome] SnakeControl: выбралась, сдвиг " + (p - _pos).magnitude.ToString("F1") + " м"); - _pos = p; - _vy = 0f; - _airborne = true; - return; } } + list.Sort((x, y) => EjectCost(x).CompareTo(EjectCost(y))); + _ejectOffsets = list.ToArray(); + } + for (int i = 0; i < _ejectOffsets.Length; i++) + { + Vector3 o = _ejectOffsets[i]; + if (o.magnitude < _minShift || !Free(_world, _pos + o, _groundOnly)) + { + continue; + } + Debug.Log("[NecromancerTome] SnakeControl: выброшена из текстур" + (_groundOnly ? " (грунт)" : "") + ", сдвиг " + o.magnitude.ToString("F1") + " м"); + _pos += o; + _vy = 0f; + _airborne = !_flying; + return true; } Debug.Log("[NecromancerTome] SnakeControl: выбраться некуда"); + return false; + } + + static float EjectCost(Vector3 _o) + { + return _o.magnitude + (_o.y < 0f ? -_o.y : 0f); + } + + static bool Free(World _world, Vector3 _p, bool _groundOnly) + { + if (!_world.IsChunkAreaCollidersLoaded(_p)) + { + return false; + } + bool ground; + if (_groundOnly) + { + return !(Inside(_world, _p + Vector3.up * BodyUp, TerrainMask, out ground) && ground); + } + return !Inside(_world, _p + Vector3.up * BodyUp, WallMask, out ground) && + !Blocked(_p + Vector3.up * 0.4f) && !Blocked(_p + Vector3.up * 1f); + } + + /// Точка внутри текстуры? Земля игры - поверхность, а не сплошная масса, и шар + /// (Blocked) изнутри её не чует. Поэтому так: луч вверх до первого потолка (поверхность + /// лицом вниз), потом луч от потолка вниз до точки. Попалась поверхность лицом вверх - + /// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает + /// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой + /// точке), а не постройка. + 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) @@ -1266,6 +1426,10 @@ namespace NecromancerTome } Vector3 body = player.position; player.position = snake.position; + if (SnakeControl.FullViewDim > 0 && player.ChunkObserver != null) + { + player.ChunkObserver.viewDim = SnakeControl.FullViewDim; + } return body; } @@ -1284,7 +1448,8 @@ namespace NecromancerTome if (player != null) { player.position = _body; - if (Possession.Active && Possession.FullViewDim > 0 && player.ChunkObserver != null) + if (((Possession.Active && Possession.FullViewDim > 0) || (SnakeControl.Controlling && SnakeControl.FullViewDim > 0)) && + player.ChunkObserver != null) { player.ChunkObserver.viewDim = Possession.BodyViewDim; } @@ -1303,11 +1468,11 @@ namespace NecromancerTome public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); } } - /// Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела). + /// Детализация зданий (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 ? SnakeAudioSwap.Prefix() : Vector3.zero; } + 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); } } diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll index 33067cb..5d05634 100644 Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ