diff --git a/Config/Localization.csv b/Config/Localization.csv
index 6ccfa97..3003601 100644
--- a/Config/Localization.csv
+++ b/Config/Localization.csv
@@ -189,6 +189,14 @@ necroSnakeModeHuman,items,Item,,,,Human,,Mensch,Humano,Humain,Umano,人間,인
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,现在是白天,現在是白天
+necroHoldNone,items,Item,,,,Bare hand,,Bloße Hand,Mano desnuda,Main nue,Mano nuda,素手,맨손,Goła ręka,Mão nua,Обычная рука,Çıplak el,空手,空手
+necroHoldDeny,items,Item,,,,Denial,,Verleugnung,Negación,Déni,Negazione,拒絶,거부,Zaprzeczenie,Negação,Отрицание,İnkâr,否认,否認
+necroHoldPifPaf,items,Item,,,,Bang-bang,,Peng-peng,Pum-pum,Pan-pan,Bang-bang,バンバン,빵야빵야,Pif-paf,Pou-pou,Пиф-паф,Bang-bang,砰砰,砰砰
+necroPossessHintBase,items,Item,,,,"LMB - strike, R - leave the body",,"LMT - Schlag, R - Körper verlassen","LMB: golpe, R: salir del cuerpo","Clic gauche : frapper, R : quitter le corps","LMB - colpo, R - lascia il corpo",左クリック: 攻撃、R: 体から出る,"좌클릭 - 공격, R - 몸에서 나가기","LPM - cios, R - opuść ciało","LMB - golpe, R - sair do corpo","ЛКМ - удар, R - выйти из тела","Sol tık - vuruş, R - bedenden çık",左键 - 攻击,R - 离开身体,左鍵 - 攻擊,R - 離開身體
+necroPossessHintRanged,items,Item,,,,RMB - ranged attack,,RMT - Fernangriff,RMB: ataque a distancia,Clic droit : attaque à distance,RMB - attacco a distanza,右クリック: 遠距離攻撃,우클릭 - 원거리 공격,PPM - atak dystansowy,RMB - ataque à distância,ПКМ - дальняя атака,Sağ tık - menzilli saldırı,右键 - 远程攻击,右鍵 - 遠程攻擊
+necroPossessHintDetonate,items,Item,,,,E - detonate,,E - Sprengen,E: detonar,E : exploser,E - detonare,E: 起爆,E - 폭발,E - detonacja,E - detonar,E - подрыв,E - patlat,E - 引爆,E - 引爆
+necroPossessHintScream,items,Item,,,,RMB - scream (calls the dead to you),,RMT - Schrei (ruft die Toten herbei),RMB: grito (llama a los muertos),Clic droit : cri (appelle les morts),RMB - urlo (richiama i morti),右クリック: 叫び(死者を呼ぶ),우클릭 - 비명 (죽은 자를 부름),PPM - krzyk (wzywa umarłych),RMB - grito (chama os mortos),ПКМ - крик (созвать мертвецов),Sağ tık - çığlık (ölüleri çağırır),右键 - 尖叫(召唤亡者),右鍵 - 尖叫(召喚亡者)
+necroPossessHintLeap,items,Item,,,,Space - long leap,,Leertaste - weiter Sprung,Espacio: salto largo,Espace : grand bond,Spazio - balzo lungo,スペース: 大ジャンプ,스페이스 - 멀리뛰기,Spacja - daleki skok,Espaço - salto longo,Пробел - дальний прыжок,Boşluk - uzun sıçrayış,空格 - 远跳,空白鍵 - 遠跳
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,蛇灵,蛇靈
necroGeoOssuary,blocks,Block,,,,Geo-Ossuary,,Geo-Ossarium,Geo-osario,Géo-ossuaire,Geo-ossario,ジオ納骨堂,지오 납골당,Geo-ossuarium,Geo-ossário,Гео-оссуарий,Jeo-Kemiklik,地脉骨堂,地脈骨堂
necroGeoOssuaryDesc,blocks,Block,,,,"An ossuary of bones and skulls that draws stone and ore up from the depths and feeds on blood.
@@ -279,3 +287,20 @@ Dört modifikasyon yuvasına takılan kan küreleriyle çalışır: küreler sı
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,抹除者,抹除者
+necroMemoryBook,blocks,Block,,,,Book of Memory,,Buch der Erinnerung,Libro de la memoria,Livre de la mémoire,Libro della memoria,記憶の書,기억의 책,Księga pamięci,Livro da memória,Книга памяти,Hafıza Kitabı,记忆之书,記憶之書
+necroMemoryBookPrompt,blocks,Block,,,,Press {0} to read,,Drücke {0} zum Lesen,Pulsa {0} para leer,Appuyez sur {0} pour lire,Premi {0} per leggere,{0} で読む,{0} 키를 눌러 읽기,"Naciśnij {0}, aby przeczytać",Pressione {0} para ler,"Нажмите {0}, чтобы прочитать",Okumak için {0} tuşuna basın,按 {0} 阅读,按 {0} 閱讀
+necroMemoryTitle,XUi,Menu,,,,Memory,,Erinnerung,Memoria,Mémoire,Memoria,記憶,기억,Pamięć,Memória,Память,Hafıza,记忆,記憶
+necroMemoryText,XUi,Menu,,,,,,,,,,,,,,,,,
+necroTomeToc,XUi,Menu,,,,Contents,,,,,,,,,,Оглавление,,,
+necroTomePrev,XUi,Menu,,,,Back,,,,,,,,,,Назад,,,
+necroTomeNext,XUi,Menu,,,,Next,,,,,,,,,,Далее,,,
+necroTomeClose,XUi,Menu,,,,Close,,,,,,,,,,Закрыть,,,
+necroTomeCh1Title,XUi,Menu,,,,Necroenergy,,,,,,,,,,Некроэнергия,,,
+necroTomeCh1P1,XUi,Menu,,,,"Kvazaŭvivo - necroenergy\n\nElectricity moves electrons. Kvazaŭvivo, necroenergy, moves the necrotic structure - the residual ""pattern of the body"" that survives death. It is not the spirit: the spirit is a separate entity. It is the frame along which a spirit can manifest and a body can move.\n\nElectricity is a current along a wire, and a wire can be bent any way you like. Kvazaŭvivo is a current along a pattern, and the pattern is already set by the shape of the body.\n\nNecroenergy has six quantities of its own.",,,,,,,,,,"Kvazaŭvivo - некроэнергия\n\nЭлектричество движет электроны. Kvazaŭvivo, некроэнергия, движет некротическую структуру - остаточную «схему тела», которая сохраняется после смерти. Это не дух: дух - отдельная сущность. Это каркас, по которому дух может проявиться, а тело - двигаться.\n\nЭлектричество - ток по проводу, и провод можно согнуть как угодно. Kvazaŭvivo - ток по шаблону, а шаблон уже задан формой тела.\n\nУ некроэнергии шесть собственных величин.",,,
+necroTomeCh1P2,XUi,Menu,,,,"Density - denso (ds)\nHow much quasi-life is packed into the structure. Too little and the body barely stirs; a lot and it is almost indistinguishable from the living, but burns out fast.\n\nDepth - profundo (pf)\nHow deep quasi-life reaches. 1-5 pf: muscles only. 6-15 pf: nerves, complex tasks and speech. 16-30 pf: personality - almost alive, and the rarest of all.\n\nCoherence - kohero (kh)\nHow consistently the structure is held. Low coherence: jerky movements, falling limbs. High: smoothness and precision.",,,,,,,,,,"Плотность - denso (ds)\nСколько квазижизни упаковано в структуру. Мало - тело еле шевелится; много - почти неотличимо от живого, но быстро выгорает.\n\nГлубина - profundo (pf)\nКак глубоко проникает квазижизнь. 1-5 pf - только мышцы. 6-15 pf - нервы, сложные задачи и речь. 16-30 pf - личность: почти живой, и это редчайшее.\n\nКогерентность - kohero (kh)\nНасколько согласованно держится структура. Низкая - движения дёрганые, конечности отваливаются. Высокая - плавность и точность.",,,
+necroTomeCh1P3,XUi,Menu,,,,"Endurance - daŭro (dr)\nHow long quasi-life holds without feeding. A fresh corpse: 50-100 dr. An old one: 5-20 dr. A mummy: 200+ dr, but it needs an enormous initial density.\n\nResonance with the spirit - rezo (rz)\nHow well quasi-life sounds in unison with the spirit in the body. Below zero, the spirit resists and the energy is burnt on suppressing it; zero, there is no spirit; above zero, the spirit helps.\n\nSaturation - saturo (st)\nThe capacity limit of the structure. Exceed it and the body burns out and collapses. Bone has a high limit, soft tissue a low one, nerves a medium but critical one.",,,,,,,,,,"Устойчивость - daŭro (dr)\nСколько квазижизнь держится без подпитки. Свежий труп - 50-100 dr. Старый - 5-20 dr. Мумия - 200+ dr, но ей нужна огромная начальная плотность.\n\nРезонанс с духом - rezo (rz)\nНасколько квазижизнь звучит в унисон с духом в теле. Ниже нуля - дух сопротивляется, и энергия уходит на его подавление; ноль - духа нет; выше нуля - дух помогает.\n\nНасыщение - saturo (st)\nПредел ёмкости структуры. Превысишь - тело выгорает и рушится. У костей предел высокий, у мягких тканей низкий, у нервов средний, но критичный.",,,
+necroTomeCh1P4,XUi,Menu,,,,"The law of quasi-life\n\ndaŭro = denso × kohero × (1 + rezo) / profundo\n\nThe deeper the animation, the faster the structure is spent. Density and coherence lengthen the life of a quasi; so does a willing spirit, while a hostile one shortens it.\n\nElectricity gives power. Kvazaŭvivo gives the likeness of life. These are different forces, and a necromancer never confuses them.",,,,,,,,,,"Закон квазижизни\n\ndaŭro = denso × kohero × (1 + rezo) / profundo\n\nЧем глубже анимация, тем быстрее расходуется структура. Плотность и когерентность продлевают жизнь кваза, согласный дух - тоже, враждебный - сокращает.\n\nЭлектричество даёт силу. Kvazaŭvivo даёт подобие жизни. Это разные силы, и некромант их не путает.",,,
+necroMindModePalace,XUi,Menu,,,,Mind Palace,,Gedächtnispalast,Palacio mental,Palais mental,Palazzo della mente,記憶の宮殿,기억의 궁전,Pałac umysłu,Palácio da mente,Чертоги разума,Zihin Sarayı,思维宫殿,思維宮殿
+necroKnifeDecor,blocks,Block,,,,Necromancer's Knife,,Nekromantenmesser,Cuchillo del nigromante,Couteau du nécromancien,Coltello del negromante,ネクロマンサーのナイフ,강령술사의 칼,Nóż nekromanty,Faca do necromante,Нож некроманта,Nekromansör Bıçağı,死灵法师之刃,死靈法師之刃
+necroPalaceBed,blocks,Block,,,,"White Bunk Bed, Made",Stackable metal framed bed with sheets neatly made proper.,"Weißes Etagenbett, gemacht","Litera blanca, tendida","Couchette blanche, faite","Letto a castello bianco, Fatto",二段ベッド(メイク済み・白),"흰색 이층 침대, 깔끔",Biała prycza — posłane,"Beliche branco, arrumado","Двухъярусная белая кровать, заправленная","Beyaz Ranza, Yapılmış",已整理的白色上下铺床,白色上下舖,已整理
+necroPosterAnatomy,blocks,Block,,,,Chart: Structure of the Body,,Schautafel: Aufbau des Körpers,Lámina: estructura del cuerpo,Planche : structure du corps,Tavola: struttura del corpo,図表:身体の構造,도표: 신체의 구조,Plansza: budowa ciała,Prancha: estrutura do corpo,Схема: строение тела,Şema: Vücudun yapısı,图表:身体结构,圖表:身體結構
diff --git a/Config/XUi_InGame/windows.xml b/Config/XUi_InGame/windows.xml
index d7d2ee5..07d7801 100644
--- a/Config/XUi_InGame/windows.xml
+++ b/Config/XUi_InGame/windows.xml
@@ -116,6 +116,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Config/blocks.xml b/Config/blocks.xml
index 00703d3..fc79f98 100644
--- a/Config/blocks.xml
+++ b/Config/blocks.xml
@@ -232,4 +232,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Config/items.xml b/Config/items.xml
index a151e6c..da80893 100644
--- a/Config/items.xml
+++ b/Config/items.xml
@@ -896,9 +896,10 @@
-
+
+
-
@@ -952,9 +953,10 @@
-
+
+
-
diff --git a/HarmonySrc/BlockNecroMemoryBook.cs b/HarmonySrc/BlockNecroMemoryBook.cs
new file mode 100644
index 0000000..822f815
--- /dev/null
+++ b/HarmonySrc/BlockNecroMemoryBook.cs
@@ -0,0 +1,200 @@
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// «Книга памяти» в чертогах разума (указание 03.10): единственный предмет чертогов, с которым
+ /// можно взаимодействовать. При наведении - «Нажмите E, чтобы прочитать», по E - окно «Память»
+ /// (потом доделаем, как окна концовки в FinalSlides.cs). Блок подключается в blocks.xml через
+ /// Class="NecromancerTome.NecroMemoryBook, NecromancerHarmony": игра (ReflectionHelpers.
+ /// GetTypeWithPrefix) вставляет приставку Block перед именем после последней точки.
+ /// Вид - одиночная книга игры (bookInvisibleKillerPrefab, как cntBookPile09 на столе префаба
+ /// chertogi), но без хранилища и лута.
+ ///
+ [Preserve]
+ public class BlockNecroMemoryBook : Block
+ {
+ public const string ReadCommand = "read";
+
+ static readonly BlockActivationCommand[] Commands =
+ {
+ new BlockActivationCommand(ReadCommand, "book", true)
+ };
+
+ public override bool HasBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
+ {
+ return true;
+ }
+
+ public override BlockActivationCommand[] GetBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
+ {
+ return Commands;
+ }
+
+ public override string GetActivationText(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
+ {
+ return Localization.Get("necroMemoryBookPrompt");
+ }
+
+ public override bool OnBlockActivated(string _commandName, WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
+ {
+ return _commandName == ReadCommand && OnBlockActivated(_world, _blockPos, _blockValue, _player);
+ }
+
+ public override bool OnBlockActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
+ {
+ Open(_player);
+ return true;
+ }
+
+ /// Окно книги (оглавление, главы). Отдельным методом - его же позовёт дух в чертогах (своё E).
+ public static void Open(EntityPlayerLocal _player)
+ {
+ LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
+ if (ui == null)
+ {
+ return;
+ }
+ // 03.10: вместо окна-сообщения - книга с оглавлением (NecroTome.cs).
+ NecroTomeBook.Open(_player);
+ }
+
+ // ---------------------------------------------------------------- автор на обложке
+
+ /// Шрифт надписи - тот же готический, что у названия в текстуре (UnifrakturMaguntia, OFL).
+ const string FontAsset = "#@modfolder(NecromancerTome):Resources/necrobooks?UnifrakturMaguntia-Book.ttf";
+ const string AuthorChild = "NecroAuthor";
+
+ // Место под автора - просвет под рисунком (make_rat_book_textures.py, Mind), в осях корня модели
+ // necroBookMindPrefab: из Blender (x, y, z) -> Unity (-x, z, -y); книга в модели повёрнута на -65°.
+ // Подбирается на ходу командой necroauthor (x y z, размер, ширина).
+ public static Vector3 AuthorPos = new Vector3(0.0944f, 0.0885f, -0.1534f);
+ public static Vector3 AuthorUp = new Vector3(-0.4226f, 0f, 0.9063f);
+ public static float AuthorHeight = 0.030f, AuthorMaxWidth = 0.21f;
+ public static Color AuthorColor = new Color(0.85f, 0.06f, 0.05f, 1f);
+
+ static Font _font;
+
+ public override void OnBlockEntityTransformAfterActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, BlockEntityData _ebcd)
+ {
+ base.OnBlockEntityTransformAfterActivated(_world, _blockPos, _blockValue, _ebcd);
+ try
+ {
+ if (_ebcd?.transform != null)
+ {
+ PlaceAuthor(_ebcd.transform);
+ }
+ }
+ catch (System.Exception e)
+ {
+ Debug.LogWarning("[NecromancerTome] книга памяти: автор не встал - " + e.Message);
+ }
+ }
+
+ /// Ник игрока на обложке, как подпись на ящике: TextMesh поверх модели (модель общая,
+ /// а ник у каждого свой, в текстуру его не запечь). Чертоги у каждого свои - пишем ник своего игрока.
+ public static void PlaceAuthor(Transform _root)
+ {
+ EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
+ string name = player != null ? player.PlayerDisplayName : null;
+ Transform old = _root.Find(AuthorChild);
+ if (string.IsNullOrEmpty(name))
+ {
+ if (old != null) Object.Destroy(old.gameObject);
+ return;
+ }
+ if (_font == null)
+ {
+ _font = DataLoader.LoadAsset(FontAsset);
+ }
+ GameObject go = old != null ? old.gameObject : new GameObject(AuthorChild);
+ go.layer = _root.gameObject.layer;
+ Transform t = go.transform;
+ t.SetParent(_root, false);
+ t.localPosition = AuthorPos;
+ // лицом вверх (обложка), низ строки - к нижнему краю обложки
+ t.localRotation = Quaternion.LookRotation(Vector3.down, AuthorUp.normalized);
+ t.localScale = Vector3.one;
+ TextMesh tm = go.GetComponent() ?? go.AddComponent();
+ MeshRenderer mr = go.GetComponent();
+ if (_font != null)
+ {
+ tm.font = _font;
+ mr.sharedMaterial = _font.material;
+ if (mr.sharedMaterial != null && (mr.sharedMaterial.shader == null || !mr.sharedMaterial.shader.isSupported))
+ {
+ Shader sh = Shader.Find("GUI/Text Shader");
+ if (sh != null) mr.sharedMaterial.shader = sh;
+ }
+ }
+ tm.text = name;
+ tm.anchor = TextAnchor.MiddleCenter;
+ tm.alignment = TextAlignment.Center;
+ tm.fontSize = 96;
+ tm.color = AuthorColor;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ tm.characterSize = 1f;
+ // Размер подгоняется кадром позже: меш надписи TextMesh строит не сразу.
+ NecroAuthorFit fit = go.GetComponent() ?? go.AddComponent();
+ fit.enabled = true;
+ }
+ }
+
+ /// Подгонка надписи автора: высота строки AuthorHeight, длинный ник ужимается до AuthorMaxWidth.
+ public class NecroAuthorFit : MonoBehaviour
+ {
+ void LateUpdate()
+ {
+ MeshRenderer mr = GetComponent();
+ Bounds b = mr != null ? mr.localBounds : default;
+ if (b.size.y < 0.0001f)
+ {
+ return; // меш ещё не построен
+ }
+ float k = BlockNecroMemoryBook.AuthorHeight / b.size.y;
+ if (b.size.x * k > BlockNecroMemoryBook.AuthorMaxWidth)
+ {
+ k = BlockNecroMemoryBook.AuthorMaxWidth / b.size.x;
+ }
+ transform.localScale = new Vector3(k, k, k);
+ enabled = false;
+ }
+ }
+
+ /// necroauthor [x y z] [h N] [w N] - подобрать место и размер ника на книге памяти на ходу.
+ [Preserve]
+ public class ConsoleCmdNecroAuthor : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necroauthor" };
+ public override string getDescription() => "NecromancerTome: ник на книге памяти - x y z | h N | w N";
+
+ public override void Execute(System.Collections.Generic.List _params, CommandSenderInfo _senderInfo)
+ {
+ System.Globalization.CultureInfo ci = System.Globalization.CultureInfo.InvariantCulture;
+ if (_params.Count == 3)
+ {
+ BlockNecroMemoryBook.AuthorPos = new Vector3(float.Parse(_params[0], ci), float.Parse(_params[1], ci), float.Parse(_params[2], ci));
+ }
+ else if (_params.Count == 2 && _params[0] == "h")
+ {
+ BlockNecroMemoryBook.AuthorHeight = float.Parse(_params[1], ci);
+ }
+ else if (_params.Count == 2 && _params[0] == "w")
+ {
+ BlockNecroMemoryBook.AuthorMaxWidth = float.Parse(_params[1], ci);
+ }
+ int n = 0;
+ foreach (NecroAuthorFit f in Object.FindObjectsOfType(true))
+ {
+ BlockNecroMemoryBook.PlaceAuthor(f.transform.parent);
+ n++;
+ }
+ Vector3 p = BlockNecroMemoryBook.AuthorPos;
+ SdtdConsole.Instance.Output(string.Format(ci, "автор: {0:0.####} {1:0.####} {2:0.####}, h {3}, w {4}; надписей {5}",
+ p.x, p.y, p.z, BlockNecroMemoryBook.AuthorHeight, BlockNecroMemoryBook.AuthorMaxWidth, n));
+ }
+
+ }
+}
diff --git a/HarmonySrc/DamagePatch.cs b/HarmonySrc/DamagePatch.cs
index b4d05e4..bf0a873 100644
--- a/HarmonySrc/DamagePatch.cs
+++ b/HarmonySrc/DamagePatch.cs
@@ -21,6 +21,9 @@ namespace NecromancerTome
/// property, quest tracking, anything else keyed off EntityFlags) is ever affected outside
/// this one call. A Stack (not a single field) survives re-entrant DamageEntity calls
/// correctly (e.g. explosions/knockback triggering further damage inside the same callstack).
+ ///
+ /// ОДЕРЖИМОСТЬ (03.10, тест: «здоровье противника не снижалось»): то же самое, если атакующий -
+ /// одержимый зомби или цель - одержимый зомби (зомби бьют его, когда он их танкует).
///
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
public static class Patch_EntityAlive_DamageEntity_CharmedZombieVsZombie
@@ -34,8 +37,8 @@ namespace NecromancerTome
{
EntityAlive attacker = __instance.world.GetEntity(_damageSource.getEntityId()) as EntityAlive;
if (attacker is EntityZombie attackerZombie
- && attackerZombie.Buffs != null
- && attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
+ && (attackerZombie.Buffs != null && attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
+ || Possession.IsPossessed(attackerZombie) || Possession.IsPossessed(__instance))
&& (attackerZombie.entityFlags & EntityFlags.Zombie) != EntityFlags.None)
{
attackerZombie.entityFlags &= ~EntityFlags.Zombie;
diff --git a/HarmonySrc/DecorColliderTagPatch.cs b/HarmonySrc/DecorColliderTagPatch.cs
new file mode 100644
index 0000000..508c40c
--- /dev/null
+++ b/HarmonySrc/DecorColliderTagPatch.cs
@@ -0,0 +1,44 @@
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Хитбокс наших декораций (03.10: «Ты не добавляешь к декору хитбокс? А как удалять, если не туда
+ /// поставил?»). В префабах книги памяти, ножа-декора и плакатов на корне стоит BoxCollider, но игра
+ /// понимает, что луч попал в блок, только по игровому тегу на объекте с коллайдером. В нашем
+ /// Unity-проекте такого тега нет (AssetRipper знает его как unknown_20004) - при сборке он стал
+ /// Untagged. Поэтому при первой загрузке модели корню ставим тег и слой корня ванильной рамки
+ /// (postersFramedGunSchematicPrefab - у неё коллайдер тоже на корне). Префаб из бандла общий,
+ /// правка одна на все экземпляры. Тот же приём, что у оссуария (GeoOssuaryPatch.CopyTags).
+ ///
+ [HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
+ public static class Patch_BlockShapeModelEntity_getPrefab_DecorColliderTag
+ {
+ const string VanillaModel = "@:Entities/Decor/postersFramedGunSchematicPrefab.prefab";
+ static readonly System.Collections.Generic.HashSet done = new System.Collections.Generic.HashSet();
+
+ static bool Ours(string _name)
+ {
+ return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name.StartsWith("necroPoster");
+ }
+
+ public static void Postfix(Transform __result)
+ {
+ if (__result == null || !Ours(__result.name) || !done.Add(__result.name))
+ {
+ return;
+ }
+ Transform vanilla = DataLoader.LoadAsset(VanillaModel);
+ if (vanilla == null)
+ {
+ Debug.LogWarning("[NecromancerTome] DecorCollider: ванильная рамка не загрузилась - тег не поставлен");
+ return;
+ }
+ __result.tag = vanilla.tag;
+ __result.gameObject.layer = vanilla.gameObject.layer;
+ Debug.Log("[NecromancerTome] DecorCollider: '" + __result.name + "' тег " + vanilla.tag + ", слой " + vanilla.gameObject.layer +
+ ", коллайдер " + (__result.GetComponent() != null ? "есть" : "НЕТ"));
+ }
+ }
+}
diff --git a/HarmonySrc/HandHolds.cs b/HarmonySrc/HandHolds.cs
new file mode 100644
index 0000000..75dc622
--- /dev/null
+++ b/HarmonySrc/HandHolds.cs
@@ -0,0 +1,323 @@
+using GUI_2;
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Хваты пустой руки (03.10, BACKLOG «Заклинания с руки и жесты»): на R - меню из трёх строк:
+ /// обычная рука, «отрицание», «пиф-паф». Пустая рука - предмет meleeHandPlayer с DynamicMelee,
+ /// меню на R игра открывает ей так же, как ножу (PlayerMoveController: HasRadial).
+ ///
+ /// Выбор НЕ сохраняется (указание 03.10: «после перезахода будет снова обычная рука») - просто поле
+ /// в памяти. Ни cvar, ни предмет не трогаем. На выходе из мира (нет мира или сменился игрок) всё
+ /// сбрасывается, и ссылки на кости, снимок заморозки и цель отпускаются (HandPose.ResetAll) - чтобы
+ /// за сессию ничего не копилось (указание: «не допустить утечку памяти на сохранение состояний»).
+ /// В хвате рука стоит позой (HandPose: кости после аниматора + заморозка остальных), а кнопки мыши
+ /// не бьют: ExecuteAction у DynamicMelee пустой руки перехвачен.
+ /// ЛКМ в «отрицании» - зомби под прицелом в леденящем ужасе: бежит от игрока с ускорением и теряет
+ /// его как цель (NecroFleeSnakeTask.ScareFrom). Звук - как у празднующего убийства Twitch.
+ /// ЛКМ в «пиф-пафе» - одержимость зомби-гуманоида под прицелом (Possession.cs).
+ /// ПКМ в хватах - ничего.
+ ///
+ public static class HandHolds
+ {
+ public const string HandItem = "meleeHandPlayer";
+ public const int None = 0, Deny = 1, PifPaf = 2;
+
+ /// Дальность и конус выбора зомби для «отрицания».
+ public const float DenyRange = 25f;
+ public const float DenyConeDeg = 12f;
+ /// Бежит, пока игрок не дальше этого: зрение зомби до 30 м, у Чака 40 (entityclasses.xml,
+ /// SightRange) - указание 03.10: «убегать он должен дальше 40 м, чтобы терять из прицела».
+ public const float DenyFarDistance = 40f;
+ /// Предел бегства на случай, если застрял и не может уйти на 40 м.
+ public const float DenyMaxSeconds = 25f;
+ /// Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»).
+ public const float DenySpeed = 2.2f;
+ public const float Cooldown = 0.6f;
+ public const string DenySound = "twitch_celebrate";
+ /// «Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок
+ /// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success).
+ public const string PifPafFailSound = "glassdestroy";
+ public const string PifPafPossessSound = "lockpick_success";
+
+ /// Выбранный хват - только на время сессии в мире.
+ public static int Hold;
+ static int _applied = -1;
+ static int _playerId = -1;
+ static float _nextCast;
+
+ public static bool IsHand(ItemClass _item) => _item != null && _item.Name == HandItem;
+
+ public static int Current(EntityPlayerLocal _player)
+ {
+ if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) ||
+ SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead())
+ {
+ return None;
+ }
+ return Hold;
+ }
+
+ /// Из HandPoseDriver.LateUpdate - до HandPose.Apply. Позу трогаем только при смене
+ /// хвата: ручная настройка командой necropose держится до следующей смены.
+ public static void Update()
+ {
+ EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
+ int id = player != null ? player.entityId : -1;
+ if (id != _playerId)
+ {
+ // Вышли из мира, зашли в другой или перезашли - обычная рука и пустые ссылки.
+ _playerId = id;
+ Hold = None;
+ _applied = None;
+ _target = null;
+ _targetFrame = -1;
+ HandPose.ResetAll();
+ return;
+ }
+ int want = Current(player);
+ if (want == _applied)
+ {
+ return;
+ }
+ _applied = want;
+ if (want == Deny)
+ {
+ HandPose.PresetDeny();
+ }
+ else if (want == PifPaf)
+ {
+ HandPose.PresetPifPaf();
+ }
+ else
+ {
+ HandPose.Enabled = false;
+ }
+ }
+
+ public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
+ {
+ if (_commandIndex >= None && _commandIndex <= PifPaf)
+ {
+ Hold = _commandIndex;
+ }
+ }
+
+ /// ЛКМ в хвате. True - нажатие наше, удар не выполнять.
+ public static void Cast(EntityPlayerLocal _player, int _hold)
+ {
+ if (Time.time < _nextCast)
+ {
+ return;
+ }
+ _nextCast = Time.time + Cooldown;
+ if (_hold == PifPaf)
+ {
+ // Одержимость (решение 03.10): «пиф-паф» по зомби-гуманоиду - управление им.
+ EntityAlive victim = Target(_player);
+ if (Possession.CanPossess(victim) && Possession.Enter(_player, victim))
+ {
+ _player.PlayOneShot(PifPafPossessSound);
+ }
+ else
+ {
+ _player.PlayOneShot(PifPafFailSound);
+ Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид"));
+ }
+ return;
+ }
+ _player.PlayOneShot(DenySound);
+ World world = _player.world;
+ if (world.IsRemote())
+ {
+ return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно
+ }
+ EntityAlive target = Target(_player);
+ if (target != null)
+ {
+ if (target.HasAnyTags(FlyerTag))
+ {
+ NecroFleeSnakeTask.ForgetNow(target, _player); // летает - наземного бегства нет, только теряет цель
+ Debug.Log("[NecromancerTome] HandHolds: стервятник " + target.entityId + " отрицание - потерял цель");
+ }
+ else
+ {
+ NecroFleeSnakeTask.ScareFrom(target, _player, DenyMaxSeconds, DenySpeed, DenyFarDistance);
+ }
+ }
+ }
+
+ static readonly FastTags ZombieTag = FastTags.Parse("zombie");
+ static readonly FastTags FlyerTag = FastTags.Parse("vulture");
+
+ /// Только зомби (указание 03.10: «кабаны и змеи хоть и противник, но не зомби, кроме
+ /// зомбокабана»; «стервятники тоже зомби, обычных в игре нету») - метка zombie в entityclasses.xml:
+ /// люди-зомби, зомби-собаки, лютые волки, зомби-медведи, зомби-кабаны, стервятники.
+ public static bool IsZombie(EntityAlive _e)
+ {
+ return _e != null && _e.HasAnyTags(ZombieTag);
+ }
+
+ static int _targetFrame = -1;
+ static EntityAlive _target;
+
+ /// Зомби под прицелом (раз за кадр): и для заклинания, и для красного прицела.
+ public static EntityAlive Target(EntityPlayerLocal _player)
+ {
+ if (_targetFrame != Time.frameCount)
+ {
+ _targetFrame = Time.frameCount;
+ _target = FindTarget(_player.world, _player);
+ }
+ return _target;
+ }
+
+ /// Прямая видимость своим лучом: между глазом и точкой нет блоков и земли (сущности на луче
+ /// не мешают). Не EntityAlive.CanSee: у игрока нет ИИ, и его кэш видимости (seeCache) не обновляется -
+ /// тест 03.10: Арлин захватывалась, зомби-коп нет.
+ static bool Visible(Vector3 _eye, Vector3 _to)
+ {
+ Vector3 d = _to - _eye;
+ return !Physics.Raycast(_eye - Origin.position, d.normalized, d.magnitude - 0.3f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
+ }
+
+ /// Зомби ближе всех к линии прицела в конусе DenyConeDeg, не дальше DenyRange,
+ /// с прямой видимостью.
+ static EntityAlive FindTarget(World _world, EntityPlayerLocal _player)
+ {
+ Transform cam = _player.cameraTransform;
+ if (cam == null)
+ {
+ return null;
+ }
+ Vector3 eye = cam.position + Origin.position;
+ Vector3 fwd = cam.forward;
+ EntityAlive best = null;
+ float bestAngle = DenyConeDeg;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null || e.IsDead() || !IsZombie(e))
+ {
+ continue;
+ }
+ Vector3 to = e.getChestPosition() - eye;
+ float d = to.magnitude;
+ if (d > DenyRange || d < 0.1f)
+ {
+ continue;
+ }
+ float angle = Vector3.Angle(fwd, to);
+ if (angle < bestAngle && Visible(eye, e.getChestPosition()))
+ {
+ bestAngle = angle;
+ best = e;
+ }
+ }
+ return best;
+ }
+ }
+
+ /// Меню на R у пустой руки - как у ножа (Patch_ItemAction_HasRadial_SnakeKnife).
+ [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))]
+ public static class Patch_ItemAction_HasRadial_HandHolds
+ {
+ public static void Postfix(ItemAction __instance, ref bool __result)
+ {
+ if (!__result && !SnakeMode.Active && !MindPalace.Active && HandHolds.IsHand(__instance.item))
+ {
+ __result = true;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
+ public static class Patch_ItemAction_SetupRadial_HandHolds
+ {
+ public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
+ {
+ if (!HandHolds.IsHand(__instance.item))
+ {
+ return true;
+ }
+ int cur = HandHolds.Hold;
+ _xuiRadialWindow.ResetRadialEntries();
+ _xuiRadialWindow.CreateRadialEntry(HandHolds.None, "ui_game_symbol_hand", "UIAtlas", "",
+ Localization.Get("necroHoldNone"), cur == HandHolds.None);
+ _xuiRadialWindow.CreateRadialEntry(HandHolds.Deny, "ui_game_symbol_run", "UIAtlas", "",
+ Localization.Get("necroHoldDeny"), cur == HandHolds.Deny);
+ _xuiRadialWindow.CreateRadialEntry(HandHolds.PifPaf, "ui_game_symbol_pistol", "UIAtlas", "",
+ Localization.Get("necroHoldPifPaf"), cur == HandHolds.PifPaf);
+ _xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
+ HandHolds.OnRadialCommand, null, cur);
+ return false;
+ }
+ }
+
+ /// В хвате кнопки мыши не бьют: ЛКМ - заклинание хвата, ПКМ - ничего (03.10: «руки
+ /// деформируются, пытаясь совершить старую анимацию»).
+ [HarmonyPatch(typeof(ItemActionDynamicMelee), nameof(ItemActionDynamicMelee.ExecuteAction))]
+ public static class Patch_ItemActionDynamicMelee_ExecuteAction_HandHolds
+ {
+ public static bool Prefix(ItemActionData _actionData, bool _bReleased)
+ {
+ if (!(_actionData?.invData?.holdingEntity is EntityPlayerLocal player))
+ {
+ return true;
+ }
+ int hold = HandHolds.Current(player);
+ if (hold == HandHolds.None || !HandHolds.IsHand(_actionData.invData.item))
+ {
+ return true;
+ }
+ if (!_bReleased && _actionData.indexInEntityOfAction == 0)
+ {
+ HandHolds.Cast(player, hold);
+ }
+ return false;
+ }
+ }
+
+ /// Прицел краснеет, когда в нём зомби, в обоих хватах (указание 03.10). Цвет прицела игра
+ /// берёт из EntityPlayerLocal.crosshairColors[настройка] - на время рисования подменяем его красным.
+ [HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))]
+ public static class Patch_EntityPlayerLocal_guiDrawCrosshair_HandHolds
+ {
+ static readonly Color Red = new Color(1f, 0.15f, 0.1f);
+
+ public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
+ {
+ __state = null;
+ int hold = HandHolds.Current(__instance);
+ EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
+ // «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
+ if (target == null || hold == HandHolds.PifPaf && !Possession.CanPossess(target))
+ {
+ return;
+ }
+ int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
+ if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
+ {
+ i = 0;
+ }
+ __state = EntityPlayerLocal.crosshairColors[i];
+ EntityPlayerLocal.crosshairColors[i] = Red;
+ }
+
+ public static void Postfix(Color? __state)
+ {
+ if (__state == null)
+ {
+ return;
+ }
+ int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
+ if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
+ {
+ i = 0;
+ }
+ EntityPlayerLocal.crosshairColors[i] = __state.Value;
+ }
+ }
+}
diff --git a/HarmonySrc/HandPose.cs b/HarmonySrc/HandPose.cs
new file mode 100644
index 0000000..8c9f2db
--- /dev/null
+++ b/HarmonySrc/HandPose.cs
@@ -0,0 +1,106 @@
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.7.0). Свой хват -
+ /// это неподвижная поза рук от первого лица, как у браслета (кулак к противнику, без движения
+ /// при действии): кости рига ставятся в нужную комбинацию после аниматора. Сначала надо знать
+ /// кости: команда пишет в лог дерево рига рук от первого лица (EModelSDCS.baseRigFP, его создаёт
+ /// SDCSUtils.CreateVizFP под Camera/UFPSRoot) с локальными поворотами и позициями, и параметры
+ /// аниматора FPPlayerController (WeaponHoldType и прочие) с текущими значениями.
+ ///
+ [Preserve]
+ public class ConsoleCmdNecroBones : ConsoleCmdAbstract
+ {
+ const string Tag = "[NecromancerTome] necrobones: ";
+
+ public override string[] getCommands() => new[] { "necrobones" };
+ public override string getDescription() => "NecromancerTome: дерево костей рук от первого лица и параметры аниматора - в лог";
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
+ if (player == null)
+ {
+ return;
+ }
+ if (!(player.emodel is EModelSDCS sdcs) || sdcs.baseRigFP == null)
+ {
+ SdtdConsole.Instance.Output("риг рук от первого лица не найден (EModelSDCS.baseRigFP)");
+ return;
+ }
+ Transform root = sdcs.baseRigFP.transform;
+ ItemClass held = player.inventory?.holdingItem;
+ Debug.Log(Tag + "в руке " + (held != null ? held.GetItemName() + ", HoldType " + held.HoldType.Value : "ничего") + ", вид от первого лица " + sdcs.IsFPV);
+ Debug.Log(Tag + "путь рига: " + PathOf(root));
+ int count = 0;
+ var sb = new StringBuilder();
+ Dump(root, 0, sb, ref count);
+ foreach (string line in sb.ToString().Split('\n'))
+ {
+ if (line.Length > 0)
+ {
+ Debug.Log(Tag + line);
+ }
+ }
+ Animator anim = root.GetComponentInChildren(true) ?? root.GetComponentInParent();
+ if (anim != null)
+ {
+ Debug.Log(Tag + "аниматор на " + anim.name + ", контроллер " + (anim.runtimeAnimatorController != null ? anim.runtimeAnimatorController.name : "нет"));
+ foreach (AnimatorControllerParameter p in anim.parameters)
+ {
+ string v = p.type == AnimatorControllerParameterType.Float ? anim.GetFloat(p.nameHash).ToString("0.###")
+ : p.type == AnimatorControllerParameterType.Int ? anim.GetInteger(p.nameHash).ToString()
+ : p.type == AnimatorControllerParameterType.Bool ? anim.GetBool(p.nameHash).ToString()
+ : "trigger";
+ Debug.Log(Tag + " параметр " + p.name + " (" + p.type + ") = " + v);
+ }
+ for (int l = 0; l < anim.layerCount; l++)
+ {
+ AnimatorClipInfo[] clips = anim.GetCurrentAnimatorClipInfo(l);
+ string names = "";
+ foreach (AnimatorClipInfo c in clips)
+ {
+ names += (names.Length > 0 ? ", " : "") + (c.clip != null ? c.clip.name : "?") + " x" + c.weight.ToString("0.##");
+ }
+ Debug.Log(Tag + " слой " + l + " " + anim.GetLayerName(l) + " вес " + anim.GetLayerWeight(l).ToString("0.##") + ": " + names);
+ }
+ }
+ else
+ {
+ Debug.Log(Tag + "аниматор не найден");
+ }
+ SdtdConsole.Instance.Output("костей рук: " + count + " - дерево и параметры аниматора в логе (строки necrobones)");
+ }
+
+ static void Dump(Transform _t, int _depth, StringBuilder _sb, ref int _count)
+ {
+ _count++;
+ Vector3 e = _t.localEulerAngles;
+ Vector3 p = _t.localPosition;
+ _sb.Append(new string(' ', _depth * 2)).Append(_t.name)
+ .Append(_t.gameObject.activeSelf ? "" : " [выкл]")
+ .Append(" пов. ").Append(e.x.ToString("0.#")).Append(' ').Append(e.y.ToString("0.#")).Append(' ').Append(e.z.ToString("0.#"))
+ .Append(" поз. ").Append(p.x.ToString("0.###")).Append(' ').Append(p.y.ToString("0.###")).Append(' ').Append(p.z.ToString("0.###"))
+ .Append('\n');
+ for (int i = 0; i < _t.childCount; i++)
+ {
+ Dump(_t.GetChild(i), _depth + 1, _sb, ref _count);
+ }
+ }
+
+ static string PathOf(Transform _t)
+ {
+ string s = _t.name;
+ for (Transform p = _t.parent; p != null; p = p.parent)
+ {
+ s = p.name + "/" + s;
+ }
+ return s;
+ }
+ }
+}
diff --git a/HarmonySrc/HandPosePose.cs b/HarmonySrc/HandPosePose.cs
new file mode 100644
index 0000000..1254faa
--- /dev/null
+++ b/HarmonySrc/HandPosePose.cs
@@ -0,0 +1,455 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.7.0). Хват - неподвижная
+ /// поза: после аниматора (LateUpdate) кости рига EModelSDCS.baseRigFP ставятся в заданные локальные
+ /// повороты. Аниматор игры не трогаем. Снимки necrobones 03.10: кулак браслета (FP_Block_Hold,
+ /// HoldType 7) - суставы пальцев повёрнуты по локальной Z на 77-92°, у пустой руки 8-38°;
+ /// направление руки - RightArm, RightArmRoll, RightForeArm (сгиб по Y), RightHand.
+ /// Пока - инструмент подбора позы (команда necropose); готовую позу потом зашить как хват.
+ ///
+ public static class HandPose
+ {
+ public static bool Enabled;
+ public static readonly Dictionary Overrides = new Dictionary();
+
+ static GameObject _rig;
+ static readonly Dictionary _bones = new Dictionary();
+ static GameObject _host;
+
+ public static void Init()
+ {
+ if (_host != null)
+ {
+ return;
+ }
+ _host = new GameObject("NecroHandPose");
+ Object.DontDestroyOnLoad(_host);
+ _host.AddComponent();
+ }
+
+ public static Transform Bone(string _name)
+ {
+ EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
+ GameObject rig = (player?.emodel as EModelSDCS)?.baseRigFP;
+ if (rig == null)
+ {
+ return null;
+ }
+ if (rig != _rig)
+ {
+ _rig = rig;
+ _bones.Clear();
+ foreach (Transform t in rig.GetComponentsInChildren(true))
+ {
+ if (!_bones.ContainsKey(t.name))
+ {
+ _bones[t.name] = t;
+ }
+ }
+ }
+ if (_bones.TryGetValue(_name, out Transform b) && b == null)
+ {
+ _rig = null; // кость уничтожена (риг пересобран) - перечитать в следующий раз
+ }
+ return b;
+ }
+
+ /// Кости, повёрнутые относительно КАМЕРЫ, а не родителя: ставятся после Overrides
+ /// (03.10: локальные углы кисти «плывут» при каждом изменении локтя - подбор вслепую).
+ public static readonly Dictionary CamOverrides = new Dictionary();
+
+ public static Transform Camera()
+ {
+ return GameManager.Instance?.World?.GetPrimaryPlayer()?.cameraTransform;
+ }
+
+ /// Заморозка (03.10: «при нажатии на кнопки руки деформируются, пытаясь совершить старую
+ /// анимацию»). Поза задаёт 20-30 костей, а удар двигает и остальные - позвоночник, ключицы, вспомогательные
+ /// кости предплечья и кожи (*_sup_*, *Roll*). Поэтому при включении хвата в покое снимается локальное
+ /// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза.
+ /// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.
+ public static bool Freeze = true;
+ static readonly List _frozen = new List();
+ static readonly List _frozenPos = new List();
+ static readonly List _frozenRot = new List();
+ static GameObject _frozenRig;
+ static readonly int ItemActionIndexHash = Animator.StringToHash("ItemActionIndex");
+
+ /// Выход из мира: всё в исходное, ссылки на кости рига отпустить (HandHolds.Update).
+ public static void ResetAll()
+ {
+ Enabled = false;
+ Overrides.Clear();
+ CamOverrides.Clear();
+ Unfreeze();
+ _bones.Clear();
+ _rig = null;
+ }
+
+ public static void Unfreeze()
+ {
+ _frozen.Clear();
+ _frozenPos.Clear();
+ _frozenRot.Clear();
+ _frozenRig = null;
+ }
+
+ static void FreezeBones()
+ {
+ Transform hips = Bone("Hips");
+ if (hips == null)
+ {
+ return;
+ }
+ if (_frozenRig != _rig || _frozen.Count == 0 || _frozen[0] == null)
+ {
+ Unfreeze();
+ Animator anim = _rig.GetComponent();
+ if (anim != null && (anim.GetInteger(ItemActionIndexHash) != -1 || anim.IsInTransition(0)))
+ {
+ return; // идёт действие - снимок позже, в покое
+ }
+ foreach (Transform t in hips.GetComponentsInChildren(true))
+ {
+ _frozen.Add(t);
+ _frozenPos.Add(t.localPosition);
+ _frozenRot.Add(t.localRotation);
+ }
+ _frozenRig = _rig;
+ Debug.Log("[NecromancerTome] HandPose: заморожено костей " + _frozen.Count);
+ return;
+ }
+ for (int i = 0; i < _frozen.Count; i++)
+ {
+ Transform t = _frozen[i];
+ if (t != null)
+ {
+ t.localPosition = _frozenPos[i];
+ t.localRotation = _frozenRot[i];
+ }
+ }
+ }
+
+ public static void Apply()
+ {
+ if (!Enabled || Overrides.Count == 0 && CamOverrides.Count == 0)
+ {
+ if (_frozen.Count > 0)
+ {
+ Unfreeze();
+ }
+ return;
+ }
+ if (Freeze)
+ {
+ FreezeBones();
+ }
+ foreach (var kv in Overrides)
+ {
+ Transform b = Bone(kv.Key);
+ if (b != null)
+ {
+ b.localRotation = Quaternion.Euler(kv.Value);
+ }
+ }
+ Transform cam = CamOverrides.Count > 0 ? Camera() : null;
+ if (cam != null)
+ {
+ foreach (var kv in CamOverrides)
+ {
+ Transform b = Bone(kv.Key);
+ if (b != null)
+ {
+ b.rotation = cam.rotation * kv.Value;
+ }
+ }
+ }
+ }
+
+ /// Кисть в осях камеры. Оси кисти правой руки (лог necrobones 03.10): пальцы - локальная +X
+ /// (суставы пальцев лежат по +X), ладонь - +Y (сгиб пальцев +Z ведёт +X к +Y), +Z - к мизинцу,
+ /// большой палец - к -Z. Нулевая поза: пальцы вверх, ладонь от игрока, тыльная сторона к камере,
+ /// большой палец влево. tilt - наклон пальцев влево (+) вокруг взгляда; back - кисть «от себя»
+ /// (+, верх пальцев уходит вперёд); turn - разворот кисти вокруг вертикали (+ ладонью вправо).
+ public static Quaternion PalmToCamera(float _tilt, float _back, float _turn)
+ {
+ Quaternion basis = Quaternion.LookRotation(Vector3.right, Vector3.forward);
+ return Quaternion.AngleAxis(_turn, Vector3.up) * Quaternion.AngleAxis(_back, Vector3.right) * Quaternion.AngleAxis(_tilt, Vector3.forward) * basis;
+ }
+
+ static readonly string[] Fingers = { "Index", "Middle", "Ring", "Pinky" };
+
+ /// Все суставы четырёх пальцев: сгиб по Z (curl), веер по Y у первого сустава
+ /// (указательный +spread, средний +spread/3, безымянный -spread/3, мизинец -spread).
+ public static void SetFingers(string _side, float _curl, float _spread)
+ {
+ float[] fan = { 1f, 1f / 3f, -1f / 3f, -1f };
+ for (int f = 0; f < Fingers.Length; f++)
+ {
+ for (int j = 1; j <= 3; j++)
+ {
+ Overrides[_side + "Hand" + Fingers[f] + j] = new Vector3(0f, j == 1 ? _spread * fan[f] : 0f, _curl);
+ }
+ }
+ }
+
+ /// Заготовка «раскрытая ладонь»: рука - как у браслета (снимок 03.10), пальцы прямые.
+ public static void PresetOpen()
+ {
+ Overrides.Clear();
+ Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
+ Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
+ Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
+ Overrides["RightHand"] = new Vector3(13.3f, 325.8f, 7.7f);
+ SetFingers("Right", 3f, 0f);
+ Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 40.6f);
+ Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
+ Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
+ Enabled = true;
+ }
+
+ /// Левая рука вдоль туловища, вне кадра (общая для хватов «отрицание» и «пиф-паф», 03.10).
+ static void LeftArmDown()
+ {
+ Overrides["LeftArm"] = new Vector3(30f, 13.6f, 57.3f);
+ }
+
+ /// Хват «отрицание» (утверждён пользователем 03.10): рука вперёд, как у браслета, ниже прицела,
+ /// кисть в линию с предплечьем, пальцы раскрыты.
+ public static void PresetDeny()
+ {
+ Overrides.Clear();
+ CamOverrides.Clear();
+ Overrides["RightArm"] = new Vector3(345.8f, 34.9f, 24.3f);
+ Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
+ Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
+ Overrides["RightHand"] = new Vector3(45f, 0f, 0f);
+ SetFingers("Right", 3f, 12f);
+ Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 40.6f);
+ Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
+ Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
+ LeftArmDown();
+ Unfreeze();
+ Enabled = true;
+ }
+
+ /// Хват «пиф-паф» (утверждён 03.10, «Стоп. Замри»): пистолет из пальцев - указательный и средний
+ /// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.
+ public static void PresetPifPaf()
+ {
+ Overrides.Clear();
+ CamOverrides.Clear();
+ Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
+ Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
+ Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
+ CamOverrides["RightHand"] = PalmToCamera(-90f, 0f, -119f);
+ for (int j = 1; j <= 3; j++)
+ {
+ Overrides["RightHandIndex" + j] = new Vector3(0f, j == 1 ? -3f : 0f, 3f);
+ Overrides["RightHandMiddle" + j] = new Vector3(0f, j == 1 ? 2f : 0f, 3f);
+ float curl = j == 3 ? 55f : 80f;
+ Overrides["RightHandRing" + j] = new Vector3(0f, 0f, curl);
+ Overrides["RightHandPinky" + j] = new Vector3(0f, 0f, curl);
+ }
+ Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 20f);
+ Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
+ Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
+ LeftArmDown();
+ Unfreeze();
+ Enabled = true;
+ }
+
+ public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture);
+ public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
+ }
+
+ public class HandPoseDriver : MonoBehaviour
+ {
+ void LateUpdate()
+ {
+ HandHolds.Update();
+ HandPose.Apply();
+ }
+ }
+
+ ///
+ /// necropose - подбор позы рук от первого лица:
+ /// on | off | clear | open (заготовка: ладонь, рука как у браслета)
+ /// <Кость> x y z - локальный поворот кости (градусы)
+ /// add <Кость> dx dy dz - добавить к текущему (заданному или от аниматора)
+ /// fingers Right|Left сгиб [веер] - все суставы четырёх пальцев
+ /// show <Кость> - текущий поворот кости
+ /// save - вся поза в лог строками кода
+ ///
+ [Preserve]
+ public class ConsoleCmdNecroPose : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necropose" };
+ public override string getDescription() => "NecromancerTome: поза рук от первого лица - on|off|clear|deny|pifpaf|freeze [off]|open|<кость> x y z|aim <кость> наклон от_себя разворот|unaim <кость>|add <кость> dx dy dz|fingers Right|Left сгиб [веер]|show <кость>|save";
+
+ static bool Num(string _s, out float _v) => float.TryParse(_s.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
+
+ void Out(string _s)
+ {
+ SdtdConsole.Instance.Output(_s);
+ Debug.Log("[NecromancerTome] necropose: " + _s);
+ }
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ string cmd = _params.Count > 0 ? _params[0] : "";
+ switch (cmd)
+ {
+ case "on":
+ HandPose.Enabled = true;
+ Out("поза включена, костей " + HandPose.Overrides.Count);
+ return;
+ case "off":
+ HandPose.Enabled = false;
+ Out("поза выключена");
+ return;
+ case "clear":
+ HandPose.Overrides.Clear();
+ HandPose.CamOverrides.Clear();
+ Out("поза очищена");
+ return;
+ case "deny":
+ HandPose.PresetDeny();
+ Out("хват «отрицание»");
+ return;
+ case "pifpaf":
+ HandPose.PresetPifPaf();
+ Out("хват «пиф-паф»");
+ return;
+ case "freeze":
+ HandPose.Freeze = _params.Count < 2 || _params[1] != "off";
+ HandPose.Unfreeze();
+ Out("заморозка остальных костей " + (HandPose.Freeze ? "включена (снимок - в покое)" : "выключена"));
+ return;
+ case "open":
+ HandPose.PresetOpen();
+ Out("заготовка «ладонь» включена, костей " + HandPose.Overrides.Count);
+ return;
+ case "save":
+ {
+ var sb = new StringBuilder();
+ foreach (var kv in HandPose.Overrides)
+ {
+ Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(kv.Value.x) + "f, " + HandPose.F(kv.Value.y) + "f, " + HandPose.F(kv.Value.z) + "f);");
+ }
+ foreach (var kv in HandPose.CamOverrides)
+ {
+ Transform cb = HandPose.Bone(kv.Key);
+ if (cb != null)
+ {
+ Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(cb.localEulerAngles.x) + "f, " + HandPose.F(cb.localEulerAngles.y) + "f, " + HandPose.F(cb.localEulerAngles.z) + "f); // от камеры (aim)");
+ }
+ }
+ Out("всего костей " + HandPose.Overrides.Count + " + от камеры " + HandPose.CamOverrides.Count + ", включена " + HandPose.Enabled);
+ return;
+ }
+ case "show":
+ {
+ if (_params.Count < 2)
+ {
+ break;
+ }
+ Transform b = HandPose.Bone(_params[1]);
+ Out(b == null ? "нет кости " + _params[1] : _params[1] + " = " + HandPose.F(b.localEulerAngles) + (HandPose.Overrides.ContainsKey(_params[1]) ? " (задана)" : " (от аниматора)"));
+ return;
+ }
+ case "fingers":
+ {
+ if (_params.Count >= 3 && (_params[1] == "Right" || _params[1] == "Left") && Num(_params[2], out float curl))
+ {
+ float spread = 0f;
+ if (_params.Count >= 4)
+ {
+ Num(_params[3], out spread);
+ }
+ HandPose.SetFingers(_params[1], curl, spread);
+ HandPose.Enabled = true;
+ Out("пальцы " + _params[1] + ": сгиб " + HandPose.F(curl) + ", веер " + HandPose.F(spread));
+ return;
+ }
+ break;
+ }
+ case "aim":
+ {
+ // aim <Кость> [наклон] [от_себя] [разворот] - кисть в осях камеры (см. HandPose.PalmToCamera)
+ if (_params.Count >= 2)
+ {
+ float tilt = 0f, back = 0f, turn = 0f;
+ if (_params.Count >= 3) Num(_params[2], out tilt);
+ if (_params.Count >= 4) Num(_params[3], out back);
+ if (_params.Count >= 5) Num(_params[4], out turn);
+ if (HandPose.Bone(_params[1]) == null)
+ {
+ Out("нет кости " + _params[1]);
+ return;
+ }
+ HandPose.Overrides.Remove(_params[1]);
+ HandPose.CamOverrides[_params[1]] = HandPose.PalmToCamera(tilt, back, turn);
+ HandPose.Enabled = true;
+ Out(_params[1] + " от камеры: наклон " + HandPose.F(tilt) + ", от себя " + HandPose.F(back) + ", разворот " + HandPose.F(turn));
+ return;
+ }
+ break;
+ }
+ case "unaim":
+ {
+ if (_params.Count >= 2 && HandPose.CamOverrides.Remove(_params[1]))
+ {
+ Out(_params[1] + " - снова от родителя (от аниматора или заданная)");
+ return;
+ }
+ break;
+ }
+ case "add":
+ {
+ if (_params.Count >= 5 && Num(_params[2], out float dx) && Num(_params[3], out float dy) && Num(_params[4], out float dz))
+ {
+ string name = _params[1];
+ Transform b = HandPose.Bone(name);
+ if (b == null)
+ {
+ Out("нет кости " + name);
+ return;
+ }
+ Vector3 cur = HandPose.Overrides.TryGetValue(name, out Vector3 v) ? v : b.localEulerAngles;
+ HandPose.Overrides[name] = cur + new Vector3(dx, dy, dz);
+ HandPose.Enabled = true;
+ Out(name + " = " + HandPose.F(HandPose.Overrides[name]));
+ return;
+ }
+ break;
+ }
+ default:
+ {
+ if (_params.Count >= 4 && Num(_params[1], out float x) && Num(_params[2], out float y) && Num(_params[3], out float z))
+ {
+ if (HandPose.Bone(cmd) == null)
+ {
+ Out("нет кости " + cmd);
+ return;
+ }
+ HandPose.CamOverrides.Remove(cmd);
+ HandPose.Overrides[cmd] = new Vector3(x, y, z);
+ HandPose.Enabled = true;
+ Out(cmd + " = " + HandPose.F(HandPose.Overrides[cmd]));
+ return;
+ }
+ break;
+ }
+ }
+ Out(getDescription());
+ }
+ }
+}
diff --git a/HarmonySrc/MindControl.cs b/HarmonySrc/MindControl.cs
new file mode 100644
index 0000000..0829df8
--- /dev/null
+++ b/HarmonySrc/MindControl.cs
@@ -0,0 +1,304 @@
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Дух в чертогах разума - камера без сущности (указание 03.10: «тело можно вообще не делать,
+ /// нужен только прицел»). Ходит по полу как человек: столкновения - по тем же слоям, что у змеи
+ /// (SnakeControl.WallMask / TerrainMask), гравитация и ступенька. Взаимодействие одно - книга
+ /// памяти по E; остальное в чертогах неприкосновенно. Камеру ставят патчи камеры режима змеи
+ /// (SnakeControl.cs), звук и мир вокруг считаются от духа через SnakeAudioSwap.
+ ///
+ /// СОН (указание: «тёплый фильтр, это как бы сон», эффекты - на моё усмотрение): готовые эффекты
+ /// экрана игры - Hot (тёплый оттенок), Blur (мягкое размытие), Drunk (медленное покачивание).
+ /// Силы крутятся командой necrodream на ходу.
+ ///
+ public static class MindControl
+ {
+ public const float EyeHeight = 1.6f;
+ public const float Radius = 0.3f;
+ public const float Height = 1.75f;
+ public const float WalkSpeed = 2.2f;
+ public const float RunSpeed = 3.6f;
+ public const float Gravity = 20f;
+ public const float JumpSpeed = 6f;
+ public const float StepUp = 0.6f;
+ public const float ReachDistance = 3f;
+
+ public static float Hot = 0.45f;
+ public static float Blur = 0.25f;
+ public static float Drunk = 0.15f;
+
+ public static bool Controlling;
+ static Vector3 _feet;
+ static Vector3 _spawn;
+ static float _yaw, _pitch, _vy;
+ static bool _grounded;
+ public static bool HasFocus;
+ static EntityPlayerLocal _player;
+
+ public static Vector3 FeetPos => _feet;
+
+ public static void Begin(EntityPlayerLocal _p, Vector3 _at, float _atYaw)
+ {
+ _player = _p;
+ _feet = _at;
+ _spawn = _at;
+ _yaw = _atYaw;
+ _pitch = 0f;
+ _vy = 0f;
+ _grounded = false;
+ HasFocus = false;
+ Controlling = true;
+ _p.PlayerUI?.windowManager.Close(SnakeControl.ToolbeltWindow);
+ Dream(_p, true);
+ Debug.Log("[NecromancerTome] MindControl: дух в чертогах, " + _at);
+ }
+
+ public static void End(EntityPlayerLocal _p)
+ {
+ if (!Controlling)
+ {
+ return;
+ }
+ Controlling = false;
+ HasFocus = false;
+ Dream(_p ?? _player, false);
+ _player = null;
+ }
+
+ public static void Dream(EntityPlayerLocal _p, bool _on)
+ {
+ ScreenEffects fx = _p?.ScreenEffectManager;
+ if (fx == null)
+ {
+ return;
+ }
+ fx.SetScreenEffect("Hot", _on ? Hot : 0f, _on ? 2f : 0.5f);
+ fx.SetScreenEffect("Blur", _on ? Blur : 0f, _on ? 2f : 0.5f);
+ fx.SetScreenEffect("Drunk", _on ? Drunk : 0f, _on ? 2f : 0.5f);
+ }
+
+ static bool Blocked(Vector3 _p)
+ {
+ Vector3 a = _p + Vector3.up * (Radius + 0.05f) - Origin.position;
+ Vector3 b = _p + Vector3.up * (Height - Radius) - Origin.position;
+ return Physics.CheckCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
+ }
+
+ static bool TryMove(Vector3 _d)
+ {
+ Vector3 c = _feet + _d;
+ if (!Blocked(c))
+ {
+ _feet = c;
+ return true;
+ }
+ // Ступенька: на полблока выше свободно - шагаем вверх.
+ if (_grounded && !Blocked(c + Vector3.up * StepUp))
+ {
+ _feet = c + Vector3.up * StepUp;
+ return true;
+ }
+ return false;
+ }
+
+ public static void Update(EntityPlayerLocal _p)
+ {
+ if (!Controlling)
+ {
+ return;
+ }
+ float dt = Time.deltaTime;
+ bool gui = _p.PlayerUI != null && _p.PlayerUI.windowManager.IsModalWindowOpen();
+ PlayerActionsLocal input = _p.playerInput;
+ Vector2 move = Vector2.zero;
+ bool run = false, jump = false;
+ if (!gui && input != null)
+ {
+ 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, -85f, 85f);
+ move = new Vector2(input.Move.X, input.Move.Y);
+ run = input.Run.IsPressed;
+ jump = input.Jump.WasPressed;
+ if (HasFocus && (input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed))
+ {
+ BlockNecroMemoryBook.Open(_p);
+ }
+ }
+ Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
+ Vector3 dir = yawRot * new Vector3(move.x, 0f, move.y);
+ if (dir.sqrMagnitude > 1f)
+ {
+ dir.Normalize();
+ }
+ if (dir.sqrMagnitude > 0.0001f)
+ {
+ Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt;
+ int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / 0.1f));
+ Vector3 d = delta / steps;
+ for (int i = 0; i < steps; i++)
+ {
+ if (!TryMove(d) && !TryMove(new Vector3(d.x, 0f, 0f)) && !TryMove(new Vector3(0f, 0f, d.z)))
+ {
+ break;
+ }
+ }
+ }
+ // Падение и прыжок: земля - луч вниз по слою ландшафта и блоков.
+ if (_grounded && jump)
+ {
+ _vy = JumpSpeed;
+ _grounded = false;
+ }
+ _vy -= Gravity * dt;
+ float dy = _vy * dt;
+ if (dy > 0f && Blocked(_feet + Vector3.up * dy))
+ {
+ _vy = 0f;
+ dy = 0f;
+ }
+ if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
+ {
+ _feet.y = hit.point.y + Origin.position.y;
+ _vy = 0f;
+ _grounded = true;
+ }
+ else
+ {
+ _feet.y += dy;
+ _grounded = false;
+ }
+ // Провалился (коллизии свежепоставленных блоков ещё не построены) - назад на точку появления.
+ if (_feet.y < _spawn.y - 3f)
+ {
+ Debug.Log("[NecromancerTome] MindControl: дух провалился - назад на точку появления");
+ _feet = _spawn + Vector3.up * 0.3f;
+ _vy = 0f;
+ }
+ }
+
+ /// Из патчей камеры режима змеи: глаза духа и поиск книги под прицелом.
+ public static void PlaceCamera(Transform _camera)
+ {
+ Vector3 eye = _feet + Vector3.up * EyeHeight - Origin.position;
+ Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
+ _camera.SetPositionAndRotation(eye, rot);
+ HasFocus = FindBook(_camera.position + Origin.position, _camera.forward);
+ }
+
+ /// Шагаем по лучу на ReachDistance: первая встреченная книга памяти - цель; твёрдый
+ /// блок раньше неё - нет цели (сквозь стены не читаем).
+ static bool FindBook(Vector3 _from, Vector3 _dir)
+ {
+ World world = GameManager.Instance?.World;
+ if (world == null)
+ {
+ return false;
+ }
+ Vector3i last = new Vector3i(int.MinValue, 0, 0);
+ for (float t = 0.1f; t <= ReachDistance; t += 0.05f)
+ {
+ Vector3i b = World.worldToBlockPos(_from + _dir * t);
+ if (b == last)
+ {
+ continue;
+ }
+ last = b;
+ Block block = world.GetBlock(b).Block;
+ if (block is BlockNecroMemoryBook)
+ {
+ return true;
+ }
+ if (block != null && block.IsCollideMovement && !block.shape.IsTerrain() && block.shape.IsSolidCube)
+ {
+ return false;
+ }
+ }
+ return false;
+ }
+ }
+
+ /// Прицел духа и подсказка у книги (OnGUI, как прицел змеи).
+ public class MindHud : MonoBehaviour
+ {
+ static GUIStyle _style;
+
+ public static void Create()
+ {
+ var go = new GameObject("NecroMindHud");
+ DontDestroyOnLoad(go);
+ go.AddComponent();
+ }
+
+ void OnGUI()
+ {
+ if (!MindControl.Controlling || Event.current.type != EventType.Repaint)
+ {
+ return;
+ }
+ EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
+ if (player == null || (player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen()))
+ {
+ return;
+ }
+ float s = 4f * Screen.height / 1080f;
+ Color old = GUI.color;
+ GUI.color = MindControl.HasFocus ? new Color(1f, 0.85f, 0.5f, 1f) : new Color(1f, 0.95f, 0.85f, 0.7f);
+ GUI.DrawTexture(new Rect(Screen.width * 0.5f - s * 0.5f, Screen.height * 0.5f - s * 0.5f, s, s), Texture2D.whiteTexture);
+ if (MindControl.HasFocus)
+ {
+ if (_style == null)
+ {
+ _style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
+ }
+ _style.fontSize = Mathf.RoundToInt(26f * Screen.height / 1080f);
+ // E в игре привязана к PermanentActions.Activate; у обычного Activate привязок нет - он даёт «Нет».
+ string key = KeyName(player.playerInput?.PermanentActions?.Activate) ?? KeyName(player.playerInput?.Activate) ?? "E";
+ string text = string.Format(Localization.Get("necroMemoryBookPrompt"), key);
+ Rect r = new Rect(0f, Screen.height * 0.5f + 30f * Screen.height / 1080f, Screen.width, 40f * Screen.height / 1080f);
+ GUI.color = new Color(0f, 0f, 0f, 0.8f);
+ GUI.Label(new Rect(r.x + 2f, r.y + 2f, r.width, r.height), text, _style);
+ GUI.color = new Color(1f, 0.9f, 0.7f, 1f);
+ GUI.Label(r, text, _style);
+ }
+ GUI.color = old;
+ }
+
+ private static string KeyName(InControl.PlayerAction action)
+ {
+ if (action == null || action.Bindings.Count == 0) return null;
+ string name = action.GetBindingString(false);
+ return string.IsNullOrEmpty(name) ? null : name;
+ }
+ }
+
+ /// necrodream hot|blur|drunk N - подобрать силу сонных эффектов на ходу.
+ [UnityEngine.Scripting.Preserve]
+ public class ConsoleCmdNecroDream : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necrodream" };
+ public override string getDescription() => "NecromancerTome: сила эффектов сна - hot|blur|drunk N (0-1)";
+
+ public override void Execute(System.Collections.Generic.List _params, CommandSenderInfo _senderInfo)
+ {
+ if (_params.Count == 2 && float.TryParse(_params[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v))
+ {
+ switch (_params[0])
+ {
+ case "hot": MindControl.Hot = v; break;
+ case "blur": MindControl.Blur = v; break;
+ case "drunk": MindControl.Drunk = v; break;
+ }
+ if (MindControl.Controlling)
+ {
+ MindControl.Dream(GameManager.Instance.World.GetPrimaryPlayer(), true);
+ }
+ }
+ SdtdConsole.Instance.Output("сон: hot " + MindControl.Hot + ", blur " + MindControl.Blur + ", drunk " + MindControl.Drunk);
+ }
+ }
+}
diff --git a/HarmonySrc/MindPalace.cs b/HarmonySrc/MindPalace.cs
new file mode 100644
index 0000000..2ab586f
--- /dev/null
+++ b/HarmonySrc/MindPalace.cs
@@ -0,0 +1,892 @@
+using System.Collections.Generic;
+using System.IO;
+using HarmonyLib;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// Ментальная форма - «Чертоги разума» (решения пользователя 02-03.10, BACKLOG «Чертоги разума»).
+ /// Вход - третья строка меню ножа некроманта на R, только ночью, здоровье не тратит. Тело
+ /// падает рэгдоллом и лежит беззащитным, как в режиме змеи; дух - камера без сущности
+ /// (MindControl.cs): ходит по полу, видит только своё, с другими игроками не встречается.
+ /// Выход - R, утро или смерть тела.
+ ///
+ /// ГДЕ ЧЕРТОГИ. Под землёй, в радиационном поясе у северо-восточного угла карты: сначала от
+ /// угла по диагонали ищется пояс (biomeProvider.GetRadiationAt > 2.5); пояс шире префаба -
+ /// чертоги в его середине, уже (Navezgane: 16 блоков) - в 45 блоках от края. Своё место у каждого
+ /// игрока - вдоль северного края с шагом SlotStep (дальше дальности показа сущностей). Места
+ /// пишутся в файл в папке сохранения мира, чтобы смена ширины пояса в новой версии игры их не
+ /// сдвинула.
+ ///
+ /// КАК СТАВЯТСЯ. Префаб chertogi (PalacePrefab/, нарисован пользователем в редакторе игры)
+ /// грузится Prefab.Load и ставится поблочно через World.SetBlocksRPC - НЕ Prefab.CopyIntoLocal:
+ /// тот перед постановкой вычищает весь столб над префабом до неба от не-земляных блоков,
+ /// и над чертогами вырубило бы деревья (видно сверху). Заодно: внешний слой оболочки
+ /// (woodShapes:cube) -> terrBedrock (в меню творчества его нет, рисовали деревом),
+ /// одиночная книга на столе -> necroMemoryBook, на стол с реактивами - нож-декорация,
+ /// кровать (SleepingBag) -> necroPalaceBed: иначе игра делает её точкой возрождения игрока.
+ /// Построены ли - по книге: нет её - ставим заново (самопочинка).
+ ///
+ /// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
+ /// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
+ /// раз в секунду выбрасываются: змея развеивается, игрок - на поверхность, прочие - удаляются.
+ ///
+ /// ОГРАНИЧЕНИЕ: одиночная игра и хост. Клиенту в мультиплеере вход пока не даётся - чертоги ставит
+ /// сервер, а камера живёт у клиента (решать отдельно).
+ ///
+ public static class MindPalace
+ {
+ public const string PrefabName = "chertogi";
+ public const string BookBlock = "necroMemoryBook";
+ public const string KnifeBlock = "necroKnifeDecor";
+ /// Кровать без класса SleepingBag - ею заменяется кровать префаба (bunkBedMadeWhite).
+ public const string BedBlock = "necroPalaceBed";
+ public const string ShellFrom = "woodShapes:cube";
+ public const string ShellTo = "terrBedrock";
+ /// Одиночная книга на обеденном столе (cntBookPile09 в префабе).
+ public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
+ /// Место ножа на столе с реактивами (указание пользователя).
+ public static readonly Vector3i KnifeAt = new Vector3i(4, 2, 11);
+ /// Входная дверь чертогов - во внутреннюю оболочку, никуда не ведёт: остаётся закрытой
+ /// (указание 03.10). Остальные четыре - между комнатами - открываются.
+ public static readonly Vector3i EntranceDoorAt = new Vector3i(8, 1, 3);
+ /// Где появляется дух: комната с книгой, свободная клетка у стола.
+ public static readonly Vector3 SpawnAt = new Vector3(11.5f, 1.05f, 11.5f);
+ public const float SpawnYaw = 180f;
+ public const int SlotStep = 300;
+ public const int FallbackInset = 45;
+ public const int FloorY = 3;
+ /// Рамка вокруг чертогов, которая заливается камнем перед постановкой.
+ public const int LooseMargin = 3;
+ public const float RadiationThreshold = 2.5f;
+ /// Поворот ножа-декорации (0-23): 9 - плашмя на столешнице (подобрано пользователем
+ /// 03.10 командой necropalace knife N).
+ public static int KnifeRotation = 0;
+
+ public static bool Active;
+ static Vector3i _origin;
+ static Prefab _prefab;
+ static ChunkManager.ChunkObserver _observer;
+ enum Stage { None, Loading, Settling, Ready }
+ static Stage _stage;
+ static float _deadline;
+ static float _settleUntil;
+ static float _ragdollAt;
+ static bool _waitStandUp;
+ static float _standUpDeadline;
+ static float _guardCheck;
+
+ static readonly Dictionary _slots = new Dictionary();
+ static string _slotsFor;
+
+ public static void Init()
+ {
+ ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
+ MindHud.Create();
+ }
+
+ // ---------------------------------------------------------------- места
+
+ static string SlotsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaces.txt");
+
+ static void LoadSlots()
+ {
+ string dir = GameIO.GetSaveGameDir();
+ if (_slotsFor == dir)
+ {
+ return;
+ }
+ _slotsFor = dir;
+ _slots.Clear();
+ try
+ {
+ if (File.Exists(SlotsFile))
+ {
+ foreach (string line in File.ReadAllLines(SlotsFile))
+ {
+ string[] p = line.Split(' ');
+ if (p.Length == 4 && int.TryParse(p[1], out int x) && int.TryParse(p[2], out int y) && int.TryParse(p[3], out int z))
+ {
+ _slots[p[0]] = new Vector3i(x, y, z);
+ }
+ }
+ }
+ }
+ catch (System.Exception ex)
+ {
+ Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать места - " + ex.Message);
+ }
+ }
+
+ static void SaveSlots()
+ {
+ try
+ {
+ var lines = new List();
+ foreach (var kv in _slots)
+ {
+ lines.Add(kv.Key + " " + kv.Value.x + " " + kv.Value.y + " " + kv.Value.z);
+ }
+ File.WriteAllLines(SlotsFile, lines);
+ }
+ catch (System.Exception ex)
+ {
+ Debug.LogWarning("[NecromancerTome] MindPalace: не записать места - " + ex.Message);
+ }
+ }
+
+ static string PlayerKey(EntityPlayer _player)
+ {
+ string id = GameManager.Instance.persistentLocalPlayer?.PrimaryId?.CombinedString;
+ return string.IsNullOrEmpty(id) ? "local" : id.Replace(' ', '_');
+ }
+
+ static Vector3i GetSlot(World _world, EntityPlayer _player)
+ {
+ LoadSlots();
+ string key = PlayerKey(_player);
+ if (_slots.TryGetValue(key, out Vector3i o))
+ {
+ return o;
+ }
+ o = ComputeSlot(_world, _slots.Count);
+ _slots[key] = o;
+ SaveSlots();
+ Debug.Log("[NecromancerTome] MindPalace: место " + _slots.Count + " для " + key + " - " + o);
+ return o;
+ }
+
+ /// Отступ от края: середина радиационного пояса, если он шире префаба, иначе 45.
+ static int Inset(World _world, Vector3i _min, Vector3i _max, int _size)
+ {
+ IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
+ int start = -1, end = -1;
+ if (biomes != null)
+ {
+ for (int t = 0; t < 600; t += 4)
+ {
+ bool rad = biomes.GetRadiationAt(_max.x - t, _max.z - t) > RadiationThreshold;
+ if (rad && start < 0) start = t;
+ if (!rad && start >= 0) { end = t; break; }
+ }
+ }
+ if (start >= 0 && end > start && end - start >= _size + 20)
+ {
+ return Mathf.Max(FallbackInset, (start + end) / 2 - _size / 2);
+ }
+ return FallbackInset;
+ }
+
+ static Vector3i ComputeSlot(World _world, int _index)
+ {
+ _world.GetWorldExtent(out Vector3i min, out Vector3i max);
+ Prefab p = LoadPrefab();
+ int size = p != null ? Mathf.Max(p.size.x, p.size.z) : 17;
+ int inset = Inset(_world, min, max, size);
+ int perEdge = Mathf.Max(1, (max.x - min.x - 2 * inset - size) / SlotStep);
+ int x, z;
+ if (_index < perEdge)
+ {
+ x = max.x - inset - size - _index * SlotStep;
+ z = max.z - inset - size;
+ }
+ else
+ {
+ x = max.x - inset - size;
+ z = max.z - inset - size - (_index - perEdge + 1) * SlotStep;
+ }
+ x = Mathf.FloorToInt(x / 16f) * 16;
+ z = Mathf.FloorToInt(z / 16f) * 16;
+ return new Vector3i(x, FloorY, z);
+ }
+
+ // ---------------------------------------------------------------- префаб и постройка
+
+ static string ModDir => Path.GetDirectoryName(typeof(MindPalace).Assembly.Location);
+
+ static Prefab LoadPrefab()
+ {
+ if (_prefab != null)
+ {
+ return _prefab;
+ }
+ string path = Path.Combine(ModDir, "PalacePrefab", PrefabName + ".tts");
+ var p = new Prefab();
+ if (!p.Load(new PathAbstractions.AbstractedLocation(PathAbstractions.EAbstractedLocationType.Mods, PrefabName, path, null, false)))
+ {
+ Debug.LogError("[NecromancerTome] MindPalace: префаб не загрузился - " + path);
+ return null;
+ }
+ _prefab = p;
+ Debug.Log("[NecromancerTome] MindPalace: префаб " + PrefabName + " " + p.size);
+ return _prefab;
+ }
+
+ static bool ChunksLoaded(World _world, Vector3i _o, Vector3i _size)
+ {
+ for (int cx = World.toChunkXZ(_o.x); cx <= World.toChunkXZ(_o.x + _size.x - 1); cx++)
+ {
+ for (int cz = World.toChunkXZ(_o.z); cz <= World.toChunkXZ(_o.z + _size.z - 1); cz++)
+ {
+ // Не просто «есть в памяти», а сгенерирован и украшен: иначе генерация досыпала
+ // землю поверх поставленного (тест 03.10: «внутри блоки камня»).
+ Chunk c = _world.GetChunkSync(cx, cz) as Chunk;
+ if (c == null || !c.IsInitialized || c.NeedsDecoration)
+ {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ /// Стоит ли блок уже в самом префабе (03.10: пользователь расставляет книгу и нож в редакторе
+ /// сам) - тогда старые принудительные замены (книга в BookAt, нож в KnifeAt) не делаем.
+ static bool PrefabHas(Prefab _p, string _block)
+ {
+ if (_hasCache == null || _hasCacheFor != _p)
+ {
+ _hasCache = new Dictionary();
+ _hasCacheFor = _p;
+ }
+ if (_hasCache.TryGetValue(_block, out bool has))
+ {
+ return has;
+ }
+ int type = Block.GetBlockValue(_block).type;
+ for (int y = 0; y < _p.size.y && !has; y++)
+ for (int z = 0; z < _p.size.z && !has; z++)
+ for (int x = 0; x < _p.size.x && !has; x++)
+ {
+ has = _p.GetBlock(x, y, z).type == type;
+ }
+ _hasCache[_block] = has;
+ return has;
+ }
+
+ static Dictionary _hasCache;
+ static Prefab _hasCacheFor;
+
+ /// Что должно стоять в клетке префаба после постановки (с заменами). False - клетку
+ /// не трогаем (дочерняя часть многоблочного предмета).
+ static bool Expected(Prefab _p, int _x, int _y, int _z, out BlockValue _bv, out sbyte _d)
+ {
+ _bv = _p.GetBlock(_x, _y, _z);
+ _d = _p.GetDensity(_x, _y, _z);
+ if (_bv.ischild)
+ {
+ return false;
+ }
+ Vector3i lp = new Vector3i(_x, _y, _z);
+ // Пол (y 0) - НЕ bedrock: в префабе он же пол комнат, а bedrock - блок-земля, её сглаженная
+ // поверхность выпирала из пола в комнаты каменными наплывами (тест 03.10: «в помещении
+ // блоки камня»). Защита снизу - отдельный слой bedrock под префабом (FloorShield).
+ bool outer = _x == 0 || _z == 0 || _x == _p.size.x - 1 || _z == _p.size.z - 1 || _y == _p.size.y - 1;
+ if (outer && !_bv.isair && _bv.Block.GetBlockName() == ShellFrom)
+ {
+ _bv = Block.GetBlockValue(ShellTo);
+ _d = MarchingCubes.DensityTerrain;
+ }
+ else if (lp == BookAt && !PrefabHas(_p, BookBlock))
+ {
+ BlockValue b = Block.GetBlockValue(BookBlock);
+ b.rotation = _bv.rotation;
+ _bv = b;
+ _d = MarchingCubes.DensityAir;
+ }
+ else if (lp == KnifeAt && !PrefabHas(_p, KnifeBlock))
+ {
+ BlockValue k = Block.GetBlockValue(KnifeBlock);
+ k.rotation = (byte)KnifeRotation;
+ _bv = k;
+ _d = MarchingCubes.DensityAir;
+ }
+ else if (!_bv.isair && _bv.Block is BlockSleepingBag)
+ {
+ // Кровать префаба - просто мебель (тест 03.10: синий камень портала отправил в чертоги).
+ // Чертоги ставятся SetBlocksRPC без игрока, а GameManager.ChangeBlocks тогда считает
+ // ставившим локального игрока и любую SleepingBag-кровать делает его точкой возрождения.
+ BlockValue bed = Block.GetBlockValue(BedBlock);
+ bed.rotation = _bv.rotation;
+ _bv = bed;
+ _d = MarchingCubes.DensityAir;
+ }
+ else if (_bv.isair || !_bv.Block.shape.IsTerrain())
+ {
+ _d = MarchingCubes.DensityAir;
+ }
+ return true;
+ }
+
+ /// Диагностика (03.10, «нож пропал со стола»): что стоит в клетке и есть ли у блока модель.
+ static void LogCell(World _world, Vector3i _wp, string _what)
+ {
+ BlockValue bv = _world.GetBlock(_wp);
+ string s = "[NecromancerTome] MindPalace: " + _what + " " + _wp + " - " + bv.Block.GetBlockName() + " пов. " + bv.rotation;
+ Chunk c = _world.GetChunkFromWorldPos(_wp) as Chunk;
+ BlockEntityData be = c?.GetBlockEntity(_wp);
+ if (be == null)
+ {
+ s += ", модели нет (нет BlockEntity)";
+ }
+ else if (!be.bHasTransform || be.transform == null)
+ {
+ s += ", модель ещё не создана";
+ }
+ else
+ {
+ Renderer[] rs = be.transform.GetComponentsInChildren(true);
+ int on = 0;
+ foreach (Renderer r in rs) if (r.enabled && r.gameObject.activeInHierarchy) on++;
+ s += ", модель " + be.transform.name + " в " + (be.transform.position + Origin.position) + ", активна " + be.transform.gameObject.activeInHierarchy + ", рендеров " + on + "/" + rs.Length;
+ }
+ Debug.Log(s);
+ }
+
+ /// Сколько клеток в мире не совпадает с префабом по типу блока (0 - чертоги целы).
+ public static int Mismatches(World _world, Vector3i _o, List _report = null)
+ {
+ Prefab p = LoadPrefab();
+ if (p == null)
+ {
+ return -1;
+ }
+ int n = 0;
+ int shieldType = Block.GetBlockValue(ShellTo).type;
+ for (int z = 0; z < p.size.z; z++)
+ for (int x = 0; x < p.size.x; x++)
+ {
+ if (_world.GetBlock(_o + new Vector3i(x, -1, z)).type != shieldType)
+ {
+ n++;
+ }
+ }
+ for (int y = 0; y < p.size.y; y++)
+ for (int z = 0; z < p.size.z; z++)
+ for (int x = 0; x < p.size.x; x++)
+ {
+ Vector3i wp = _o + new Vector3i(x, y, z);
+ if (!Expected(p, x, y, z, out BlockValue want, out sbyte wantD))
+ {
+ // Дочерняя клетка: там не должно быть земли/камня.
+ BlockValue c = _world.GetBlock(wp);
+ if (c.Block.shape.IsTerrain() || _world.GetDensity(wp.x, wp.y, wp.z) < 0)
+ {
+ n++;
+ if (_report != null && _report.Count < 40)
+ {
+ _report.Add("(" + x + "," + y + "," + z + ") дочерняя клетка, стоит " + c.Block.GetBlockName() + " плотн. " + _world.GetDensity(wp.x, wp.y, wp.z));
+ }
+ }
+ continue;
+ }
+ BlockValue have = _world.GetBlock(wp);
+ // Плотность тоже: тип блока - воздух, а плотность земли - и клетка рисуется камнем
+ // (тест 03.10: «камень под столом, в дверном проёме, под потолком»).
+ sbyte haveD = _world.GetDensity(wp.x, wp.y, wp.z);
+ bool densityBad = (wantD < 0) != (haveD < 0);
+ // И покраска (указание 03.10: «покраску обязательно перенеси из префаба»).
+ bool paintBad = !want.isair && want.type == p.GetBlock(x, y, z).type &&
+ _world.GetTextureFullArray(wp.x, wp.y, wp.z) != p.GetTexture(x, y, z);
+ if (have.type != want.type || densityBad || paintBad)
+ {
+ n++;
+ if (_report != null && _report.Count < 40)
+ {
+ _report.Add("(" + x + "," + y + "," + z + ") ждали " + want.Block.GetBlockName() + " плотн. " + wantD +
+ ", стоит " + have.Block.GetBlockName() + " плотн. " + haveD + (paintBad ? ", не та покраска" : ""));
+ }
+ }
+ }
+ return n;
+ }
+
+ public static bool IsBuilt(World _world, Vector3i _o)
+ {
+ return Mismatches(_world, _o) == 0;
+ }
+
+ /// Все двери чертогов - открыты (в редакторе сохранились закрытыми, а дух дверей не
+ /// открывает). Состояние двери - в её TEFeatureDoor, не в блоке.
+ public static void OpenDoors(World _world, Vector3i _o)
+ {
+ Vector3i s = Size;
+ int opened = 0;
+ for (int y = 0; y < s.y; y++)
+ for (int z = 0; z < s.z; z++)
+ for (int x = 0; x < s.x; x++)
+ {
+ if (_world.GetTileEntity(_o + new Vector3i(x, y, z)) is TileEntityComposite tec)
+ {
+ TEFeatureDoor door = tec.GetFeature();
+ // Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
+ bool wantOpen = new Vector3i(x, y, z) != EntranceDoorAt;
+ if (door != null && door.IsOpen() != wantOpen)
+ {
+ door.SetOpen(wantOpen);
+ opened++;
+ }
+ }
+ }
+ if (opened > 0)
+ {
+ Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + opened);
+ }
+ }
+
+ public static void Stamp(World _world, Vector3i _o)
+ {
+ Prefab p = LoadPrefab();
+ if (p == null)
+ {
+ return;
+ }
+ // ПОРЯДОК ВАЖЕН (тест 03.10: при очистке всей коробки разом на миг открывалась пустота
+ // 17x8x17, земля над ней и края теряли опору - обвал, падающие блоки ушли под мир):
+ // 1) слой bedrock под чертогами;
+ // 2) оболочка - пол (y 0), стены (края по x/z) и крыша - сразу на место земли, без очистки;
+ // 3) очистка внутренности (её уже держит оболочка) - в том числе дочерних клеток стола,
+ // дверей, шкафов, где иначе оставался камень;
+ // 4) всё внутреннее по префабу.
+ var list = new List();
+ // 0) Рамка LooseMargin блоков вокруг чертогов, со всех сторон и сверху, - сплошь камнем
+ // (указание 03.10: «заполни сначала камнем, на 3 блока во все стороны»): земля, песок,
+ // гравий (осыпаются без опоры) и пустоты пещер -> terrStone. Не земля и не воздух
+ // (чужие постройки) - не трогаем.
+ BlockValue stone = Block.GetBlockValue("terrStone");
+ int loose = 0;
+ for (int y = -1 - LooseMargin; y < p.size.y + LooseMargin; y++)
+ for (int z = -LooseMargin; z < p.size.z + LooseMargin; z++)
+ for (int x = -LooseMargin; x < p.size.x + LooseMargin; x++)
+ {
+ bool inside = y >= -1 && y < p.size.y && x >= 0 && x < p.size.x && z >= 0 && z < p.size.z;
+ if (inside)
+ {
+ continue;
+ }
+ Vector3i wp = _o + new Vector3i(x, y, z);
+ if (wp.y < 1)
+ {
+ continue; // ниже 1 игра блоки не ставит (дно мира)
+ }
+ BlockValue have = _world.GetBlock(wp);
+ Block b = have.Block;
+ if (have.type != stone.type && (have.isair || b != null && b.shape.IsTerrain()))
+ {
+ list.Add(new BlockChangeInfo(wp, stone, MarchingCubes.DensityTerrain));
+ loose++;
+ }
+ }
+ if (loose > 0)
+ {
+ Debug.Log("[NecromancerTome] MindPalace: рамка вокруг залита камнем - " + loose + " клеток");
+ }
+ BlockValue shield = Block.GetBlockValue(ShellTo);
+ for (int z = 0; z < p.size.z; z++)
+ for (int x = 0; x < p.size.x; x++)
+ {
+ list.Add(new BlockChangeInfo(_o + new Vector3i(x, -1, z), shield, MarchingCubes.DensityTerrain));
+ }
+ for (int pass = 0; pass < 3; pass++)
+ for (int yi = 0; yi < p.size.y; yi++)
+ for (int z = 0; z < p.size.z; z++)
+ for (int x = 0; x < p.size.x; x++)
+ {
+ // Очистка (проход 1) - СВЕРХУ ВНИЗ: снизу вверх убранный слой оставлял над собой
+ // песок/гравий без опоры, тот падал и рушил уже поставленные стены (тест 03.10,
+ // necropalace test: 30 расхождений сразу, 309 - при следующем входе). Постановка -
+ // снизу вверх, на опору.
+ int y = pass == 1 ? p.size.y - 1 - yi : yi;
+ bool shellCell = y == 0 || y == p.size.y - 1 || x == 0 || z == 0 || x == p.size.x - 1 || z == p.size.z - 1;
+ Vector3i wp = _o + new Vector3i(x, y, z);
+ if (pass == 0 && shellCell || pass == 2 && !shellCell)
+ {
+ if (Expected(p, x, y, z, out BlockValue bv, out sbyte d))
+ {
+ // С покраской из префаба (указание 03.10).
+ list.Add(new BlockChangeInfo(wp, bv, d, p.GetTexture(x, y, z)));
+ }
+ }
+ else if (pass == 1 && !shellCell)
+ {
+ list.Add(new BlockChangeInfo(wp, BlockValue.Air, MarchingCubes.DensityAir));
+ }
+ }
+ _world.SetBlocksRPC(list);
+ Debug.Log("[NecromancerTome] MindPalace: чертоги поставлены в " + _o + ", блоков " + list.Count);
+ }
+
+ static Vector3i Size => _prefab != null ? _prefab.size : new Vector3i(17, 8, 17);
+
+ /// Проверка постановки в нетронутую землю (указание 03.10): залить камнем коробку на
+ /// 2 блока шире чертогов во все стороны (включая слой bedrock снизу), подождать, поставить
+ /// чертоги заново, выставить двери. Сразу заливкой, без предварительной очистки - очистка и
+ /// есть та пустота без опоры, из-за которой был обвал.
+ public static System.Collections.IEnumerator StressTest(World _world, Vector3i _o)
+ {
+ Vector3i s = Size;
+ // Гравием - худший случай: сыпучее, падает без опоры (указание 03.10).
+ BlockValue stone = Block.GetBlockValue("terrGravel");
+ var list = new List();
+ for (int y = -3; y < s.y + 2; y++)
+ for (int z = -2; z < s.z + 2; z++)
+ for (int x = -2; x < s.x + 2; x++)
+ {
+ list.Add(new BlockChangeInfo(_o + new Vector3i(x, y, z), stone, MarchingCubes.DensityTerrain));
+ }
+ _world.SetBlocksRPC(list);
+ Debug.Log("[NecromancerTome] necropalace test: залито гравием " + list.Count + " клеток, ждём 2 с");
+ yield return new WaitForSeconds(2f);
+ Stamp(_world, _o);
+ yield return new WaitForSeconds(1f);
+ OpenDoors(_world, _o);
+ Debug.Log("[NecromancerTome] necropalace test: готово, расхождений с префабом " + Mismatches(_world, _o));
+ }
+
+ public static bool InAnyPalace(Vector3i _pos, int _margin = 0)
+ {
+ if (_slots.Count == 0)
+ {
+ return false;
+ }
+ Vector3i s = Size;
+ foreach (Vector3i o in _slots.Values)
+ {
+ if (_pos.x >= o.x - _margin && _pos.x < o.x + s.x + _margin &&
+ _pos.y >= o.y - 1 - _margin && _pos.y < o.y + s.y + _margin &&
+ _pos.z >= o.z - _margin && _pos.z < o.z + s.z + _margin)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // ---------------------------------------------------------------- вход и выход
+
+ public static void Enter(EntityPlayerLocal _player)
+ {
+ if (Active || SnakeMode.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
+ {
+ return;
+ }
+ if (!SnakeMode.IsNight())
+ {
+ Debug.Log("[NecromancerTome] MindPalace: днём чертоги недоступны");
+ return;
+ }
+ World world = GameManager.Instance.World;
+ if (world.IsRemote())
+ {
+ Debug.Log("[NecromancerTome] MindPalace: в мультиплеере у клиента пока недоступно");
+ return;
+ }
+ if (LoadPrefab() == null)
+ {
+ return;
+ }
+ _origin = GetSlot(world, _player);
+ Vector3 center = _origin.ToVector3() + new Vector3(Size.x * 0.5f, 0f, Size.z * 0.5f);
+ // Дальность как у змеи: при радиусе 2 игра не строила коллизии участка (тест 03.10:
+ // «чертоги не построились вовремя» - ждали коллизий 30 с).
+ _observer = GameManager.Instance.AddChunkObserver(center, true, GameUtils.GetViewDistance(), -1);
+ _stage = Stage.Loading;
+ _deadline = Time.time + 30f;
+ Active = true;
+ _player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 1f, 0.6f);
+ _player.SetFirstPersonView(false, true);
+ _ragdollAt = Time.time + SnakeMode.RagdollDelay;
+ _waitStandUp = false;
+ Debug.Log("[NecromancerTome] MindPalace: вход, чертоги в " + _origin);
+ }
+
+ public static void Exit(EntityPlayerLocal _player)
+ {
+ if (!Active)
+ {
+ return;
+ }
+ Active = false;
+ _stage = Stage.None;
+ MindControl.End(_player);
+ RemoveObserver();
+ _player?.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1f);
+ if (_player == null || _player.IsDead())
+ {
+ return;
+ }
+ _ragdollAt = 0f;
+ _player.emodel.ragdollTime = _player.emodel.ragdollDuration;
+ _waitStandUp = true;
+ _standUpDeadline = Time.time + 5f;
+ Debug.Log("[NecromancerTome] MindPalace: выход");
+ }
+
+ static void RemoveObserver()
+ {
+ if (_observer != null && GameManager.Instance?.World != null)
+ {
+ GameManager.Instance.RemoveChunkObserver(_observer);
+ }
+ _observer = null;
+ }
+
+ static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
+ {
+ World world = GameManager.Instance?.World;
+ if (world == null)
+ {
+ Active = false;
+ _stage = Stage.None;
+ _observer = null;
+ MindControl.End(null);
+ _slotsFor = null;
+ return;
+ }
+ EntityPlayerLocal player = world.GetPrimaryPlayer();
+ Guard(world);
+ if (!Active)
+ {
+ if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline))
+ {
+ _waitStandUp = false;
+ player.SetFirstPersonView(true, true);
+ }
+ return;
+ }
+ if (player == null)
+ {
+ return;
+ }
+ if (player.IsDead())
+ {
+ Debug.Log("[NecromancerTome] MindPalace: тело убито");
+ Exit(player);
+ return;
+ }
+ if (!SnakeMode.IsNight())
+ {
+ Debug.Log("[NecromancerTome] MindPalace: наступило утро - обратно в тело");
+ Exit(player);
+ return;
+ }
+ Body(player);
+ switch (_stage)
+ {
+ case Stage.Loading:
+ if (ChunksLoaded(world, _origin, Size))
+ {
+ var report = new List();
+ int bad = Mismatches(world, _origin, report);
+ if (bad != 0)
+ {
+ Debug.Log("[NecromancerTome] MindPalace: расхождений с префабом " + bad + " - ставим заново");
+ for (int i = 0; i < report.Count && i < 10; i++)
+ {
+ Debug.Log("[NecromancerTome] MindPalace: " + report[i]);
+ }
+ Stamp(world, _origin);
+ }
+ _stage = Stage.Settling;
+ _settleUntil = Time.time + 1.5f;
+ }
+ else if (Time.time > _deadline)
+ {
+ Debug.LogWarning("[NecromancerTome] MindPalace: участки чертогов не загрузились");
+ Exit(player);
+ }
+ break;
+ case Stage.Settling:
+ Vector3 spawn = _origin.ToVector3() + SpawnAt;
+ // Коллизии готовы - или прошло 5 с: тогда всё равно пускаем, от провала страхует
+ // MindControl (возврат на точку появления).
+ if (Time.time > _settleUntil && (world.IsChunkAreaCollidersLoaded(spawn) || Time.time > _settleUntil + 5f))
+ {
+ Debug.Log("[NecromancerTome] MindPalace: коллизии " + (world.IsChunkAreaCollidersLoaded(spawn) ? "готовы" : "НЕ готовы, пускаем по таймеру"));
+ _stage = Stage.Ready;
+ OpenDoors(world, _origin);
+ LogCell(world, _origin + KnifeAt, "нож");
+ LogCell(world, _origin + BookAt, "книга");
+ MindControl.Begin(player, spawn, SpawnYaw);
+ player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1.5f);
+ }
+ else if (Time.time > _deadline)
+ {
+ Debug.LogWarning("[NecromancerTome] MindPalace: чертоги не построились вовремя");
+ Exit(player);
+ }
+ break;
+ case Stage.Ready:
+ MindControl.Update(player);
+ break;
+ }
+ // Выход по R - когда тело уже лежит и не открыто окно (как у змеи).
+ PlayerActionsLocal input = player.playerInput;
+ bool gui = player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen();
+ if (_ragdollAt <= 0f && player.emodel.IsRagdollActive && input != null && !gui &&
+ (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
+ {
+ Exit(player);
+ }
+ }
+
+ /// Тело падает и лежит - как в режиме змеи (SnakeMode.OnUnityUpdate).
+ static void Body(EntityPlayerLocal _player)
+ {
+ if (_ragdollAt > 0f)
+ {
+ if (Time.time >= _ragdollAt)
+ {
+ _ragdollAt = 0f;
+ _player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
+ }
+ }
+ else if (!_player.emodel.IsRagdollActive)
+ {
+ _player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
+ }
+ }
+
+ /// Раз в секунду: всё живое в коробке чертогов - вон. Дух сущности не имеет.
+ static void Guard(World _world)
+ {
+ _guardCheck -= Time.deltaTime;
+ if (_guardCheck > 0f || _world.IsRemote())
+ {
+ return;
+ }
+ _guardCheck = 1f;
+ LoadSlots();
+ if (_slots.Count == 0)
+ {
+ return;
+ }
+ // Игроков в EntityAlives нет (World.SpawnEntityInWorld кладёт туда только не-игроков) - их
+ // отдельно, по Players.list (тест 03.10: камень портала привёл в чертоги, охрана молчала).
+ for (int i = _world.Players.list.Count - 1; i >= 0; i--)
+ {
+ EntityPlayer pl = _world.Players.list[i];
+ if (pl == null || pl.IsDead() || !InAnyPalace(World.worldToBlockPos(pl.position)))
+ {
+ continue;
+ }
+ Vector3i b = World.worldToBlockPos(pl.position);
+ Vector3 up = new Vector3(pl.position.x, _world.GetTerrainHeight(b.x, b.z) + 2f, pl.position.z);
+ Debug.Log("[NecromancerTome] MindPalace: игрок " + pl.entityId + " в чертогах - выброшен на " + up);
+ if (pl is EntityPlayerLocal)
+ {
+ // Как камень портала (PortalStonePatch.TeleportToBedroll).
+ NetPackageManager.GetPackage().Setup(up, null).ProcessPackage(_world, GameManager.Instance);
+ }
+ else
+ {
+ pl.Teleport(up);
+ }
+ }
+ for (int i = _world.EntityAlives.Count - 1; i >= 0; i--)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null || e.IsDead() || !InAnyPalace(World.worldToBlockPos(e.position)))
+ {
+ continue;
+ }
+ EntityAlive snake = SnakeMode.Snake(_world);
+ if (snake != null && e == snake)
+ {
+ Debug.Log("[NecromancerTome] MindPalace: змея в чертогах - развеяна");
+ SnakeMode.Exit(_world.GetPrimaryPlayer());
+ }
+ else
+ {
+ Debug.Log("[NecromancerTome] MindPalace: " + e.EntityName + " в чертогах - удалён");
+ _world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
+ }
+ }
+ }
+ }
+
+ /// В чертогах и в рамке камня вокруг ничего не падает (указание 03.10: «почему не
+ /// отключить просчёт физики в той области»): World.AddFallingBlock - единственная очередь
+ /// падающих блоков (туда же игра сама не пускает блоки со StabilityIgnore). Тест гравием
+ /// 03.10: после постановки нижний/верхний ряд внутренней стены всё равно обрушивался.
+ [HarmonyPatch(typeof(World), nameof(World.AddFallingBlock))]
+ public static class Patch_World_AddFallingBlock_MindPalace
+ {
+ public static bool Prefix(Vector3i _blockPos)
+ {
+ return !MindPalace.InAnyPalace(_blockPos, MindPalace.LooseMargin);
+ }
+ }
+
+ /// Чертоги - «зона торговца»: блоки не ломаются ударом и взрывом, строить нельзя
+ /// (ItemActionAttack, Explosion, Block.DamageBlock проверяют IsWithinTraderArea).
+ [HarmonyPatch(typeof(World), nameof(World.IsWithinTraderArea), new[] { typeof(Vector3i) })]
+ public static class Patch_World_IsWithinTraderArea_MindPalace
+ {
+ public static void Postfix(Vector3i _worldBlockPos, ref bool __result)
+ {
+ if (!__result && MindPalace.InAnyPalace(_worldBlockPos))
+ {
+ __result = true;
+ }
+ }
+ }
+
+ ///
+ /// necropalace - отладка чертогов: where (где мои), rebuild (поставить заново, если участки
+ /// загружены), knife N (поворот ножа 0-23 и переставить его).
+ ///
+ [Preserve]
+ public class ConsoleCmdNecroPalace : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necropalace" };
+ public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N";
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ World world = GameManager.Instance.World;
+ EntityPlayerLocal player = world?.GetPrimaryPlayer();
+ if (player == null)
+ {
+ return;
+ }
+ Vector3i o = Traverse.Create(typeof(MindPalace)).Method("GetSlot", world, (EntityPlayer)player).GetValue();
+ string cmd = _params.Count > 0 ? _params[0] : "where";
+ if (cmd == "knife" && _params.Count > 1 && int.TryParse(_params[1], out int r))
+ {
+ MindPalace.KnifeRotation = Mathf.Clamp(r, 0, 3);
+ BlockValue k = Block.GetBlockValue(MindPalace.KnifeBlock);
+ k.rotation = (byte)MindPalace.KnifeRotation;
+ world.SetBlockRPC(o + MindPalace.KnifeAt, k, MarchingCubes.DensityAir);
+ SdtdConsole.Instance.Output("нож: поворот " + MindPalace.KnifeRotation);
+ }
+ else if (cmd == "scan")
+ {
+ var report = new List();
+ int n = MindPalace.Mismatches(world, o, report);
+ SdtdConsole.Instance.Output("расхождений с префабом: " + n);
+ foreach (string line in report)
+ {
+ SdtdConsole.Instance.Output(line);
+ Debug.Log("[NecromancerTome] necropalace scan: " + line);
+ }
+ }
+ else if (cmd == "test")
+ {
+ GameManager.Instance.StartCoroutine(MindPalace.StressTest(world, o));
+ SdtdConsole.Instance.Output("проверка: заливка гравием, через 2 с - постановка (итог - в логе)");
+ }
+ else if (cmd == "rebuild")
+ {
+ MindPalace.Stamp(world, o);
+ SdtdConsole.Instance.Output("чертоги поставлены заново в " + o);
+ }
+ else
+ {
+ SdtdConsole.Instance.Output("чертоги: " + o + ", построены: " + MindPalace.IsBuilt(world, o));
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/ModEntry.cs b/HarmonySrc/ModEntry.cs
index 5225d53..b9cc914 100644
--- a/HarmonySrc/ModEntry.cs
+++ b/HarmonySrc/ModEntry.cs
@@ -61,6 +61,10 @@ namespace NecromancerTome
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
GhostTraderPatch.Init();
SnakeMode.Init();
+ MindPalace.Init();
+ HandPose.Init();
+ NecroMailbox.Init();
+ Possession.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/NecroFleeSnakeTask.cs b/HarmonySrc/NecroFleeSnakeTask.cs
index ee98454..b951cf0 100644
--- a/HarmonySrc/NecroFleeSnakeTask.cs
+++ b/HarmonySrc/NecroFleeSnakeTask.cs
@@ -37,6 +37,15 @@ namespace NecromancerTome
public static Vector3 SnakePos;
public float Until;
+ /// Своя угроза вместо змеи (хват «отрицание», 03.10): бежать от этой точки.
+ public bool HasThreat;
+ public Vector3 Threat;
+ public float Speed = FleeSpeed;
+ /// Кого забыть, когда бегство кончится (хват «отрицание»: «должен терять меня как цель»).
+ /// Он же угроза: бежать от его ТЕКУЩЕГО места.
+ public EntityAlive Forget;
+ /// Больше нуля - бежать, пока Forget ближе этого (и не дольше Until).
+ public float FarDistance;
Vector3 targetPos;
int retargetTicks;
@@ -49,7 +58,7 @@ namespace NecromancerTome
public override bool CanExecute()
{
- return Time.time < Until && FindFleePos();
+ return Time.time < Until && !FarEnough() && FindFleePos();
}
/// Сколько криков тревоги уже было в этом кадре: у толпы кричат не все.
@@ -78,13 +87,18 @@ namespace NecromancerTome
public override void Reset()
{
+ if (Forget != null)
+ {
+ ForgetNow(theEntity, Forget);
+ Forget = null;
+ }
theEntity.moveHelper.Stop();
theEntity.SetLookPosition(Vector3.zero);
}
public override bool Continue()
{
- return Time.time < Until && !theEntity.IsDead();
+ return Time.time < Until && !theEntity.IsDead() && !FarEnough();
}
float _diagTime;
@@ -104,7 +118,7 @@ namespace NecromancerTome
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"));
+ " скор " + Speed.ToString("F2"));
}
if (--retargetTicks > 0 || PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId))
{
@@ -113,7 +127,7 @@ namespace NecromancerTome
retargetTicks = RetargetTicks;
var sw = System.Diagnostics.Stopwatch.StartNew();
FindFleePos();
- theEntity.FindPath(targetPos, FleeSpeed, true, this);
+ theEntity.FindPath(targetPos, Speed, true, this);
if (sw.Elapsed.TotalMilliseconds > 3.0)
{
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: долгий перерасчёт у " + theEntity.entityId + " - " + sw.Elapsed.TotalMilliseconds.ToString("F1") + " мс");
@@ -122,7 +136,11 @@ namespace NecromancerTome
bool FindFleePos()
{
- Vector3 away = theEntity.position - SnakePos;
+ if (Forget != null && FarDistance > 0f)
+ {
+ Threat = Forget.position;
+ }
+ Vector3 away = theEntity.position - (HasThreat ? Threat : SnakePos);
away.y = 0f;
if (away.sqrMagnitude < 0.01f)
{
@@ -166,9 +184,58 @@ namespace NecromancerTome
_e.aiManager.tasks.AddTask(Priority, task);
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " в ужасе");
}
+ task.HasThreat = false;
+ task.Speed = FleeSpeed;
+ task.Forget = null;
+ task.FarDistance = 0f;
task.Until = Time.time + FearSeconds;
}
+ /// Хват «отрицание» (03.10): напугать зомби жестом игрока - бежит от игрока быстрее обычного
+ /// _seconds секунд, сразу и в конце бегства теряет игрока как цель.
+ bool FarEnough()
+ {
+ return FarDistance > 0f && Forget != null && (theEntity.position - Forget.position).sqrMagnitude > FarDistance * FarDistance;
+ }
+
+ public static void ScareFrom(EntityAlive _e, EntityAlive _threat, float _seconds, float _speed, float _farDistance = 0f)
+ {
+ if (_e.aiManager == null)
+ {
+ return;
+ }
+ _e.ConditionalTriggerSleeperWakeUp();
+ NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask();
+ if (task == null)
+ {
+ task = new NecroFleeSnakeTask();
+ task.Init(_e);
+ _e.aiManager.tasks.AddTask(Priority, task);
+ }
+ task.HasThreat = true;
+ task.Threat = _threat.position;
+ task.Speed = _speed;
+ task.Forget = _threat;
+ task.FarDistance = _farDistance;
+ task.Until = Time.time + _seconds;
+ ForgetNow(_e, _threat);
+ // Сетка поиска пути строится вокруг игроков - здесь она есть; но зомби может быть на краю.
+ AstarManager.Instance?.AddLocation(_e.position, 40);
+ Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " отрицание - бежит до " + _farDistance + " м (не дольше " + _seconds + " с), скор " + _speed);
+ }
+
+ public static void ForgetNow(EntityAlive _e, EntityAlive _who)
+ {
+ if (_e.GetAttackTarget() == _who)
+ {
+ _e.SetAttackTarget(null, 0);
+ }
+ if (_e.GetRevengeTarget() == _who)
+ {
+ _e.SetRevengeTarget(null);
+ }
+ }
+
public override string ToString()
{
return base.ToString() + ", flee to " + targetPos + ", " + (Until - Time.time).ToString("F1") + " s";
diff --git a/HarmonySrc/NecroMailbox.cs b/HarmonySrc/NecroMailbox.cs
new file mode 100644
index 0000000..1a477ea
--- /dev/null
+++ b/HarmonySrc/NecroMailbox.cs
@@ -0,0 +1,91 @@
+using System.Collections.Generic;
+using System.IO;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// Почтовый ящик консоли - только для разработки (03.10: ассистент подбирает позу рук сам, без
+ /// клавиатуры). Работает, лишь если есть папка %APPDATA%\7DaysToDie\NecroMailbox - у игроков её
+ /// нет. Раз в полсекунды: команды из cmd.txt (по строке) выполняются в консоли игры, как набранные
+ /// вручную, файл удаляется, ответ - в лог строками «[NecromancerTome] mailbox:».
+ ///
+ public static class NecroMailbox
+ {
+ public static string Dir => Path.Combine(GameIO.GetUserGameDataDir(), "NecroMailbox");
+ static string CmdFile => Path.Combine(Dir, "cmd.txt");
+ static float _next;
+
+ public static void Init()
+ {
+ ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
+ }
+
+ static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
+ {
+ if (Time.unscaledTime < _next)
+ {
+ return;
+ }
+ _next = Time.unscaledTime + 0.5f;
+ if (!File.Exists(CmdFile) || SdtdConsole.Instance == null)
+ {
+ return;
+ }
+ string[] lines;
+ try
+ {
+ lines = File.ReadAllLines(CmdFile);
+ File.Delete(CmdFile);
+ }
+ catch (IOException)
+ {
+ return; // файл ещё пишется - в следующий раз
+ }
+ foreach (string raw in lines)
+ {
+ string line = raw.Trim();
+ if (line.Length == 0)
+ {
+ continue;
+ }
+ Debug.Log("[NecromancerTome] mailbox: > " + line);
+ try
+ {
+ List res = SdtdConsole.Instance.ExecuteSync(line, null);
+ if (res != null)
+ {
+ foreach (string r in res)
+ {
+ Debug.Log("[NecromancerTome] mailbox: " + r);
+ }
+ }
+ }
+ catch (System.Exception ex)
+ {
+ Debug.LogWarning("[NecromancerTome] mailbox: ошибка - " + ex);
+ }
+ }
+ }
+ }
+
+ /// necroshot [имя] - снимок экрана игры в %APPDATA%\7DaysToDie\NecroMailbox\shots\имя.png
+ /// (как F9, только в известное место). Файл появляется в конце кадра.
+ [Preserve]
+ public class ConsoleCmdNecroShot : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necroshot" };
+ public override string getDescription() => "NecromancerTome: снимок экрана в NecroMailbox/shots/<имя>.png";
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ string dir = Path.Combine(NecroMailbox.Dir, "shots");
+ Directory.CreateDirectory(dir);
+ string name = _params.Count > 0 ? _params[0] : System.DateTime.Now.ToString("HHmmss");
+ string path = Path.Combine(dir, name + ".png");
+ ScreenCapture.CaptureScreenshot(path);
+ SdtdConsole.Instance.Output("снимок: " + path);
+ }
+ }
+}
diff --git a/HarmonySrc/NecroTome.cs b/HarmonySrc/NecroTome.cs
new file mode 100644
index 0000000..11970d7
--- /dev/null
+++ b/HarmonySrc/NecroTome.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»). Окно necroTome
+ /// (Config/XUi_InGame/windows.xml): оглавление - кнопки глав (до восьми), глава - страницы
+ /// с «Назад/Далее», «Оглавление» возвращает к списку, «Закрыть» и Esc закрывают.
+ ///
+ /// СОДЕРЖАНИЕ - список Chapters ниже: ключ названия, ключи страниц (Localization.csv,
+ /// necroTome*), условие открытия. Закрытая глава в оглавлении не показывается. Условия брать
+ /// из того, что игра уже хранит (уровень некромантии, рецепты, испытания), своё хранение - только
+ /// для чистых событий, по cvar на событие.
+ ///
+ public static class NecroTomeBook
+ {
+ public const string WindowGroup = "necroTome";
+
+ public class Chapter
+ {
+ public string TitleKey;
+ public string[] PageKeys;
+ public Func Unlocked;
+ }
+
+ public static readonly List Chapters = new List
+ {
+ // Глава 1 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
+ new Chapter
+ {
+ TitleKey = "necroTomeCh1Title",
+ PageKeys = new[] { "necroTomeCh1P1", "necroTomeCh1P2", "necroTomeCh1P3", "necroTomeCh1P4" },
+ Unlocked = _p => true
+ },
+ };
+
+ public static void Open(EntityPlayerLocal _player)
+ {
+ LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
+ ui?.windowManager.Open(WindowGroup, true);
+ }
+ }
+
+ [Preserve]
+ public class XUiC_NecroTome : XUiController
+ {
+ const int MaxButtons = 8;
+
+ XUiV_Label _title, _text, _page;
+ XUiC_SimpleButton _toc, _prev, _next, _close;
+ readonly XUiC_SimpleButton[] _chapterButtons = new XUiC_SimpleButton[MaxButtons];
+ /// Глава по кнопке оглавления (только открытые главы, по порядку).
+ readonly int[] _buttonChapter = new int[MaxButtons];
+ int _chapter = -1, _pageIndex;
+
+ public override void Init()
+ {
+ base.Init();
+ _title = GetChildById("lblTitle")?.ViewComponent as XUiV_Label;
+ _text = GetChildById("lblText")?.ViewComponent as XUiV_Label;
+ _page = GetChildById("lblPage")?.ViewComponent as XUiV_Label;
+ _toc = GetChildById("btnToc") as XUiC_SimpleButton;
+ _prev = GetChildById("btnPrev") as XUiC_SimpleButton;
+ _next = GetChildById("btnNext") as XUiC_SimpleButton;
+ _close = GetChildById("btnClose") as XUiC_SimpleButton;
+ if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc();
+ if (_prev != null) _prev.OnPressed += (_s, _m) => Turn(-1);
+ if (_next != null) _next.OnPressed += (_s, _m) => Turn(1);
+ if (_close != null) _close.OnPressed += (_s, _m) => xui.playerUI.windowManager.Close(NecroTomeBook.WindowGroup);
+ for (int i = 0; i < MaxButtons; i++)
+ {
+ int idx = i;
+ _chapterButtons[i] = GetChildById("btnCh" + (i + 1)) as XUiC_SimpleButton;
+ if (_chapterButtons[i] != null)
+ {
+ _chapterButtons[i].OnPressed += (_s, _m) => ShowChapter(_buttonChapter[idx], 0);
+ }
+ }
+ }
+
+ public override void OnOpen()
+ {
+ base.OnOpen();
+ ShowToc();
+ }
+
+ void ShowToc()
+ {
+ _chapter = -1;
+ SetLabel(_title, Localization.Get("necroTomeToc"));
+ SetLabel(_text, "");
+ SetLabel(_page, "");
+ EntityPlayerLocal player = xui.playerUI.entityPlayer;
+ int b = 0;
+ for (int c = 0; c < NecroTomeBook.Chapters.Count && b < MaxButtons; c++)
+ {
+ NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
+ if (ch.Unlocked != null && !ch.Unlocked(player))
+ {
+ continue;
+ }
+ _buttonChapter[b] = c;
+ if (_chapterButtons[b] != null)
+ {
+ _chapterButtons[b].Text = Localization.Get(ch.TitleKey);
+ _chapterButtons[b].ViewComponent.IsVisible = true;
+ }
+ b++;
+ }
+ for (; b < MaxButtons; b++)
+ {
+ if (_chapterButtons[b] != null)
+ {
+ _chapterButtons[b].ViewComponent.IsVisible = false;
+ }
+ }
+ SetVisible(_toc, false);
+ SetVisible(_prev, false);
+ SetVisible(_next, false);
+ }
+
+ void ShowChapter(int _c, int _p)
+ {
+ if (_c < 0 || _c >= NecroTomeBook.Chapters.Count)
+ {
+ return;
+ }
+ NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[_c];
+ _chapter = _c;
+ _pageIndex = Mathf.Clamp(_p, 0, ch.PageKeys.Length - 1);
+ foreach (XUiC_SimpleButton btn in _chapterButtons)
+ {
+ SetVisible(btn, false);
+ }
+ SetLabel(_title, Localization.Get(ch.TitleKey));
+ SetLabel(_text, Localization.Get(ch.PageKeys[_pageIndex]));
+ SetLabel(_page, (_pageIndex + 1) + " / " + ch.PageKeys.Length);
+ SetVisible(_toc, true);
+ SetVisible(_prev, true);
+ SetVisible(_next, true);
+ if (_prev != null) _prev.Enabled = _pageIndex > 0;
+ if (_next != null) _next.Enabled = _pageIndex < ch.PageKeys.Length - 1;
+ }
+
+ void Turn(int _d)
+ {
+ if (_chapter >= 0)
+ {
+ ShowChapter(_chapter, _pageIndex + _d);
+ }
+ }
+
+ static void SetLabel(XUiV_Label _l, string _s)
+ {
+ if (_l != null)
+ {
+ _l.Text = _s;
+ }
+ }
+
+ static void SetVisible(XUiController _c, bool _v)
+ {
+ if (_c?.ViewComponent != null)
+ {
+ _c.ViewComponent.IsVisible = _v;
+ }
+ }
+ }
+
+ /// necrotome - открыть книгу (проверка окна без чертогов, в том числе через почтовый ящик).
+ [Preserve]
+ public class ConsoleCmdNecroTome : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necrotome" };
+ public override string getDescription() => "NecromancerTome: открыть книгу с оглавлением";
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
+ if (player != null)
+ {
+ NecroTomeBook.Open(player);
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/NecromancerHarmony.csproj b/HarmonySrc/NecromancerHarmony.csproj
index 56cdd2a..05267ba 100644
--- a/HarmonySrc/NecromancerHarmony.csproj
+++ b/HarmonySrc/NecromancerHarmony.csproj
@@ -28,6 +28,10 @@
..\..\..\7DaysToDie_Data\Managed\Unity.Addressables.dll
false
+
+ ..\..\..\7DaysToDie_Data\Managed\UnityEngine.ScreenCaptureModule.dll
+ false
+
..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll
false
diff --git a/HarmonySrc/NecromancyKillCreditPatch.cs b/HarmonySrc/NecromancyKillCreditPatch.cs
index 92d84a3..d77603e 100644
--- a/HarmonySrc/NecromancyKillCreditPatch.cs
+++ b/HarmonySrc/NecromancyKillCreditPatch.cs
@@ -153,6 +153,11 @@ namespace NecromancerTome
{
return;
}
+ // Одержимый зомби, пока в нём игрок, в некромантию не идёт (решение 03.10).
+ if (Possession.IsPossessed(killedEntity))
+ {
+ return;
+ }
float kills = AddKillsCVar(__instance);
SyncNecromancyLevel(__instance, kills);
diff --git a/HarmonySrc/Possession.cs b/HarmonySrc/Possession.cs
new file mode 100644
index 0000000..c2f1438
--- /dev/null
+++ b/HarmonySrc/Possession.cs
@@ -0,0 +1,688 @@
+using GamePath;
+using System.Collections.Generic;
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Одержимость - заклинание хвата «пиф-паф» (решения пользователя 03.10, BACKLOG «Одержимость»).
+ /// ЛКМ по зомби-гуманоиду (EntityZombie и наследники; не звери и не стервятники) - игрок перехватывает
+ /// управление им: бегает, прыгает, бьёт. Тело игрока не падает, а цепенеет и стоит (аниматор на паузе,
+ /// ввод снят) - его могут атаковать, это и есть цена. Любое время суток, и в кровавую луну.
+ /// Остальные зомби, увидев одержимого, бросаются на него, как на игрока, - можно танковать.
+ /// Выход - R, гибель одержимого или смерть тела. После выхода зомби пугается и убегает, как при
+ /// «отрицании». Убийства одержимым игроку не засчитываются (урон от его имени), его собственная
+ /// смерть - тоже (NecromancyKillCreditPatch).
+ ///
+ /// КАК ВЕДЁМ ЗОМБИ. Его ИИ выключен (EntityAlive.updateTasks пропущен), а движение - родное:
+ /// SetMoveForwardWithModifiers, как делает сама игра при DebugStopEnemiesMoving и EntityMoveHelper;
+ /// прыжок - Jumping + SetJumpDistance, как EntityMoveHelper.StartJump; удар - Attack(false/true),
+ /// как EAIApproachAndAttackTarget. Походка, анимация и урон - самого зомби.
+ ///
+ /// КАК СТОИТ ТЕЛО. PlayerMoveController.Update пропущен (и сам вызывает stopMoving, как у тела в
+ /// рэгдолле), вид - от третьего лица, аниматор модели на паузе. Камера - за головой зомби (постфикс
+ /// к vp_FPCamera.LateUpdate, как у змеи).
+ ///
+ /// ПРИЁМЫ ЗОМБИ (указание 03.10: «у копа есть плевок и подрыв - чтобы такие спеллы становились доступны»):
+ /// ПКМ - дальняя атака, если у зомби есть задача ИИ EAIRangedAttackTarget (Ранчер, Чак, коп, мутант):
+ /// её параметры берутся из задачи захваченного зомби, а шаги повторяют её Start/Update/Reset -
+ /// замах (StartAnimAction startAnimType+3000), отпуск (ContinueAnimAction +1), выстрел родным
+ /// оружием (UseHoldingItem itemType), пока держишь кнопку и не вышло attackDuration. Направление -
+ /// точка под прицелом камеры (SetLookPosition), как у ИИ - голова цели.
+ /// Пробел - прыжок на собственную дальность зомби (jumpMaxDistance из JumpMaxDistance): у паука 7-9 м,
+ /// как его EAILeap, у остальных их 1-4 м.
+ /// ПКМ у крикуньи - её крик (SoundAlert): все зомби в ScreamRange бросаются на неё, даже не видя (её
+ /// ванильный зов орды - часть разведки AIScoutHordeSpawner, напрямую не вызываем).
+ /// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько
+ /// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело.
+ ///
+ /// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время
+ /// одержимости; на выходе и при выходе из мира всё обнуляется.
+ ///
+ public static class Possession
+ {
+ public static bool Active;
+ public static EntityAlive Zombie;
+
+ /// Камера - позади и выше головы зомби.
+ public const float CamBack = 2.6f;
+ public const float CamUp = 0.6f;
+ /// Как далеко зомби замечают одержимого: зрение зомби - 30 м (entityclasses.xml SightRange).
+ public const float TauntRange = 30f;
+ public const float TauntPeriod = 1f;
+ public const int TauntTicks = 1200;
+ /// Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить
+ /// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора
+ /// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков
+ /// хватает, чтобы тело стояло на земле и зомби могли к нему подойти.
+ public const int BodyViewDim = 3;
+ static int _bodyViewDim = -1;
+ /// Полный радиус области игрока (до сжатия) - для систем, рисующих мир у одержимого
+ /// (SnakeAudioSwap: дальний ландшафт, Dynamic Mesh, детализация зданий). -1 - не сжата.
+ public static int FullViewDim => _bodyViewDim;
+ public const float ScreamRange = 50f;
+ public const float ScreamCooldown = 10f;
+ static float _nextScream;
+
+ static EntityPlayerLocal _player;
+ static float _yaw, _pitch;
+ /// Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») -
+ /// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера.
+ static float _bodyYaw;
+ public const float TurnSpeed = 540f;
+ static float _nextAttack;
+ static float _nextTaunt;
+ static ChunkManager.ChunkObserver _observer;
+ static readonly List _frozen = new List();
+ static readonly List _frozenSpeed = new List();
+ static bool _loggedCamera;
+ // Дальняя атака захваченного зомби (копия параметров его EAIRangedAttackTarget).
+ static bool _hasRanged;
+ static int _rItem, _rStartAnim;
+ static float _rRelease, _rDuration, _rCooldown;
+ static string _rSoundStart, _rSoundRelease;
+ enum RState { Idle, StartAnim, Release, Fire }
+ static RState _rState;
+ static float _rTime, _rNext;
+
+ static GameObject _hud;
+
+ public static void Init()
+ {
+ ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
+ if (_hud == null)
+ {
+ _hud = new GameObject("NecroPossessionHud");
+ Object.DontDestroyOnLoad(_hud);
+ _hud.AddComponent();
+ }
+ }
+
+ public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
+
+ /// Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).
+ public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
+
+ /// True - вселились (для звука «пиф-пафа»).
+ public static bool Enter(EntityPlayerLocal _player0, EntityAlive _zombie)
+ {
+ if (Active || SnakeMode.Active || MindPalace.Active || _player0 == null || _player0.IsDead() ||
+ _player0.AttachedToEntity != null || !CanPossess(_zombie) || _player0.world.IsRemote())
+ {
+ return false;
+ }
+ Active = true;
+ _player = _player0;
+ Zombie = _zombie;
+ _yaw = _player0.rotation.y;
+ _bodyYaw = _zombie.rotation.y;
+ _pitch = 0f;
+ _nextAttack = 0f;
+ _nextTaunt = 0f;
+ _nextScream = 0f;
+ _loggedCamera = false;
+
+ // Зомби - в руки игрока: цели, путь и шаги ИИ сброшены.
+ _zombie.SetAttackTarget(null, 0);
+ _zombie.SetRevengeTarget(null);
+ PathFinderThread.Instance.RemovePathsFor(_zombie.entityId);
+ _zombie.navigator?.clearPath();
+ _zombie.moveHelper?.Stop();
+ _zombie.ConditionalTriggerSleeperWakeUp();
+ _zombie.SetRotation(new Vector3(0f, _yaw, 0f));
+
+ // Тело цепенеет: вид от третьего лица, аниматоры модели на паузе.
+ _player0.SetFirstPersonView(false, true);
+ FreezeBody(_player0, true);
+ _observer = GameManager.Instance.AddChunkObserver(_zombie.position, true, GameUtils.GetViewDistance(), -1);
+ ChunkManager.ChunkObserver body = _player0.ChunkObserver;
+ if (body != null && body.viewDim > BodyViewDim)
+ {
+ _bodyViewDim = body.viewDim;
+ body.viewDim = BodyViewDim;
+ }
+ ReadRanged(_zombie);
+ Hint(_player0, _zombie);
+ Debug.Log("[NecromancerTome] Possession: вселение в " + _zombie.EntityName + " (" + _zombie.entityId + ")");
+ return true;
+ }
+
+ public static void Exit(string _why)
+ {
+ if (!Active)
+ {
+ return;
+ }
+ Active = false;
+ EntityAlive z = Zombie;
+ EntityPlayerLocal player = _player;
+ if (_rState != RState.Idle && z != null && !z.IsDead())
+ {
+ StopRanged(z);
+ }
+ _rState = RState.Idle;
+ _hasRanged = false;
+ Zombie = null;
+ _player = null;
+ Debug.Log("[NecromancerTome] Possession: выход - " + _why);
+ FreezeBody(player, false);
+ if (_bodyViewDim > 0 && player?.ChunkObserver != null)
+ {
+ player.ChunkObserver.viewDim = _bodyViewDim;
+ }
+ _bodyViewDim = -1;
+ if (player != null && !player.IsDead())
+ {
+ player.SetFirstPersonView(true, true);
+ }
+ if (_observer != null && GameManager.Instance?.World != null)
+ {
+ GameManager.Instance.RemoveChunkObserver(_observer);
+ }
+ _observer = null;
+ World world = GameManager.Instance?.World;
+ if (world == null || z == null)
+ {
+ return;
+ }
+ // Кто гнался за одержимым - отпускает его.
+ for (int i = 0; i < world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = world.EntityAlives[i];
+ if (e != null && e != z && e.GetAttackTarget() == z)
+ {
+ e.SetAttackTarget(null, 0);
+ }
+ }
+ // Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»).
+ if (!z.IsDead() && player != null)
+ {
+ z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
+ NecroFleeSnakeTask.ScareFrom(z, player, HandHolds.DenyMaxSeconds, HandHolds.DenySpeed, HandHolds.DenyFarDistance);
+ }
+ }
+
+ static void FreezeBody(EntityPlayerLocal _p, bool _freeze)
+ {
+ if (_freeze)
+ {
+ _frozen.Clear();
+ _frozenSpeed.Clear();
+ Transform model = _p?.emodel?.GetModelTransform();
+ if (model == null)
+ {
+ return;
+ }
+ foreach (Animator a in model.GetComponentsInChildren(true))
+ {
+ _frozen.Add(a);
+ _frozenSpeed.Add(a.speed);
+ a.speed = 0f;
+ }
+ return;
+ }
+ for (int i = 0; i < _frozen.Count; i++)
+ {
+ if (_frozen[i] != null)
+ {
+ _frozen[i].speed = _frozenSpeed[i];
+ }
+ }
+ _frozen.Clear();
+ _frozenSpeed.Clear();
+ }
+
+ static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
+ {
+ World world = GameManager.Instance?.World;
+ if (world == null)
+ {
+ if (Active || Zombie != null || _frozen.Count > 0)
+ {
+ Active = false;
+ Zombie = null;
+ _player = null;
+ _observer = null;
+ _bodyViewDim = -1;
+ _frozen.Clear();
+ _frozenSpeed.Clear();
+ }
+ return;
+ }
+ if (!Active)
+ {
+ return;
+ }
+ EntityAlive z = Zombie;
+ if (_player == null || _player.IsDead())
+ {
+ Exit("тело погибло");
+ return;
+ }
+ if (z == null || z.IsDead() || z.IsMarkedForUnload())
+ {
+ Exit("одержимый погиб");
+ return;
+ }
+ PlayerActionsLocal input = _player.playerInput;
+ bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
+ if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
+ {
+ Exit("R");
+ return;
+ }
+ Vector2 move = Vector2.zero;
+ bool run = false, jump = false, attack = false, ranged = false, rangedHeld = false, detonate = false;
+ if (input != null && !gui)
+ {
+ Look();
+ move = new Vector2(input.Move.X, input.Move.Y);
+ run = input.Run.IsPressed;
+ jump = input.Jump.WasPressed;
+ attack = input.Primary.WasPressed;
+ ranged = input.Secondary.WasPressed;
+ rangedHeld = input.Secondary.IsPressed;
+ // E на клавиатуре - в PlayerActionsPermanent.Activate; у PlayerActionsLocal.Activate только
+ // кнопка геймпада (тест 03.10: «подрыва нету»).
+ detonate = input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed;
+ }
+ if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
+ {
+ Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
+ cop.HandlePrimingDetonator();
+ }
+ Ranged(z, ranged, rangedHeld);
+
+ bool moving = move.sqrMagnitude > 0.01f;
+ float wantYaw = moving ? _yaw + Mathf.Atan2(move.x, move.y) * Mathf.Rad2Deg : _yaw;
+ _bodyYaw = Mathf.MoveTowardsAngle(_bodyYaw, wantYaw, TurnSpeed * Time.deltaTime);
+ z.SetRotation(new Vector3(0f, _bodyYaw, 0f));
+ z.rotation.x = -_pitch; // наклон головы - для луча удара
+ if (moving)
+ {
+ z.SetMoveForwardWithModifiers(run ? z.GetMoveSpeedAggro() : z.GetMoveSpeed(), 1f, 0f, false);
+ }
+ else
+ {
+ z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
+ }
+ if (jump && z.onGround && !z.Jumping)
+ {
+ z.Jumping = true;
+ z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f);
+ }
+ if (ranged && IsScreamer(z))
+ {
+ Scream(world, z);
+ }
+ if (attack && Time.time >= _nextAttack && z.Attack(false))
+ {
+ z.Attack(true);
+ _nextAttack = Time.time + Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
+ }
+ _observer?.SetPosition(z.position);
+ Taunt(world, z);
+ }
+
+ static void ReadRanged(EntityAlive _z)
+ {
+ _hasRanged = false;
+ _rState = RState.Idle;
+ _rNext = 0f;
+ List tasks = _z.aiManager?.GetTasks();
+ if (tasks == null || tasks.Count == 0)
+ {
+ return;
+ }
+ EAIRangedAttackTarget t = tasks[0];
+ _hasRanged = true;
+ _rItem = t.itemActionType;
+ _rStartAnim = t.startAnimType;
+ _rRelease = t.releaseDelay;
+ _rDuration = t.attackDuration > 0f ? t.attackDuration : 3f;
+ // Вдвое быстрее ИИ (тест 03.10: «перезаряд медленный»).
+ _rCooldown = Mathf.Max(1f, t.baseCooldown * 0.5f);
+ _rSoundStart = t.soundStartName;
+ _rSoundRelease = t.soundReleaseName;
+ Debug.Log("[NecromancerTome] Possession: дальняя атака - оружие " + _rItem + ", замах " + _rStartAnim + ", длит. " + _rDuration + " с, откат " + _rCooldown + " с");
+ }
+
+ /// Подсказка при вселении: что умеет этот зомби.
+ static void Hint(EntityPlayerLocal _p, EntityAlive _z)
+ {
+ string text = Localization.Get("necroPossessHintBase");
+ if (_hasRanged)
+ {
+ text += ", " + Localization.Get("necroPossessHintRanged");
+ }
+ if (IsScreamer(_z))
+ {
+ text += ", " + Localization.Get("necroPossessHintScream");
+ }
+ if (_z.jumpMaxDistance >= 5f)
+ {
+ text += ", " + Localization.Get("necroPossessHintLeap");
+ }
+ if (_z is EntityZombieCop)
+ {
+ text += ", " + Localization.Get("necroPossessHintDetonate");
+ }
+ GameManager.ShowTooltip(_p, text);
+ }
+
+ /// Точка под прицелом камеры (в мировых координатах), до 50 м.
+ static Vector3 AimPoint()
+ {
+ Transform cam = _player?.cameraTransform;
+ if (cam == null)
+ {
+ return Zombie.position + Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * 30f;
+ }
+ if (Physics.Raycast(cam.position, cam.forward, out RaycastHit hit, 50f, -5, QueryTriggerInteraction.Ignore) &&
+ hit.distance > CamBack + 0.5f)
+ {
+ return hit.point + Origin.position;
+ }
+ return cam.position + Origin.position + cam.forward * 50f;
+ }
+
+ /// ПКМ - дальняя атака зомби по шагам ванильной EAIRangedAttackTarget.
+ static void Ranged(EntityAlive _z, bool _pressed, bool _held)
+ {
+ if (!_hasRanged)
+ {
+ return;
+ }
+ float dt = Time.deltaTime;
+ switch (_rState)
+ {
+ case RState.Idle:
+ if (!_pressed)
+ {
+ return;
+ }
+ if (Time.time < _rNext || !_z.IsAttackValid())
+ {
+ Debug.Log("[NecromancerTome] Possession: дальняя атака не готова - откат " + Mathf.Max(0f, _rNext - Time.time).ToString("F1") + " с, атака " + (_z.IsAttackValid() ? "можно" : "нельзя"));
+ return;
+ }
+ RearmRanged(_z);
+ _z.emodel.avatarController.hitWeightMax = 0.5f;
+ _rTime = 0f;
+ if (!string.IsNullOrEmpty(_rSoundStart))
+ {
+ Audio.Manager.BroadcastPlay(_z, _rSoundStart);
+ }
+ if (_rStartAnim >= 0)
+ {
+ _z.StartAnimAction(_rStartAnim + 3000);
+ _rState = RState.StartAnim;
+ }
+ else
+ {
+ _rState = RState.Fire;
+ }
+ return;
+ case RState.StartAnim:
+ _z.SetLookPosition(AimPoint());
+ if (_z.GetAnimActionState() != AvatarController.ActionState.Ready)
+ {
+ return;
+ }
+ _z.ContinueAnimAction(_rStartAnim + 1 + 3000);
+ if (!string.IsNullOrEmpty(_rSoundRelease))
+ {
+ Audio.Manager.BroadcastPlay(_z, _rSoundRelease);
+ }
+ _rState = RState.Release;
+ _rTime = 0f;
+ return;
+ case RState.Release:
+ _z.SetLookPosition(AimPoint());
+ _rTime += dt;
+ if (_rTime >= _rRelease)
+ {
+ _rState = RState.Fire;
+ _rTime = 0f;
+ }
+ return;
+ case RState.Fire:
+ _z.SetLookPosition(AimPoint());
+ _rTime += dt;
+ _z.UseHoldingItem(_rItem, false);
+ // Доводим до конца по одному нажатию: у плевка сначала 1-3 предупреждения по 1.2 с
+ // (ItemActionVomit), и с отпущенной кнопкой он сбрасывался (тест 03.10: «если зажать
+ // ПКМ - плюёт»). Конец - оружие закончило или вышла длительность задачи.
+ if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration)
+ {
+ StopRanged(_z);
+ }
+ return;
+ }
+ }
+
+ /// Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
+ /// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
+ /// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).
+ static void RearmRanged(EntityAlive _z)
+ {
+ ItemInventoryData inv = _z.inventory?.holdingItemData;
+ if (inv == null || _rItem >= inv.actionData.Count)
+ {
+ return;
+ }
+ ItemValue iv = _z.inventory.holdingItemItemValue;
+ string before = "патронов " + (iv != null ? iv.Meta.ToString() : "?");
+ if (inv.actionData[_rItem] is ItemActionRanged.ItemActionDataRanged d && _z.inventory.holdingItem.Actions[_rItem] is ItemActionRanged ranged)
+ {
+ before += ", очередь " + d.curBurstCount + ", перезарядка " + d.isReloading;
+ ranged.ResetBurstShot(d);
+ d.bReleased = true;
+ d.bPressed = false;
+ d.isReloading = false;
+ d.isReloadRequested = false;
+ }
+ // Плевок: без лишних «позывов» - сразу последнее предупреждение (ItemActionVomit: 1-3 по 1.2 с).
+ if (inv.actionData[_rItem] is ItemActionVomit.ItemActionDataVomit dv && _z.inventory.holdingItem.Actions[_rItem] is ItemActionVomit vomit)
+ {
+ dv.numWarningsPlayed = Mathf.Max(0, Traverse.Create(vomit).Field("warningMax").GetValue() - 1);
+ dv.warningTime = 0f;
+ }
+ if (iv != null && iv.Meta < 1)
+ {
+ iv.Meta = 1;
+ }
+ Debug.Log("[NecromancerTome] Possession: дальняя атака - взвод (" + before + ")");
+ }
+
+ static void StopRanged(EntityAlive _z)
+ {
+ _z.ShowHoldingItem(false);
+ _z.UseHoldingItem(_rItem, true);
+ _z.StartAnimAction(9999);
+ _z.SetLookPosition(Vector3.zero);
+ _z.emodel.avatarController.hitWeightMax = 1f;
+ _rState = RState.Idle;
+ _rNext = Time.time + _rCooldown;
+ }
+
+ static bool IsScreamer(EntityAlive _z)
+ {
+ return EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c) && c.entityClassName.StartsWith("zombieScreamer");
+ }
+
+ /// Крик одержимой крикуньи: её звук тревоги и все зомби в ScreamRange - на неё (танкование).
+ static void Scream(World _world, EntityAlive _z)
+ {
+ if (Time.time < _nextScream)
+ {
+ return;
+ }
+ _nextScream = Time.time + ScreamCooldown;
+ string snd = _z.GetSoundAlert();
+ if (!string.IsNullOrEmpty(snd))
+ {
+ Audio.Manager.BroadcastPlay(_z, snd);
+ }
+ int n = 0;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
+ PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _z.position).sqrMagnitude > ScreamRange * ScreamRange)
+ {
+ continue;
+ }
+ e.ConditionalTriggerSleeperWakeUp();
+ AllowZombieTarget(e);
+ e.SetAttackTarget(_z, TauntTicks);
+ n++;
+ }
+ Debug.Log("[NecromancerTome] Possession: крик крикуньи - на зов идут " + n);
+ }
+
+ 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, 70f);
+ }
+
+ /// Раз в секунду: зомби, которые видят одержимого, бросаются на него (решение 03.10:
+ /// «зомби начинают считать этого зомби ещё одним игроком»). Ванильный подход к цели принимает
+ /// только свои классы (EntityPlayer...) - добавляем EntityZombie, как CharmPatch; это безопасно:
+ /// цель-зомби им ставим только мы (поиск цели EAISetNearestEntityAsTarget не трогаем).
+ static void Taunt(World _world, EntityAlive _z)
+ {
+ if (Time.time < _nextTaunt)
+ {
+ return;
+ }
+ _nextTaunt = Time.time + TauntPeriod;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.GetAttackTarget() == _z ||
+ e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null ||
+ (e.position - _z.position).sqrMagnitude > TauntRange * TauntRange || !e.CanSee(_z))
+ {
+ continue;
+ }
+ AllowZombieTarget(e);
+ e.SetAttackTarget(_z, TauntTicks);
+ }
+ }
+
+ static void AllowZombieTarget(EntityAlive _e)
+ {
+ List tasks = _e.aiManager.GetTasks();
+ if (tasks == null)
+ {
+ return;
+ }
+ foreach (EAIApproachAndAttackTarget t in tasks)
+ {
+ bool has = false;
+ foreach (EAIApproachAndAttackTarget.TargetClass c in t.targetClasses)
+ {
+ has |= c.type == typeof(EntityZombie);
+ }
+ if (!has)
+ {
+ t.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = typeof(EntityZombie), chaseTimeMax = 0f });
+ }
+ }
+ }
+
+ /// Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.
+ public static void PlaceCamera(Transform _camera)
+ {
+ EntityAlive z = Zombie;
+ if (z == null)
+ {
+ return;
+ }
+ Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null;
+ Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f;
+ Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
+ Vector3 eye = anchor + Vector3.up * CamUp - rot * Vector3.forward * CamBack;
+ Vector3 to = eye - anchor;
+ if (Physics.Raycast(anchor, to.normalized, out RaycastHit hit, to.magnitude + 0.2f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
+ {
+ eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f);
+ }
+ _camera.SetPositionAndRotation(eye, rot);
+ if (!_loggedCamera)
+ {
+ _loggedCamera = true;
+ Debug.Log("[NecromancerTome] Possession: камера за " + (head != null ? "головой " + head.name : "корнем (головы нет)"));
+ }
+ }
+ }
+
+ /// Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы.
+ [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))]
+ public static class Patch_EntityAlive_updateTasks_Possession
+ {
+ public static bool Prefix(EntityAlive __instance)
+ {
+ return !Possession.IsPossessed(__instance);
+ }
+ }
+
+ /// Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).
+ [HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
+ public static class Patch_PlayerMoveController_Update_Possession
+ {
+ public static bool Prefix(PlayerMoveController __instance)
+ {
+ if (!Possession.Active)
+ {
+ return true;
+ }
+ __instance.stopMoving();
+ return false;
+ }
+ }
+
+ /// Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
+ /// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
+ /// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.
+ public class PossessionHud : MonoBehaviour
+ {
+ const float Width = 420f, Height = 16f, Bottom = 150f;
+ static readonly Color Back = new Color(0f, 0f, 0f, 0.6f);
+ static readonly Color Fill = new Color(0.55f, 0.08f, 0.06f, 0.95f);
+ static GUIStyle _style;
+
+ void OnGUI()
+ {
+ EntityAlive z = Possession.Active ? Possession.Zombie : null;
+ if (z == null || Event.current.type != EventType.Repaint)
+ {
+ return;
+ }
+ float scale = Screen.height / 1080f;
+ float w = Width * scale, h = Height * scale;
+ Rect r = new Rect((Screen.width - w) / 2f, Screen.height - Bottom * scale - h, w, h);
+ float frac = Mathf.Clamp01(z.Health / (float)Mathf.Max(1, z.GetMaxHealth()));
+ Color prev = GUI.color;
+ GUI.color = Back;
+ GUI.DrawTexture(r, Texture2D.whiteTexture);
+ GUI.color = Fill;
+ GUI.DrawTexture(new Rect(r.x + 2f, r.y + 2f, (r.width - 4f) * frac, r.height - 4f), Texture2D.whiteTexture);
+ GUI.color = prev;
+ if (_style == null)
+ {
+ _style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.LowerCenter };
+ _style.normal.textColor = new Color(0.9f, 0.85f, 0.8f);
+ }
+ _style.fontSize = Mathf.RoundToInt(18f * scale);
+ string name = Localization.Get(z.EntityName);
+ GUI.Label(new Rect(r.x, r.y - 26f * scale, r.width, 24f * scale), name + " " + z.Health + " / " + z.GetMaxHealth(), _style);
+ }
+ }
+}
diff --git a/HarmonySrc/SnakeControl.cs b/HarmonySrc/SnakeControl.cs
index c9c6f27..6adfe0a 100644
--- a/HarmonySrc/SnakeControl.cs
+++ b/HarmonySrc/SnakeControl.cs
@@ -876,6 +876,11 @@ namespace NecromancerTome
{
public static bool Prefix(EntityPlayerLocal __instance)
{
+ if (MindControl.Controlling)
+ {
+ MindControl.PlaceCamera(__instance.cameraTransform);
+ return false;
+ }
if (!SnakeControl.Controlling)
{
return true;
@@ -923,6 +928,14 @@ namespace NecromancerTome
{
SnakeControl.PlaceCamera(__instance.transform);
}
+ else if (Possession.Active)
+ {
+ Possession.PlaceCamera(__instance.transform);
+ }
+ else if (MindControl.Controlling)
+ {
+ MindControl.PlaceCamera(__instance.transform);
+ }
}
}
@@ -1111,13 +1124,10 @@ namespace NecromancerTome
public static void Prefix(out Vector3 __state)
{
__state = Vector3.zero;
- if (!SnakeControl.Controlling)
- {
- return;
- }
World world = GameManager.Instance.World;
+ // И у одержимого зомби (03.10: «если одержимый отошёл подальше, новые не спавнятся возле него»).
+ EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(world) : Possession.Active ? Possession.Zombie : null;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
- EntityAlive snake = SnakeMode.Snake(world);
if (player == null || snake == null)
{
return;
@@ -1150,12 +1160,18 @@ namespace NecromancerTome
{
public static bool Prefix(EntityAlive __instance, ref int ___ticksNoPlayerAdjacent)
{
- if (!SnakeControl.Controlling)
+ EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(__instance.world as World) : Possession.Active ? Possession.Zombie : null;
+ if (snake == null)
{
return true;
}
- EntityAlive snake = SnakeMode.Snake(__instance.world as World);
- if (snake == null || snake == __instance || (__instance.position - snake.position).sqrMagnitude > 6400f)
+ // Одержимый сам не исчезает, как бы далеко от тела ни ушёл.
+ if (snake == __instance)
+ {
+ ___ticksNoPlayerAdjacent = 0;
+ return !Possession.IsPossessed(__instance);
+ }
+ if ((__instance.position - snake.position).sqrMagnitude > 6400f)
{
return true;
}
@@ -1172,7 +1188,7 @@ namespace NecromancerTome
{
public static bool Prefix(GUIWindow _w)
{
- return !(SnakeControl.Controlling && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
+ return !((SnakeControl.Controlling || MindControl.Controlling) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
}
}
@@ -1182,8 +1198,61 @@ namespace NecromancerTome
/// змеи. На время вызова переносим позицию игрока к змее.
public static class SnakeAudioSwap
{
+ /// Вложенность подмены: CheckLODs зовётся и изнутри WorldEnvironment.Update, где позиция уже
+ /// подменена. Подменяет и возвращает только внешний вызов, вложенные ничего не трогают.
+ static int _swapped, _swapFrame;
+
public static Vector3 Prefix()
{
+ if (_swapped > 0 && _swapFrame != Time.frameCount)
+ {
+ _swapped = 0; // подмена не вернулась (исключение посреди вызова) - не держим её дольше кадра
+ }
+ if (_swapped > 0)
+ {
+ _swapped++;
+ return Vector3.zero;
+ }
+ Vector3 body = PrefixOuter();
+ if (body != Vector3.zero)
+ {
+ _swapped = 1;
+ _swapFrame = Time.frameCount;
+ }
+ return body;
+ }
+
+ static Vector3 PrefixOuter()
+ {
+ if (MindControl.Controlling)
+ {
+ // Дух чертогов: звук, мир и компас - от него (MindControl.cs).
+ EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
+ if (p == null)
+ {
+ return Vector3.zero;
+ }
+ Vector3 bodyPos = p.position;
+ p.position = MindControl.FeetPos;
+ return bodyPos;
+ }
+ if (Possession.Active && Possession.Zombie != null)
+ {
+ // Одержимость (тест 03.10: «без звуков») - звуки мерятся от одержимого зомби, а не от тела.
+ EntityPlayerLocal pp = GameManager.Instance?.World?.GetPrimaryPlayer();
+ if (pp == null)
+ {
+ return Vector3.zero;
+ }
+ Vector3 bodyAt = pp.position;
+ pp.position = Possession.Zombie.position;
+ // Область тела сжата (Possession.BodyViewDim) - этим системам радиус полный, они рисуют у зомби.
+ if (Possession.FullViewDim > 0 && pp.ChunkObserver != null)
+ {
+ pp.ChunkObserver.viewDim = Possession.FullViewDim;
+ }
+ return bodyAt;
+ }
if (!SnakeControl.Controlling)
{
return Vector3.zero;
@@ -1204,16 +1273,44 @@ namespace NecromancerTome
{
if (_body == Vector3.zero)
{
+ if (_swapped > 1)
+ {
+ _swapped--;
+ }
return;
}
+ _swapped = 0;
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player != null)
{
player.position = _body;
+ if (Possession.Active && Possession.FullViewDim > 0 && player.ChunkObserver != null)
+ {
+ player.ChunkObserver.viewDim = Possession.BodyViewDim;
+ }
}
}
}
+ /// Полная модель блоков (двери, люки, мебель) ближе 41 м, дальше - упрощённая замена:
+ /// ChunkGameObject.CheckLODs меряет расстояние до ИГРОКА - тела. Его зовёт не только WorldEnvironment.Update
+ /// (там позиция уже подменена), но и сам чанк при появлении - тест 03.10: «змея и одержимый видят вместо
+ /// дверей деревянные блоки». Подменяем позицию и здесь: змея, одержимый, дух чертогов.
+ [HarmonyPatch(typeof(ChunkGameObject), nameof(ChunkGameObject.CheckLODs))]
+ public static class Patch_ChunkGameObject_CheckLODs_Snake
+ {
+ public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
+ public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
+ }
+
+ /// Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела).
+ [HarmonyPatch(typeof(PrefabLODManager), nameof(PrefabLODManager.FrameUpdate))]
+ public static class Patch_PrefabLODManager_FrameUpdate_Possession
+ {
+ public static void Prefix(out Vector3 __state) { __state = Possession.Active ? SnakeAudioSwap.Prefix() : Vector3.zero; }
+ public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
+ }
+
[HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Vector3), typeof(string), typeof(int), typeof(bool), typeof(float) })]
public static class Patch_AudioManager_PlayAt_Snake
{
diff --git a/HarmonySrc/SnakeMode.cs b/HarmonySrc/SnakeMode.cs
index 22b855d..9f53fcc 100644
--- a/HarmonySrc/SnakeMode.cs
+++ b/HarmonySrc/SnakeMode.cs
@@ -72,7 +72,7 @@ namespace NecromancerTome
public static void Enter(EntityPlayerLocal _player)
{
- if (Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
+ if (Active || MindPalace.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
{
return;
}
@@ -318,6 +318,10 @@ namespace NecromancerTome
{
Enter(_sender.xui.playerUI.entityPlayer);
}
+ else if (_commandIndex == 2)
+ {
+ MindPalace.Enter(_sender.xui.playerUI.entityPlayer);
+ }
}
}
@@ -326,7 +330,7 @@ namespace NecromancerTome
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
- if (!__result && !SnakeMode.Active && SnakeMode.IsKnife(__instance.item))
+ if (!__result && !SnakeMode.Active && !MindPalace.Active && SnakeMode.IsKnife(__instance.item))
{
__result = true;
}
@@ -336,6 +340,9 @@ namespace NecromancerTome
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_SnakeKnife
{
+ /// Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается).
+ public const string MindPalaceIcon = "ui_game_symbol_book";
+
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (!SnakeMode.IsKnife(__instance.item))
@@ -355,6 +362,17 @@ namespace NecromancerTome
_xuiRadialWindow.CreateRadialEntry(1, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false);
}
+ // Чертоги разума (указание 03.10: «вход тоже через нож») - третья строка, тоже только ночью.
+ if (SnakeMode.IsNight())
+ {
+ _xuiRadialWindow.CreateRadialEntry(2, MindPalaceIcon, "UIAtlas", "",
+ Localization.Get("necroMindModePalace"), _highlighted: false);
+ }
+ else
+ {
+ _xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "",
+ Localization.Get("necroSnakeModeDay"), _highlighted: false);
+ }
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0);
return false;
diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll
index 2357f81..33067cb 100644
Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ
diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb
index 6016616..0714b35 100644
Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ
diff --git a/PalacePrefab/chertogi.blocks.nim b/PalacePrefab/chertogi.blocks.nim
new file mode 100644
index 0000000..39e84a7
Binary files /dev/null and b/PalacePrefab/chertogi.blocks.nim differ
diff --git a/PalacePrefab/chertogi.ins b/PalacePrefab/chertogi.ins
new file mode 100644
index 0000000..6d85463
Binary files /dev/null and b/PalacePrefab/chertogi.ins differ
diff --git a/PalacePrefab/chertogi.tts b/PalacePrefab/chertogi.tts
new file mode 100644
index 0000000..ffd135b
Binary files /dev/null and b/PalacePrefab/chertogi.tts differ
diff --git a/PalacePrefab/chertogi.xml b/PalacePrefab/chertogi.xml
new file mode 100644
index 0000000..d9feaa6
--- /dev/null
+++ b/PalacePrefab/chertogi.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Resources/UnifrakturMaguntia-OFL.txt b/Resources/UnifrakturMaguntia-OFL.txt
new file mode 100644
index 0000000..cae7aa5
--- /dev/null
+++ b/Resources/UnifrakturMaguntia-OFL.txt
@@ -0,0 +1,94 @@
+Copyright (c) 2010, j. 'mach' wust, with Reserved Font Name UnifrakturMaguntia.
+Copyright (c) 2009, Peter Wiegel.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/Resources/necrobooks b/Resources/necrobooks
index 172f5fa..952ed4a 100644
Binary files a/Resources/necrobooks and b/Resources/necrobooks differ
diff --git a/Resources/necroknife b/Resources/necroknife
index 10b5741..29966f2 100644
Binary files a/Resources/necroknife and b/Resources/necroknife differ
diff --git a/Resources/necroposters b/Resources/necroposters
new file mode 100644
index 0000000..8bb39b5
Binary files /dev/null and b/Resources/necroposters differ
diff --git a/UIAtlases/ItemIconAtlas/RatBook.png b/UIAtlases/ItemIconAtlas/RatBook.png
index 8886122..ee0637f 100644
Binary files a/UIAtlases/ItemIconAtlas/RatBook.png and b/UIAtlases/ItemIconAtlas/RatBook.png differ
diff --git a/UIAtlases/ItemIconAtlas/SummonZombieBear.png b/UIAtlases/ItemIconAtlas/SummonZombieBear.png
index 57fb2c5..92d21f3 100644
Binary files a/UIAtlases/ItemIconAtlas/SummonZombieBear.png and b/UIAtlases/ItemIconAtlas/SummonZombieBear.png differ
diff --git a/UIAtlases/ItemIconAtlas/SummonZombieDog.png b/UIAtlases/ItemIconAtlas/SummonZombieDog.png
index 3397c8a..afa3c85 100644
Binary files a/UIAtlases/ItemIconAtlas/SummonZombieDog.png and b/UIAtlases/ItemIconAtlas/SummonZombieDog.png differ
diff --git a/UIAtlases/ItemIconAtlas/SummonZombieGriffin.png b/UIAtlases/ItemIconAtlas/SummonZombieGriffin.png
index b3162d6..2925505 100644
Binary files a/UIAtlases/ItemIconAtlas/SummonZombieGriffin.png and b/UIAtlases/ItemIconAtlas/SummonZombieGriffin.png differ
diff --git a/UIAtlases/ItemIconAtlas/SummonZombieWolf.png b/UIAtlases/ItemIconAtlas/SummonZombieWolf.png
index a39deca..2dd64b7 100644
Binary files a/UIAtlases/ItemIconAtlas/SummonZombieWolf.png and b/UIAtlases/ItemIconAtlas/SummonZombieWolf.png differ
diff --git a/UIAtlases/ItemIconAtlas/necroMemoryBook.png b/UIAtlases/ItemIconAtlas/necroMemoryBook.png
new file mode 100644
index 0000000..45966aa
Binary files /dev/null and b/UIAtlases/ItemIconAtlas/necroMemoryBook.png differ
diff --git a/UIAtlases/ItemIconAtlas/necroPosterAnatomy.png b/UIAtlases/ItemIconAtlas/necroPosterAnatomy.png
new file mode 100644
index 0000000..1eeabaa
Binary files /dev/null and b/UIAtlases/ItemIconAtlas/necroPosterAnatomy.png differ
diff --git a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieBear.png b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieBear.png
index 65ac1ff..72ed681 100644
Binary files a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieBear.png and b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieBear.png differ
diff --git a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieDog.png b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieDog.png
index 432ddce..7434caf 100644
Binary files a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieDog.png and b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieDog.png differ
diff --git a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieGriffin.png b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieGriffin.png
index 2724dcb..de133a7 100644
Binary files a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieGriffin.png and b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieGriffin.png differ
diff --git a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieWolf.png b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieWolf.png
index 95c9719..305ea3a 100644
Binary files a/UIAtlases/ItemIconAtlasGreyscale/SummonZombieWolf.png and b/UIAtlases/ItemIconAtlasGreyscale/SummonZombieWolf.png differ