Режим змеи: леденящий ужас, Стиратель, радиация без таймера

- Радиация: проверка раз в 2 с, в поясе змея сразу развеивается (таймер 30 с убран).
- Леденящий ужас: учуянные змеёй зомби (30 м) бегут, ломая препятствия;
  очередь испуга, отметка места змеи для поиска пути, выключен в кровавую луну.
  «Шум» (зомби шли к змее) удалён. Диагностика бегства в логе пока оставлена.
- Стиратель (zombieEraser): в орде игрока-змеи с шансом 0.5; взрывается на любой
  атаке и при разрыве змеёй, отбрасывает змею; тело - чёрный разрыв. Вид: своя
  раскраска на модели гавайца (Resources/Eraser), свечение маджента, звуки спайдера.
- Фиолетовая змея на карте (necroSnakeSpirit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-03 00:32:20 +03:00
co-authored by Claude Opus 5.5
parent 6ded684d2c
commit c828202c66
12 changed files with 662 additions and 85 deletions
+1
View File
@@ -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,抹除者,抹除者
1 Key File Type UsedInMainMenu NoTranslate KeepLoaded english Context / Alternate Text german spanish french italian japanese koreana polish brazilian russian turkish schinese tchinese
278
279
280
281
+15
View File
@@ -343,4 +343,19 @@
</buff>
</append>
<!-- «Стиратель» (02.10.2026): свечение маджента - свободный цвет (у игры зелёный/голубой/огонь,
у мода зелёный, фиолетовый, жёлто-зелёный, бирюзовый). Цвет даёт ParticlePatch.cs (EraserGlow).
Вешается свойством Buffs сущности zombieEraser. -->
<append xpath="/buffs">
<buff name="buffNecroEraser" hidden="true" remove_on_death="false">
<stack_type value="ignore"/>
<duration value="0"/>
<effect_group>
<triggered_effect trigger="onSelfBuffStart" action="AttachParticleEffectToEntity" particle="RadiatedParticlesOnMesh" local_offset="0,0.75,0"/>
<triggered_effect trigger="onSelfBuffRemove" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
<triggered_effect trigger="onSelfDied" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
</effect_group>
</buff>
</append>
</config>
+33
View File
@@ -442,4 +442,37 @@
<property name="LootList" value="vehicleMotorcycle"/>
</entity_class>
</append>
<!-- «Стиратель» (02.10.2026, HarmonySrc/EraserPatch.cs): античит к режиму змеи - приходит в орду
кровавой луны игрока-змеи. Маленький, быстрый; взрывается, когда пытается сломать блок или
когда его разрывает змея. Имя НЕ на necro (IsNecroEntity - иначе змея не считает врагом).
Временный вид: тело толстого гавайца по ссылке игры + свечение маджента (buffNecroEraser);
свои вздутия и морда спайдера - потом. -->
<append xpath="/entity_classes">
<entity_class name="zombieEraser" extends="zombieFatHawaiian">
<property name="UserSpawnType" value="Menu"/>
<property name="Tags" value="entity,zombie,walker,eraser"/>
<property name="SizeScale" value="0.75"/>
<property name="MoveSpeedAggro" value="1.0, 1.6"/>
<!-- Звуки спайдера (указание 02.10) -->
<property name="SoundRandom" value="spiderroam"/>
<property name="SoundAlert" value="spideralert"/>
<property name="SoundAttack" value="spiderattack"/>
<property name="SoundDeath" value="spiderdeath"/>
<property name="SoundHurt" value="spiderpain"/>
<property name="SoundSense" value="spidersense"/>
<property name="SoundStepType" value="barestep"/>
<property name="Buffs" value="zombieBuffStatusCheck01,buffNecroEraser"/>
<property class="Explosion">
<property name="ParticleIndex" value="2"/>
<property name="RadiusBlocks" value="4"/>
<property name="RadiusEntities" value="5"/>
<property name="BlockDamage" value="2500"/>
<property name="EntityDamage" value="300"/>
<property class="DamageBonus">
<property name="earth" value=".1"/>
</property>
</property>
</entity_class>
</append>
</config>
+15
View File
@@ -84,4 +84,19 @@
</compass_settings>
</nav_object_class>
</append>
<!-- Дух змеи на карте (02.10.2026, просьба пользователя): фиолетовая змея - иконка следа змеи
(ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же
змеи бессмысленна. Вешается в SnakeControl.Begin, снимается в End. -->
<append xpath="/nav_object_classes">
<nav_object_class name="necroSnakeSpirit">
<map_settings>
<property name="sprite_name" value="ui_game_symbol_tracking_snake"/>
<property name="min_distance" value="0"/>
<property name="max_distance" value="-1"/>
<property name="color" value="170,60,255,255"/>
<property name="has_pulse" value="false"/>
</map_settings>
</nav_object_class>
</append>
</config>
+282
View File
@@ -0,0 +1,282 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// «Стиратель» (указание 02.10) - античит к режиму змеи: если игрок в кровавую луну стал змеёй,
/// в его орде с большой вероятностью приходят Стиратели - маленькие быстрые зомби с
/// нитроглицериновыми вздутиями. Взрываются, когда пытаются сломать блок и когда их разрывает
/// змея; взрыв ломает блоки, ранит тело игрока и соседних зомби, змею не ранит (она бессмертна),
/// а отбрасывает.
///
/// Сущность - zombieEraser (entityclasses.xml): имя НЕ на necro, иначе IsNecroEntity сочтёт её
/// своей и змея перестанет видеть в ней врага. Параметры взрыва - блок Explosion там же,
/// подрыв - как у ванильного EntityZombieCop: ExplosionServer + SetDead.
/// </summary>
public static class EraserPatch
{
public const string EntityClassName = "zombieEraser";
/// <summary>Доля Стирателей в орде игрока-змеи («с большой вероятностью»).</summary>
public const float HordeChance = 0.5f;
/// <summary>Отброс змеи: дальше этого от взрыва змею не задевает.</summary>
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);
}
}
/// <summary>Подрыв на сервере. Возвращает false, если подрывать нечего или не здесь.</summary>
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;
}
}
/// <summary>Стиратель упёрся и начинает ломать блок - вместо удара взрыв.</summary>
[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));
}
}
/// <summary>Любая атака Стирателя - по блоку (BreakBlock, DestroyArea) или по цели
/// (ApproachAndAttackTarget) - идёт через EntityAlive.Attack: взрыв вместо удара (тест 02.10:
/// «взрываются не всегда, хотя атакуют»).</summary>
[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;
}
}
/// <summary>
/// Подмешивание в орду. Ванильный образец - AIDirectorBloodMoonParty.SpawnZombie: игрок в
/// транспорте получает в орду стервятников с шансом 0.5. Класс зомби там берётся из
/// EntityGroups.GetRandomEntityFromGroupMaxTier; на время SpawnZombie запоминаем цель и, если
/// это игрок в режиме змеи, с шансом подменяем результат на Стирателя.
/// Режим змеи - состояние локального игрока (SnakeControl.Controlling), поэтому работает в
/// одиночной игре и у хоста; змея подключённого клиента орду не меняет.
/// </summary>
[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;
}
}
}
/// <summary>
/// Вид Стирателя (02.10): тело - ванильная модель толстого гавайца (скелет, физика, теги,
/// анимации игры), а раскраска своя. Игра в EModelBase.createModel сама копирует материал тела
/// (ветка MatColor/AltMats); после неё подставляем в материал LOD-рендереров свои текстуры
/// (Resources/Eraser/*.png, нарисованы скриптом по развёртке гавайца): _Albedo, _Normal и _RMOE
/// (шероховатость, металл, затенение, в альфе - маска свечения), свечение - _EmissiveColor.
/// Глаза - отдельные рендереры *Eye* - прячем: на их месте в текстуре чёрные провалы.
/// Ступни босые - пальцы и ногти нарисованы в текстуре (слоновья нога масштабом костей не
/// получилась, 02.10).
/// </summary>
[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;
/// <summary>Своя раскраска на материал тела (только у материалов с _Albedo - тело и куски).</summary>
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<SkinnedMeshRenderer>(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);
}
}
/// <summary>
/// Отрубленная конечность (тест 02.10: «если у трупа отрубить руку, он становится туристом»):
/// игра при расчленении подменяет материалы тела и создаёт отлетающий кусок с ванильным
/// материалом гавайца. После RemoveLimb заново красим тело и новые куски.
/// </summary>
[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<EntityAlive>();
if (!EraserPatch.IsEraser(e) || e.emodel == null)
{
return;
}
foreach (Renderer r in e.emodel.GetComponentsInChildren<Renderer>(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<Renderer>(true))
{
Patch_EModelBase_createModel_EraserLook.Paint(r);
}
}
}
}
}
/// <summary>Стиратель убит чем угодно (игроком, взрывом соседа, под бафом жертвы) - чёрный
/// разрыв, трупа не остаётся (указание 02.10). Тот же путь, что у змеи: постфикс ClientKill.</summary>
[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);
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
using GamePath;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// «Леденящий ужас» (указание 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-задачи в сейв не пишутся.
/// </summary>
public class NecroFleeSnakeTask : EAIBase
{
public const int Priority = 1;
/// <summary>На сколько блоков вперёд выбирается точка бегства; как у ванили.</summary>
public const float FleeDistance = 20f;
/// <summary>Сколько секунд зомби ещё бежит после последнего «учуяла» (Sense - раз в 1 с).</summary>
public const float FearSeconds = 4f;
/// <summary>Тиков ИИ (20 в секунду) между выборами новой точки и между запросами пути.</summary>
public const int RetargetTicks = 40;
/// <summary>Скорость бегства - максимум бега зомби-людей в entityclasses.xml (MoveSpeedAggro до
/// 1.5; быстрее только звери: медведь 2.0, пума 2.3). Указание 02.10: «пусть убегают быстро».</summary>
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();
}
/// <summary>Сколько криков тревоги уже было в этом кадре: у толпы кричат не все.</summary>
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;
}
/// <summary>Уже напуган - продлить испуг (дёшево). False - ещё не напуган, нужна очередь.</summary>
public static bool Refresh(EntityAlive _e)
{
NecroFleeSnakeTask task = _e.aiManager?.tasks.GetTask<NecroFleeSnakeTask>();
if (task == null)
{
return false;
}
task.Until = Time.time + FearSeconds;
return true;
}
/// <summary>Из очереди SnakeControl, только на сервере: напугать (или продлить испуг).</summary>
public static void Scare(EntityAlive _e)
{
if (_e.aiManager == null)
{
return;
}
_e.ConditionalTriggerSleeperWakeUp();
NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask<NecroFleeSnakeTask>();
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";
}
}
}
+9 -1
View File
@@ -77,6 +77,9 @@ namespace NecromancerTome
/// светятся сильнее обычного; плотнее и крупнее, чем у девиантов.</summary>
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";
/// <summary>«Стиратель» (02.10): маджента - этим цветом зомби больше никто не светится.</summary>
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;
+130 -84
View File
@@ -40,8 +40,9 @@ namespace NecromancerTome
/// позади головы: голова впереди и чуть ниже, в поле зрения.
public const float EyeUp = 0.5f;
public const float EyeForward = -1.2f;
/// <summary>Сколько змея живёт в радиации края карты (указание 28.09: «таймер 30 сек»).</summary>
public const float RadiationTime = 30f;
/// <summary>Как часто змея проверяет радиацию края карты. Указание 02.10: без таймера, сразу
/// в тело; проверка раз в 2 с ради нагрузки (было 30 с отсчёта и проверка раз в 0.5 с).</summary>
public const float RadiationCheckPeriod = 2f;
/// <summary>Порог игры: buffRadiation03 вешается при _biomeradiation &gt; 2.5
/// (buffs.xml, buffStatusCheck01), значение - biomeProvider.GetRadiationAt.</summary>
public const float RadiationThreshold = 2.5f;
@@ -78,6 +79,11 @@ namespace NecromancerTome
public const float KillFreeze = 0.35f;
public const float BurstFollow = 1f;
static float _freeze;
/// <summary>Отброс взрывом Стирателя (02.10): горизонтальная скорость, гаснет сама.</summary>
static Vector3 _kick;
public const float KickSpeed = 14f;
public const float KickUp = 8f;
public const float KickDamping = 3f;
static int _killId = -1;
static Transform _burst;
static Vector3 _burstOffset;
@@ -92,6 +98,15 @@ namespace NecromancerTome
public const string SenseNavObject = "zombie";
static readonly System.Collections.Generic.HashSet<int> _sensed = new System.Collections.Generic.HashSet<int>();
static float _senseCheck;
/// <summary>Очередь испуга (указание 02.10: «пусть пугаются по очереди» - толпа, напуганная
/// разом, давала фризы): новые учуянные встают в очередь, пугается по одному раз в
/// FearInterval. Уже напуганным страх продлевается сразу, это дёшево.</summary>
static readonly System.Collections.Generic.Queue<int> _fearQueue = new System.Collections.Generic.Queue<int>();
static readonly System.Collections.Generic.HashSet<int> _fearQueued = new System.Collections.Generic.HashSet<int>();
static float _fearCheck;
public const string SnakeMapNavObject = "necroSnakeSpirit";
static EntityAlive _mapSnake;
public const float FearInterval = 0.25f;
/// <summary>Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
/// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.</summary>
public static readonly System.Collections.Generic.HashSet<int> NoLoot = new System.Collections.Generic.HashSet<int>();
@@ -99,18 +114,11 @@ namespace NecromancerTome
/// или лута, она для повышения уровня Некромантии»). Снимается в патче AddKillXP.</summary>
public static readonly System.Collections.Generic.HashSet<int> NoXp = new System.Collections.Generic.HashSet<int>();
/// <summary>Зомби, которые заметили змею (указание 28.09: «встречные зомби просто стоят,
/// скучно - пусть начинают ходить, если змея рядом»): не дальше StirRange идут к ней, как на
/// шум. Нападать всё равно не станут - змей зомби не трогают.</summary>
public const float StirRange = 20f;
static float _stirCheck;
public const string ToolbeltWindow = "toolbelt";
public static bool Controlling;
public static float AnimForward;
/// <summary>Секунды до развеивания; меньше нуля - змея не в радиации.</summary>
public static float Countdown = -1f;
static float _radiationCheck;
/// <summary>Свой наблюдатель чанков у змеи: мир подгружается вокруг наблюдателей, а у игрока
/// он стоит у тела (тест 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;
}
}
/// <summary>Шаг рывка: по прямой к ногам цели, без земли и стен. Долетела - убийство: цель
/// помечается для чёрного разрыва (NecroBurst.Pending) и получает смертельный урон ОТ ИГРОКА -
/// тогда игра сама выдаёт опыт, а NecromancyKillCreditPatch засчитывает убийство в Некромантию.</summary>
@@ -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() ? "убит" : "ВЫЖИЛ"));
}
/// <summary>Из EraserPatch.Explode: змею ближе _range от взрыва отбрасывает прочь и вверх.
/// Не ранит - змея бессмертна.</summary>
public static void Knockback(Vector3 _from, float _range)
{
if (!Controlling)
{
return;
}
Vector3 away = _pos - _from;
if (away.sqrMagnitude > _range * _range)
{
return;
}
away.y = 0f;
if (away.sqrMagnitude < 0.01f)
{
away = Quaternion.Euler(0f, _yaw, 0f) * Vector3.back;
}
_dashTarget = null;
_kick = away.normalized * KickSpeed;
_vy = KickUp;
_airborne = true;
_freeze = 0.6f;
Debug.Log("[NecromancerTome] SnakeControl: отброшена взрывом Стирателя");
}
/// <summary>Из NecroBurst.OnBurst: наш разрыв - запоминаем частицы, чтобы вести их за змеёй.</summary>
public static void OnBurst(EntityAlive _victim, Transform _effect)
{
@@ -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();
}
/// <summary>Раз в 2 с: спокойные зомби рядом со змеёй идут к ней, как на шум.</summary>
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);
}
}
/// <summary>Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под
/// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это
/// не враг»). Общее для тревоги у тела и для прицела.</summary>
@@ -539,32 +629,17 @@ namespace NecromancerTome
}
}
/// <summary>Отсчёт в радиации края карты. Вышла из зоны - отсчёт сбрасывается.</summary>
/// <summary>Радиация края карты: змея в ней сразу развеивается, игрок в теле.</summary>
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;
/// <summary>Вместо глаза - секунды до развеивания, красным, с тенью.</summary>
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;
/// <summary>Надпись под прицелом, пока враг у тела.</summary>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB