diff --git a/Config/Localization.csv b/Config/Localization.csv
index df911f1..146794a 100644
--- a/Config/Localization.csv
+++ b/Config/Localization.csv
@@ -164,3 +164,8 @@ necroVersionGuardTitle,UI,XUi MainMenu,x,,,Necromancer's Tome: incompatible game
necroVersionGuardText,UI,XUi MainMenu,x,,,"Necromancer's Tome {0} is made for 7 Days to Die {1} and is not compatible with your game version ({2}). Playing with it would damage your saves. Please update the mod to the version made for your game, or remove it. The game will now close.",,Necromancer's Tome {0} ist für 7 Days to Die {1} gemacht und nicht mit deiner Spielversion ({2}) kompatibel. Damit zu spielen würde deine Spielstände beschädigen. Bitte aktualisiere die Mod auf die Version für dein Spiel oder entferne sie. Das Spiel wird jetzt beendet.,Necromancer's Tome {0} está hecho para 7 Days to Die {1} y no es compatible con tu versión del juego ({2}). Jugar con él dañaría tus partidas guardadas. Actualiza el mod a la versión para tu juego o elimínalo. El juego se cerrará ahora.,"Necromancer's Tome {0} est conçu pour 7 Days to Die {1} et n'est pas compatible avec ta version du jeu ({2}). Jouer avec endommagerait tes sauvegardes. Mets le mod à jour vers la version prévue pour ton jeu, ou retire-le. Le jeu va maintenant se fermer.",Necromancer's Tome {0} è fatto per 7 Days to Die {1} e non è compatibile con la tua versione del gioco ({2}). Giocarci danneggerebbe i tuoi salvataggi. Aggiorna la mod alla versione per il tuo gioco oppure rimuovila. Il gioco ora verrà chiuso.,Necromancer's Tome {0} は 7 Days to Die {1} 用に作られており、お使いのゲームバージョン({2})とは互換性がありません。このままプレイするとセーブデータが損傷します。お使いのゲーム用のバージョンにMODを更新するか、MODを削除してください。ゲームを終了します。,Necromancer's Tome {0}은(는) 7 Days to Die {1}용으로 만들어졌으며 현재 게임 버전({2})과 호환되지 않습니다. 이대로 플레이하면 저장 파일이 손상됩니다. 게임에 맞는 버전으로 모드를 업데이트하거나 모드를 제거하세요. 이제 게임이 종료됩니다.,Necromancer's Tome {0} jest przeznaczony dla 7 Days to Die {1} i nie jest zgodny z twoją wersją gry ({2}). Gra z nim uszkodziłaby twoje zapisy. Zaktualizuj moda do wersji dla twojej gry albo go usuń. Gra zostanie teraz zamknięta.,Necromancer's Tome {0} foi feito para 7 Days to Die {1} e não é compatível com a sua versão do jogo ({2}). Jogar com ele danificaria seus saves. Atualize o mod para a versão feita para o seu jogo ou remova-o. O jogo será fechado agora.,"Мод «Книга некроманта» {0} сделан для 7 Days to Die {1} и несовместим с вашей версией игры ({2}). Игра с ним повредит ваши сохранения. Пожалуйста, обновите мод до версии для вашей игры или удалите его. Сейчас игра закроется.","Necromancer's Tome {0}, 7 Days to Die {1} için yapıldı ve oyun sürümünüzle ({2}) uyumlu değil. Onunla oynamak kayıtlarınıza zarar verir. Lütfen modu oyununuza uygun sürüme güncelleyin veya kaldırın. Oyun şimdi kapanacak.",Necromancer's Tome {0} 为 7 Days to Die {1} 制作,与你的游戏版本({2})不兼容。继续使用会损坏你的存档。请将模组更新到适合你游戏的版本,或将其移除。游戏现在将关闭。,Necromancer's Tome {0} 為 7 Days to Die {1} 製作,與你的遊戲版本({2})不相容。繼續使用會損壞你的存檔。請將模組更新到適合你遊戲的版本,或將其移除。遊戲現在將關閉。
necroVersionGuardQuit,UI,XUi MainMenu,x,,,Quit game,,Spiel beenden,Salir del juego,Quitter le jeu,Esci dal gioco,ゲームを終了,게임 종료,Wyjdź z gry,Sair do jogo,Выйти из игры,Oyundan çık,退出游戏,退出遊戲
braceletSpatialVaultUnreadable,items,Item,,,,This storage rift was saved by another version of the mod. It is kept untouched until you install the mod made for your game version.,,"Dieser Lagerriss wurde von einer anderen Version der Mod gespeichert. Er bleibt unangetastet, bis du die Mod für deine Spielversion installierst.",Esta grieta de almacenamiento se guardó con otra versión del mod. Se conserva intacta hasta que instales el mod para tu versión del juego.,Cette faille de stockage a été enregistrée par une autre version du mod. Elle reste intacte jusqu'à ce que tu installes le mod prévu pour ta version du jeu.,Questa fenditura di deposito è stata salvata da un'altra versione della mod. Resta intatta finché non installi la mod per la tua versione del gioco.,この収納の裂け目は別のバージョンのMODで保存されました。お使いのゲームバージョン用のMODを入れるまで、手を付けずに保持されます。,이 보관 균열은 다른 버전의 모드로 저장되었습니다. 게임 버전에 맞는 모드를 설치할 때까지 그대로 보존됩니다.,"Ta szczelina magazynowa została zapisana przez inną wersję moda. Pozostaje nietknięta, dopóki nie zainstalujesz moda dla twojej wersji gry.",Esta fenda de armazenamento foi salva por outra versão do mod. Ela fica intacta até você instalar o mod feito para a sua versão do jogo.,"Этот разлом хранилища сохранён другой версией мода. Он останется нетронутым, пока вы не поставите мод для вашей версии игры.",Bu depolama yarığı modun başka bir sürümüyle kaydedildi. Oyun sürümünüze uygun modu kurana kadar dokunulmadan saklanır.,这个储物裂隙由另一个版本的模组保存。在你安装适合你游戏版本的模组之前,它会原样保留。,這個儲物裂隙由另一個版本的模組儲存。在你安裝適合你遊戲版本的模組之前,它會原樣保留。
+necroSnakeModeHuman,items,Item,,,,Human,,Mensch,Humano,Humain,Umano,人間,인간,Człowiek,Humano,Человек,İnsan,人类,人類
+necroSnakeModeSnake,items,Item,,,,Snake,,Schlange,Serpiente,Serpent,Serpente,蛇,뱀,Wąż,Cobra,Змея,Yılan,蛇,蛇
+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,现在是白天,現在是白天
+necroSnakeSpirit,entityclasses,Entity,,,,Snake Spirit,,Schlangengeist,Espíritu de serpiente,Esprit du serpent,Spirito del serpente,蛇の霊,뱀의 영혼,Duch węża,Espírito da serpente,Дух змеи,Yılan Ruhu,蛇灵,蛇靈
diff --git a/Config/buffs.xml b/Config/buffs.xml
index 9df8acf..9efdc41 100644
--- a/Config/buffs.xml
+++ b/Config/buffs.xml
@@ -116,6 +116,21 @@
It reads green/energy rather than "necromantic purple", but it's a real full-body
glow, not just a floating cloud; RemoveParticleEffectFromEntity on death/removal so it
doesn't linger on the corpse. -->
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Config/entityclasses.xml b/Config/entityclasses.xml
index f68dd01..6d97c64 100644
--- a/Config/entityclasses.xml
+++ b/Config/entityclasses.xml
@@ -409,4 +409,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HarmonySrc/GhostTraderPatch.cs b/HarmonySrc/GhostTraderPatch.cs
index 5f144b9..df56b25 100644
--- a/HarmonySrc/GhostTraderPatch.cs
+++ b/HarmonySrc/GhostTraderPatch.cs
@@ -318,6 +318,11 @@ namespace NecromancerTome
{
return false;
}
+ // Призрачная змея режима змеи (SnakeMode.cs, 2026-09-28): «текстуры как у торговцев».
+ if (entityClass.entityClassName == SnakeMode.SnakeClassName)
+ {
+ return true;
+ }
return Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(entityClass.entityClassName);
}
@@ -429,7 +434,15 @@ namespace NecromancerTome
// Before anything is touched, because the body's replacement shader is found on the
// trader's OWN hair and the hair is not guaranteed to come first in this array.
+ // Змея не должна становиться источником BlendShader: он кэшируется на всю сессию, и
+ // торговцы после неё перекрашивались бы шейдером змеи, а не своих волос.
+ bool isSnake = _trader.EntityClass.entityClassName == SnakeMode.SnakeClassName;
+ Shader keptBlendShader = BlendShader;
FindBlendShader(renderers);
+ if (isSnake)
+ {
+ BlendShader = keptBlendShader;
+ }
int converted = 0;
int leversBefore = TintedMaterials.Count;
@@ -456,6 +469,17 @@ namespace NecromancerTome
}
_body.Renderers = taken.ToArray();
+ // Змея режима змеи идёт по ветке «блендим на месте» (Autodesk Interactive), и альфа там
+ // не видна даже с полной шестёркой настроек (_Mode 2, _ZWrite 0, ключевые слова, очередь)
+ // - проверено 28.09 дважды. Вероятная причина: копия шейдера из сборки змеи собрана без
+ // варианта _ALPHABLEND_ON (Unity выкидывает неиспользуемые варианты при сборке). Поэтому
+ // материалы змеи пересобираются на шейдер, который этот вариант ТОЧНО имеет - его берём
+ // у любого загруженного материала с включённым _ALPHABLEND_ON.
+ if (isSnake)
+ {
+ MakeSnakeFade(taken);
+ }
+
// The lever count is the half that answers "will the console command reach him":
// desaturation and opacity come from different properties, and the body had the first
// without the second until 2026-09-14. Fewer levers than converted renderers means
@@ -466,6 +490,95 @@ namespace NecromancerTome
return true;
}
+ static Shader _fadeShader;
+
+ /// Шейдер, у которого точно есть прозрачный вариант: берётся с любого загруженного
+ /// материала, где _ALPHABLEND_ON включён. Предпочтение - тем же шейдерам, что у змеи.
+ public static Shader FindFadeShader()
+ {
+ if (_fadeShader != null)
+ {
+ return _fadeShader;
+ }
+ Shader best = null;
+ int bestRank = int.MaxValue;
+ HashSet seen = new HashSet();
+ foreach (Material m in Resources.FindObjectsOfTypeAll())
+ {
+ if (m == null || m.shader == null || !m.IsKeywordEnabled("_ALPHABLEND_ON") ||
+ !m.HasProperty("_MainTex") || !m.HasProperty("_Color") || !m.HasProperty("_SrcBlend"))
+ {
+ continue;
+ }
+ string name = m.shader.name;
+ seen.Add(name + " (" + m.name + ")");
+ int rank = name == "Autodesk Interactive" ? 0 : name == "Standard" ? 1 : 2;
+ if (rank < bestRank)
+ {
+ best = m.shader;
+ bestRank = rank;
+ }
+ }
+ Debug.Log("[NecromancerTome] GhostTraderPatch: fade shaders loaded: " +
+ (seen.Count == 0 ? "" : string.Join(", ", seen)) +
+ " -> " + (best != null ? best.name : "NONE"));
+ _fadeShader = best;
+ return best;
+ }
+
+ /// Пересобирает материалы змеи на шейдер из FindFadeShader: серая текстура и карта
+ /// нормалей переносятся, прозрачность - полной шестёркой.
+ public static void MakeSnakeFade(List _renderers)
+ {
+ Shader fade = FindFadeShader();
+ if (fade == null)
+ {
+ return;
+ }
+ int done = 0;
+ foreach (Renderer renderer in _renderers)
+ {
+ Material[] current = renderer.sharedMaterials;
+ Material[] rebuilt = new Material[current.Length];
+ for (int i = 0; i < current.Length; i++)
+ {
+ Material src = current[i];
+ if (src == null)
+ {
+ continue;
+ }
+ Material m = new Material(fade);
+ string albedo = FindAlbedoProperty(src);
+ if (albedo != null)
+ {
+ m.SetTexture("_MainTex", src.GetTexture(albedo));
+ }
+ Texture normal = FindTexture(src, NormalNameHints);
+ if (normal != null && m.HasProperty("_BumpMap"))
+ {
+ m.SetTexture("_BumpMap", normal);
+ m.EnableKeyword("_NORMALMAP");
+ }
+ MakeMatte(m);
+ if (m.HasProperty("_Mode"))
+ {
+ m.SetFloat("_Mode", 2f);
+ }
+ if (m.HasProperty("_ZWrite"))
+ {
+ m.SetFloat("_ZWrite", 0f);
+ }
+ m.DisableKeyword("_ALPHATEST_ON");
+ m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
+ ApplyTransparency(m);
+ rebuilt[i] = m;
+ done++;
+ }
+ renderer.materials = rebuilt;
+ }
+ Debug.Log("[NecromancerTome] GhostTraderPatch: snake re-shaded onto '" + fade.name + "', " + done + " material(s)");
+ }
+
/// Builds and installs this renderer's ghost materials from the ORIGINALS it was
/// handed. True when anything was actually changed - a renderer nothing could be done to
/// keeps the materials it has rather than being handed a half-built array.
diff --git a/HarmonySrc/ModEntry.cs b/HarmonySrc/ModEntry.cs
index f850c40..5225d53 100644
--- a/HarmonySrc/ModEntry.cs
+++ b/HarmonySrc/ModEntry.cs
@@ -60,6 +60,7 @@ namespace NecromancerTome
// Traders rendered as washed-out ghosts (request 2026-09-13). Polls rather than
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
GhostTraderPatch.Init();
+ SnakeMode.Init();
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
diff --git a/HarmonySrc/NecroBurst.cs b/HarmonySrc/NecroBurst.cs
new file mode 100644
index 0000000..5e8efbf
--- /dev/null
+++ b/HarmonySrc/NecroBurst.cs
@@ -0,0 +1,221 @@
+using System.Collections.Generic;
+using HarmonyLib;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// Чёрный разрыв: зомби на смерти рассыпается облаком чёрных частиц со звуком «gore_head», и
+ /// трупа не остаётся. Свой аналог ванильного Twitch-эффекта «Celebrate» (конфетти), продиктовано
+ /// 28.09.2026 как заготовка под дистанционную атаку (BACKLOG: «рывок и взрыв зомби»).
+ ///
+ /// КАК УСТРОЕН ОРИГИНАЛ (EntityAlive.ClientKill, игра 3.3). Уже после выпадения добычи
+ /// (OnEntityDeath) и начисления опыта (AwardKillXPServer), если убийца - игрок, а на нём
+ /// пассивка CelebrationKill, сервер делает три вещи: SpawnParticleEffectServer("confetti"),
+ /// Manager.BroadcastPlay("twitch_celebrate") и World.RemoveEntity(Killed) - вместо рэгдолла.
+ /// Имена частиц и звука там зашиты строками, поэтому ванильный путь не переиспользуется: мы
+ /// повторяем те же три вызова своим постфиксом на ClientKill для помеченных зомби.
+ ///
+ /// ПОЧЕМУ ЧАСТИЦЫ - КОПИЯ, А НЕ ПЕРЕКРАСКА. Цвет из ParticleEffect игра применяет только к
+ /// рендерерам БЕЗ ParticleSystem (ParticleEffect.SpawnParticleEffect), то есть конфетти он
+ /// не красит. Поэтому при первом обращении клонируем префаб «confetti», красим в клоне сами
+ /// системы частиц и кладём его в реестр игры (ParticleEffect.loadedTs) под своим именем.
+ /// Ванильные конфетти остаются цветными. Клон строится лениво в GetDynamicTransform - это та
+ /// точка, через которую идёт любой спавн частиц и на сервере, и на клиенте.
+ ///
+ /// Счёт Некромантии и добыча не страдают: к моменту постфикса они уже отработали. А вот
+ /// красный труп Жертвы с разрывом несовместим - тело удаляется (решать при дистанционной атаке).
+ ///
+ public static class NecroBurst
+ {
+ public const string ParticleName = "necroblackburst";
+ public const string SourceParticleName = "confetti";
+ public const string SoundName = "gore_head";
+
+ /// Почти чёрный, а не 0,0,0: у чистого чёрного пропадает объём частиц.
+ public static readonly Color Tint = new Color(0.04f, 0.035f, 0.045f, 1f);
+
+ /// Зомби, которые на ближайшей смерти должны разорваться. Сервер.
+ public static readonly HashSet Pending = new HashSet();
+
+ /// Разрыв случился: жертва и Transform частиц (может быть null).
+ public static System.Action OnBurst;
+
+ static GameObject _holder;
+
+ public static int ParticleId => ParticleEffect.ToId(ParticleName);
+
+ /// Строит чёрный клон конфетти и регистрирует его. true - клон есть.
+ public static bool EnsureRegistered()
+ {
+ int id = ParticleId;
+ if (ParticleEffect.loadedTs.ContainsKey(id))
+ {
+ return true;
+ }
+ if (!ParticleEffect.loadedTs.TryGetValue(ParticleEffect.ToId(SourceParticleName), out Transform src) || src == null)
+ {
+ Debug.LogWarning("[NecromancerTome] NecroBurst: ванильный эффект '" + SourceParticleName + "' не найден");
+ return false;
+ }
+
+ // Неактивный держатель: клон под ним не играет сам, а копии, которые игра делает из
+ // клона через Instantiate, выходят активными (активность берётся у самого клона).
+ if (_holder == null)
+ {
+ _holder = new GameObject("NecroBurstPrefabs");
+ _holder.SetActive(false);
+ Object.DontDestroyOnLoad(_holder);
+ }
+ Transform clone = Object.Instantiate(src, _holder.transform);
+ clone.name = ParticleEffect.prefix + ParticleName;
+
+ foreach (ParticleSystem ps in clone.GetComponentsInChildren(true))
+ {
+ ParticleSystem.MainModule main = ps.main;
+ main.startColor = new ParticleSystem.MinMaxGradient(Tint);
+
+ ParticleSystem.ColorOverLifetimeModule col = ps.colorOverLifetime;
+ if (col.enabled)
+ {
+ // Прозрачность по времени жизни оставляем как есть, цвет - в чёрный.
+ Gradient g = new Gradient();
+ GradientAlphaKey[] alpha = col.color.gradient != null
+ ? col.color.gradient.alphaKeys
+ : new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) };
+ g.SetKeys(new[] { new GradientColorKey(Tint, 0f), new GradientColorKey(Tint, 1f) }, alpha);
+ col.color = new ParticleSystem.MinMaxGradient(g);
+ }
+
+ ParticleSystemRenderer r = ps.GetComponent();
+ if (r != null && r.sharedMaterial != null)
+ {
+ // Печатаем шейдер: если он аддитивный, чёрный на нём невидим, и это сразу видно
+ // в логе, а не гаданием по пустому месту на экране.
+ Debug.Log("[NecromancerTome] NecroBurst: система '" + ps.name + "', шейдер '" +
+ r.sharedMaterial.shader.name + "', материал '" + r.sharedMaterial.name + "'");
+ }
+ }
+
+ ParticleEffect.loadedTs[id] = clone;
+ Debug.Log("[NecromancerTome] NecroBurst: зарегистрирован эффект '" + ParticleName + "'");
+ return true;
+ }
+
+ /// Разорвать уже мёртвого зомби: частицы, звук, удаление тела. Только сервер.
+ public static void Burst(EntityAlive _victim, Entity _soundSource)
+ {
+ if (!SingletonMonoBehaviour.Instance.IsServer)
+ {
+ return;
+ }
+ World world = GameManager.Instance.World;
+ Vector3 pos = _victim.position;
+ Transform effect = null;
+ if (EnsureRegistered())
+ {
+ float light = world.GetLightBrightness(_victim.GetBlockPosition());
+ ParticleEffect pe = new ParticleEffect(ParticleName, pos, light, Color.white, null, null, _OLDCreateColliders: false);
+ // Как GameManager.SpawnParticleEffectServer, но с Transform на руках - режиму змеи
+ // нужно вести частицы за собой (OnBurst).
+ effect = ParticleEffect.SpawnParticleEffect(pe, _victim.entityId, false, true);
+ SingletonMonoBehaviour.Instance.SendPackage(
+ NetPackageManager.GetPackage().Setup(pe, _victim.entityId, false, true),
+ _onlyClientsAttachedToAnEntity: false, -1, _victim.entityId);
+ }
+ OnBurst?.Invoke(_victim, effect);
+ Audio.Manager.BroadcastPlay(_soundSource, pos, SoundName);
+ world.RemoveEntity(_victim.entityId, EnumRemoveEntityReason.Killed);
+ }
+ }
+
+ /// Ленивая регистрация клона: любой спавн по нашему id сначала строит его.
+ [HarmonyPatch(typeof(ParticleEffect), nameof(ParticleEffect.GetDynamicTransform))]
+ public static class Patch_ParticleEffect_GetDynamicTransform_NecroBurst
+ {
+ public static void Prefix(int _particleId)
+ {
+ if (_particleId == NecroBurst.ParticleId)
+ {
+ NecroBurst.EnsureRegistered();
+ }
+ }
+ }
+
+ /// Помеченный зомби умер - разрываем. Постфикс: добыча и опыт уже выданы.
+ [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.ClientKill))]
+ public static class Patch_EntityAlive_ClientKill_NecroBurst
+ {
+ public static void Postfix(EntityAlive __instance)
+ {
+ if (!NecroBurst.Pending.Remove(__instance.entityId) || !__instance.IsDead())
+ {
+ return;
+ }
+ NecroBurst.Burst(__instance, __instance.entityThatKilledMe != null ? (Entity)__instance.entityThatKilledMe : __instance);
+ }
+ }
+
+ ///
+ /// necroburst - убить чёрным разрывом зомби, на которого смотришь (до 30 м). Проверка эффекта,
+ /// пока дистанционной атаки нет. Убийство засчитывается игроку как обычное.
+ ///
+ [Preserve]
+ public class ConsoleCmdNecroBurst : ConsoleCmdAbstract
+ {
+ public override bool IsExecuteOnClient => true;
+
+ public override bool AllowedInMainMenu => false;
+
+ public override string[] getCommands()
+ {
+ return new string[] { "necroburst" };
+ }
+
+ public override string getDescription()
+ {
+ return "Necromancer's Tome: kill the zombie you look at with the black burst (effect test).";
+ }
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ World world = GameManager.Instance.World;
+ EntityPlayerLocal player = world?.GetPrimaryPlayer();
+ if (player == null)
+ {
+ return;
+ }
+ Ray look = player.GetLookRay();
+ EntityAlive best = null;
+ float bestAngle = 15f;
+ foreach (Entity e in world.Entities.list)
+ {
+ if (!(e is EntityZombie z) || z.IsDead())
+ {
+ continue;
+ }
+ Vector3 to = z.getChestPosition() - look.origin - Origin.position;
+ if (to.magnitude > 30f)
+ {
+ continue;
+ }
+ float angle = Vector3.Angle(look.direction, to);
+ if (angle < bestAngle)
+ {
+ bestAngle = angle;
+ best = z;
+ }
+ }
+ if (best == null)
+ {
+ SingletonMonoBehaviour.Instance.Output("necroburst: не вижу зомби перед собой (до 30 м)");
+ return;
+ }
+ NecroBurst.Pending.Add(best.entityId);
+ best.DamageEntity(new DamageSourceEntity(EnumDamageSource.External, EnumDamageTypes.Piercing, player.entityId),
+ 99999, false);
+ SingletonMonoBehaviour.Instance.Output("necroburst: " + best.EntityName + " (" + best.entityId + ")");
+ }
+ }
+}
diff --git a/HarmonySrc/NecromancerHarmony.csproj b/HarmonySrc/NecromancerHarmony.csproj
index 05034d0..3bc3d27 100644
--- a/HarmonySrc/NecromancerHarmony.csproj
+++ b/HarmonySrc/NecromancerHarmony.csproj
@@ -69,6 +69,32 @@
own comment for why that call can't compile in THIS project at all. Private=false: it's
built and deployed separately (its own dotnet build + copy to the mod root), same as
0Harmony above - not something this project's own build should try to copy/rebuild. -->
+
+
+ ..\..\..\7DaysToDie_Data\Managed\NGUI.dll
+ false
+
+
+ ..\..\..\7DaysToDie_Data\Managed\UnityEngine.IMGUIModule.dll
+ false
+
+
+
+ ..\..\..\7DaysToDie_Data\Managed\UnityEngine.TextRenderingModule.dll
+ false
+
+
+
+ ..\..\..\7DaysToDie_Data\Managed\UnityEngine.ImageConversionModule.dll
+ false
+
+
+
+ ..\..\..\7DaysToDie_Data\Managed\Unity.Postprocessing.Runtime.dll
+ false
+
..\TEPersistenceSrc\bin\NecromancerTEPersistence.dll
false
diff --git a/HarmonySrc/ParticlePatch.cs b/HarmonySrc/ParticlePatch.cs
index 1becff9..b761803 100644
--- a/HarmonySrc/ParticlePatch.cs
+++ b/HarmonySrc/ParticlePatch.cs
@@ -73,6 +73,10 @@ namespace NecromancerTome
/// the same "obscures the view" reason - the zombie glow's own 0.5 was deliberately subtle,
/// this one shouldn't be. DensityFactor=5 - the actual fix for "редкие-редкие", multiplies
/// both emission rate and maxParticles together (see GlowConfig's own doc on why both).
+ /// Зрение змеи: яркий гнилостный зелёно-бурый. Цвет больше 1 - HDR, частицы
+ /// светятся сильнее обычного; плотнее и крупнее, чем у девиантов.
+ 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";
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)
@@ -92,7 +96,8 @@ namespace NecromancerTome
bool isPortalChannel = entity.Buffs.HasBuff(Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelBuffName);
bool isVictim = entity.Buffs.HasBuff(Patch_EntityAlive_dropItemOnDeath_VictimBag.VictimBuffName);
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
- if (!isPortalChannel && !isVictim && !isCharm)
+ bool isSnakeSight = entity.Buffs.HasBuff(SnakeSightBuffName);
+ if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight)
{
return;
}
@@ -104,6 +109,10 @@ namespace NecromancerTome
{
config = PortalChannelGlow;
}
+ else if (isSnakeSight)
+ {
+ config = SnakeSightGlow;
+ }
else if (isVictim && isCharm)
{
config = new GlowConfig { Tint = Color.Lerp(CharmGlow.Tint, VictimGlow.Tint, 0.5f), SizeFactor = CharmGlow.SizeFactor, AlphaFactor = CharmGlow.AlphaFactor, DensityFactor = 1f };
diff --git a/HarmonySrc/SnakeControl.cs b/HarmonySrc/SnakeControl.cs
new file mode 100644
index 0000000..0a5fb95
--- /dev/null
+++ b/HarmonySrc/SnakeControl.cs
@@ -0,0 +1,1212 @@
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Режим змеи, этап 2а (2026-09-28): камера, управление и прицел. Рывок на F и пояс «Press R» -
+ /// позже.
+ ///
+ /// ПОЧЕМУ ДВИЖЕНИЕ СВОЁ, А НЕ ФИЗИКОЙ ИГРЫ. Змея - животное с RootMotion: скорость ей даёт
+ /// анимация, и 6-16 м/с из неё не выжать. Поэтому змея переведена в режим полёта без коллизий
+ /// (как `dm` у игрока - её собственный тик больше не тянет её вниз и не толкает), а положение
+ /// ведём сами: шаг по горизонтали не длиннее 0,25 м, стена - это два твёрдых блока друг над
+ /// другом (на один блок змея вползает), землю ищем лучом по слою коллизий ландшафта (65536,
+ /// как у самой игры для дронов и грифов), без земли под собой - падает с ускорением.
+ ///
+ /// ПОЧЕМУ МЫШЬ ЧИТАЕМ САМИ. Пока тело игрока в рэгдолле, PlayerMoveController.Update выходит
+ /// в самом начале, и взгляд игры не обновляется. Берём оси мыши и чувствительность из тех же
+ /// мест, что и он (Input "Mouse X/Y", mouseLookSensitivity, invertMouse).
+ ///
+ /// КАМЕРА. vp_FPCamera в третьем лице каждый LateUpdate ставит камеру за телом игрока; постфикс
+ /// к нему переставляет её за змею. Порядок гарантирован - постфикс идёт сразу после.
+ ///
+ /// ГРАНИЦА. Змея не заходит туда, где у чанков нет коллизий: мир подгружается вокруг ТЕЛА, и
+ /// за его пределами она провалилась бы сквозь пустоту.
+ ///
+ public static class SnakeControl
+ {
+ public const float WalkSpeed = 6f; // велосипед, vehicles.xml velocityMax
+ public const float RunSpeed = 16.5f; // мотоцикл турбо с нагнетателем
+ public const float Gravity = 20f;
+ /// Прыжок на Пробел (указание 28.09: «иначе не выбраться из ям»). 10 м/с при g = 20 -
+ /// подъём на 2,5 м: вместе с заползанием на блок хватает на стену в три блока.
+ public const float JumpSpeed = 10f;
+ public const float MaxStep = 0.25f;
+ /// Глаза змеи: над головой и чуть впереди, чтобы не смотреть изнутри своей морды.
+ /// Тест 28.09: «упирается в складки местности - камера уходит под текстуры» → глаза выше и
+ /// позади головы, плюс не пускаем камеру в землю (см. PlaceCamera).
+ /// Тест 28.09 (второй): «камеру чуть выше, чтобы видеть голову змеи» → глаза над телом,
+ /// позади головы: голова впереди и чуть ниже, в поле зрения.
+ public const float EyeUp = 0.5f;
+ public const float EyeForward = -1.2f;
+ /// Сколько змея живёт в радиации края карты (указание 28.09: «таймер 30 сек»).
+ public const float RadiationTime = 30f;
+ /// Порог игры: buffRadiation03 вешается при _biomeradiation > 2.5
+ /// (buffs.xml, buffStatusCheck01), значение - biomeProvider.GetRadiationAt.
+ public const float RadiationThreshold = 2.5f;
+ public const float TargetRange = 30f; // дальность будущего рывка
+ public const float TargetRadius = 0.9f; // насколько мимо центра противника можно целиться
+ public const int TerrainMask = 65536;
+ /// Слои, о которые змея упирается: ландшафт и блоки (16) плюс модели блоков - двери,
+ /// люки, мебель (30). Та же маска, что у игры для лучей от головы игрока
+ /// (EntityPlayerLocal: 1073807360).
+ public const int WallMask = 1073807360;
+ /// Радиус проверочных сфер при шаге. Меньше полублока - в дверной проём и щель в
+ /// один блок змея пролезает.
+ public const float BodyRadius = 0.25f;
+
+ /// Параметр Forward аниматора змеи при ползании и на Shift. Подобраны на глаз:
+ /// у животного с RootMotion полный ход даёт landMovementFactor * 2.5 = 0.25.
+ public static float AnimWalk = 0.25f;
+ public static float AnimRun = 0.6f;
+
+ /// Тревога у тела (указание 28.09): враг ближе 10 блоков к лежащему телу - прицел
+ /// мигает раз в секунду и надпись «Враг приближается к телу». Орды и прочие идут к телу, не к змее.
+ public const float BodyDangerRange = 10f;
+ public static bool BodyDanger;
+ static float _bodyCheck;
+
+ /// Рывок на правую кнопку мыши (указание 28.09: «там обычно атака»): по прямой к
+ /// цели в прицеле, сквозь препятствия, со звуком атаки змеи; цель убита чёрным разрывом.
+ public const float DashSpeed = 60f;
+ public const float DashStop = 0.6f;
+ public const string AttackSound = "snakeattack";
+ static EntityAlive _dashTarget;
+ /// После удара змея замирает (указание 28.09: «резко останавливалась, иначе
+ /// проскакивает частицы»), а частицы разрыва секунду едут вместе с ней.
+ public const float KillFreeze = 0.35f;
+ public const float BurstFollow = 1f;
+ static float _freeze;
+ static int _killId = -1;
+ static Transform _burst;
+ static Vector3 _burstOffset;
+ static float _burstFollow;
+ static float _sleeperCheck;
+ /// Тёмное чутьё змеи (указание 28.09: «змее можно добавить тёмное чутьё, это логично»):
+ /// то же, что бафф ножа buffNecroDarkSense (buffs.xml), только от змеи - враги ближе 30 м
+ /// получают метку "zombie" на компасе и карте, дальше 60 м и мёртвые её теряют. Питомцы и
+ /// девианты не метятся (IsFoe). Бафф ножа считает от тела и тут не годится.
+ public const float SenseRange = 30f;
+ public const float SenseDropRange = 60f;
+ public const string SenseNavObject = "zombie";
+ static readonly System.Collections.Generic.HashSet _sensed = new System.Collections.Generic.HashSet();
+ static float _senseCheck;
+ /// Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
+ /// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.
+ public static readonly System.Collections.Generic.HashSet NoLoot = 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: «змея не может уйти далеко от тела»). Так же игра грузит
+ /// место под сброс припасов (AIAirDrop) - только здесь с видимой сеткой и дальностью
+ /// обзора из настроек.
+ static ChunkManager.ChunkObserver _observer;
+ public static EntityAlive Target;
+ static Vector3 _pos;
+ static float _vy;
+ static bool _airborne;
+ static float _yaw;
+ static float _pitch;
+ static float _snakeYaw;
+ static bool _loggedCamera;
+ /// Где голова относительно корня змеи (вперёд и вверх), замеряется один раз, пока
+ /// змея стоит. Камера держится этой точки на ровно едущем корне, а не самой головы: голова
+ /// при ползании ходит из стороны в сторону (тест 28.09: «камеру мотает, укачивает»).
+ static float _headForward = -1f;
+ static float _headUp;
+
+ public static void Begin(EntityPlayerLocal _player, EntityAlive _snake)
+ {
+ _pos = _snake.position;
+ _vy = 0f;
+ _airborne = false;
+ _yaw = _player.rotation.y;
+ _pitch = -15f;
+ _snakeYaw = _snake.rotation.y;
+ _snake.IsFlyMode.Value = true;
+ _snake.IsNoCollisionMode.Value = true;
+ _loggedCamera = false;
+ _headForward = -1f;
+ _dashTarget = null;
+ _stirCheck = 0f;
+ _freeze = 0f;
+ _killId = -1;
+ _burst = null;
+ _sleeperCheck = 0f;
+ _senseCheck = 0f;
+ Target = null;
+ Countdown = -1f;
+ _radiationCheck = 0f;
+ BodyDanger = false;
+ _bodyCheck = 0f;
+ _observer = GameManager.Instance.AddChunkObserver(_pos, true, GameUtils.GetViewDistance(), -1);
+ Controlling = true;
+ // Пояс прячется на весь режим (указание 28.09); обратно его откроет сама игра в OnHUD.
+ _player.PlayerUI?.windowManager.Close(ToolbeltWindow);
+ LogScreenEffectsOnce(_player);
+ SnakeVision.On(_player);
+ Debug.Log("[NecromancerTome] SnakeControl: управление на змее, наблюдатель чанков " + _observer.id +
+ ", дальность " + _observer.viewDim);
+ }
+
+ static bool _screenEffectsLogged;
+
+ /// Разведка под «зрение змеи» (28.09: красные тона, контраст, «как комикс»): все
+ /// экранные эффекты игры (Resources/ScreenEffects) с шейдером и цветовыми/числовыми
+ /// свойствами - из чего можно собрать своё. Раз за сессию.
+ static void LogScreenEffectsOnce(EntityPlayerLocal _player)
+ {
+ if (_screenEffectsLogged || _player.ScreenEffectManager == null)
+ {
+ return;
+ }
+ _screenEffectsLogged = true;
+ foreach (ScreenEffects.ScreenEffect e in _player.ScreenEffectManager.loadedEffects)
+ {
+ Material m = e?.Material;
+ if (m == null || m.shader == null)
+ {
+ continue;
+ }
+ System.Text.StringBuilder sb = new System.Text.StringBuilder();
+ Shader sh = m.shader;
+ for (int i = 0; i < sh.GetPropertyCount(); i++)
+ {
+ string name = sh.GetPropertyName(i);
+ switch (sh.GetPropertyType(i))
+ {
+ case UnityEngine.Rendering.ShaderPropertyType.Color:
+ sb.Append(' ').Append(name).Append('=').Append(m.GetColor(name).ToString("F2"));
+ break;
+ case UnityEngine.Rendering.ShaderPropertyType.Float:
+ case UnityEngine.Rendering.ShaderPropertyType.Range:
+ sb.Append(' ').Append(name).Append('=').Append(m.GetFloat(name).ToString("0.###"));
+ break;
+ case UnityEngine.Rendering.ShaderPropertyType.Texture:
+ sb.Append(' ').Append(name).Append("=tex");
+ break;
+ }
+ }
+ Debug.Log("[NecromancerTome] ScreenEffect '" + e.Name + "' shader '" + sh.name + "':" + sb);
+ }
+ }
+
+ public static void End()
+ {
+ ClearSense();
+ Controlling = false;
+ SnakeVision.Off(GameManager.Instance?.World?.GetPrimaryPlayer());
+ _dashTarget = null;
+ Target = null;
+ Countdown = -1f;
+ BodyDanger = false;
+ if (_observer != null)
+ {
+ if (GameManager.Instance != null && GameManager.Instance.World != null)
+ {
+ GameManager.Instance.RemoveChunkObserver(_observer);
+ }
+ _observer = null;
+ }
+ }
+
+ /// Каждый кадр, из SnakeMode.OnUnityUpdate. True - змея развеялась, пора в тело.
+ public static bool Update(EntityPlayerLocal _player, EntityAlive _snake)
+ {
+ if (!Controlling)
+ {
+ return false;
+ }
+ float dt = Time.deltaTime;
+ bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
+ PlayerActionsLocal input = _player.playerInput;
+ Vector2 move = Vector2.zero;
+ bool run = false;
+ bool jump = false;
+ bool attack = false;
+ World world = GameManager.Instance.World;
+ if (_freeze > 0f)
+ {
+ _freeze -= dt;
+ }
+ FollowBurst(dt);
+ if (!gui && input != null)
+ {
+ Look();
+ }
+ if (!gui && input != null && _freeze <= 0f)
+ {
+ move = new Vector2(input.Move.X, input.Move.Y);
+ run = input.Run.IsPressed;
+ jump = input.Jump.WasPressed;
+ // Указание 28.09: атака на левую кнопку, правая - выбраться из застревания.
+ attack = input.Primary.WasPressed;
+ if (input.Secondary.WasPressed)
+ {
+ Unstick(world);
+ }
+ }
+
+ if (_dashTarget == null && attack && Target != null)
+ {
+ _dashTarget = Target;
+ _vy = 0f;
+ _airborne = false;
+ Audio.Manager.BroadcastPlay(_snake, _snake.position, AttackSound);
+ Debug.Log("[NecromancerTome] SnakeControl: рывок к " + _dashTarget.EntityName + " (" + _dashTarget.entityId +
+ "), " + (_dashTarget.position - _pos).magnitude.ToString("F1") + " м");
+ }
+ if (_dashTarget != null)
+ {
+ Dash(_player);
+ _observer?.SetPosition(_pos);
+ _snake.SetPosition(_pos);
+ _snake.SetRotation(new Vector3(0f, _snakeYaw, 0f));
+ _snake.motion = Vector3.zero;
+ _snake.moveDirection = Vector3.zero;
+ AnimForward = AnimRun;
+ CheckBody(world, _player, _snake, dt);
+ return Radiation(world, dt);
+ }
+ Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
+ Vector3 dir = yawRot * Vector3.forward * move.y + yawRot * Vector3.right * move.x;
+ if (dir.sqrMagnitude > 1f)
+ {
+ dir.Normalize();
+ }
+ bool moving = dir.sqrMagnitude > 0.01f;
+ if (moving)
+ {
+ Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt;
+ int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / MaxStep));
+ Vector3 d = delta / steps;
+ for (int i = 0; i < steps; i++)
+ {
+ // Упёрлись - пробуем скользить вдоль стены по одной из осей.
+ if (!TryStep(world, d) && !TryStep(world, new Vector3(d.x, 0f, 0f)) && !TryStep(world, new Vector3(0f, 0f, d.z)))
+ {
+ break;
+ }
+ }
+ }
+ // Вид от первого лица: змея смотрит туда же, куда игрок.
+ _snakeYaw = Mathf.LerpAngle(_snakeYaw, _yaw, dt * 12f);
+ Fall(dt, jump);
+ _observer?.SetPosition(_pos);
+
+ _snake.SetPosition(_pos);
+ _snake.SetRotation(new Vector3(0f, _snakeYaw, 0f));
+ _snake.motion = Vector3.zero;
+ // Анимацию ползания даёт патч AvatarAnimalController.LateUpdate ниже (AnimForward).
+ _snake.moveDirection = Vector3.zero;
+ AnimForward = moving ? (run ? AnimRun : AnimWalk) : 0f;
+ CheckBody(world, _player, _snake, dt);
+ Stir(world, _snake, dt);
+ Sleepers(world, _player, dt);
+ Sense(world, _snake, dt);
+ return Radiation(world, dt);
+ }
+
+ /// Шаг рывка: по прямой к ногам цели, без земли и стен. Долетела - убийство: цель
+ /// помечается для чёрного разрыва (NecroBurst.Pending) и получает смертельный урон ОТ ИГРОКА -
+ /// тогда игра сама выдаёт опыт, а NecromancyKillCreditPatch засчитывает убийство в Некромантию.
+ static void Dash(EntityPlayerLocal _player)
+ {
+ EntityAlive target = _dashTarget;
+ if (target == null || target.IsDead() || target.IsMarkedForUnload())
+ {
+ _dashTarget = null;
+ return;
+ }
+ Vector3 to = target.position - _pos;
+ float dist = to.magnitude;
+ Vector3 flat = new Vector3(to.x, 0f, to.z);
+ if (flat.sqrMagnitude > 0.0001f)
+ {
+ _snakeYaw = Mathf.Atan2(flat.x, flat.z) * Mathf.Rad2Deg;
+ }
+ float step = DashSpeed * Time.deltaTime;
+ if (dist - DashStop > step)
+ {
+ _pos += to / dist * step;
+ return;
+ }
+ _pos = target.position - (flat.sqrMagnitude > 0.0001f ? flat.normalized * DashStop : Vector3.zero);
+ _dashTarget = null;
+ _freeze = KillFreeze;
+ _killId = target.entityId;
+ NoLoot.Add(target.entityId);
+ NecroBurst.Pending.Add(target.entityId);
+ target.DamageEntity(new DamageSourceEntity(EnumDamageSource.External, EnumDamageTypes.Piercing, _player.entityId),
+ 99999, false);
+ if (!target.IsDead())
+ {
+ NecroBurst.Pending.Remove(target.entityId);
+ NoLoot.Remove(target.entityId);
+ }
+ Debug.Log("[NecromancerTome] SnakeControl: удар по " + target.EntityName + ", " + (target.IsDead() ? "убит" : "ВЫЖИЛ"));
+ }
+
+ /// Из NecroBurst.OnBurst: наш разрыв - запоминаем частицы, чтобы вести их за змеёй.
+ public static void OnBurst(EntityAlive _victim, Transform _effect)
+ {
+ if (!Controlling || _victim == null || _victim.entityId != _killId || _effect == null)
+ {
+ return;
+ }
+ _killId = -1;
+ _burst = _effect;
+ _burstOffset = _effect.position - (_pos - Origin.position);
+ _burstFollow = BurstFollow;
+ }
+
+ static void FollowBurst(float _dt)
+ {
+ if (_burst == null)
+ {
+ return;
+ }
+ _burstFollow -= _dt;
+ if (_burstFollow <= 0f)
+ {
+ _burst = null;
+ return;
+ }
+ _burst.position = _pos - Origin.position + _burstOffset;
+ }
+
+ /// Комнаты со спящими в зданиях просыпаются, когда в них входит ИГРОК:
+ /// EntityPlayer.CheckSleeperTriggers каждый тик зовёт World.CheckSleeperVolumeTouching и
+ /// CheckTriggerVolumeTrigger, а те берут player.position (тест 28.09: «зомби в локациях не
+ /// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи.
+ static void Sleepers(World _world, EntityPlayerLocal _player, float _dt)
+ {
+ _sleeperCheck -= _dt;
+ if (_sleeperCheck > 0f || _world.IsRemote())
+ {
+ return;
+ }
+ _sleeperCheck = 0.25f;
+ Vector3 body = _player.position;
+ _player.position = _pos;
+ try
+ {
+ _world.CheckSleeperVolumeTouching(_player);
+ _world.CheckTriggerVolumeTrigger(_player);
+ }
+ finally
+ {
+ _player.position = body;
+ }
+ }
+
+ static void Sense(World _world, EntityAlive _snake, float _dt)
+ {
+ _senseCheck -= _dt;
+ if (_senseCheck > 0f)
+ {
+ return;
+ }
+ _senseCheck = 1f;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null)
+ {
+ continue;
+ }
+ float d2 = (e.position - _pos).sqrMagnitude;
+ bool foe = IsFoe(e, _snake);
+ if (foe && d2 < SenseRange * SenseRange)
+ {
+ if (_sensed.Add(e.entityId))
+ {
+ e.AddNavObject(SenseNavObject, null, null);
+ // Подсветка врагов (buffNecroSnakeSight) снята 28.09: фильтр зрения глушит её цвет.
+ // Бафф и его снятие оставлены - на случай возврата и для уже сохранённых зомби.
+ }
+ }
+ else if (_sensed.Contains(e.entityId) && (!foe || d2 > SenseDropRange * SenseDropRange))
+ {
+ e.RemoveNavObject(SenseNavObject);
+ e.Buffs?.RemoveBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName);
+ _sensed.Remove(e.entityId);
+ }
+ }
+ // Исчезнувшие (разорванные, выгруженные) - просто забываем: метку игра снимет сама.
+ _sensed.RemoveWhere(id => _world.GetEntity(id) == null);
+ }
+
+ static void ClearSense()
+ {
+ World world = GameManager.Instance?.World;
+ if (world != null)
+ {
+ foreach (int id in _sensed)
+ {
+ EntityAlive e = world.GetEntity(id) as EntityAlive;
+ e?.RemoveNavObject(SenseNavObject);
+ e?.Buffs?.RemoveBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName);
+ }
+ }
+ _sensed.Clear();
+ }
+
+ /// Раз в 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: «если под бафом девианта, то это
+ /// не враг»). Общее для тревоги у тела и для прицела.
+ static bool IsFoe(EntityAlive _e, EntityAlive _snake)
+ {
+ return _e != null && _e != _snake && _e is EntityEnemy && !_e.IsDead() &&
+ !GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
+ }
+
+ /// Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу игрока.
+ static void CheckBody(World _world, EntityPlayerLocal _player, EntityAlive _snake, float _dt)
+ {
+ _bodyCheck -= _dt;
+ if (_bodyCheck > 0f)
+ {
+ return;
+ }
+ _bodyCheck = 0.5f;
+ bool danger = false;
+ Vector3 body = _player.position;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (IsFoe(e, _snake) && (e.position - body).sqrMagnitude < BodyDangerRange * BodyDangerRange)
+ {
+ danger = true;
+ break;
+ }
+ }
+ if (danger != BodyDanger)
+ {
+ BodyDanger = danger;
+ Debug.Log("[NecromancerTome] SnakeControl: " + (danger ? "враг у тела" : "у тела чисто"));
+ }
+ }
+
+ /// Отсчёт в радиации края карты. Вышла из зоны - отсчёт сбрасывается.
+ static bool Radiation(World _world, float _dt)
+ {
+ _radiationCheck -= _dt;
+ if (_radiationCheck <= 0f)
+ {
+ _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)
+ {
+ return false;
+ }
+ Countdown -= _dt;
+ if (Countdown > 0f)
+ {
+ return false;
+ }
+ Debug.Log("[NecromancerTome] SnakeControl: змея развеялась в радиации");
+ return true;
+ }
+
+ static void Look()
+ {
+ PlayerMoveController pmc = PlayerMoveController.Instance;
+ Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
+ int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
+ _yaw += Input.GetAxis("Mouse X") * sens.x;
+ _pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 60f);
+ }
+
+ static bool TryStep(World _world, Vector3 _d)
+ {
+ if (_d.sqrMagnitude < 0.000001f)
+ {
+ return false;
+ }
+ Vector3 c = _pos + _d;
+ if (!_world.IsChunkAreaCollidersLoaded(c))
+ {
+ return false;
+ }
+ // Один блок - ступенька, два - стена. Проверяем настоящую геометрию, а не тип блока:
+ // у блока есть только флаг «твёрдый», и открытая дверь для него такая же стена, как
+ // закрытая (тест 28.09: «змея не может пройти в открытые двери»).
+ if (Blocked(c + Vector3.up * 0.4f) && Blocked(c + Vector3.up * 1.35f))
+ {
+ return false;
+ }
+ // В полёте землю под собой не ищем - иначе прыжок обрывался бы на первом шаге.
+ // Вверх подтягиваем и в полёте: так змея цепляется за край уступа.
+ if (Ground(c + Vector3.up * 1.6f, 2.4f, out float y) && (!_airborne || y > c.y))
+ {
+ c.y = y;
+ if (_airborne && _vy <= 0f)
+ {
+ _airborne = false;
+ _vy = 0f;
+ }
+ }
+ _pos = c;
+ return true;
+ }
+
+ static void Fall(float _dt, bool _jump)
+ {
+ if (!Ground(_pos + Vector3.up * 0.3f, 300f, out float ground))
+ {
+ _vy = 0f;
+ _airborne = false;
+ return;
+ }
+ if (!_airborne && _jump && ground >= _pos.y - 0.05f)
+ {
+ _vy = JumpSpeed;
+ _airborne = true;
+ }
+ if (!_airborne && ground >= _pos.y - 0.02f)
+ {
+ _pos.y = ground;
+ _vy = 0f;
+ return;
+ }
+ _airborne = true;
+ _vy -= Gravity * _dt;
+ float dy = _vy * _dt;
+ // Потолок: удар головой гасит подъём.
+ if (dy > 0f && Physics.Raycast(_pos + Vector3.up * 0.3f - Origin.position, Vector3.up, dy + 0.3f, TerrainMask))
+ {
+ _vy = 0f;
+ dy = 0f;
+ }
+ _pos.y += dy;
+ if (_pos.y <= ground)
+ {
+ _pos.y = ground;
+ _vy = 0f;
+ _airborne = false;
+ }
+ }
+
+ static bool Blocked(Vector3 _p)
+ {
+ return Physics.CheckSphere(_p - Origin.position, BodyRadius, WallMask, QueryTriggerInteraction.Ignore);
+ }
+
+ /// Правая кнопка: выбраться из текстур. Ближайшее свободное место - сначала выше
+ /// на месте, потом кольцами до 3 м и до 4 м вверх; там, где тело змеи ни во что не упирается.
+ /// Дальше змея сама падает на землю.
+ static void Unstick(World _world)
+ {
+ if (_dashTarget != null)
+ {
+ return;
+ }
+ for (float r = 0f; r <= 3f; r += 1f)
+ {
+ int dirs = r == 0f ? 1 : 8;
+ // На месте - минимум на полметра вверх: проверка может счесть место свободным, а
+ // змея всё равно не идёт (тест 28.09: дважды «выбралась, сдвиг 0.0 м»).
+ for (float h = r == 0f ? 0.5f : 0f; h <= 4f; h += 0.5f)
+ {
+ for (int i = 0; i < dirs; i++)
+ {
+ Vector3 p = _pos + Quaternion.Euler(0f, _yaw + i * 45f, 0f) * Vector3.forward * r + Vector3.up * h;
+ if (!_world.IsChunkAreaCollidersLoaded(p) || Blocked(p + Vector3.up * 0.4f) || Blocked(p + Vector3.up * 1f))
+ {
+ continue;
+ }
+ Debug.Log("[NecromancerTome] SnakeControl: выбралась, сдвиг " + (p - _pos).magnitude.ToString("F1") + " м");
+ _pos = p;
+ _vy = 0f;
+ _airborne = true;
+ return;
+ }
+ }
+ }
+ Debug.Log("[NecromancerTome] SnakeControl: выбраться некуда");
+ }
+
+ static bool Solid(World _world, Vector3 _p)
+ {
+ return _world.GetBlock(World.worldToBlockPos(_p)).Block.IsCollideMovement;
+ }
+
+ static bool Ground(Vector3 _from, float _dist, out float _y)
+ {
+ if (Physics.Raycast(_from - Origin.position, Vector3.down, out RaycastHit hit, _dist, TerrainMask))
+ {
+ _y = hit.point.y + Origin.position.y;
+ return true;
+ }
+ _y = 0f;
+ return false;
+ }
+
+ /// Из патчей камеры: вид из глаз змеи и поиск цели под прицелом.
+ public static void PlaceCamera(Transform _camera)
+ {
+ World world = GameManager.Instance?.World;
+ EntityAlive snake = SnakeMode.Snake(world);
+ if (snake == null)
+ {
+ return;
+ }
+ Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
+ Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
+ Vector3 root = _pos - Origin.position;
+ Transform head = snake.emodel != null ? snake.emodel.GetHeadTransform() : null;
+ if (_headForward < 0f)
+ {
+ if (head != null)
+ {
+ Vector3 rel = head.position - (snake.position - Origin.position);
+ Vector3 snakeForward = Quaternion.Euler(0f, snake.rotation.y, 0f) * Vector3.forward;
+ _headForward = Mathf.Clamp(Vector3.Dot(rel, snakeForward), 0f, 1.5f);
+ _headUp = Mathf.Clamp(rel.y, 0f, 1f);
+ }
+ else
+ {
+ _headForward = 0.5f;
+ _headUp = 0.3f;
+ }
+ }
+ Vector3 eye = root + Vector3.up * (_headUp + EyeUp) + yawRot * Vector3.forward * (_headForward + EyeForward);
+ // Между телом змеи и глазом - земля? Камеру к телу, до препятствия.
+ Vector3 anchor = snake.position - Origin.position + Vector3.up * 0.5f;
+ Vector3 toEye = eye - anchor;
+ if (Physics.Raycast(anchor, toEye.normalized, out RaycastHit hit, toEye.magnitude + 0.15f, TerrainMask))
+ {
+ eye = anchor + toEye.normalized * Mathf.Max(0f, hit.distance - 0.15f);
+ }
+ // И не ниже поверхности прямо под глазом.
+ if (Physics.Raycast(eye + Vector3.up * 1.5f, Vector3.down, out hit, 1.5f + 0.2f, TerrainMask) && hit.point.y + 0.2f > eye.y)
+ {
+ eye.y = hit.point.y + 0.2f;
+ }
+ _camera.SetPositionAndRotation(eye, rot);
+ if (!_loggedCamera)
+ {
+ _loggedCamera = true;
+ Debug.Log("[NecromancerTome] SnakeControl: камера в глазах змеи" + (head != null ? " (голова " + head.name + ")" : " (головы нет)") +
+ ", голова впереди " + _headForward.ToString("F2") + " м, выше " + _headUp.ToString("F2") + " м");
+ }
+ Target = FindTarget(world, snake, _camera.position, _camera.forward);
+ }
+
+ /// Ближайший живой противник на луче прицела не дальше TargetRange от змеи.
+ /// Сквозь стены - так же, как пойдёт рывок. Питомцы (призрачные) не цель.
+ static EntityAlive FindTarget(World _world, EntityAlive _snake, Vector3 _from, Vector3 _dir)
+ {
+ EntityAlive best = null;
+ float bestAlong = float.MaxValue;
+ Vector3 snakePos = _snake.position - Origin.position;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (!IsFoe(e, _snake))
+ {
+ continue;
+ }
+ Vector3 p = e.GetChestTransformPosition();
+ if ((p - snakePos).sqrMagnitude > TargetRange * TargetRange)
+ {
+ continue;
+ }
+ float along = Vector3.Dot(p - _from, _dir);
+ if (along <= 0f || along >= bestAlong)
+ {
+ continue;
+ }
+ if (Vector3.Cross(p - _from, _dir).magnitude > TargetRadius)
+ {
+ continue;
+ }
+ best = e;
+ bestAlong = along;
+ }
+ return best;
+ }
+ }
+
+ /// Пока тело в рэгдолле, игра выключает vp_FPCamera и сама ведёт «камеру на себя»
+ /// (EntityPlayerLocal.OnRagdoll -> StartSelfCamera, каждый кадр SelfCameraFrameUpdate из Update).
+ /// Тест 28.09: «камера всё ещё у игрока» - постфикс к vp_FPCamera успел сработать один кадр,
+ /// до падения. Здесь её место занимает камера змеи.
+ [HarmonyPatch(typeof(EntityPlayerLocal), "SelfCameraFrameUpdate")]
+ public static class Patch_EntityPlayerLocal_SelfCamera_Snake
+ {
+ public static bool Prefix(EntityPlayerLocal __instance)
+ {
+ if (!SnakeControl.Controlling)
+ {
+ return true;
+ }
+ SnakeControl.PlaceCamera(__instance.cameraTransform);
+ return false;
+ }
+ }
+
+ /// Аниматор животного в режиме полёта получает скорость 0 (AvatarAnimalController.LateUpdate:
+ /// `if (!entity.IsFlyMode.Value) num = entity.speedForward`) - змея скользила неподвижной
+ /// (тест 28.09: «не извивается»). На время этого метода снимаем полёт и подставляем скорость.
+ [HarmonyPatch(typeof(AvatarAnimalController), "LateUpdate")]
+ public static class Patch_AvatarAnimalController_LateUpdate_Snake
+ {
+ public static void Prefix(AvatarAnimalController __instance, out bool __state)
+ {
+ __state = false;
+ EntityAlive e = __instance.entity;
+ if (!SnakeControl.Controlling || e == null || e.entityClass != EntityClass.FromString(SnakeMode.SnakeClassName) || !e.IsFlyMode.Value)
+ {
+ return;
+ }
+ __state = true;
+ e.IsFlyMode.Value = false;
+ e.speedForward = SnakeControl.AnimForward;
+ e.speedStrafe = 0f;
+ }
+
+ public static void Postfix(AvatarAnimalController __instance, bool __state)
+ {
+ if (__state)
+ {
+ __instance.entity.IsFlyMode.Value = true;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(vp_FPCamera), "LateUpdate")]
+ public static class Patch_vpFPCamera_LateUpdate_Snake
+ {
+ public static void Postfix(vp_FPCamera __instance)
+ {
+ if (SnakeControl.Controlling)
+ {
+ SnakeControl.PlaceCamera(__instance.transform);
+ }
+ }
+ }
+
+ /// Прицел змеи: пиктограмма глаза из атласа игры посреди экрана, красная, когда в
+ /// прицеле противник. Рисуется через OnGUI, чтобы не заводить своё окно XUi.
+ public class SnakeCrosshair : MonoBehaviour
+ {
+ public const string Sprite = "ui_game_symbol_twitch_vision_effects";
+ static readonly Color Idle = new Color(0.85f, 0.95f, 1f, 0.8f);
+ static readonly Color OnTarget = new Color(1f, 0.15f, 0.1f, 1f);
+
+ /// Свой значок (указание 28.09: «вытащить из игры, чтобы я смог переделать»): если в
+ /// Resources мода лежит этот PNG, рисуется он, а не спрайт игры. Высота на экране та же,
+ /// ширина - по пропорциям картинки.
+ public const string CustomFile = "necrosnake_crosshair.png";
+ /// Куда один раз выгружается спрайт игры для перерисовки: закрытая папка рядом с
+ /// модом. У игроков её нет - тогда выгрузка просто не делается.
+ public const string ExportFile = "snake_crosshair_original.png";
+
+ INGUIAtlas _atlas;
+ UISpriteData _sprite;
+ Texture2D _custom;
+ bool _failed;
+
+ static string ModDir => System.IO.Path.GetDirectoryName(typeof(SnakeCrosshair).Assembly.Location);
+
+ public static void Create()
+ {
+ GameObject go = new GameObject("NecroSnakeCrosshair");
+ DontDestroyOnLoad(go);
+ go.AddComponent();
+ }
+
+ void OnGUI()
+ {
+ if (!SnakeControl.Controlling || GameManager.Instance == null || GameManager.Instance.World == null ||
+ Event.current.type != EventType.Repaint || !Resolve())
+ {
+ return;
+ }
+ if (SnakeControl.BodyDanger)
+ {
+ DrawBodyDanger();
+ }
+ if (SnakeControl.Countdown >= 0f)
+ {
+ DrawCountdown();
+ return;
+ }
+ Texture tex = _custom != null ? (Texture)_custom : _atlas.texture;
+ Rect uv = _custom != null
+ ? new Rect(0f, 0f, 1f, 1f)
+ : new Rect((float)_sprite.x / tex.width, 1f - (float)(_sprite.y + _sprite.height) / tex.height,
+ (float)_sprite.width / tex.width, (float)_sprite.height / tex.height);
+ float aspect = _custom != null ? (float)_custom.width / _custom.height : (float)_sprite.width / _sprite.height;
+ float h = 32f * Screen.height / 1080f;
+ float w = h * aspect;
+ Rect rect = new Rect((Screen.width - w) * 0.5f, (Screen.height - h) * 0.5f, w, h);
+ Color old = GUI.color;
+ // Враг у тела не красит прицел, а заставляет его мигать: секунду виден, секунду нет
+ // (указание 28.09). Красный остаётся только за целью в прицеле.
+ if (SnakeControl.BodyDanger && Mathf.FloorToInt(Time.unscaledTime) % 2 == 1)
+ {
+ return;
+ }
+ GUI.color = SnakeControl.Target != null ? OnTarget : Idle;
+ GUI.DrawTextureWithTexCoords(rect, tex, uv);
+ GUI.color = old;
+ }
+
+ GUIStyle _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;
+
+ /// Надпись под прицелом, пока враг у тела.
+ void DrawBodyDanger()
+ {
+ if (_dangerStyle == null)
+ {
+ _dangerStyle = new GUIStyle(GUI.skin.label)
+ {
+ alignment = TextAnchor.UpperCenter,
+ fontStyle = FontStyle.Bold
+ };
+ }
+ _dangerStyle.fontSize = Mathf.RoundToInt(26f * Screen.height / 1080f);
+ string text = Localization.Get("necroSnakeBodyDanger");
+ float top = Screen.height * 0.5f + 40f * Screen.height / 1080f;
+ Rect rect = new Rect(0f, top, Screen.width, 60f * Screen.height / 1080f);
+ Color old = GUI.color;
+ GUI.color = new Color(0f, 0f, 0f, 0.8f);
+ GUI.Label(new Rect(rect.x + 2f, rect.y + 2f, rect.width, rect.height), text, _dangerStyle);
+ GUI.color = OnTarget;
+ GUI.Label(rect, text, _dangerStyle);
+ GUI.color = old;
+ }
+
+ bool Resolve()
+ {
+ if (_sprite != null)
+ {
+ return true;
+ }
+ if (_failed)
+ {
+ return false;
+ }
+ XUi xui = LocalPlayerUI.GetUIForPrimaryPlayer()?.xui;
+ if (xui == null)
+ {
+ return false;
+ }
+ _atlas = xui.GetAtlasByName("UIAtlas", Sprite);
+ _sprite = _atlas?.GetSprite(Sprite);
+ if (_sprite == null || _atlas.texture == null)
+ {
+ _failed = true;
+ _sprite = null;
+ Debug.LogWarning("[NecromancerTome] SnakeCrosshair: спрайт '" + Sprite + "' не найден");
+ return false;
+ }
+ Debug.Log("[NecromancerTome] SnakeCrosshair: прицел '" + Sprite + "' " + _sprite.width + "x" + _sprite.height);
+ LoadCustom();
+ ExportOnce();
+ return true;
+ }
+
+ void LoadCustom()
+ {
+ string path = System.IO.Path.Combine(ModDir, "Resources", CustomFile);
+ if (!System.IO.File.Exists(path))
+ {
+ return;
+ }
+ Texture2D t = new Texture2D(2, 2, TextureFormat.RGBA32, false);
+ if (ImageConversion.LoadImage(t, System.IO.File.ReadAllBytes(path)))
+ {
+ t.filterMode = FilterMode.Bilinear;
+ _custom = t;
+ Debug.Log("[NecromancerTome] SnakeCrosshair: свой значок " + t.width + "x" + t.height);
+ }
+ else
+ {
+ Debug.LogWarning("[NecromancerTome] SnakeCrosshair: не прочитался " + path);
+ }
+ }
+
+ /// Вырезает спрайт из атласа в PNG. Атлас на видеокарте и нечитаем напрямую -
+ /// тот же приём, что GhostTraderPatch.Desaturate: копия в RenderTexture и ReadPixels.
+ /// У NGUI y спрайта считается сверху, у Unity - снизу.
+ void ExportOnce()
+ {
+ string dir = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(ModDir), "_NecromancerTome_private", "ForPainting");
+ string path = System.IO.Path.Combine(dir, ExportFile);
+ if (!System.IO.Directory.Exists(dir) || System.IO.File.Exists(path))
+ {
+ return;
+ }
+ Texture atlas = _atlas.texture;
+ RenderTexture previous = RenderTexture.active;
+ RenderTexture rt = RenderTexture.GetTemporary(atlas.width, atlas.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
+ try
+ {
+ Graphics.Blit(atlas, rt);
+ RenderTexture.active = rt;
+ Texture2D icon = new Texture2D(_sprite.width, _sprite.height, TextureFormat.RGBA32, false);
+ icon.ReadPixels(new Rect(_sprite.x, atlas.height - _sprite.y - _sprite.height, _sprite.width, _sprite.height), 0, 0);
+ icon.Apply();
+ System.IO.File.WriteAllBytes(path, ImageConversion.EncodeToPNG(icon));
+ Destroy(icon);
+ Debug.Log("[NecromancerTome] SnakeCrosshair: спрайт выгружен в " + path);
+ }
+ catch (System.Exception e)
+ {
+ Debug.LogWarning("[NecromancerTome] SnakeCrosshair: выгрузка не удалась: " + e.Message);
+ }
+ finally
+ {
+ RenderTexture.active = previous;
+ RenderTexture.ReleaseTemporary(rt);
+ }
+ }
+ }
+
+ /// Зомби вокруг змеи (тест 28.09: «вокруг змеи не спавнятся зомби»). Биомный спавн
+ /// работает в чанках у игрока: SpawnManagerBiomes.SpawnUpdate пропускает область, если ни один
+ /// игрок не стоит ближе 40 м, и ставит зомби не ближе 28 м от игроков и вне их взгляда. Чанки
+ /// вокруг змеи загружены её наблюдателем и перебираются миром, но эти проверки смотрят на тело.
+ /// На время вызова переносим позицию игрока к змее - все проверки считаются от неё.
+ [HarmonyPatch(typeof(SpawnManagerBiomes), "SpawnUpdate")]
+ public static class Patch_SpawnManagerBiomes_SpawnUpdate_Snake
+ {
+ public static void Prefix(out Vector3 __state)
+ {
+ __state = Vector3.zero;
+ if (!SnakeControl.Controlling)
+ {
+ return;
+ }
+ World world = GameManager.Instance.World;
+ EntityPlayerLocal player = world?.GetPrimaryPlayer();
+ EntityAlive snake = SnakeMode.Snake(world);
+ if (player == null || snake == null)
+ {
+ return;
+ }
+ __state = player.position;
+ player.position = snake.position;
+ }
+
+ public static void Postfix(Vector3 __state)
+ {
+ if (__state == Vector3.zero)
+ {
+ return;
+ }
+ EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
+ if (player != null)
+ {
+ player.position = __state;
+ }
+ }
+ }
+
+ /// Зомби у змеи не исчезают (тест 28.09: «зомби почему-то исчезают возле змеи»).
+ /// EntityAlive.CheckDespawn мерит расстояние до ближайшего ИГРОКА, то есть до тела: биомный
+ /// зомби дальше 128 м от него, которого давно не видели, удаляется. Если зомби не дальше 80 м от
+ /// змеи (тот же порог, при котором игра обнуляет счётчик «рядом нет игрока»), проверку
+ /// пропускаем и счётчик сбрасываем. Остальные - по правилам игры, от тела.
+ [HarmonyPatch(typeof(EntityAlive), "CheckDespawn")]
+ public static class Patch_EntityAlive_CheckDespawn_Snake
+ {
+ public static bool Prefix(EntityAlive __instance, ref int ___ticksNoPlayerAdjacent)
+ {
+ if (!SnakeControl.Controlling)
+ {
+ return true;
+ }
+ EntityAlive snake = SnakeMode.Snake(__instance.world as World);
+ if (snake == null || snake == __instance || (__instance.position - snake.position).sqrMagnitude > 6400f)
+ {
+ return true;
+ }
+ ___ticksNoPlayerAdjacent = 0;
+ return false;
+ }
+ }
+
+ /// Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»). Игра каждый
+ /// кадр в EntityPlayerLocal.OnHUD открывает окно "toolbelt", если оно закрыто, - поэтому не
+ /// закрываем его раз за разом, а не даём открыться: все Open сходятся в openInternal.
+ [HarmonyPatch(typeof(GUIWindowManager), "openInternal")]
+ public static class Patch_GUIWindowManager_openInternal_SnakeToolbelt
+ {
+ public static bool Prefix(GUIWindow _w)
+ {
+ return !(SnakeControl.Controlling && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
+ }
+ }
+
+ /// Звуки у змеи слышны (тест 28.09: «звук при убийстве не срабатывает»). Audio.Manager
+ /// мерит расстояние до ИГРОКА (LocalPlayer().position - тело) и не создаёт звук дальше
+ /// maxDistance его источника (LoadAudio). Громкость потом считает сам Unity от слушателя - камеры
+ /// змеи. На время вызова переносим позицию игрока к змее.
+ public static class SnakeAudioSwap
+ {
+ public static Vector3 Prefix()
+ {
+ if (!SnakeControl.Controlling)
+ {
+ return Vector3.zero;
+ }
+ World world = GameManager.Instance?.World;
+ EntityPlayerLocal player = world?.GetPrimaryPlayer();
+ EntityAlive snake = SnakeMode.Snake(world);
+ if (player == null || snake == null)
+ {
+ return Vector3.zero;
+ }
+ Vector3 body = player.position;
+ player.position = snake.position;
+ return body;
+ }
+
+ public static void Postfix(Vector3 _body)
+ {
+ if (_body == Vector3.zero)
+ {
+ return;
+ }
+ EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
+ if (player != null)
+ {
+ player.position = _body;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Vector3), typeof(string), typeof(int), typeof(bool), typeof(float) })]
+ public static class Patch_AudioManager_PlayAt_Snake
+ {
+ public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
+ public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
+ }
+
+ [HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Entity), typeof(string), typeof(float), typeof(bool) })]
+ public static class Patch_AudioManager_PlayOn_Snake
+ {
+ public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
+ public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
+ }
+
+ /// Отрисовка мира вокруг змеи (тест 28.09: «есть блоки, которые прорисовываются, только
+ /// если рядом игрок»). Сами чанки у змеи грузит и строит её наблюдатель, но несколько систем
+ /// каждый кадр смотрят на позицию ИГРОКА - тела:
+ /// - WorldEnvironment.Update: дальний ландшафт (UnityDistantTerrainTest.FrameUpdate - вокруг
+ /// игрока в нём вырез под настоящие чанки) и ChunkGameObject.CheckLODs (декали ближе 41 м,
+ /// трава ближе 48-112 м);
+ /// - DynamicMeshManager.Update: упрощённые модели зданий вдали (Dynamic Mesh) - какие регионы
+ /// грузить и выгружать и где показывать настоящие чанки вместо упрощённых.
+ /// На время этих вызовов позиция игрока - у змеи, как и для звука (SnakeAudioSwap).
+ [HarmonyPatch(typeof(WorldEnvironment), "Update")]
+ public static class Patch_WorldEnvironment_Update_Snake
+ {
+ public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
+ public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
+ }
+
+ [HarmonyPatch(typeof(DynamicMeshManager), "Update")]
+ public static class Patch_DynamicMeshManager_Update_Snake
+ {
+ public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
+ public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
+ }
+
+ /// Мешок с добычей падает в EntityAlive.dropItemOnDeath (шанс LootDropProb). Для
+ /// убитых змеёй - не зовём вовсе.
+ [HarmonyPatch(typeof(EntityAlive), "dropItemOnDeath")]
+ public static class Patch_EntityAlive_dropItemOnDeath_Snake
+ {
+ public static bool Prefix(EntityAlive __instance)
+ {
+ return !SnakeControl.NoLoot.Remove(__instance.entityId);
+ }
+ }
+
+ /// Компас в режиме змеи: направление он и так берёт у камеры (то есть у змеи), а точку
+ /// отсчёта - у игрока, то есть у тела. На время его Update позиция игрока - у змеи, иначе
+ /// метки Тёмного чутья показывали бы направление от тела.
+ [HarmonyPatch(typeof(XUiC_CompassWindow), nameof(XUiC_CompassWindow.Update))]
+ public static class Patch_XUiC_CompassWindow_Update_Snake
+ {
+ public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
+ public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
+ }
+}
diff --git a/HarmonySrc/SnakeMode.cs b/HarmonySrc/SnakeMode.cs
new file mode 100644
index 0000000..31a6b50
--- /dev/null
+++ b/HarmonySrc/SnakeMode.cs
@@ -0,0 +1,360 @@
+using GUI_2;
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Режим змеи Ножа некроманта (продиктовано 2026-09-28, BACKLOG «Замысел: режим змеи»).
+ /// Зажать R с ножом в руке - меню, как у выбора патронов: человек (по умолчанию) и змея. Выбор
+ /// змеи отнимает 95% здоровья (назад не возвращается), тело игрока падает и остаётся лежать
+ /// беззащитным, рядом появляется призрачная змея в 1,4 раза больше обычной (было 2 - «удав получился», 28.09). Выход - снова R.
+ /// Если тело убили, змея исчезает, и игрок видит обычную смерть.
+ ///
+ /// ЭТАП 1 (этот файл): меню, вход, выход, смерть тела. ЭТАП 2а (SnakeControl.cs): камера,
+ /// управление, прицел. Рывок на F и скрытый пояс с надписью «Press R» - дальше.
+ ///
+ /// ПОЧЕМУ МЕНЮ ОТКРЫВАЕТСЯ. PlayerMoveController открывает радиальное меню на R для огнестрела
+ /// И для DynamicMelee, если GetHoldingPrimary().HasRadial(). Наш нож - DynamicMelee
+ /// (наследник костяного ножа), а ItemActionDynamic унаследован прямо от ItemAction и
+ /// HasRadial/SetupRadial не переопределяет - значит, патчим пустые базовые методы ItemAction.
+ /// Выбор в предмете НЕ хранится: меню служит только для входа, а выход идёт по R, пока тело
+ /// лежит. Так сейв ножа не меняется вовсе.
+ ///
+ /// ПОЧЕМУ ТЕЛО - РЭГДОЛЛ. PlayerMoveController.Update первым делом проверяет
+ /// emodel.IsRagdollActive и, если тело лежит, ничего не делает: ни ходьбы, ни E, ни R, ни меню.
+ /// То есть упавшее тело само запрещает лутать и пользоваться поясом. Лежит оно, пока идёт
+ /// ragdollDuration (ставим почти бесконечную); на выходе дотягиваем ragdollTime до конца, и
+ /// игра сама поднимает персонажа.
+ ///
+ public static class SnakeMode
+ {
+ public const string KnifeName = "necroWpnBladeNecroKnife";
+ public const string SnakeClassName = "necroSnakeSpirit";
+ public const float HealthCost = 0.95f;
+ /// Зазор между телом игрока и змеёй, метры (указание 28.09: «в 1 метре»).
+ public const float Gap = 1f;
+ public const float PlayerRadius = 0.4f;
+ /// Сколько ждать после перехода на третье лицо, прежде чем ронять тело.
+ public const float RagdollDelay = 0.25f;
+
+ public static bool Active;
+ static int _snakeId = -1;
+ static float _sweepTimer;
+ static float _ragdollAt;
+ static bool _placeSnake;
+ static Vector3 _snakeDir;
+ static bool _waitStandUp;
+ static float _standUpDeadline;
+
+ public static void Init()
+ {
+ ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
+ SnakeCrosshair.Create();
+ NecroBurst.OnBurst += SnakeControl.OnBurst;
+ }
+
+ /// Дух змеи - только ночью (указание 28.09): днём в меню вместо змеи циферблат и
+ /// «Сейчас день», выбрать нельзя; наступило утро - змея развеивается, игрок в теле. «Ночь» -
+ /// та же, что у игры (World.IsDark: часы из настроек длины дня, плюс вечный день/ночь).
+ public const string DayIcon = "ui_game_symbol_clock";
+
+ public static bool IsNight()
+ {
+ World world = GameManager.Instance?.World;
+ return world != null && world.IsDark();
+ }
+
+ public static bool IsKnife(ItemClass _item)
+ {
+ return _item != null && _item.Name == KnifeName;
+ }
+
+ public static void Enter(EntityPlayerLocal _player)
+ {
+ if (Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
+ {
+ return;
+ }
+ if (!IsNight())
+ {
+ Debug.Log("[NecromancerTome] SnakeMode: днём дух змеи недоступен");
+ return;
+ }
+ World world = GameManager.Instance.World;
+ EntityClass snakeClass = EntityClass.list[EntityClass.FromString(SnakeClassName)];
+ if (snakeClass == null)
+ {
+ Debug.LogWarning("[NecromancerTome] SnakeMode: класс '" + SnakeClassName + "' не найден");
+ return;
+ }
+
+ int cost = Mathf.CeilToInt(_player.GetMaxHealth() * HealthCost);
+ _player.Health = Mathf.Max(1, _player.Health - cost);
+
+ Vector3 pos = FindFreeSpot(world, _player, out _snakeDir);
+ float yaw = Quaternion.LookRotation(_snakeDir).eulerAngles.y;
+ Entity snake = EntityFactory.CreateEntity(EntityClass.FromString(SnakeClassName), pos, new Vector3(0f, yaw, 0f));
+ world.SpawnEntityInWorld(snake);
+ _snakeId = snake.entityId;
+ _placeSnake = true;
+
+ // Сначала третье лицо, потом падение. switchModelView меняет модель игрока, и рэгдолл,
+ // включённый до переключения, остаётся на старой модели - видимое тело стоит (проверено
+ // в игре 28.09: «персонаж остался стоять», хотя IsRagdollActive был true).
+ _player.SetFirstPersonView(false, true);
+ _ragdollAt = Time.time + RagdollDelay;
+ _waitStandUp = false;
+ Active = true;
+ Debug.Log("[NecromancerTome] SnakeMode: вход, здоровье " + _player.Health + ", змея " + _snakeId);
+ }
+
+ /// Свободное место для змеи: по кругу, начиная с направления взгляда, шагами по
+ /// 45 градусов; две клетки высоты без коллизии. Точное расстояние доводит PlaceSnake.
+ static Vector3 FindFreeSpot(World _world, EntityPlayerLocal _player, out Vector3 _dir)
+ {
+ float yaw = _player.rotation.y;
+ Vector3 origin = _player.position;
+ for (int step = 0; step < 8; step++)
+ {
+ int k = (step + 1) / 2 * (step % 2 == 0 ? -1 : 1);
+ Vector3 dir = Quaternion.Euler(0f, yaw + k * 45f, 0f) * Vector3.forward;
+ for (float dist = 1.5f; dist <= 3.5f; dist += 1f)
+ {
+ Vector3 p = origin + dir * dist;
+ if (IsFree(_world, p) && IsFree(_world, (origin + p) * 0.5f))
+ {
+ _dir = dir;
+ return p;
+ }
+ }
+ }
+ _dir = Quaternion.Euler(0f, yaw, 0f) * Vector3.forward;
+ return origin + _dir * 1.5f;
+ }
+
+ static bool IsFree(World _world, Vector3 _pos)
+ {
+ Vector3i b = World.worldToBlockPos(_pos);
+ return !_world.GetBlock(b).Block.IsCollideMovement && !_world.GetBlock(b + Vector3i.up).Block.IsCollideMovement;
+ }
+
+ /// Когда модель змеи уже есть, сдвигает её вдоль _snakeDir так, чтобы от тела
+ /// игрока до ближайшей точки змеи было Gap метров (по горизонтали).
+ static bool PlaceSnake(EntityPlayerLocal _player, EntityAlive _snake)
+ {
+ Transform model = _snake.emodel != null ? _snake.emodel.GetModelTransform() : null;
+ Renderer[] rs = model != null ? model.GetComponentsInChildren() : null;
+ if (rs == null || rs.Length == 0)
+ {
+ return false;
+ }
+ Bounds bounds = rs[0].bounds;
+ for (int i = 1; i < rs.Length; i++)
+ {
+ bounds.Encapsulate(rs[i].bounds);
+ }
+ Vector3 center = _player.position - Origin.position;
+ Vector3 closest = bounds.ClosestPoint(new Vector3(center.x, bounds.center.y, center.z));
+ float gap = new Vector2(closest.x - center.x, closest.z - center.z).magnitude - PlayerRadius;
+ Vector3 target = _snake.position + _snakeDir * (Gap - gap);
+ if (IsFree(GameManager.Instance.World, target))
+ {
+ _snake.SetPosition(target);
+ }
+ Debug.Log("[NecromancerTome] SnakeMode: змея " + bounds.size.ToString("F2") + ", зазор был " +
+ gap.ToString("F2") + " м, сдвиг " + (Gap - gap).ToString("F2") + " м");
+ return true;
+ }
+
+ public static void Exit(EntityPlayerLocal _player)
+ {
+ RemoveSnake();
+ Active = false;
+ if (_player == null || _player.IsDead())
+ {
+ return;
+ }
+ // Сначала встать, потом первое лицо: переключение вида меняет модель, и посреди
+ // рэгдолла это оставило бы тело лежать. Переключаем в OnUnityUpdate, когда встал.
+ _ragdollAt = 0f;
+ _player.emodel.ragdollTime = _player.emodel.ragdollDuration;
+ _waitStandUp = true;
+ _standUpDeadline = Time.time + 5f;
+ Debug.Log("[NecromancerTome] SnakeMode: выход");
+ }
+
+ public static EntityAlive Snake(World _world)
+ {
+ return _snakeId != -1 && _world != null ? _world.GetEntity(_snakeId) as EntityAlive : null;
+ }
+
+ static void RemoveSnake()
+ {
+ SnakeControl.End();
+ World world = GameManager.Instance.World;
+ if (_snakeId != -1 && world != null && world.GetEntity(_snakeId) != null)
+ {
+ world.RemoveEntity(_snakeId, EnumRemoveEntityReason.Despawned);
+ }
+ _snakeId = -1;
+ }
+
+ static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
+ {
+ World world = GameManager.Instance?.World;
+ if (world == null)
+ {
+ // Выход в меню посреди режима (тест 28.09: «прицел оставался на экране»).
+ Active = false;
+ _snakeId = -1;
+ SnakeControl.End();
+ return;
+ }
+ EntityPlayerLocal player = world.GetPrimaryPlayer();
+ if (!Active)
+ {
+ if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline))
+ {
+ _waitStandUp = false;
+ player.SetFirstPersonView(true, true);
+ }
+ SweepStraySnakes(world);
+ return;
+ }
+ if (player == null)
+ {
+ return;
+ }
+ if (player.IsDead())
+ {
+ // Тело убито: змея исчезает, камеру и смерть ведёт игра.
+ RemoveSnake();
+ Active = false;
+ Debug.Log("[NecromancerTome] SnakeMode: тело убито, режим снят");
+ return;
+ }
+ EntityAlive snake = world.GetEntity(_snakeId) as EntityAlive;
+ if (snake == null)
+ {
+ Exit(player);
+ return;
+ }
+ snake.Health = snake.GetMaxHealth();
+ if (!IsNight())
+ {
+ Debug.Log("[NecromancerTome] SnakeMode: наступило утро - обратно в тело");
+ Exit(player);
+ return;
+ }
+ if (_placeSnake && PlaceSnake(player, snake))
+ {
+ _placeSnake = false;
+ SnakeControl.Begin(player, snake);
+ }
+ if (SnakeControl.Update(player, snake))
+ {
+ // Развеялась в радиации - обратно в тело.
+ Exit(player);
+ return;
+ }
+ if (_ragdollAt > 0f)
+ {
+ if (Time.time >= _ragdollAt)
+ {
+ _ragdollAt = 0f;
+ player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
+ Debug.Log("[NecromancerTome] SnakeMode: тело упало, рэгдолл " + player.emodel.IsRagdollActive);
+ }
+ }
+ else if (!player.emodel.IsRagdollActive)
+ {
+ player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
+ }
+ PlayerActionsLocal input = player.playerInput;
+ if (input != null && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
+ {
+ Exit(player);
+ }
+ }
+
+ /// Змея не должна пережить сейв: после загрузки режима нет, а сущность в мире
+ /// осталась бы. Раз в пару секунд убираем все такие, если режим не активен.
+ static void SweepStraySnakes(World _world)
+ {
+ _sweepTimer += Time.deltaTime;
+ if (_sweepTimer < 2f)
+ {
+ return;
+ }
+ _sweepTimer = 0f;
+ if (!SingletonMonoBehaviour.Instance.IsServer)
+ {
+ return;
+ }
+ int classId = EntityClass.FromString(SnakeClassName);
+ for (int i = _world.Entities.list.Count - 1; i >= 0; i--)
+ {
+ Entity e = _world.Entities.list[i];
+ // Подсветка зрения змеи, пережившая сейв: баффы зомби сохраняются.
+ if (e is EntityAlive alive && alive.Buffs != null &&
+ alive.Buffs.HasBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName))
+ {
+ alive.Buffs.RemoveBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName);
+ }
+ if (e != null && e.entityClass == classId)
+ {
+ _world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
+ }
+ }
+ }
+
+ public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
+ {
+ if (_commandIndex == 1)
+ {
+ Enter(_sender.xui.playerUI.entityPlayer);
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))]
+ public static class Patch_ItemAction_HasRadial_SnakeKnife
+ {
+ public static void Postfix(ItemAction __instance, ref bool __result)
+ {
+ if (!__result && !SnakeMode.Active && SnakeMode.IsKnife(__instance.item))
+ {
+ __result = true;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
+ public static class Patch_ItemAction_SetupRadial_SnakeKnife
+ {
+ public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
+ {
+ if (!SnakeMode.IsKnife(__instance.item))
+ {
+ return true;
+ }
+ _xuiRadialWindow.ResetRadialEntries();
+ _xuiRadialWindow.CreateRadialEntry(0, "ui_game_symbol_player", "UIAtlas", "",
+ Localization.Get("necroSnakeModeHuman"), _highlighted: true);
+ if (SnakeMode.IsNight())
+ {
+ _xuiRadialWindow.CreateRadialEntry(1, "ui_game_symbol_tracking_snake", "UIAtlas", "",
+ Localization.Get("necroSnakeModeSnake"), _highlighted: false);
+ }
+ else
+ {
+ _xuiRadialWindow.CreateRadialEntry(1, SnakeMode.DayIcon, "UIAtlas", "",
+ Localization.Get("necroSnakeModeDay"), _highlighted: false);
+ }
+ _xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
+ SnakeMode.OnRadialCommand, null, 0);
+ return false;
+ }
+ }
+}
diff --git a/HarmonySrc/SnakeVision.cs b/HarmonySrc/SnakeVision.cs
new file mode 100644
index 0000000..40f19b7
--- /dev/null
+++ b/HarmonySrc/SnakeVision.cs
@@ -0,0 +1,305 @@
+using System.Collections.Generic;
+using System.Globalization;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// Зрение змеи (указание 28.09: «всё в более контрастном виде и в красных тонах», «как комикс»).
+ /// Своих фильтров не заводим: у игры в Resources/ScreenEffects есть готовые, которыми она сама не
+ /// пользуется (проверено по коду и XML):
+ /// - "Vibrant" - цветокоррекция Code/ColorCorrection_Favale: _color, contrast, saturation,
+ /// lows/mids/highs, whitepoint, _Gamma. Её материал перенастраиваем под красный и возвращаем
+ /// исходные значения при выходе;
+ /// - "Posterize" - ступени цвета (_steps), плоский «комиксный» вид.
+ /// Что именно значит каждое свойство в шейдере Favale - видно только в игре, поэтому всё крутится
+ /// командой `necrovision` на ходу; найденные значения потом переносятся в Default*.
+ ///
+ public static class SnakeVision
+ {
+ public const string GradeEffect = "Vibrant";
+ public const string PosterEffect = "Posterize";
+ /// Первая проверка 28.09 ночью (03:04): «слишком темно, ничего не разглядеть» -
+ /// контраст и ступени загнали всё тёмное в чёрный. Под красное кладём игровое ночное
+ /// зрение (осветляет), контраст обычный, ступеней больше.
+ public const string NightEffect = "NightVision";
+ /// Проверка 28.09: «фильтр не пропускает другие цвета», «наложи в пол-силы, как бы
+ /// полупрозрачным». Сила эффекта у игры - шейдерное свойство Intensity (ScreenEffect.Render:
+ /// Material.SetFloat("Intensity", Clamp01)), а оно есть только у Vibrant и Bright. У
+ /// NightVision и Posterize его нет - они всегда во всю силу, и ночное зрение делает картинку
+ /// одноцветной. Поэтому по умолчанию они выключены, а оттенок и осветление - в половину.
+ public static float DefaultNight = 0f;
+ /// «Яркость можно и повысить» (28.09): эффект "Bright" - та же цветокоррекция Favale,
+ /// настроенная игрой на осветление, и игрой не используемый.
+ public const string BrightEffect = "Bright";
+ public static float DefaultBright = 0.5f;
+ public static float DefaultStrength = 0.5f;
+
+ /// Фиолетово-пурпурный оттенок, а не перекраска всего (указание 28.09: «эффект потустороннего,
+ /// но чтобы был оттенок»): светлый пурпур и насыщенность 0.6 - свои цвета мира остаются.
+ public static Color DefaultColor = new Color(0.8f, 0.55f, 1f, 1f);
+ public static float DefaultContrast = 1f;
+ public static float DefaultSaturation = 0.6f;
+ public static int DefaultSteps = 8;
+
+ static readonly string[] FloatProps = { "contrast", "saturation", "lows", "mids", "highs", "whitepoint", "_Gamma", "Intensity" };
+
+ static bool _on;
+ static bool _posterize = false;
+ static float? _strength;
+ static Material _grade;
+ static readonly Dictionary _savedFloats = new Dictionary();
+ static Color _savedColor;
+ static readonly Dictionary _tuned = new Dictionary();
+ static Color? _tunedColor;
+ static int _steps;
+ static float? _night;
+ static float? _bright;
+
+ public static void On(EntityPlayerLocal _player)
+ {
+ ScreenEffects fx = _player?.ScreenEffectManager;
+ if (fx == null || _on)
+ {
+ return;
+ }
+ _grade = Find(fx, GradeEffect)?.Material;
+ if (_grade == null)
+ {
+ Debug.LogWarning("[NecromancerTome] SnakeVision: эффекта '" + GradeEffect + "' нет");
+ return;
+ }
+ _savedFloats.Clear();
+ foreach (string p in FloatProps)
+ {
+ if (_grade.HasProperty(p))
+ {
+ _savedFloats[p] = _grade.GetFloat(p);
+ }
+ }
+ _savedColor = _grade.GetColor("_color");
+ _on = true;
+ Apply(fx);
+ Debug.Log("[NecromancerTome] SnakeVision: включено");
+ }
+
+ public static void Off(EntityPlayerLocal _player)
+ {
+ if (!_on)
+ {
+ return;
+ }
+ _on = false;
+ ScreenEffects fx = _player?.ScreenEffectManager;
+ fx?.SetScreenEffect(GradeEffect, 0f, 0f);
+ fx?.SetScreenEffect(PosterEffect, 0f, 0f);
+ fx?.SetScreenEffect(NightEffect, 0f, 0f);
+ fx?.SetScreenEffect(BrightEffect, 0f, 0f);
+ if (_grade != null)
+ {
+ foreach (KeyValuePair kv in _savedFloats)
+ {
+ _grade.SetFloat(kv.Key, kv.Value);
+ }
+ _grade.SetColor("_color", _savedColor);
+ }
+ Debug.Log("[NecromancerTome] SnakeVision: выключено");
+ }
+
+ static void Apply(ScreenEffects _fx)
+ {
+ _grade.SetColor("_color", _tunedColor ?? DefaultColor);
+ SetF("contrast", DefaultContrast);
+ SetF("saturation", DefaultSaturation);
+ foreach (KeyValuePair kv in _tuned)
+ {
+ _grade.SetFloat(kv.Key, kv.Value);
+ }
+ _fx.SetScreenEffect(GradeEffect, _strength ?? DefaultStrength, 0.3f);
+ Material poster = Find(_fx, PosterEffect)?.Material;
+ if (poster != null)
+ {
+ poster.SetFloat("_steps", _steps > 0 ? _steps : DefaultSteps);
+ }
+ _fx.SetScreenEffect(PosterEffect, _posterize ? 1f : 0f, 0.3f);
+ _fx.SetScreenEffect(NightEffect, _night ?? DefaultNight, 0.3f);
+ _fx.SetScreenEffect(BrightEffect, _bright ?? DefaultBright, 0.3f);
+ }
+
+ static void SetF(string _p, float _v)
+ {
+ if (_grade.HasProperty(_p))
+ {
+ _grade.SetFloat(_p, _v);
+ }
+ }
+
+ static ScreenEffects.ScreenEffect Find(ScreenEffects _fx, string _name)
+ {
+ foreach (ScreenEffects.ScreenEffect e in _fx.loadedEffects)
+ {
+ if (e != null && e.Name == _name)
+ {
+ return e;
+ }
+ }
+ return null;
+ }
+
+ static string Queue(string _name)
+ {
+ ScreenEffects fx = GameManager.Instance?.World?.GetPrimaryPlayer()?.ScreenEffectManager;
+ ScreenEffects.ScreenEffect e = fx != null ? Find(fx, _name) : null;
+ return e?.Material != null ? e.Material.renderQueue.ToString() : "?";
+ }
+
+ public static string Describe()
+ {
+ if (_grade == null)
+ {
+ return "necrovision: not initialised yet (enter snake mode or 'necrovision on')";
+ }
+ string s = "necrovision: " + (_on ? "ON" : "off") + ", posterize " + (_posterize ? (_steps > 0 ? _steps : DefaultSteps).ToString() : "off") +
+ ", nv " + (_night ?? DefaultNight).ToString("0.##", CultureInfo.InvariantCulture) +
+ ", bright " + (_bright ?? DefaultBright).ToString("0.##", CultureInfo.InvariantCulture) +
+ ", strength " + (_strength ?? DefaultStrength).ToString("0.##", CultureInfo.InvariantCulture) +
+ ", queue grade/poster/nv " + Queue(GradeEffect) + "/" + Queue(PosterEffect) + "/" + Queue(NightEffect) +
+ "\n color " + _grade.GetColor("_color").ToString("F2");
+ foreach (string p in FloatProps)
+ {
+ if (_grade.HasProperty(p))
+ {
+ s += "\n " + p + " " + _grade.GetFloat(p).ToString("0.###", CultureInfo.InvariantCulture);
+ }
+ }
+ return s;
+ }
+
+ /// Консоль: подстройка на ходу. Значения живут до конца сессии.
+ public static string Command(List _params, EntityPlayerLocal _player)
+ {
+ if (_params.Count == 0)
+ {
+ return Describe();
+ }
+ string cmd = _params[0].ToLowerInvariant();
+ if (cmd == "on")
+ {
+ On(_player);
+ return Describe();
+ }
+ if (cmd == "off")
+ {
+ Off(_player);
+ return "necrovision: off";
+ }
+ if (cmd == "reset")
+ {
+ _tuned.Clear();
+ _tunedColor = null;
+ _steps = 0;
+ _posterize = false;
+ _night = null;
+ _strength = null;
+ _bright = null;
+ }
+ else if (cmd == "color" && _params.Count >= 4 &&
+ float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float r) &&
+ float.TryParse(_params[2], NumberStyles.Float, CultureInfo.InvariantCulture, out float g) &&
+ float.TryParse(_params[3], NumberStyles.Float, CultureInfo.InvariantCulture, out float b))
+ {
+ _tunedColor = new Color(r, g, b, 1f);
+ }
+ else if (cmd == "nv" && _params.Count >= 2 &&
+ float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float nv))
+ {
+ _night = nv;
+ }
+ else if (cmd == "strength" && _params.Count >= 2 &&
+ float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float st))
+ {
+ _strength = st;
+ }
+ else if (cmd == "bright" && _params.Count >= 2 &&
+ float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float br))
+ {
+ _bright = br;
+ }
+ else if (cmd == "posterize" && _params.Count >= 2)
+ {
+ if (_params[1] == "off")
+ {
+ _posterize = false;
+ }
+ else if (int.TryParse(_params[1], out int steps))
+ {
+ _posterize = true;
+ _steps = steps;
+ }
+ }
+ else if (_params.Count >= 2 && float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float v))
+ {
+ string prop = null;
+ foreach (string p in FloatProps)
+ {
+ if (p.ToLowerInvariant() == cmd || p.TrimStart('_').ToLowerInvariant() == cmd)
+ {
+ prop = p;
+ }
+ }
+ if (prop == null)
+ {
+ return "necrovision: unknown '" + cmd + "'. Try: help necrovision";
+ }
+ _tuned[prop] = v;
+ }
+ else
+ {
+ return "necrovision: bad arguments. Try: help necrovision";
+ }
+ if (_on && _player?.ScreenEffectManager != null)
+ {
+ Apply(_player.ScreenEffectManager);
+ }
+ return Describe();
+ }
+ }
+
+ [Preserve]
+ public class ConsoleCmdNecroVision : ConsoleCmdAbstract
+ {
+ public override bool IsExecuteOnClient => true;
+
+ public override bool AllowedInMainMenu => false;
+
+ public override string[] getCommands()
+ {
+ return new string[] { "necrovision" };
+ }
+
+ public override string getDescription()
+ {
+ return "Necromancer's Tome: tune the snake's vision filter live.";
+ }
+
+ public override string getHelp()
+ {
+ return "necrovision - show current values\n" +
+ "necrovision on | off - switch the filter (snake mode switches it itself)\n" +
+ "necrovision color R G B - tint, 0..1 each (default 0.8 0.55 1)\n" +
+ "necrovision contrast V - also: saturation, lows, mids, highs, whitepoint, gamma, intensity\n" +
+ "necrovision strength V - how strongly the tint lies over the picture, 0..1 (default 0.5)\n" +
+ "necrovision posterize N|off - flat colour steps, always full strength (default off)\n" +
+ "necrovision nv V - game night vision under the red, 0..1 (default 0 = off; full strength only, kills colour)\n" +
+ "necrovision bright V - game 'Bright' grade, 0 = off 0..1 (default 0.5)\n" +
+ "necrovision reset - back to defaults";
+ }
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
+ SingletonMonoBehaviour.Instance.Output(SnakeVision.Command(_params, player));
+ }
+ }
+}
diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll
index 39f2b25..2694904 100644
Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ
diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb
index 0fc1d14..60c21e3 100644
Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ