diff --git a/Config/Localization.csv b/Config/Localization.csv index 385677d..6ccfa97 100644 --- a/Config/Localization.csv +++ b/Config/Localization.csv @@ -278,3 +278,4 @@ Dört modifikasyon yuvasına takılan kan küreleriyle çalışır: küreler sı 以放入四個改裝槽的血之球為燃料:血之球依次消耗,一顆相當於一滿箱。無法用油桶加油。用骨頭修理(每次50根)。 任一槽中放入血石即可永遠滿箱。" necroBoneSteedNeedBones,UI,Tooltip,,,,You need {0} bones to repair the Bone Steed.,,"Du brauchst {0} Knochen, um das Knochenross zu reparieren.",Necesitas {0} huesos para reparar el Corcel de hueso.,Il te faut {0} os pour réparer le Destrier d'os.,Servono {0} ossa per riparare il Destriero d'ossa.,骨の駿馬の修理には骨が{0}個必要だ。,뼈 군마를 수리하려면 뼈 {0}개가 필요하다.,Do naprawy Kościanego rumaka potrzeba {0} kości.,Você precisa de {0} ossos para reparar o Corcel de osso.,Для ремонта Костяного скакуна нужно {0} костей.,Kemik Atı onarmak için {0} kemik gerekir.,修理骨骸坐骑需要{0}根骨头。,修理骨骸坐騎需要{0}根骨頭。 necroBoneSteed,entityclasses,Entity,,,,Bone Steed,,Knochenross,Corcel de hueso,Destrier d'os,Destriero d'ossa,骨の駿馬,뼈 군마,Kościany rumak,Corcel de osso,Костяной скакун,Kemik At,骨骸坐骑,骨骸坐騎 +zombieEraser,entityclasses,Entity,,,,Eraser,,Radierer,Borrador,Effaceur,Cancellatore,イレイサー,이레이저,Wymazywacz,Apagador,Стиратель,Silici,抹除者,抹除者 diff --git a/Config/buffs.xml b/Config/buffs.xml index 9efdc41..b07eb0a 100644 --- a/Config/buffs.xml +++ b/Config/buffs.xml @@ -343,4 +343,19 @@ + + + + + diff --git a/Config/entityclasses.xml b/Config/entityclasses.xml index 4a21ba3..3a5708a 100644 --- a/Config/entityclasses.xml +++ b/Config/entityclasses.xml @@ -442,4 +442,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Config/nav_objects.xml b/Config/nav_objects.xml index 66f2e0a..1792532 100644 --- a/Config/nav_objects.xml +++ b/Config/nav_objects.xml @@ -84,4 +84,19 @@ + + + + + + + + + + + + + diff --git a/HarmonySrc/EraserPatch.cs b/HarmonySrc/EraserPatch.cs new file mode 100644 index 0000000..fe2a213 --- /dev/null +++ b/HarmonySrc/EraserPatch.cs @@ -0,0 +1,282 @@ +using HarmonyLib; +using UnityEngine; + +namespace NecromancerTome +{ + /// + /// «Стиратель» (указание 02.10) - античит к режиму змеи: если игрок в кровавую луну стал змеёй, + /// в его орде с большой вероятностью приходят Стиратели - маленькие быстрые зомби с + /// нитроглицериновыми вздутиями. Взрываются, когда пытаются сломать блок и когда их разрывает + /// змея; взрыв ломает блоки, ранит тело игрока и соседних зомби, змею не ранит (она бессмертна), + /// а отбрасывает. + /// + /// Сущность - zombieEraser (entityclasses.xml): имя НЕ на necro, иначе IsNecroEntity сочтёт её + /// своей и змея перестанет видеть в ней врага. Параметры взрыва - блок Explosion там же, + /// подрыв - как у ванильного EntityZombieCop: ExplosionServer + SetDead. + /// + public static class EraserPatch + { + public const string EntityClassName = "zombieEraser"; + /// Доля Стирателей в орде игрока-змеи («с большой вероятностью»). + public const float HordeChance = 0.5f; + /// Отброс змеи: дальше этого от взрыва змею не задевает. + public const float KnockRange = 8f; + + static int _classId = -1; + + public static bool IsEraser(Entity _e) + { + if (_e == null) + { + return false; + } + if (_classId == -1) + { + _classId = EntityClass.FromString(EntityClassName); + } + return _e.entityClass == _classId; + } + + static System.Collections.IEnumerator BurstNextFrame(EntityAlive _e) + { + yield return null; + if (_e != null && _e.world != null && _e.world.GetEntity(_e.entityId) != null) + { + NecroBurst.Burst(_e, _e); + } + } + + /// Подрыв на сервере. Возвращает false, если подрывать нечего или не здесь. + public static bool Explode(EntityAlive _e) + { + if (_e == null || _e.IsDead() || _e.world == null || _e.world.IsRemote()) + { + return false; + } + Vector3 pos = _e.GetPosition(); + _e.NotifySleeperDeath(); + _e.SetModelLayer(2); + GameManager.Instance.ExplosionServer(pos, World.worldToBlockPos(pos), _e.transform.rotation, + EntityClass.list[_e.entityClass].explosionData, _e.entityId, 0f, false); + _e.timeStayAfterDeath = 0; + _e.SetDead(); + SnakeControl.Knockback(pos, KnockRange); + // Тела не остаётся (указание 02.10: «после взрыва остаются трупы туристов»): чёрный + // разрыв, как от змеи. Не сразу, а в следующем кадре: подрыв идёт изнутри хода ИИ + // (Attack из задачи), и удаление посреди него роняло EntityAlive.updateTasks (NRE, тест 02.10). + GameManager.Instance.StartCoroutine(BurstNextFrame(_e)); + Debug.Log("[NecromancerTome] EraserPatch: Стиратель " + _e.entityId + " взорвался в " + pos); + return true; + } + } + + /// Стиратель упёрся и начинает ломать блок - вместо удара взрыв. + [HarmonyPatch(typeof(EAIBreakBlock), nameof(EAIBreakBlock.Start))] + public static class Patch_EAIBreakBlock_Start_Eraser + { + public static bool Prefix(EAIBreakBlock __instance) + { + return !(EraserPatch.IsEraser(__instance.theEntity) && EraserPatch.Explode(__instance.theEntity)); + } + } + + /// Любая атака Стирателя - по блоку (BreakBlock, DestroyArea) или по цели + /// (ApproachAndAttackTarget) - идёт через EntityAlive.Attack: взрыв вместо удара (тест 02.10: + /// «взрываются не всегда, хотя атакуют»). + [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.Attack))] + public static class Patch_EntityAlive_Attack_Eraser + { + public static bool Prefix(EntityAlive __instance, ref bool __result) + { + if (EraserPatch.IsEraser(__instance) && EraserPatch.Explode(__instance)) + { + __result = false; + return false; + } + return true; + } + } + + /// + /// Подмешивание в орду. Ванильный образец - AIDirectorBloodMoonParty.SpawnZombie: игрок в + /// транспорте получает в орду стервятников с шансом 0.5. Класс зомби там берётся из + /// EntityGroups.GetRandomEntityFromGroupMaxTier; на время SpawnZombie запоминаем цель и, если + /// это игрок в режиме змеи, с шансом подменяем результат на Стирателя. + /// Режим змеи - состояние локального игрока (SnakeControl.Controlling), поэтому работает в + /// одиночной игре и у хоста; змея подключённого клиента орду не меняет. + /// + [HarmonyPatch(typeof(AIDirectorBloodMoonParty), "SpawnZombie")] + public static class Patch_BloodMoonParty_SpawnZombie_Eraser + { + [System.ThreadStatic] public static EntityPlayer Target; + + public static void Prefix(EntityPlayer _target) + { + Target = _target; + } + + public static void Finalizer() + { + Target = null; + } + } + + [HarmonyPatch(typeof(EntityGroups), nameof(EntityGroups.GetRandomEntityFromGroupMaxTier))] + public static class Patch_EntityGroups_GetRandom_Eraser + { + public static void Postfix(ref int __result) + { + EntityPlayer target = Patch_BloodMoonParty_SpawnZombie_Eraser.Target; + if (target == null || __result == -1 || !SnakeControl.Controlling || + !(target is EntityPlayerLocal) || Random.value >= EraserPatch.HordeChance) + { + return; + } + int id = EntityClass.FromString(EraserPatch.EntityClassName); + if (id != -1 && EntityClass.list.ContainsKey(id)) + { + __result = id; + } + } + } + + /// + /// Вид Стирателя (02.10): тело - ванильная модель толстого гавайца (скелет, физика, теги, + /// анимации игры), а раскраска своя. Игра в EModelBase.createModel сама копирует материал тела + /// (ветка MatColor/AltMats); после неё подставляем в материал LOD-рендереров свои текстуры + /// (Resources/Eraser/*.png, нарисованы скриптом по развёртке гавайца): _Albedo, _Normal и _RMOE + /// (шероховатость, металл, затенение, в альфе - маска свечения), свечение - _EmissiveColor. + /// Глаза - отдельные рендереры *Eye* - прячем: на их месте в текстуре чёрные провалы. + /// Ступни босые - пальцы и ногти нарисованы в текстуре (слоновья нога масштабом костей не + /// получилась, 02.10). + /// + [HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))] + public static class Patch_EModelBase_createModel_EraserLook + { + public static readonly Color Emission = new Color(1f, 0.1f, 0.65f) * 2.5f; + + static Texture2D _albedo, _normal, _rmoe; + + /// Своя раскраска на материал тела (только у материалов с _Albedo - тело и куски). + public static void Paint(Renderer _r) + { + // Сначала смотрим общий материал: обращение к .material создаёт копию, и делать её + // у глаз, огня и прочих рендереров без _Albedo незачем. Если копия уже наша - .material + // вернёт её же, повторной копии не будет. Брошенные копии убирает сама игра + // (GameManager: Resources.UnloadUnusedAssets по таймеру). + Material sm = _r.sharedMaterial; + if (sm == null || !sm.HasProperty("_Albedo") || sm.GetTexture("_Albedo") == _albedo && sm.GetColor("_EmissiveColor") == Emission) + { + return; + } + Material m = _r.material; + if (_albedo != null) m.SetTexture("_Albedo", _albedo); + if (_normal != null) m.SetTexture("_Normal", _normal); + if (_rmoe != null) m.SetTexture("_RMOE", _rmoe); + m.SetColor("_EmissiveColor", Emission); + } + static bool _loaded; + + static Texture2D Load(string _file, bool _linear) + { + string path = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(typeof(EraserPatch).Assembly.Location), "Resources", "Eraser", _file); + if (!System.IO.File.Exists(path)) + { + Debug.LogWarning("[NecromancerTome] EraserLook: нет файла " + path); + return null; + } + Texture2D t = new Texture2D(2, 2, TextureFormat.RGBA32, true, _linear); + if (!ImageConversion.LoadImage(t, System.IO.File.ReadAllBytes(path))) + { + return null; + } + t.name = "Eraser_" + _file; + t.Apply(true, true); + return t; + } + + public static void Postfix(EModelBase __instance) + { + Entity e = __instance.entity; + if (!EraserPatch.IsEraser(e)) + { + return; + } + if (!_loaded) + { + _loaded = true; + _albedo = Load("eraser_albedo.png", false); + _normal = Load("eraser_normal.png", true); + _rmoe = Load("eraser_rmoe.png", true); + } + foreach (SkinnedMeshRenderer r in __instance.GetComponentsInChildren(true)) + { + if (r.name.IndexOf("Eye", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + r.enabled = false; + continue; + } + if (!r.CompareTag("LOD")) + { + continue; + } + Paint(r); + } + Debug.Log("[NecromancerTome] EraserLook: вид Стирателя на " + e.entityId); + } + } + + /// + /// Отрубленная конечность (тест 02.10: «если у трупа отрубить руку, он становится туристом»): + /// игра при расчленении подменяет материалы тела и создаёт отлетающий кусок с ванильным + /// материалом гавайца. После RemoveLimb заново красим тело и новые куски. + /// + [HarmonyPatch(typeof(AvatarZombieController), nameof(AvatarZombieController.RemoveLimb))] + public static class Patch_AvatarZombieController_RemoveLimb_EraserLook + { + public static void Prefix(out int __state) + { + __state = DismembermentManager.Instance?.parts.Count ?? 0; + } + + public static void Postfix(AvatarZombieController __instance, int __state) + { + EntityAlive e = Traverse.Create(__instance).Field("entity").GetValue(); + if (!EraserPatch.IsEraser(e) || e.emodel == null) + { + return; + } + foreach (Renderer r in e.emodel.GetComponentsInChildren(true)) + { + Patch_EModelBase_createModel_EraserLook.Paint(r); + } + var parts = DismembermentManager.Instance?.parts; + for (int i = __state; parts != null && i < parts.Count; i++) + { + foreach (Transform t in new[] { parts[i].prefabT, parts[i].detachT }) + { + if (t == null) continue; + foreach (Renderer r in t.GetComponentsInChildren(true)) + { + Patch_EModelBase_createModel_EraserLook.Paint(r); + } + } + } + } + } + + /// Стиратель убит чем угодно (игроком, взрывом соседа, под бафом жертвы) - чёрный + /// разрыв, трупа не остаётся (указание 02.10). Тот же путь, что у змеи: постфикс ClientKill. + [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.ClientKill))] + public static class Patch_EntityAlive_ClientKill_EraserBurst + { + public static void Postfix(EntityAlive __instance) + { + if (EraserPatch.IsEraser(__instance) && __instance.IsDead() && __instance.world != null && + __instance.world.GetEntity(__instance.entityId) != null) + { + NecroBurst.Burst(__instance, __instance.entityThatKilledMe != null ? (Entity)__instance.entityThatKilledMe : __instance); + } + } + } +} diff --git a/HarmonySrc/NecroFleeSnakeTask.cs b/HarmonySrc/NecroFleeSnakeTask.cs new file mode 100644 index 0000000..ee98454 --- /dev/null +++ b/HarmonySrc/NecroFleeSnakeTask.cs @@ -0,0 +1,177 @@ +using GamePath; +using UnityEngine; + +namespace NecromancerTome +{ + /// + /// «Леденящий ужас» (указание 02.10): зомби, которого учуяла змея (тёмное чутьё, 30 м, сквозь + /// стены), сразу пугается и бежит от неё как можно дальше, ломая препятствия. Спящих будим - + /// «пусть будет хаос». Заменяет «шум» (зомби шли к змее, 28.09). + /// + /// ОБРАЗЕЦ - ванильный EAIRunAway (основа RunawayFromEntity у зверей): точка в FleeDistance от + /// угрозы через RandomPositionGenerator.CalcPositionInDirection, пересчёт по таймеру. Отличия: + /// - путь с canBreak: true. У ванили false, и зверь не ломает ничего. Ломает не эта задача, а + /// ванильный BreakBlock (AITask-1 у зомби): он включается, когда moveHelper упёрся + /// (BlockedTime ≥ 0.35) на пути с CanBreakBlocks - а этот флаг приходит из нашего пути. + /// - угроза - позиция змеи, её даёт SnakeControl.Sense; задача сама ничего не ищет. + /// - работает, пока Until не истёк: Sense продлевает его, пока змея чует зомби. + /// + /// ПРИОРИТЕТ 1, MutexBits 3. Задачи у зомби: 1 BreakBlock (биты 8), 2 ApproachAndAttackTarget + /// (3), 3 ApproachSpot (3) ... EAITaskList.isBestTask: задача с меньшим номером и общими битами + /// вытесняет остальные, с BreakBlock биты не пересекаются - он работает вместе с бегством. + /// Добавляется в рантайме (AddTask), как NecroFollowOwnerTask; из списка не убирается - + /// с истёкшим Until CanExecute сразу false, а AI-задачи в сейв не пишутся. + /// + public class NecroFleeSnakeTask : EAIBase + { + public const int Priority = 1; + /// На сколько блоков вперёд выбирается точка бегства; как у ванили. + public const float FleeDistance = 20f; + /// Сколько секунд зомби ещё бежит после последнего «учуяла» (Sense - раз в 1 с). + public const float FearSeconds = 4f; + /// Тиков ИИ (20 в секунду) между выборами новой точки и между запросами пути. + public const int RetargetTicks = 40; + /// Скорость бегства - максимум бега зомби-людей в entityclasses.xml (MoveSpeedAggro до + /// 1.5; быстрее только звери: медведь 2.0, пума 2.3). Указание 02.10: «пусть убегают быстро». + public const float FleeSpeed = 1.5f; + + public static Vector3 SnakePos; + public float Until; + Vector3 targetPos; + int retargetTicks; + + public override void Init(EntityAlive _theEntity) + { + base.Init(_theEntity); + MutexBits = 3; + executeDelay = 0.1f; + } + + public override bool CanExecute() + { + return Time.time < Until && FindFleePos(); + } + + /// Сколько криков тревоги уже было в этом кадре: у толпы кричат не все. + static int _alertFrame = -1, _alertCount; + public const int MaxAlertsPerFrame = 2; + + public override void Start() + { + // Тест 02.10: «если пугать зомби одновременно - фризы». Все испуганные за один проход + // чутья стартовали в одном кадре: каждый кричал и сразу просил путь с ломанием блоков + // (дорогой для пафайндера). Теперь первый путь - через случайные 0-1.5 с, крик - не + // больше двух за кадр. + retargetTicks = theEntity.rand.RandomRange(0, 30); + PathFinderThread.Instance.RemovePathsFor(theEntity.entityId); + if (_alertFrame != Time.frameCount) + { + _alertFrame = Time.frameCount; + _alertCount = 0; + } + string alert = theEntity.GetSoundAlert(); + if (alert != null && ++_alertCount <= MaxAlertsPerFrame) + { + theEntity.PlayOneShot(alert); + } + } + + public override void Reset() + { + theEntity.moveHelper.Stop(); + theEntity.SetLookPosition(Vector3.zero); + } + + public override bool Continue() + { + return Time.time < Until && !theEntity.IsDead(); + } + + float _diagTime; + + public override void Update() + { + PathEntity path = theEntity.navigator.getPath(); + if (path != null) + { + theEntity.moveHelper.CalcIfUnreachablePos(); + } + theEntity.SetLookPosition(targetPos + Vector3.up * 0.8f); + if (Time.time > _diagTime) + { + // Диагностика (тест 02.10: «зомби не убегает, стоит на месте») - убрать после проверки. + _diagTime = Time.time + 3f; + Debug.Log("[NecromancerTome] NecroFleeSnakeTask: " + theEntity.entityId + " поз " + theEntity.position.ToString("F0") + + " цель " + targetPos.ToString("F0") + " путь " + (path == null ? "нет" : path.NodeCountRemaining() + " узл.") + + " считается " + PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId) + + " скор " + FleeSpeed.ToString("F2")); + } + if (--retargetTicks > 0 || PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId)) + { + return; + } + retargetTicks = RetargetTicks; + var sw = System.Diagnostics.Stopwatch.StartNew(); + FindFleePos(); + theEntity.FindPath(targetPos, FleeSpeed, true, this); + if (sw.Elapsed.TotalMilliseconds > 3.0) + { + Debug.Log("[NecromancerTome] NecroFleeSnakeTask: долгий перерасчёт у " + theEntity.entityId + " - " + sw.Elapsed.TotalMilliseconds.ToString("F1") + " мс"); + } + } + + bool FindFleePos() + { + Vector3 away = theEntity.position - SnakePos; + away.y = 0f; + if (away.sqrMagnitude < 0.01f) + { + away = UnityEngine.Random.insideUnitSphere; + away.y = 0f; + } + Vector3 p = RandomPositionGenerator.CalcPositionInDirection(theEntity, theEntity.position, away, FleeDistance, 60f); + // Ванильный подбор вернул ноль (нет чанка, не нашлось опоры) - всё равно прочь по прямой: + // путь найдёт пафайндер, а препятствия на нём сломает BreakBlock. + targetPos = p.y != 0f ? p : theEntity.position + away.normalized * FleeDistance; + // Как ванильный ApproachSpot: цель - на опорном блоке, а не в воздухе/стене. + targetPos = theEntity.world.FindSupportingBlockPos(targetPos); + return true; + } + + /// Уже напуган - продлить испуг (дёшево). False - ещё не напуган, нужна очередь. + public static bool Refresh(EntityAlive _e) + { + NecroFleeSnakeTask task = _e.aiManager?.tasks.GetTask(); + if (task == null) + { + return false; + } + task.Until = Time.time + FearSeconds; + return true; + } + + /// Из очереди SnakeControl, только на сервере: напугать (или продлить испуг). + public static void Scare(EntityAlive _e) + { + if (_e.aiManager == null) + { + return; + } + _e.ConditionalTriggerSleeperWakeUp(); + NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask(); + if (task == null) + { + task = new NecroFleeSnakeTask(); + task.Init(_e); + _e.aiManager.tasks.AddTask(Priority, task); + Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " в ужасе"); + } + task.Until = Time.time + FearSeconds; + } + + public override string ToString() + { + return base.ToString() + ", flee to " + targetPos + ", " + (Until - Time.time).ToString("F1") + " s"; + } + } +} diff --git a/HarmonySrc/ParticlePatch.cs b/HarmonySrc/ParticlePatch.cs index b761803..fbef274 100644 --- a/HarmonySrc/ParticlePatch.cs +++ b/HarmonySrc/ParticlePatch.cs @@ -77,6 +77,9 @@ namespace NecromancerTome /// светятся сильнее обычного; плотнее и крупнее, чем у девиантов. public static readonly GlowConfig SnakeSightGlow = new GlowConfig { Tint = new Color(1.1f, 1.5f, 0.15f), SizeFactor = 0.4f, AlphaFactor = 1f, DensityFactor = 3f }; public const string SnakeSightBuffName = "buffNecroSnakeSight"; + /// «Стиратель» (02.10): маджента - этим цветом зомби больше никто не светится. + public static readonly GlowConfig EraserGlow = new GlowConfig { Tint = new Color(1f, 0.1f, 0.65f), SizeFactor = 0.4f, AlphaFactor = 1f, DensityFactor = 3f }; + public const string EraserBuffName = "buffNecroEraser"; public static readonly GlowConfig PortalChannelGlow = new GlowConfig { Tint = new Color(0.1f, 0.85f, 0.8f), SizeFactor = 0.9f, AlphaFactor = 0.9f, DensityFactor = 5f }; public static void Postfix(MinEventActionAttachParticleEffectToEntity __instance, MinEventParams _params) @@ -97,7 +100,8 @@ namespace NecromancerTome bool isVictim = entity.Buffs.HasBuff(Patch_EntityAlive_dropItemOnDeath_VictimBag.VictimBuffName); bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName); bool isSnakeSight = entity.Buffs.HasBuff(SnakeSightBuffName); - if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight) + bool isEraser = entity.Buffs.HasBuff(EraserBuffName); + if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight && !isEraser) { return; } @@ -109,6 +113,10 @@ namespace NecromancerTome { config = PortalChannelGlow; } + else if (isEraser) + { + config = EraserGlow; + } else if (isSnakeSight) { config = SnakeSightGlow; diff --git a/HarmonySrc/SnakeControl.cs b/HarmonySrc/SnakeControl.cs index 4718fcf..c9c6f27 100644 --- a/HarmonySrc/SnakeControl.cs +++ b/HarmonySrc/SnakeControl.cs @@ -40,8 +40,9 @@ namespace NecromancerTome /// позади головы: голова впереди и чуть ниже, в поле зрения. public const float EyeUp = 0.5f; public const float EyeForward = -1.2f; - /// Сколько змея живёт в радиации края карты (указание 28.09: «таймер 30 сек»). - public const float RadiationTime = 30f; + /// Как часто змея проверяет радиацию края карты. Указание 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; @@ -78,6 +79,11 @@ namespace NecromancerTome 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; @@ -92,6 +98,15 @@ namespace NecromancerTome 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; + public const string SnakeMapNavObject = "necroSnakeSpirit"; + static EntityAlive _mapSnake; + public const float FearInterval = 0.25f; /// Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи /// лут не выпадал вообще»). Снимается в патче dropItemOnDeath. public static readonly System.Collections.Generic.HashSet NoLoot = new System.Collections.Generic.HashSet(); @@ -99,18 +114,11 @@ namespace NecromancerTome /// или лута, она для повышения уровня Некромантии»). Снимается в патче AddKillXP. public static readonly System.Collections.Generic.HashSet NoXp = new System.Collections.Generic.HashSet(); - /// Зомби, которые заметили змею (указание 28.09: «встречные зомби просто стоят, - /// скучно - пусть начинают ходить, если змея рядом»): не дальше StirRange идут к ней, как на - /// шум. Нападать всё равно не станут - змей зомби не трогают. - public const float StirRange = 20f; - static float _stirCheck; public const string ToolbeltWindow = "toolbelt"; public static bool Controlling; public static float AnimForward; - /// Секунды до развеивания; меньше нуля - змея не в радиации. - public static float Countdown = -1f; static float _radiationCheck; /// Свой наблюдатель чанков у змеи: мир подгружается вокруг наблюдателей, а у игрока /// он стоит у тела (тест 28.09: «змея не может уйти далеко от тела»). Так же игра грузит @@ -144,14 +152,19 @@ namespace NecromancerTome _loggedCamera = false; _headForward = -1f; _dashTarget = null; - _stirCheck = 0f; _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; - Countdown = -1f; _radiationCheck = 0f; BodyDanger = false; _bodyCheck = 0f; @@ -210,11 +223,17 @@ namespace NecromancerTome 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; - Countdown = -1f; BodyDanger = false; if (_observer != null) { @@ -305,6 +324,20 @@ namespace NecromancerTome } } } + 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); @@ -317,12 +350,37 @@ namespace NecromancerTome _snake.moveDirection = Vector3.zero; AnimForward = moving ? (run ? AnimRun : AnimWalk) : 0f; CheckBody(world, _player, _snake, dt); - Stir(world, _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; + } + } + /// Шаг рывка: по прямой к ногам цели, без земли и стен. Долетела - убийство: цель /// помечается для чёрного разрыва (NecroBurst.Pending) и получает смертельный урон ОТ ИГРОКА - /// тогда игра сама выдаёт опыт, а NecromancyKillCreditPatch засчитывает убийство в Некромантию. @@ -349,6 +407,11 @@ namespace NecromancerTome } _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); @@ -365,6 +428,32 @@ namespace NecromancerTome Debug.Log("[NecromancerTome] SnakeControl: удар по " + target.EntityName + ", " + (target.IsDead() ? "убит" : "ВЫЖИЛ")); } + /// Из EraserPatch.Explode: змею ближе _range от взрыва отбрасывает прочь и вверх. + /// Не ранит - змея бессмертна. + 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: отброшена взрывом Стирателя"); + } + /// Из NecroBurst.OnBurst: наш разрыв - запоминаем частицы, чтобы вести их за змеёй. public static void OnBurst(EntityAlive _victim, Transform _effect) { @@ -426,6 +515,19 @@ namespace NecromancerTome 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]; @@ -437,6 +539,12 @@ namespace NecromancerTome 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); @@ -453,6 +561,10 @@ namespace NecromancerTome } // Исчезнувшие (разорванные, выгруженные) - просто забываем: метку игра снимет сама. _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() @@ -470,28 +582,6 @@ namespace NecromancerTome _sensed.Clear(); } - /// Раз в 2 с: спокойные зомби рядом со змеёй идут к ней, как на шум. - static void Stir(World _world, EntityAlive _snake, float _dt) - { - _stirCheck -= _dt; - if (_stirCheck > 0f) - { - return; - } - _stirCheck = 2f; - for (int i = 0; i < _world.EntityAlives.Count; i++) - { - EntityAlive e = _world.EntityAlives[i]; - if (!(e is EntityZombie) || !IsFoe(e, _snake) || e.IsSleeping || e.GetAttackTarget() != null || - e.HasInvestigatePosition || (e.position - _pos).sqrMagnitude > StirRange * StirRange) - { - continue; - } - Vector3 spot = _pos + new Vector3(Random.Range(-3f, 3f), 0f, Random.Range(-3f, 3f)); - e.SetInvestigatePosition(spot, 300, false); - } - } - /// Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под /// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это /// не враг»). Общее для тревоги у тела и для прицела. @@ -539,32 +629,17 @@ namespace NecromancerTome } } - /// Отсчёт в радиации края карты. Вышла из зоны - отсчёт сбрасывается. + /// Радиация края карты: змея в ней сразу развеивается, игрок в теле. static bool Radiation(World _world, float _dt) { _radiationCheck -= _dt; - if (_radiationCheck <= 0f) - { - _radiationCheck = 0.5f; - IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider(); - bool inside = biomes != null && biomes.GetRadiationAt((int)_pos.x, (int)_pos.z) > RadiationThreshold; - if (inside && Countdown < 0f) - { - Countdown = RadiationTime; - Debug.Log("[NecromancerTome] SnakeControl: радиация, отсчёт " + RadiationTime + " с"); - } - else if (!inside && Countdown >= 0f) - { - Countdown = -1f; - Debug.Log("[NecromancerTome] SnakeControl: вышла из радиации"); - } - } - if (Countdown < 0f) + if (_radiationCheck > 0f) { return false; } - Countdown -= _dt; - if (Countdown > 0f) + _radiationCheck = RadiationCheckPeriod; + IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider(); + if (biomes == null || biomes.GetRadiationAt((int)_pos.x, (int)_pos.z) <= RadiationThreshold) { return false; } @@ -892,11 +967,6 @@ namespace NecromancerTome { DrawBodyDanger(); } - if (SnakeControl.Countdown >= 0f) - { - DrawCountdown(); - return; - } Texture tex = _custom != null ? (Texture)_custom : _atlas.texture; Rect uv = _custom != null ? new Rect(0f, 0f, 1f, 1f) @@ -918,30 +988,6 @@ namespace NecromancerTome GUI.color = old; } - GUIStyle _countdownStyle; - - /// Вместо глаза - секунды до развеивания, красным, с тенью. - void DrawCountdown() - { - if (_countdownStyle == null) - { - _countdownStyle = new GUIStyle(GUI.skin.label) - { - alignment = TextAnchor.MiddleCenter, - fontStyle = FontStyle.Bold - }; - } - _countdownStyle.fontSize = Mathf.RoundToInt(44f * Screen.height / 1080f); - string text = Mathf.CeilToInt(SnakeControl.Countdown).ToString(); - Rect rect = new Rect(Screen.width * 0.5f - 100f, Screen.height * 0.5f - 50f, 200f, 100f); - 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, _countdownStyle); - GUI.color = OnTarget; - GUI.Label(rect, text, _countdownStyle); - GUI.color = old; - } - GUIStyle _dangerStyle; /// Надпись под прицелом, пока враг у тела. diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll index abb33f1..2357f81 100644 Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ diff --git a/Resources/Eraser/eraser_albedo.png b/Resources/Eraser/eraser_albedo.png new file mode 100644 index 0000000..1479bbd Binary files /dev/null and b/Resources/Eraser/eraser_albedo.png differ diff --git a/Resources/Eraser/eraser_normal.png b/Resources/Eraser/eraser_normal.png new file mode 100644 index 0000000..c5f0d67 Binary files /dev/null and b/Resources/Eraser/eraser_normal.png differ diff --git a/Resources/Eraser/eraser_rmoe.png b/Resources/Eraser/eraser_rmoe.png new file mode 100644 index 0000000..98f5785 Binary files /dev/null and b/Resources/Eraser/eraser_rmoe.png differ