Чертоги разума, хваты пустой руки, одержимость, книга с оглавлением

- Чертоги разума: дух в префабе chertogi под землёй, книга памяти, нож и плакат;
  кровать префаба - мебель necroPalaceBed (иначе становилась точкой возрождения);
  охрана выбрасывает и игроков (их нет в EntityAlives).
- Хваты пустой руки по R: «отрицание» и «пиф-паф» - поза рук костями поверх аниматора,
  остальные кости заморожены, ЛКМ - заклинание, прицел краснеет на зомби.
- «Отрицание»: зомби в ужасе бежит дальше 40 м и теряет игрока как цель.
- «Пиф-паф»: одержимость зомби-гуманоидом - бег, прыжок, удар, дальняя атака,
  подрыв, крик крикуньи; тело цепенеет, зомби танкуют одержимого; область
  вокруг тела сжата; полоска здоровья; спавн и прорисовка у одержимого.
- Книга с оглавлением (окно necroTome), глава «Некроэнергия».
- Прорисовка дверей у змеи и одержимого (CheckLODs от камеры, а не от тела).
- Почтовый ящик консоли и команды necrobones/necropose/necroshot для разработки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-04 00:10:05 +03:00
co-authored by Claude Opus 5.5
parent c828202c66
commit 66a0dcf403
43 changed files with 3798 additions and 22 deletions
+25
View File
@@ -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ı,图表:身体结构,圖表:身體結構
1 Key File Type UsedInMainMenu NoTranslate KeepLoaded english Context / Alternate Text german spanish french italian japanese koreana polish brazilian russian turkish schinese tchinese
189 necroSnakeModeSnake items Item Snake Schlange Serpiente Serpent Serpente 蛇 뱀 Wąż Cobra Змея Yılan 蛇 蛇
190 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 敌人正在接近你的身体 敵人正在接近你的身體
191 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 现在是白天 現在是白天
192 necroHoldNone items Item Bare hand Bloße Hand Mano desnuda Main nue Mano nuda 素手 맨손 Goła ręka Mão nua Обычная рука Çıplak el 空手 空手
193 necroHoldDeny items Item Denial Verleugnung Negación Déni Negazione 拒絶 거부 Zaprzeczenie Negação Отрицание İnkâr 否认 否認
194 necroHoldPifPaf items Item Bang-bang Peng-peng Pum-pum Pan-pan Bang-bang バンバン 빵야빵야 Pif-paf Pou-pou Пиф-паф Bang-bang 砰砰 砰砰
195 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 - 離開身體
196 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ı 右键 - 远程攻击 右鍵 - 遠程攻擊
197 necroPossessHintDetonate items Item E - detonate E - Sprengen E: detonar E : exploser E - detonare E: 起爆 E - 폭발 E - detonacja E - detonar E - подрыв E - patlat E - 引爆 E - 引爆
198 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) 右键 - 尖叫(召唤亡者) 右鍵 - 尖叫(召喚亡者)
199 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ış 空格 - 远跳 空白鍵 - 遠跳
200 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 蛇灵 蛇靈
201 necroGeoOssuary blocks Block Geo-Ossuary Geo-Ossarium Geo-osario Géo-ossuaire Geo-ossario ジオ納骨堂 지오 납골당 Geo-ossuarium Geo-ossário Гео-оссуарий Jeo-Kemiklik 地脉骨堂 地脈骨堂
202 necroGeoOssuaryDesc blocks Block An ossuary of bones and skulls that draws stone and ore up from the depths and feeds on blood. Put up to three resources into the mod slots: stone, coal, nitrate, iron or oil shale. The resource stays in its slot and decides what the matching output cell produces. Every 5-8 hours each output cell yields a full stack of its resource and drains a tenth of a Blood Sphere's durability: one sphere lasts ten stacks. Works anywhere, even underground. Repair with bones. A Blood Stone among the fuel runs it forever. Ein Beinhaus aus Knochen und Schädeln, das Stein und Erz aus der Tiefe holt und sich von Blut nährt. Lege bis zu drei Ressourcen in die Mod-Plätze: Stein, Kohle, Salpeter, Eisen oder Ölschiefer. Die Ressource bleibt im Platz und bestimmt, was die zugehörige Ausgabezelle herstellt. Alle 5-8 Stunden liefert jede Ausgabezelle einen vollen Stapel ihrer Ressource und verbraucht ein Zehntel der Haltbarkeit einer Blutsphäre: eine Sphäre reicht für zehn Stapel. Funktioniert überall, auch unter der Erde. Mit Knochen reparieren. Ein Blutstein im Brennstoff lässt es für immer laufen. Un osario de huesos y calaveras que extrae piedra y mineral de las profundidades y se alimenta de sangre. Coloca hasta tres recursos en las ranuras de mods: piedra, carbón, nitrato, hierro o esquisto bituminoso. El recurso permanece en su ranura y decide qué produce la celda de salida correspondiente. Cada 5-8 horas cada celda de salida produce una pila completa de su recurso y consume una décima parte de la durabilidad de una esfera de sangre: una esfera rinde diez pilas. Funciona en cualquier lugar, incluso bajo tierra. Se repara con huesos. Una Piedra de sangre entre el combustible lo hace funcionar para siempre. Un ossuaire d'os et de crânes qui tire la pierre et le minerai des profondeurs et se nourrit de sang. Placez jusqu'à trois ressources dans les emplacements de mods : pierre, charbon, nitrate, fer ou schiste bitumineux. La ressource reste dans son emplacement et détermine ce que produit la case de sortie correspondante. Toutes les 5 à 8 heures, chaque case de sortie produit une pile complète de sa ressource et consomme un dixième de la durabilité d'une sphère de sang : une sphère suffit pour dix piles. Fonctionne partout, même sous terre. Se répare avec des os. Une Pierre de sang parmi le combustible le fait fonctionner pour toujours. Un ossario di ossa e teschi che estrae pietra e minerale dalle profondità e si nutre di sangue. Inserisci fino a tre risorse negli slot dei mod: pietra, carbone, nitrato, ferro o scisto bituminoso. La risorsa resta nel suo slot e decide cosa produce la casella di uscita corrispondente. Ogni 5-8 ore ogni casella di uscita produce una pila completa della sua risorsa e consuma un decimo della durabilità di una sfera di sangue: una sfera basta per dieci pile. Funziona ovunque, anche sottoterra. Si ripara con le ossa. Una Pietra di sangue tra il combustibile lo fa funzionare per sempre. 骨と頭蓋骨でできた納骨堂。血を糧に、地の底から石と鉱石を引き上げる。 MODスロットに資源を3つまで入れる:石、石炭、硝石、鉄、オイルシェール。資源はスロットに残り、対応する出力マスで何を生み出すかを決める。 5~8時間ごとに各出力マスが資源1スタックを生み出し、血の球の耐久度を10分の1消費する。球1つで10スタック分。 どこでも動く。地下でも。 骨で修理する。 燃料に血の石を入れれば永遠に動き続ける。 뼈와 해골로 만든 납골당. 피를 먹고 땅속 깊은 곳에서 돌과 광석을 끌어올린다. 모드 슬롯에 자원을 최대 세 개까지 넣는다: 돌, 석탄, 질산염, 철, 오일 셰일. 자원은 슬롯에 남아 대응하는 출력 칸에서 무엇을 만들지 정한다. 5~8시간마다 각 출력 칸이 자원 한 묶음을 만들고 피의 구슬 내구도의 10분의 1을 소모한다. 구슬 하나로 열 묶음. 어디서든 작동한다. 땅속에서도. 뼈로 수리한다. 연료 칸에 피의 돌을 넣으면 영원히 작동한다. Ossuarium z kości i czaszek, które wyciąga kamień i rudę z głębin i żywi się krwią. Włóż do trzech surowców w sloty modów: kamień, węgiel, saletrę, żelazo lub łupek bitumiczny. Surowiec zostaje w slocie i decyduje, co wytwarza odpowiadające mu pole wyjściowe. Co 5-8 godzin każde pole wyjściowe daje pełny stos swojego surowca i zużywa jedną dziesiątą wytrzymałości krwawej sfery: jedna sfera starcza na dziesięć stosów. Działa wszędzie, nawet pod ziemią. Naprawiane kośćmi. Krwawy kamień wśród paliwa zasila je na zawsze. Um ossário de ossos e crânios que extrai pedra e minério das profundezas e se alimenta de sangue. Coloque até três recursos nos espaços de mods: pedra, carvão, nitrato, ferro ou xisto betuminoso. O recurso fica no espaço e decide o que a célula de saída correspondente produz. A cada 5-8 horas cada célula de saída produz uma pilha completa do seu recurso e gasta um décimo da durabilidade de uma esfera de sangue: uma esfera rende dez pilhas. Funciona em qualquer lugar, até debaixo da terra. Repara-se com ossos. Uma Pedra de sangue entre o combustível o mantém funcionando para sempre. Оссуарий из костей и черепов: питается кровью и вытягивает из недр камень и руду. В ячейки модификаций положите до трёх ресурсов: камень, уголь, селитру, железо или сланец. Ресурс остаётся в ячейке и задаёт, что производит соответствующая ячейка продукции. Раз в 5-8 часов каждая ячейка продукции выдаёт полный стак своего ресурса и тратит десятую часть прочности кровавой сферы: одной сферы хватает на десять стаков. Работает где угодно, даже под землёй. Чинится костями. Кровавый камень среди топлива питает его вечно. Kemik ve kafataslarından yapılmış, kanla beslenen ve derinlerden taş ile cevher çeken bir kemiklik. Mod yuvalarına en fazla üç kaynak koy: taş, kömür, nitrat, demir veya bitümlü şist. Kaynak yuvasında kalır ve karşılık gelen çıktı hücresinin ne üreteceğini belirler. Her 5-8 saatte bir her çıktı hücresi kendi kaynağından tam bir yığın üretir ve bir Kan Küresi'nin dayanıklılığının onda birini harcar: bir küre on yığın yeter. Her yerde çalışır, yer altında bile. Kemikle onarılır. Yakıtlar arasındaki bir Kan Taşı onu sonsuza dek çalıştırır. 由骨头与头骨筑成的骨堂,以血为食,从地底深处汲取石头与矿石。 在模组槽中放入最多三种资源:石头、煤、硝石、铁或油页岩。资源会留在槽中,并决定对应产出格产出什么。 每5-8小时,每个产出格产出一整组对应资源,并消耗血之球十分之一的耐久:一颗血之球可产出十组。 可在任何地方运作,包括地下。 用骨头修理。 燃料槽中放入血石即可永久运转。 由骨頭與頭骨築成的骨堂,以血為食,從地底深處汲取石頭與礦石。 在模組槽中放入最多三種資源:石頭、煤、硝石、鐵或油頁岩。資源會留在槽中,並決定對應產出格產出什麼。 每5-8小時,每個產出格產出一整組對應資源,並消耗血之球十分之一的耐久:一顆血之球可產出十組。 可在任何地方運作,包括地下。 用骨頭修理。 燃料槽中放入血石即可永久運轉。
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
+27
View File
@@ -116,6 +116,33 @@
<sprite name="blackout" depth="0" sprite="menu_empty" type="sliced" color="[black]" anchor_left="#cam,0,-10" anchor_right="#cam,1,10" anchor_bottom="#cam,0,-10" anchor_top="#cam,1,10" globalopacitymod="0"/>
</window>
</append>
<!-- Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»): оглавление кнопками,
глава по страницам, «Назад/Далее», «Оглавление», «Закрыть». Содержание и условия открытия глав -
в HarmonySrc/NecroTome.cs, тексты - Localization.csv (necroTome*). Открывает книга памяти в
чертогах (BlockNecroMemoryBook.Open) и консольная команда necrotome. -->
<append xpath="/windows">
<window name="necroTome" anchor="Center" pos="-650,420" width="1300" height="840" cursor_area="true" depth="1001" lock_navigation="true" controller="NecromancerTome.NecroTome, NecromancerHarmony">
<sprite depth="0" name="bg" sprite="menu_empty" color="14,10,10,250" type="sliced" />
<sprite depth="1" name="frame" sprite="menu_empty2px" color="120,18,14,255" type="sliced" fillcenter="false" />
<label depth="2" name="lblTitle" pos="0,-22" width="1300" height="50" justify="center" font_size="38" color="205,40,30,255" effect="outline" upper_case="false" />
<sprite depth="1" name="rule" pos="200,-82" width="900" height="2" sprite="menu_empty" color="120,18,14,255" />
<label depth="2" name="lblText" pos="60,-104" width="1180" height="640" justify="left" font_size="27" color="215,205,190,255" overflow="shrinkcontent" upper_case="false" />
<simplebutton name="btnCh1" depth="3" pos="350,-130" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh2" depth="3" pos="350,-190" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh3" depth="3" pos="350,-250" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh4" depth="3" pos="350,-310" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh5" depth="3" pos="350,-370" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh6" depth="3" pos="350,-430" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh7" depth="3" pos="350,-490" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh8" depth="3" pos="350,-550" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnToc" depth="3" pos="40,-790" width="260" height="46" font_size="26" caption_key="necroTomeToc" />
<simplebutton name="btnPrev" depth="3" pos="440,-790" width="160" height="46" font_size="26" caption_key="necroTomePrev" />
<label depth="2" name="lblPage" pos="600,-772" width="100" height="40" justify="center" font_size="24" color="160,150,140,255" />
<simplebutton name="btnNext" depth="3" pos="700,-790" width="160" height="46" font_size="26" caption_key="necroTomeNext" />
<simplebutton name="btnClose" depth="3" pos="1000,-790" width="260" height="46" font_size="26" caption_key="necroTomeClose" />
</window>
</append>
<!-- ФИОЛЕТОВАЯ ШКАЛА НЕКРОМАНТИИ, 2026-09-17. Продиктовано: "Есть индикатор опыта. Это шкала
до уровня. А мы сделаем ещё одну шкалу, которая заполняется каждые 20 зомби... Каждое
заполнение индикатора пусть добавляет уровень некромантии, а индикатор обнуляет."
+6
View File
@@ -42,4 +42,10 @@
<window name="necroFinalBlack" />
</window_group>
</append>
<!-- Книга с оглавлением (см. windows.xml, necroTome). -->
<append xpath="/xui">
<window_group name="necroTome">
<window name="necroTome" />
</window_group>
</append>
</xui>
+61
View File
@@ -232,4 +232,65 @@
</property>
</block>
</append>
<!-- «Книга памяти» (03.10.2026): единственный интерактивный предмет чертогов разума, по E - окно
«Память» (HarmonySrc/BlockNecroMemoryBook.cs). Вид - одиночная книга игры, без хранилища.
CreativeMode Dev - чтобы можно было поставить в редакторе префабов. -->
<append xpath="/blocks">
<block name="necroMemoryBook">
<property name="Extends" value="decoEntityClothMaster"/>
<property name="Class" value="NecromancerTome.NecroMemoryBook, NecromancerHarmony"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="necroMemoryBook"/> <!-- 03.10: свой рендер книги памяти -->
<!-- 03.10: своя модель - книга некроманта: чёрная кожа, кроваво-алый рисунок (z-mind), крупнее и толще книг призыва -->
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookMindPrefab.prefab"/>
<property name="ModelOffset" value="0,0,0"/>
<property name="GndAlign" value="1"/>
<property name="WaterFlow" value="permitted"/>
<property name="CanPickup" value="false"/>
</block>
</append>
<!-- Нож-декорация в чертогах разума (03.10.2026): копия ножа некроманта на столе с реактивами,
просто декорация, руны светятся (эмиссия материала ножа). Модель - свой бандл necroknife.
Поворот задаёт MindPalace.KnifeRotation (подбирается командой necropalace knife N). -->
<append xpath="/blocks">
<block name="necroKnifeDecor">
<property name="Extends" value="decoEntityClothMaster"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="NecromancersKnife"/> <!-- 03.10: иконка ножа-предмета (имя предмета иконкой не работает) -->
<!-- 03.10: своя модель-декорация - нож уже лежит плашмя, низом на дне клетки (как нижняя обложка
книги памяти). Модель ножа-предмета стоит вертикально (для руки), а блоки-модели в 3.x ставятся
«реквизитом», для которого код блока не вызывается - поэтому плашмя в самой модели, не в коде. -->
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroknife?necroKnifeDecorPrefab.prefab"/>
<property name="ModelOffset" value="0,0,0"/>
<property name="WaterFlow" value="permitted"/>
<property name="CanPickup" value="false"/>
</block>
</append>
<!-- Кровать чертогов (03.10.2026): копия bunkBedMadeWhite без Class="SleepingBag" - просто мебель.
Чертоги ставятся без игрока, и игра делала кровать префаба точкой возрождения: синий камень
портала отправлял в чертоги. MindPalace.Expected подменяет ею кровать префаба при постановке. -->
<append xpath="/blocks">
<block name="necroPalaceBed">
<property name="Extends" value="bunkBedMadeWhite" param1="Class,DescriptionKey,DowngradeBlock"/>
<property name="CreativeMode" value="None"/>
<property name="CanPickup" value="false"/>
</block>
</append>
<!-- Плакаты-схемы в чертогах (03.10.2026): по образцу ванильной pictureFrameGunSchematic, крупнее.
Блок 2×1×1, рамка-плакат 1.405×0.95 м (полотно 3:2 под плакат), тонкий багет 2 см; модель - бандл
necroposters (на каждый плакат свой префаб с тем же багетом). Исходник рамки:
BlenderWork/necroSchemeFramePrefab/build_frame.py. -->
<append xpath="/blocks">
<block name="necroPosterAnatomy">
<property name="Extends" value="pictureFrame_01a"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="necroPosterAnatomy"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroPosterAnatomyPrefab.prefab"/>
<property name="ModelOffset" value="0,0,-0.5"/>
<property name="MultiBlockDim" value="2,1,1"/>
<property name="CanPickup" value="false"/>
</block>
</append>
</config>
+6 -4
View File
@@ -896,9 +896,10 @@
<property name="DescriptionKey" value="bookSummonZombieBearDesc"/>
<property name="DisplayType" value="ammoGrenadeFire"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
<!-- Своя модель книги (03.10.2026): кожа тёмно-бурая, в тон шкуре зомбомедведя, светящийся фиолетовый рисунок из референса;
та же модель, что у книги крысы (бандл necrobooks). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookBearPrefab.prefab"/>
<property name="Material" value="Mpaper"/>
<property name="TintColor" value="160, 220, 40"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
@@ -952,9 +953,10 @@
<property name="DescriptionKey" value="bookSummonZombieWolfDesc"/>
<property name="DisplayType" value="ammoGrenadeFire"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
<!-- Своя модель книги (03.10.2026): кожа серая, в тон шкуре зомбоволка, светящийся фиолетовый рисунок из референса;
та же модель, что у книги крысы (бандл necrobooks). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookWolfPrefab.prefab"/>
<property name="Material" value="Mpaper"/>
<property name="TintColor" value="160, 220, 40"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
+200
View File
@@ -0,0 +1,200 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// «Книга памяти» в чертогах разума (указание 03.10): единственный предмет чертогов, с которым
/// можно взаимодействовать. При наведении - «Нажмите E, чтобы прочитать», по E - окно «Память»
/// (потом доделаем, как окна концовки в FinalSlides.cs). Блок подключается в blocks.xml через
/// Class="NecromancerTome.NecroMemoryBook, NecromancerHarmony": игра (ReflectionHelpers.
/// GetTypeWithPrefix) вставляет приставку Block перед именем после последней точки.
/// Вид - одиночная книга игры (bookInvisibleKillerPrefab, как cntBookPile09 на столе префаба
/// chertogi), но без хранилища и лута.
/// </summary>
[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;
}
/// <summary>Окно книги (оглавление, главы). Отдельным методом - его же позовёт дух в чертогах (своё E).</summary>
public static void Open(EntityPlayerLocal _player)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
if (ui == null)
{
return;
}
// 03.10: вместо окна-сообщения - книга с оглавлением (NecroTome.cs).
NecroTomeBook.Open(_player);
}
// ---------------------------------------------------------------- автор на обложке
/// <summary>Шрифт надписи - тот же готический, что у названия в текстуре (UnifrakturMaguntia, OFL).</summary>
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);
}
}
/// <summary>Ник игрока на обложке, как подпись на ящике: TextMesh поверх модели (модель общая,
/// а ник у каждого свой, в текстуру его не запечь). Чертоги у каждого свои - пишем ник своего игрока.</summary>
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<Font>(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<TextMesh>() ?? go.AddComponent<TextMesh>();
MeshRenderer mr = go.GetComponent<MeshRenderer>();
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<NecroAuthorFit>() ?? go.AddComponent<NecroAuthorFit>();
fit.enabled = true;
}
}
/// <summary>Подгонка надписи автора: высота строки AuthorHeight, длинный ник ужимается до AuthorMaxWidth.</summary>
public class NecroAuthorFit : MonoBehaviour
{
void LateUpdate()
{
MeshRenderer mr = GetComponent<MeshRenderer>();
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;
}
}
/// <summary>necroauthor [x y z] [h N] [w N] - подобрать место и размер ника на книге памяти на ходу.</summary>
[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<string> _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<NecroAuthorFit>(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));
}
}
}
+5 -2
View File
@@ -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, тест: «здоровье противника не снижалось»): то же самое, если атакующий -
/// одержимый зомби или цель - одержимый зомби (зомби бьют его, когда он их танкует).
/// </summary>
[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;
+44
View File
@@ -0,0 +1,44 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Хитбокс наших декораций (03.10: «Ты не добавляешь к декору хитбокс? А как удалять, если не туда
/// поставил?»). В префабах книги памяти, ножа-декора и плакатов на корне стоит BoxCollider, но игра
/// понимает, что луч попал в блок, только по игровому тегу на объекте с коллайдером. В нашем
/// Unity-проекте такого тега нет (AssetRipper знает его как unknown_20004) - при сборке он стал
/// Untagged. Поэтому при первой загрузке модели корню ставим тег и слой корня ванильной рамки
/// (postersFramedGunSchematicPrefab - у неё коллайдер тоже на корне). Префаб из бандла общий,
/// правка одна на все экземпляры. Тот же приём, что у оссуария (GeoOssuaryPatch.CopyTags).
/// </summary>
[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<string> done = new System.Collections.Generic.HashSet<string>();
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<Transform>(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<Collider>() != null ? "есть" : "НЕТ"));
}
}
}
+323
View File
@@ -0,0 +1,323 @@
using GUI_2;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Хваты пустой руки (03.10, BACKLOG «Заклинания с руки и жесты»): на R - меню из трёх строк:
/// обычная рука, «отрицание», «пиф-паф». Пустая рука - предмет meleeHandPlayer с DynamicMelee,
/// меню на R игра открывает ей так же, как ножу (PlayerMoveController: HasRadial).
///
/// Выбор НЕ сохраняется (указание 03.10: «после перезахода будет снова обычная рука») - просто поле
/// в памяти. Ни cvar, ни предмет не трогаем. На выходе из мира (нет мира или сменился игрок) всё
/// сбрасывается, и ссылки на кости, снимок заморозки и цель отпускаются (HandPose.ResetAll) - чтобы
/// за сессию ничего не копилось (указание: «не допустить утечку памяти на сохранение состояний»).
/// В хвате рука стоит позой (HandPose: кости после аниматора + заморозка остальных), а кнопки мыши
/// не бьют: ExecuteAction у DynamicMelee пустой руки перехвачен.
/// ЛКМ в «отрицании» - зомби под прицелом в леденящем ужасе: бежит от игрока с ускорением и теряет
/// его как цель (NecroFleeSnakeTask.ScareFrom). Звук - как у празднующего убийства Twitch.
/// ЛКМ в «пиф-пафе» - одержимость зомби-гуманоида под прицелом (Possession.cs).
/// ПКМ в хватах - ничего.
/// </summary>
public static class HandHolds
{
public const string HandItem = "meleeHandPlayer";
public const int None = 0, Deny = 1, PifPaf = 2;
/// <summary>Дальность и конус выбора зомби для «отрицания».</summary>
public const float DenyRange = 25f;
public const float DenyConeDeg = 12f;
/// <summary>Бежит, пока игрок не дальше этого: зрение зомби до 30 м, у Чака 40 (entityclasses.xml,
/// SightRange) - указание 03.10: «убегать он должен дальше 40 м, чтобы терять из прицела».</summary>
public const float DenyFarDistance = 40f;
/// <summary>Предел бегства на случай, если застрял и не может уйти на 40 м.</summary>
public const float DenyMaxSeconds = 25f;
/// <summary>Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»).</summary>
public const float DenySpeed = 2.2f;
public const float Cooldown = 0.6f;
public const string DenySound = "twitch_celebrate";
/// <summary>«Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок
/// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success).</summary>
public const string PifPafFailSound = "glassdestroy";
public const string PifPafPossessSound = "lockpick_success";
/// <summary>Выбранный хват - только на время сессии в мире.</summary>
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;
}
/// <summary>Из HandPoseDriver.LateUpdate - до HandPose.Apply. Позу трогаем только при смене
/// хвата: ручная настройка командой necropose держится до следующей смены.</summary>
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;
}
}
/// <summary>ЛКМ в хвате. True - нажатие наше, удар не выполнять.</summary>
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<TagGroup.Global> ZombieTag = FastTags<TagGroup.Global>.Parse("zombie");
static readonly FastTags<TagGroup.Global> FlyerTag = FastTags<TagGroup.Global>.Parse("vulture");
/// <summary>Только зомби (указание 03.10: «кабаны и змеи хоть и противник, но не зомби, кроме
/// зомбокабана»; «стервятники тоже зомби, обычных в игре нету») - метка zombie в entityclasses.xml:
/// люди-зомби, зомби-собаки, лютые волки, зомби-медведи, зомби-кабаны, стервятники.</summary>
public static bool IsZombie(EntityAlive _e)
{
return _e != null && _e.HasAnyTags(ZombieTag);
}
static int _targetFrame = -1;
static EntityAlive _target;
/// <summary>Зомби под прицелом (раз за кадр): и для заклинания, и для красного прицела.</summary>
public static EntityAlive Target(EntityPlayerLocal _player)
{
if (_targetFrame != Time.frameCount)
{
_targetFrame = Time.frameCount;
_target = FindTarget(_player.world, _player);
}
return _target;
}
/// <summary>Прямая видимость своим лучом: между глазом и точкой нет блоков и земли (сущности на луче
/// не мешают). Не EntityAlive.CanSee: у игрока нет ИИ, и его кэш видимости (seeCache) не обновляется -
/// тест 03.10: Арлин захватывалась, зомби-коп нет.</summary>
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);
}
/// <summary>Зомби ближе всех к линии прицела в конусе DenyConeDeg, не дальше DenyRange,
/// с прямой видимостью.</summary>
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;
}
}
/// <summary>Меню на R у пустой руки - как у ножа (Patch_ItemAction_HasRadial_SnakeKnife).</summary>
[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;
}
}
/// <summary>В хвате кнопки мыши не бьют: ЛКМ - заклинание хвата, ПКМ - ничего (03.10: «руки
/// деформируются, пытаясь совершить старую анимацию»).</summary>
[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;
}
}
/// <summary>Прицел краснеет, когда в нём зомби, в обоих хватах (указание 03.10). Цвет прицела игра
/// берёт из EntityPlayerLocal.crosshairColors[настройка] - на время рисования подменяем его красным.</summary>
[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;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.7.0). Свой хват -
/// это неподвижная поза рук от первого лица, как у браслета (кулак к противнику, без движения
/// при действии): кости рига ставятся в нужную комбинацию после аниматора. Сначала надо знать
/// кости: команда пишет в лог дерево рига рук от первого лица (EModelSDCS.baseRigFP, его создаёт
/// SDCSUtils.CreateVizFP под Camera/UFPSRoot) с локальными поворотами и позициями, и параметры
/// аниматора FPPlayerController (WeaponHoldType и прочие) с текущими значениями.
/// </summary>
[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<string> _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<Animator>(true) ?? root.GetComponentInParent<Animator>();
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;
}
}
}
+455
View File
@@ -0,0 +1,455 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Свой хват рук от первого лица (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); готовую позу потом зашить как хват.
/// </summary>
public static class HandPose
{
public static bool Enabled;
public static readonly Dictionary<string, Vector3> Overrides = new Dictionary<string, Vector3>();
static GameObject _rig;
static readonly Dictionary<string, Transform> _bones = new Dictionary<string, Transform>();
static GameObject _host;
public static void Init()
{
if (_host != null)
{
return;
}
_host = new GameObject("NecroHandPose");
Object.DontDestroyOnLoad(_host);
_host.AddComponent<HandPoseDriver>();
}
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<Transform>(true))
{
if (!_bones.ContainsKey(t.name))
{
_bones[t.name] = t;
}
}
}
if (_bones.TryGetValue(_name, out Transform b) && b == null)
{
_rig = null; // кость уничтожена (риг пересобран) - перечитать в следующий раз
}
return b;
}
/// <summary>Кости, повёрнутые относительно КАМЕРЫ, а не родителя: ставятся после Overrides
/// (03.10: локальные углы кисти «плывут» при каждом изменении локтя - подбор вслепую).</summary>
public static readonly Dictionary<string, Quaternion> CamOverrides = new Dictionary<string, Quaternion>();
public static Transform Camera()
{
return GameManager.Instance?.World?.GetPrimaryPlayer()?.cameraTransform;
}
/// <summary>Заморозка (03.10: «при нажатии на кнопки руки деформируются, пытаясь совершить старую
/// анимацию»). Поза задаёт 20-30 костей, а удар двигает и остальные - позвоночник, ключицы, вспомогательные
/// кости предплечья и кожи (*_sup_*, *Roll*). Поэтому при включении хвата в покое снимается локальное
/// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза.
/// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.</summary>
public static bool Freeze = true;
static readonly List<Transform> _frozen = new List<Transform>();
static readonly List<Vector3> _frozenPos = new List<Vector3>();
static readonly List<Quaternion> _frozenRot = new List<Quaternion>();
static GameObject _frozenRig;
static readonly int ItemActionIndexHash = Animator.StringToHash("ItemActionIndex");
/// <summary>Выход из мира: всё в исходное, ссылки на кости рига отпустить (HandHolds.Update).</summary>
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<Animator>();
if (anim != null && (anim.GetInteger(ItemActionIndexHash) != -1 || anim.IsInTransition(0)))
{
return; // идёт действие - снимок позже, в покое
}
foreach (Transform t in hips.GetComponentsInChildren<Transform>(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;
}
}
}
}
/// <summary>Кисть в осях камеры. Оси кисти правой руки (лог necrobones 03.10): пальцы - локальная +X
/// (суставы пальцев лежат по +X), ладонь - +Y (сгиб пальцев +Z ведёт +X к +Y), +Z - к мизинцу,
/// большой палец - к -Z. Нулевая поза: пальцы вверх, ладонь от игрока, тыльная сторона к камере,
/// большой палец влево. tilt - наклон пальцев влево (+) вокруг взгляда; back - кисть «от себя»
/// (+, верх пальцев уходит вперёд); turn - разворот кисти вокруг вертикали (+ ладонью вправо).</summary>
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" };
/// <summary>Все суставы четырёх пальцев: сгиб по Z (curl), веер по Y у первого сустава
/// (указательный +spread, средний +spread/3, безымянный -spread/3, мизинец -spread).</summary>
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);
}
}
}
/// <summary>Заготовка «раскрытая ладонь»: рука - как у браслета (снимок 03.10), пальцы прямые.</summary>
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;
}
/// <summary>Левая рука вдоль туловища, вне кадра (общая для хватов «отрицание» и «пиф-паф», 03.10).</summary>
static void LeftArmDown()
{
Overrides["LeftArm"] = new Vector3(30f, 13.6f, 57.3f);
}
/// <summary>Хват «отрицание» (утверждён пользователем 03.10): рука вперёд, как у браслета, ниже прицела,
/// кисть в линию с предплечьем, пальцы раскрыты.</summary>
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;
}
/// <summary>Хват «пиф-паф» (утверждён 03.10, «Стоп. Замри»): пистолет из пальцев - указательный и средний
/// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.</summary>
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();
}
}
/// <summary>
/// necropose - подбор позы рук от первого лица:
/// on | off | clear | open (заготовка: ладонь, рука как у браслета)
/// &lt;Кость&gt; x y z - локальный поворот кости (градусы)
/// add &lt;Кость&gt; dx dy dz - добавить к текущему (заданному или от аниматора)
/// fingers Right|Left сгиб [веер] - все суставы четырёх пальцев
/// show &lt;Кость&gt; - текущий поворот кости
/// save - вся поза в лог строками кода
/// </summary>
[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<string> _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());
}
}
}
+304
View File
@@ -0,0 +1,304 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Дух в чертогах разума - камера без сущности (указание 03.10: «тело можно вообще не делать,
/// нужен только прицел»). Ходит по полу как человек: столкновения - по тем же слоям, что у змеи
/// (SnakeControl.WallMask / TerrainMask), гравитация и ступенька. Взаимодействие одно - книга
/// памяти по E; остальное в чертогах неприкосновенно. Камеру ставят патчи камеры режима змеи
/// (SnakeControl.cs), звук и мир вокруг считаются от духа через SnakeAudioSwap.
///
/// СОН (указание: «тёплый фильтр, это как бы сон», эффекты - на моё усмотрение): готовые эффекты
/// экрана игры - Hot (тёплый оттенок), Blur (мягкое размытие), Drunk (медленное покачивание).
/// Силы крутятся командой necrodream на ходу.
/// </summary>
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;
}
}
/// <summary>Из патчей камеры режима змеи: глаза духа и поиск книги под прицелом.</summary>
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);
}
/// <summary>Шагаем по лучу на ReachDistance: первая встреченная книга памяти - цель; твёрдый
/// блок раньше неё - нет цели (сквозь стены не читаем).</summary>
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;
}
}
/// <summary>Прицел духа и подсказка у книги (OnGUI, как прицел змеи).</summary>
public class MindHud : MonoBehaviour
{
static GUIStyle _style;
public static void Create()
{
var go = new GameObject("NecroMindHud");
DontDestroyOnLoad(go);
go.AddComponent<MindHud>();
}
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;
}
}
/// <summary>necrodream hot|blur|drunk N - подобрать силу сонных эффектов на ходу.</summary>
[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<string> _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);
}
}
}
+892
View File
@@ -0,0 +1,892 @@
using System.Collections.Generic;
using System.IO;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Ментальная форма - «Чертоги разума» (решения пользователя 02-03.10, BACKLOG «Чертоги разума»).
/// Вход - третья строка меню ножа некроманта на R, только ночью, здоровье не тратит. Тело
/// падает рэгдоллом и лежит беззащитным, как в режиме змеи; дух - камера без сущности
/// (MindControl.cs): ходит по полу, видит только своё, с другими игроками не встречается.
/// Выход - R, утро или смерть тела.
///
/// ГДЕ ЧЕРТОГИ. Под землёй, в радиационном поясе у северо-восточного угла карты: сначала от
/// угла по диагонали ищется пояс (biomeProvider.GetRadiationAt &gt; 2.5); пояс шире префаба -
/// чертоги в его середине, уже (Navezgane: 16 блоков) - в 45 блоках от края. Своё место у каждого
/// игрока - вдоль северного края с шагом SlotStep (дальше дальности показа сущностей). Места
/// пишутся в файл в папке сохранения мира, чтобы смена ширины пояса в новой версии игры их не
/// сдвинула.
///
/// КАК СТАВЯТСЯ. Префаб chertogi (PalacePrefab/, нарисован пользователем в редакторе игры)
/// грузится Prefab.Load и ставится поблочно через World.SetBlocksRPC - НЕ Prefab.CopyIntoLocal:
/// тот перед постановкой вычищает весь столб над префабом до неба от не-земляных блоков,
/// и над чертогами вырубило бы деревья (видно сверху). Заодно: внешний слой оболочки
/// (woodShapes:cube) -&gt; terrBedrock (в меню творчества его нет, рисовали деревом),
/// одиночная книга на столе -&gt; necroMemoryBook, на стол с реактивами - нож-декорация,
/// кровать (SleepingBag) -&gt; necroPalaceBed: иначе игра делает её точкой возрождения игрока.
/// Построены ли - по книге: нет её - ставим заново (самопочинка).
///
/// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
/// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
/// раз в секунду выбрасываются: змея развеивается, игрок - на поверхность, прочие - удаляются.
///
/// ОГРАНИЧЕНИЕ: одиночная игра и хост. Клиенту в мультиплеере вход пока не даётся - чертоги ставит
/// сервер, а камера живёт у клиента (решать отдельно).
/// </summary>
public static class MindPalace
{
public const string PrefabName = "chertogi";
public const string BookBlock = "necroMemoryBook";
public const string KnifeBlock = "necroKnifeDecor";
/// <summary>Кровать без класса SleepingBag - ею заменяется кровать префаба (bunkBedMadeWhite).</summary>
public const string BedBlock = "necroPalaceBed";
public const string ShellFrom = "woodShapes:cube";
public const string ShellTo = "terrBedrock";
/// <summary>Одиночная книга на обеденном столе (cntBookPile09 в префабе).</summary>
public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
/// <summary>Место ножа на столе с реактивами (указание пользователя).</summary>
public static readonly Vector3i KnifeAt = new Vector3i(4, 2, 11);
/// <summary>Входная дверь чертогов - во внутреннюю оболочку, никуда не ведёт: остаётся закрытой
/// (указание 03.10). Остальные четыре - между комнатами - открываются.</summary>
public static readonly Vector3i EntranceDoorAt = new Vector3i(8, 1, 3);
/// <summary>Где появляется дух: комната с книгой, свободная клетка у стола.</summary>
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;
/// <summary>Рамка вокруг чертогов, которая заливается камнем перед постановкой.</summary>
public const int LooseMargin = 3;
public const float RadiationThreshold = 2.5f;
/// <summary>Поворот ножа-декорации (0-23): 9 - плашмя на столешнице (подобрано пользователем
/// 03.10 командой necropalace knife N).</summary>
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<string, Vector3i> _slots = new Dictionary<string, Vector3i>();
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<string>();
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;
}
/// <summary>Отступ от края: середина радиационного пояса, если он шире префаба, иначе 45.</summary>
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;
}
/// <summary>Стоит ли блок уже в самом префабе (03.10: пользователь расставляет книгу и нож в редакторе
/// сам) - тогда старые принудительные замены (книга в BookAt, нож в KnifeAt) не делаем.</summary>
static bool PrefabHas(Prefab _p, string _block)
{
if (_hasCache == null || _hasCacheFor != _p)
{
_hasCache = new Dictionary<string, bool>();
_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<string, bool> _hasCache;
static Prefab _hasCacheFor;
/// <summary>Что должно стоять в клетке префаба после постановки (с заменами). False - клетку
/// не трогаем (дочерняя часть многоблочного предмета).</summary>
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;
}
/// <summary>Диагностика (03.10, «нож пропал со стола»): что стоит в клетке и есть ли у блока модель.</summary>
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<Renderer>(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);
}
/// <summary>Сколько клеток в мире не совпадает с префабом по типу блока (0 - чертоги целы).</summary>
public static int Mismatches(World _world, Vector3i _o, List<string> _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;
}
/// <summary>Все двери чертогов - открыты (в редакторе сохранились закрытыми, а дух дверей не
/// открывает). Состояние двери - в её TEFeatureDoor, не в блоке.</summary>
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<TEFeatureDoor>();
// Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
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<BlockChangeInfo>();
// 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);
/// <summary>Проверка постановки в нетронутую землю (указание 03.10): залить камнем коробку на
/// 2 блока шире чертогов во все стороны (включая слой bedrock снизу), подождать, поставить
/// чертоги заново, выставить двери. Сразу заливкой, без предварительной очистки - очистка и
/// есть та пустота без опоры, из-за которой был обвал.</summary>
public static System.Collections.IEnumerator StressTest(World _world, Vector3i _o)
{
Vector3i s = Size;
// Гравием - худший случай: сыпучее, падает без опоры (указание 03.10).
BlockValue stone = Block.GetBlockValue("terrGravel");
var list = new List<BlockChangeInfo>();
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<string>();
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);
}
}
/// <summary>Тело падает и лежит - как в режиме змеи (SnakeMode.OnUnityUpdate).</summary>
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);
}
}
/// <summary>Раз в секунду: всё живое в коробке чертогов - вон. Дух сущности не имеет.</summary>
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<NetPackageTeleportPlayer>().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);
}
}
}
}
/// <summary>В чертогах и в рамке камня вокруг ничего не падает (указание 03.10: «почему не
/// отключить просчёт физики в той области»): World.AddFallingBlock - единственная очередь
/// падающих блоков (туда же игра сама не пускает блоки со StabilityIgnore). Тест гравием
/// 03.10: после постановки нижний/верхний ряд внутренней стены всё равно обрушивался.</summary>
[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);
}
}
/// <summary>Чертоги - «зона торговца»: блоки не ломаются ударом и взрывом, строить нельзя
/// (ItemActionAttack, Explosion, Block.DamageBlock проверяют IsWithinTraderArea).</summary>
[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;
}
}
}
/// <summary>
/// necropalace - отладка чертогов: where (где мои), rebuild (поставить заново, если участки
/// загружены), knife N (поворот ножа 0-23 и переставить его).
/// </summary>
[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<string> _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<Vector3i>();
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<string>();
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));
}
}
}
}
+4
View File
@@ -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.
+72 -5
View File
@@ -37,6 +37,15 @@ namespace NecromancerTome
public static Vector3 SnakePos;
public float Until;
/// <summary>Своя угроза вместо змеи (хват «отрицание», 03.10): бежать от этой точки.</summary>
public bool HasThreat;
public Vector3 Threat;
public float Speed = FleeSpeed;
/// <summary>Кого забыть, когда бегство кончится (хват «отрицание»: «должен терять меня как цель»).
/// Он же угроза: бежать от его ТЕКУЩЕГО места.</summary>
public EntityAlive Forget;
/// <summary>Больше нуля - бежать, пока Forget ближе этого (и не дольше Until).</summary>
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();
}
/// <summary>Сколько криков тревоги уже было в этом кадре: у толпы кричат не все.</summary>
@@ -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;
}
/// <summary>Хват «отрицание» (03.10): напугать зомби жестом игрока - бежит от игрока быстрее обычного
/// _seconds секунд, сразу и в конце бегства теряет игрока как цель.</summary>
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<NecroFleeSnakeTask>();
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";
+91
View File
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Почтовый ящик консоли - только для разработки (03.10: ассистент подбирает позу рук сам, без
/// клавиатуры). Работает, лишь если есть папка %APPDATA%\7DaysToDie\NecroMailbox - у игроков её
/// нет. Раз в полсекунды: команды из cmd.txt (по строке) выполняются в консоли игры, как набранные
/// вручную, файл удаляется, ответ - в лог строками «[NecromancerTome] mailbox:».
/// </summary>
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<string> 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);
}
}
}
}
/// <summary>necroshot [имя] - снимок экрана игры в %APPDATA%\7DaysToDie\NecroMailbox\shots\имя.png
/// (как F9, только в известное место). Файл появляется в конце кадра.</summary>
[Preserve]
public class ConsoleCmdNecroShot : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroshot" };
public override string getDescription() => "NecromancerTome: снимок экрана в NecroMailbox/shots/<имя>.png";
public override void Execute(List<string> _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);
}
}
}
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»). Окно necroTome
/// (Config/XUi_InGame/windows.xml): оглавление - кнопки глав (до восьми), глава - страницы
/// с «Назад/Далее», «Оглавление» возвращает к списку, «Закрыть» и Esc закрывают.
///
/// СОДЕРЖАНИЕ - список Chapters ниже: ключ названия, ключи страниц (Localization.csv,
/// necroTome*), условие открытия. Закрытая глава в оглавлении не показывается. Условия брать
/// из того, что игра уже хранит (уровень некромантии, рецепты, испытания), своё хранение - только
/// для чистых событий, по cvar на событие.
/// </summary>
public static class NecroTomeBook
{
public const string WindowGroup = "necroTome";
public class Chapter
{
public string TitleKey;
public string[] PageKeys;
public Func<EntityPlayerLocal, bool> Unlocked;
}
public static readonly List<Chapter> Chapters = new List<Chapter>
{
// Глава 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];
/// <summary>Глава по кнопке оглавления (только открытые главы, по порядку).</summary>
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;
}
}
}
/// <summary>necrotome - открыть книгу (проверка окна без чертогов, в том числе через почтовый ящик).</summary>
[Preserve]
public class ConsoleCmdNecroTome : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrotome" };
public override string getDescription() => "NecromancerTome: открыть книгу с оглавлением";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
if (player != null)
{
NecroTomeBook.Open(player);
}
}
}
}
+4
View File
@@ -28,6 +28,10 @@
<HintPath>..\..\..\7DaysToDie_Data\Managed\Unity.Addressables.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.ScreenCaptureModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.ScreenCaptureModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
+5
View File
@@ -153,6 +153,11 @@ namespace NecromancerTome
{
return;
}
// Одержимый зомби, пока в нём игрок, в некромантию не идёт (решение 03.10).
if (Possession.IsPossessed(killedEntity))
{
return;
}
float kills = AddKillsCVar(__instance);
SyncNecromancyLevel(__instance, kills);
+688
View File
@@ -0,0 +1,688 @@
using GamePath;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Одержимость - заклинание хвата «пиф-паф» (решения пользователя 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, через несколько
/// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело.
///
/// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время
/// одержимости; на выходе и при выходе из мира всё обнуляется.
/// </summary>
public static class Possession
{
public static bool Active;
public static EntityAlive Zombie;
/// <summary>Камера - позади и выше головы зомби.</summary>
public const float CamBack = 2.6f;
public const float CamUp = 0.6f;
/// <summary>Как далеко зомби замечают одержимого: зрение зомби - 30 м (entityclasses.xml SightRange).</summary>
public const float TauntRange = 30f;
public const float TauntPeriod = 1f;
public const int TauntTicks = 1200;
/// <summary>Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить
/// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора
/// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков
/// хватает, чтобы тело стояло на земле и зомби могли к нему подойти.</summary>
public const int BodyViewDim = 3;
static int _bodyViewDim = -1;
/// <summary>Полный радиус области игрока (до сжатия) - для систем, рисующих мир у одержимого
/// (SnakeAudioSwap: дальний ландшафт, Dynamic Mesh, детализация зданий). -1 - не сжата.</summary>
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;
/// <summary>Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») -
/// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера.</summary>
static float _bodyYaw;
public const float TurnSpeed = 540f;
static float _nextAttack;
static float _nextTaunt;
static ChunkManager.ChunkObserver _observer;
static readonly List<Animator> _frozen = new List<Animator>();
static readonly List<float> _frozenSpeed = new List<float>();
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<PossessionHud>();
}
}
public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
/// <summary>Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).</summary>
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
/// <summary>True - вселились (для звука «пиф-пафа»).</summary>
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<Animator>(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<EAIRangedAttackTarget> tasks = _z.aiManager?.GetTasks<EAIRangedAttackTarget>();
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 + " с");
}
/// <summary>Подсказка при вселении: что умеет этот зомби.</summary>
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);
}
/// <summary>Точка под прицелом камеры (в мировых координатах), до 50 м.</summary>
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;
}
/// <summary>ПКМ - дальняя атака зомби по шагам ванильной EAIRangedAttackTarget.</summary>
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;
}
}
/// <summary>Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
/// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
/// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).</summary>
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<int>() - 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");
}
/// <summary>Крик одержимой крикуньи: её звук тревоги и все зомби в ScreamRange - на неё (танкование).</summary>
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);
}
/// <summary>Раз в секунду: зомби, которые видят одержимого, бросаются на него (решение 03.10:
/// «зомби начинают считать этого зомби ещё одним игроком»). Ванильный подход к цели принимает
/// только свои классы (EntityPlayer...) - добавляем EntityZombie, как CharmPatch; это безопасно:
/// цель-зомби им ставим только мы (поиск цели EAISetNearestEntityAsTarget не трогаем).</summary>
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<EAIApproachAndAttackTarget> tasks = _e.aiManager.GetTasks<EAIApproachAndAttackTarget>();
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 });
}
}
}
/// <summary>Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.</summary>
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 : "корнем (головы нет)"));
}
}
}
/// <summary>Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))]
public static class Patch_EntityAlive_updateTasks_Possession
{
public static bool Prefix(EntityAlive __instance)
{
return !Possession.IsPossessed(__instance);
}
}
/// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary>
[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;
}
}
/// <summary>Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
/// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
/// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.</summary>
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);
}
}
}
+106 -9
View File
@@ -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
/// змеи. На время вызова переносим позицию игрока к змее.</summary>
public static class SnakeAudioSwap
{
/// <summary>Вложенность подмены: CheckLODs зовётся и изнутри WorldEnvironment.Update, где позиция уже
/// подменена. Подменяет и возвращает только внешний вызов, вложенные ничего не трогают.</summary>
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;
}
}
}
}
/// <summary>Полная модель блоков (двери, люки, мебель) ближе 41 м, дальше - упрощённая замена:
/// ChunkGameObject.CheckLODs меряет расстояние до ИГРОКА - тела. Его зовёт не только WorldEnvironment.Update
/// (там позиция уже подменена), но и сам чанк при появлении - тест 03.10: «змея и одержимый видят вместо
/// дверей деревянные блоки». Подменяем позицию и здесь: змея, одержимый, дух чертогов.</summary>
[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); }
}
/// <summary>Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела).</summary>
[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
{
+20 -2
View File
@@ -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
{
/// <summary>Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается).</summary>
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;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<prefab>
<property name="CopyAirBlocks" value="True" />
<property name="ExcludeDistantPOIMesh" value="False" />
<property name="ExcludePOICulling" value="False" />
<property name="DistantPOIYOffset" value="0" />
<property name="AllowDecorations" value="True" />
<property name="DifficultyTier" value="0" />
<property name="ShowQuestClearCount" value="1" />
<property name="TraderArea" value="False" />
<property name="PrefabSize" value="17, 8, 17" />
<property name="RotationToFaceNorth" value="2" />
<property name="Zoning" value="none" />
<property class="IndexedBlockOffsets">
<property class="POILight">
<property name="0" value="11, 3, 9" />
<property name="1" value="12, 3, 9" />
<property name="2" value="8, 4, 10" />
<property name="3" value="12, 4, 10" />
<property name="4" value="12, 4, 12" />
<property name="5" value="9, 3, 13" />
</property>
<property class="challenge_sinks">
<property name="0" value="11, 1, 3" />
<property name="1" value="13, 1, 4" />
</property>
<property class="TraderOnOff">
<property name="0" value="8, 1, 8" />
<property name="1" value="10, 1, 11" />
</property>
<property class="challenge_gore">
<property name="0" value="3, 2, 9" />
</property>
<property class="challenge_mech_parts">
<property name="0" value="6, 1, 13" />
</property>
</property>
<property class="Stats">
<property name="LightsVolume" value="5516.637" />
<property name="TotalVertices" value="91813" />
<property name="TotalTriangles" value="61958" />
<property class="BlockEntities">
<property name="Vertices" value="73400" />
<property name="Triangles" value="52089" />
</property>
<property class="ChunkMeshes0">
<property name="Vertices" value="18121" />
<property name="Triangles" value="9645" />
</property>
<property class="ChunkMeshes1">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
<property class="ChunkMeshes2">
<property name="Vertices" value="292" />
<property name="Triangles" value="224" />
</property>
<property class="ChunkMeshes3">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
<property class="ChunkMeshes4">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
<property class="ChunkMeshes5">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
</property>
</prefab>
+94
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 34 KiB