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