diff --git a/Config/Localization.csv b/Config/Localization.csv
index 7869912..8c4b862 100644
--- a/Config/Localization.csv
+++ b/Config/Localization.csv
@@ -77,7 +77,7 @@ EntityLootContainerVictim,entityclasses,Entity Info,,,,Victim's Bag,,Beutel des
noteDuke01,items,Quest - Note,,,,Note,,Notiz,Nota,Mot,Biglietto,書き置き,쪽지,List,Bilhete,Записка,Not,纸条,紙條
noteDuke01Desc,items,Quest - Note,,,,"You don't deserve death by my hand. You deserve death by a thousand hands. Every seven days, this world will tear you apart - and every time, it will hurt more, terrify more. You will build, only to lose it all. You will hope, only to despair. And when you tire, when you finally break, you will simply cease to be. You will become one of them. And even then, I won't remember your name. Let the dead make you dead. Let death bring you no peace. Let the living forget you forever. Goodbye, mistake.",,"Du verdienst keinen Tod durch meine Hand. Du verdienst den Tod durch tausend Hände. Alle sieben Tage wird diese Welt dich zerreißen – und jedes Mal wird es mehr wehtun, mehr Angst machen. Du wirst bauen, nur um alles zu verlieren. Du wirst hoffen, nur um zu verzweifeln. Und wenn du müde wirst, wenn du endlich zerbrichst, wirst du einfach aufhören zu sein. Du wirst einer von ihnen. Und selbst dann werde ich mich nicht an deinen Namen erinnern. Sollen die Toten dich tot machen. Soll der Tod dir keinen Frieden bringen. Sollen die Lebenden dich für immer vergessen. Leb wohl, Fehler.","No mereces morir a mi mano. Mereces morir a manos de mil. Cada siete días, este mundo te hará pedazos, y cada vez dolerá más y aterrará más. Construirás solo para perderlo todo. Tendrás esperanza solo para desesperar. Y cuando te canses, cuando por fin te quiebres, sencillamente dejarás de existir. Te convertirás en uno de ellos. Y ni siquiera entonces recordaré tu nombre. Que los muertos te hagan morir. Que la muerte no te traiga paz. Que los vivos te olviden para siempre. Adiós, error.","Tu ne mérites pas de mourir de ma main. Tu mérites de mourir de mille mains. Tous les sept jours, ce monde te mettra en pièces – et chaque fois, la douleur sera plus vive, la terreur plus grande. Tu bâtiras pour tout perdre. Tu espéreras pour désespérer. Et quand tu seras las, quand tu finiras par te briser, tu cesseras simplement d'être. Tu deviendras l'un d'eux. Et même alors, je ne me souviendrai pas de ton nom. Que les morts te rendent mort. Que la mort ne t'apporte aucune paix. Que les vivants t'oublient à jamais. Adieu, erreur.","Non meriti di morire per mano mia. Meriti di morire per mille mani. Ogni sette giorni questo mondo ti farà a pezzi, e ogni volta farà più male, spaventerà di più. Costruirai solo per perdere tutto. Spererai solo per disperare. E quando sarai stanco, quando alla fine ti spezzerai, semplicemente cesserai di essere. Diventerai uno di loro. E nemmeno allora ricorderò il tuo nome. Che i morti ti rendano morto. Che la morte non ti porti pace. Che i vivi ti dimentichino per sempre. Addio, errore.",お前は私の手にかかって死ぬに値しない。千の手にかかって死ぬに値する。七日ごとに、この世界はお前を引き裂く。そのたびに痛みは増し、恐怖は深まる。築いては、すべて失う。望んでは、絶望する。そして疲れ果て、ついに折れたとき、お前はただ在ることをやめる。お前は奴らの一人になる。そのときでさえ、私はお前の名を覚えていない。死者にお前を死者たらしめよ。死がお前に安らぎを与えぬように。生者が永久にお前を忘れるように。さらばだ、過ち。,"너는 내 손에 죽을 자격이 없다. 너는 천 개의 손에 죽을 자격이 있다. 이레마다 이 세계가 너를 찢어놓을 것이다. 그리고 그때마다 더 아프고, 더 두려울 것이다. 너는 세울 것이고, 모두 잃을 것이다. 너는 바랄 것이고, 절망할 것이다. 그러다 지치고, 마침내 부러지면, 너는 그저 존재하기를 그친다. 너는 그들 중 하나가 된다. 그때조차 나는 네 이름을 기억하지 못할 것이다. 죽은 자들이 너를 죽게 하리라. 죽음이 네게 안식을 주지 않기를. 산 자들이 너를 영원히 잊기를. 잘 가라, 실수여.","Nie zasługujesz na śmierć z mojej ręki. Zasługujesz na śmierć z tysiąca rąk. Co siedem dni ten świat będzie cię rozdzierał – i za każdym razem będzie bolało bardziej, przerażało bardziej. Będziesz budował, by wszystko stracić. Będziesz miał nadzieję, by rozpaczać. A gdy się zmęczysz, gdy w końcu pękniesz, po prostu przestaniesz być. Staniesz się jednym z nich. I nawet wtedy nie przypomnę sobie twojego imienia. Niech umarli uczynią cię umarłym. Niech śmierć nie przyniesie ci spokoju. Niech żywi zapomną o tobie na zawsze. Żegnaj, pomyłko.","Você não merece morrer pela minha mão. Merece morrer por mil mãos. A cada sete dias, este mundo vai te despedaçar - e a cada vez doerá mais, apavorará mais. Você vai construir, só para perder tudo. Vai esperar, só para se desesperar. E quando se cansar, quando finalmente se quebrar, simplesmente deixará de existir. Você se tornará um deles. E nem mesmo então eu lembrarei o seu nome. Que os mortos te façam morto. Que a morte não te traga paz. Que os vivos te esqueçam para sempre. Adeus, erro.","Ты не заслуживаешь смерти от моей руки. Ты заслуживаешь смерти от тысяч рук. Каждые семь дней этот мир будет рвать тебя на части, и каждый раз — всё больнее, всё страшнее. Ты будешь строить, чтобы терять. Ты будешь надеяться, чтобы отчаиваться. И когда ты устанешь, когда сломаешься — ты просто перестанешь быть. Ты превратишься в одного из них. И даже тогда — я не запомню твоё имя. Пусть мёртвые сделают тебя мёртвым. Пусть смерть не принесёт тебе покоя. Пусть живые навсегда забудут тебя. Прощай, ошибка.","Benim elimden ölmeyi hak etmiyorsun. Bin elin altında ölmeyi hak ediyorsun. Her yedi günde bir bu dünya seni paramparça edecek - ve her seferinde daha çok acıtacak, daha çok korkutacak. İnşa edeceksin, sonra hepsini kaybedeceksin. Umut edeceksin, sonra umutsuzluğa düşeceksin. Yorulduğunda, sonunda kırıldığında ise yalnızca var olmaktan çıkacaksın. Onlardan biri olacaksın. O zaman bile adını hatırlamayacağım. Bırak ölüler seni öldürsün. Bırak ölüm sana huzur getirmesin. Bırak yaşayanlar seni sonsuza dek unutsun. Elveda, hata.",你不配死在我手上。你配死在一千双手下。每隔七天,这个世界都会把你撕碎——而且一次比一次更痛,一次比一次更恐怖。你会建起一切,只为失去一切。你会心存希望,只为坠入绝望。等你倦了,等你终于崩溃,你便会干脆不再存在。你会成为它们中的一员。即便到那时,我也不会记得你的名字。让死者置你于死地。愿死亡不予你安宁。愿生者永远将你遗忘。永别了,错误。,你不配死在我手上。你配死在一千雙手下。每隔七天,這個世界都會把你撕碎——而且一次比一次更痛,一次比一次更恐怖。你會建起一切,只為失去一切。你會心存希望,只為墜入絕望。等你倦了,等你終於崩潰,你便會乾脆不再存在。你會成為牠們中的一員。即便到那時,我也不會記得你的名字。讓死者置你於死地。願死亡不予你安寧。願生者永遠將你遺忘。永別了,錯誤。
braceletSpatialVault,items,Item,,,,Spatial Bracelet,,Raumarmband,Brazalete espacial,Bracelet spatial,Bracciale spaziale,空間の腕輪,공간의 팔찌,Bransoleta przestrzenna,Bracelete espacial,Пространственный браслет,Uzam Bileziği,空间手环,空間手環
-braceletSpatialVaultDesc,items,Item,,,,"A necromantic bracelet. Power attack to tear open a personal storage rift - its size grows with your Necromancy skill. Hold the regular attack on a block and it is pulled into the rift: ten seconds up close, one more for every block of distance. The power attack interrupts that and opens the rift instead. Pulling blocks needs a Blood Sphere or a Blood Stone in the bracelet's mod slot, and every second of the pull costs the sphere one point of durability. The Stone is never spent. Hold the regular attack on a victim's red corpse to tear a skull out of it (three seconds); it rarely works - usually the corpse bursts into black dust.",,"Ein nekromantisches Armband. Ein Machtangriff reißt einen persönlichen Lagerriss auf - seine Größe wächst mit deiner Nekromantie-Fertigkeit. Halte den normalen Angriff auf einem Block und er wird in den Riss gezogen: zehn Sekunden aus der Nähe und eine weitere pro Block Entfernung. Der Machtangriff bricht das ab und öffnet stattdessen den Riss. Zum Einziehen von Blöcken muss eine Blutsphäre oder ein Blutstein im Modslot stecken, und jede Sekunde des Einziehens kostet die Sphäre einen Haltbarkeitspunkt. Der Blutstein wird dabei nie verbraucht. Halte den normalen Angriff auf die rote Leiche eines Opfers, um ihr den Schädel zu entreißen (drei Sekunden); das gelingt selten - meist zerplatzt die Leiche zu schwarzem Staub.","Un brazalete nigromántico. El ataque potente abre una grieta de almacenamiento personal; su tamaño crece con tu habilidad de Nigromancia. Mantén el ataque normal sobre un bloque y este será atraído a la grieta: diez segundos de cerca y uno más por cada bloque de distancia. El ataque potente lo interrumpe y abre la grieta. Para absorber bloques hace falta una esfera de sangre o una piedra de sangre en la ranura de modificación, y cada segundo de absorción cuesta un punto de durabilidad. La piedra de sangre no se gasta nunca. Mantén el ataque normal sobre el cadáver rojo de una víctima para arrancarle el cráneo (tres segundos); rara vez sale bien: casi siempre el cadáver estalla en polvo negro.","Un bracelet nécromantique. L'attaque puissante ouvre une faille de stockage personnelle - sa taille augmente avec votre compétence de Nécromancie. Maintenez l'attaque normale sur un bloc et il est aspiré dans la faille : dix secondes de près et une de plus par bloc de distance. L'attaque puissante interrompt cela et ouvre la faille. Pour aspirer des blocs, il faut une sphère de sang ou une pierre de sang dans l'emplacement de mod, et chaque seconde d'aspiration coûte un point de durabilité. La pierre de sang, elle, ne s'épuise jamais. Maintiens l'attaque normale sur le cadavre rouge d'une victime pour lui arracher le crâne (trois secondes) ; cela réussit rarement : le plus souvent, le cadavre éclate en poussière noire.","Un bracciale negromantico. L'attacco potente apre una faglia di stoccaggio personale - la sua dimensione cresce con l'abilità Negromanzia. Tieni premuto l'attacco normale su un blocco e verrà attirato nella faglia: dieci secondi da vicino e uno in più per ogni blocco di distanza. L'attacco potente lo interrompe e apre la faglia. Per assorbire blocchi serve una sfera di sangue o una pietra di sangue nello slot mod, e ogni secondo di assorbimento costa un punto di durabilità. La pietra di sangue, invece, non si consuma mai. Tieni premuto l'attacco normale sul cadavere rosso di una vittima per strappargli il teschio (tre secondi); riesce di rado: di solito il cadavere esplode in polvere nera.",ネクロマンサーの腕輪。パワーアタックで個人用の収納亀裂を開きます。容量はネクロマンシーのスキルと共に増えます。ブロックに照準して通常攻撃を押し続けると亀裂に引き込まれます。密着で十秒、距離が1ブロック増えるごとに1秒追加されます。パワーアタックで中断し、亀裂を開きます。ブロックの吸収には、腕輪の改造スロットに血の球か血の石が必要で、吸収の1秒ごとに球の耐久値を1消費する。血の石は決して消耗しない。生贄の赤い死体に通常攻撃を長押しすると頭蓋骨を引き抜ける(3秒)。成功はまれで、たいてい死体は黒い塵となって弾ける。,"강령술사의 팔지. 강타로 개인 보관 균열을 열며 크기는 강령술 스킬에 따라 커집니다. 블록을 조준한 채 일반 공격을 누르고 있으면 그 블록이 균열로 빨려들어갑니다. 밀착 시 10초이며 거리 1블록마다 1초씩 늘어납니다. 강타는 이를 중단하고 균열을 엽니다. 블록을 흡수하려면 개조 슬롯에 피의 구체나 피의 돌이 있어야 하며, 흡수 1초마다 구체의 내구도가 1씩 소모됩니다. 피의 돌은 결코 소모되지 않습니다. 제물의 붉은 시체에 일반 공격을 누르고 있으면 두개골을 뜯어낸다(3초). 성공은 드물고, 대개 시체는 검은 먼지로 터진다.","Nekromancka bransoleta. Silny atak rozrywa osobistą szczelinę magazynową - jej rozmiar rośnie wraz z umiejętnością Nekromancji. Przytrzymaj zwykły atak na bloku a zostanie on wciągnięty do szczeliny: dziesięć sekund z bliska i jedna więcej za każdy blok odległości. Silny atak przerywa to i otwiera szczelinę. Wciąganie bloków wymaga krwawej sfery albo krwawego kamienia w gnieździe modyfikacji, a każda sekunda kosztuje punkt wytrzymałości. Krwawy kamień nie zużywa się nigdy. Przytrzymaj zwykły atak na czerwonym ciele ofiary, by wyrwać z niego czaszkę (trzy sekundy); rzadko się udaje - zwykle ciało rozpada się w czarny pył.","Uma bracelete necromântica. O ataque forte abre uma fenda de armazenamento pessoal - seu tamanho cresce com a sua habilidade de Necromancia. Segure o ataque normal sobre um bloco e ele será puxado para a fenda: dez segundos de perto e mais um por bloco de distância. O ataque forte interrompe isso e abre a fenda. Para absorver blocos é preciso uma esfera de sangue ou uma pedra de sangue no encaixe, e cada segundo de absorção custa um ponto de durabilidade. A pedra de sangue nunca se gasta. Segure o ataque normal sobre o cadáver vermelho de uma vítima para arrancar o crânio (três segundos); raramente funciona - quase sempre o cadáver explode em pó negro.","Некромантский браслет. Силовая атака вскрывает личный разлом-хранилище - его размер растёт вместе с навыком Некромантии. Зажмите обычную атаку на блоке - и он втянется в разлом: десять секунд вплотную и ещё по секунде за каждый блок расстояния. Силовая атака прерывает это и открывает хранилище. Чтобы поглощать блоки, в слоте модификаций браслета должна быть кровавая сфера или кровавый камень, и каждая секунда поглощения тратит единицу её прочности. А кровавый камень не тратится вовсе. Зажатая обычная атака по красному трупу жертвы вырывает из него череп (три секунды); удаётся редко - обычно труп разрывается чёрной пылью.",Nekromantik bir bileklik. Güçlü saldırı kişisel bir depo yarığı açar; boyutu Nekromantik becerinle birlikte büyür. Normal saldırıyı bir bloka basılı tut ve blok yarığa çekilsin: yakından on saniye ve her blokluk mesafe için bir saniye daha. Güçlü saldırı bunu keser ve yarığı açar. Blok çekmek için mod yuvasında bir kan küresi ya da kan taşı bulunmalı ve çekmenin her saniyesi bir dayanıklılık puanı götürür. Kan taşı ise hiç eksilmez. Bir kurbanın kızıl cesedine normal saldırıyı basılı tutarsan kafatasını koparırsın (üç saniye); nadiren başarılı olur - ceset çoğu zaman kara toza dönüşüp patlar.,亡灵法师的手镯。重击可撕开个人储物裂隙,其容量随亡灵法术技能增长。对准方块按住普通攻击,方块便会被拉入裂隙:贴近时为十秒,每远一格再加一秒。重击可中断并打开裂隙。吸收方块需要在改造槽中装有血之球或血石,且吸收每持续一秒便消耗一点耐久度。而血石则永不消耗。对祭品的红色尸体按住普通攻击,可扯出头骨(3秒);很少成功——尸体通常会炸成黑色尘埃。,亡靈法師的手鐠。重擊可撕開個人儲物裂隙,其容量隨亡靈法術技能增長。對準方塊按住普通攻擊,方塊便會被拉入裂隙:貼近時為十秒,每遠一格再加一秒。重擊可中斷並打開裂隙。吸收方塊需要在改造槽中裝有血之球或血石,且吸收每持續一秒便消耗一點耐久度。而血石則永不消耗。對祭品的紅色屍體按住普通攻擊,可扯出頭骨(3秒);很少成功——屍體通常會炸成黑色塵埃。
+braceletSpatialVaultDesc,items,Item,,,,"A necromantic bracelet. Power attack to tear open a personal storage rift - its size grows with your Necromancy skill. Hold the regular attack on a block and it is pulled into the rift: ten seconds up close, one more for every block of distance. The power attack interrupts that and opens the rift instead. Pulling blocks needs a Blood Sphere or a Blood Stone in the bracelet's mod slot, and every second of the pull costs the sphere one point of durability. The Stone is never spent.",,"Ein nekromantisches Armband. Ein Machtangriff reißt einen persönlichen Lagerriss auf - seine Größe wächst mit deiner Nekromantie-Fertigkeit. Halte den normalen Angriff auf einem Block und er wird in den Riss gezogen: zehn Sekunden aus der Nähe und eine weitere pro Block Entfernung. Der Machtangriff bricht das ab und öffnet stattdessen den Riss. Zum Einziehen von Blöcken muss eine Blutsphäre oder ein Blutstein im Modslot stecken, und jede Sekunde des Einziehens kostet die Sphäre einen Haltbarkeitspunkt. Der Blutstein wird dabei nie verbraucht.","Un brazalete nigromántico. El ataque potente abre una grieta de almacenamiento personal; su tamaño crece con tu habilidad de Nigromancia. Mantén el ataque normal sobre un bloque y este será atraído a la grieta: diez segundos de cerca y uno más por cada bloque de distancia. El ataque potente lo interrumpe y abre la grieta. Para absorber bloques hace falta una esfera de sangre o una piedra de sangre en la ranura de modificación, y cada segundo de absorción cuesta un punto de durabilidad. La piedra de sangre no se gasta nunca.","Un bracelet nécromantique. L'attaque puissante ouvre une faille de stockage personnelle - sa taille augmente avec votre compétence de Nécromancie. Maintenez l'attaque normale sur un bloc et il est aspiré dans la faille : dix secondes de près et une de plus par bloc de distance. L'attaque puissante interrompt cela et ouvre la faille. Pour aspirer des blocs, il faut une sphère de sang ou une pierre de sang dans l'emplacement de mod, et chaque seconde d'aspiration coûte un point de durabilité. La pierre de sang, elle, ne s'épuise jamais.","Un bracciale negromantico. L'attacco potente apre una faglia di stoccaggio personale - la sua dimensione cresce con l'abilità Negromanzia. Tieni premuto l'attacco normale su un blocco e verrà attirato nella faglia: dieci secondi da vicino e uno in più per ogni blocco di distanza. L'attacco potente lo interrompe e apre la faglia. Per assorbire blocchi serve una sfera di sangue o una pietra di sangue nello slot mod, e ogni secondo di assorbimento costa un punto di durabilità. La pietra di sangue, invece, non si consuma mai.",ネクロマンサーの腕輪。パワーアタックで個人用の収納亀裂を開きます。容量はネクロマンシーのスキルと共に増えます。ブロックに照準して通常攻撃を押し続けると亀裂に引き込まれます。密着で十秒、距離が1ブロック増えるごとに1秒追加されます。パワーアタックで中断し、亀裂を開きます。ブロックの吸収には、腕輪の改造スロットに血の球か血の石が必要で、吸収の1秒ごとに球の耐久値を1消費する。血の石は決して消耗しない。,"강령술사의 팔지. 강타로 개인 보관 균열을 열며 크기는 강령술 스킬에 따라 커집니다. 블록을 조준한 채 일반 공격을 누르고 있으면 그 블록이 균열로 빨려들어갑니다. 밀착 시 10초이며 거리 1블록마다 1초씩 늘어납니다. 강타는 이를 중단하고 균열을 엽니다. 블록을 흡수하려면 개조 슬롯에 피의 구체나 피의 돌이 있어야 하며, 흡수 1초마다 구체의 내구도가 1씩 소모됩니다. 피의 돌은 결코 소모되지 않습니다.","Nekromancka bransoleta. Silny atak rozrywa osobistą szczelinę magazynową - jej rozmiar rośnie wraz z umiejętnością Nekromancji. Przytrzymaj zwykły atak na bloku a zostanie on wciągnięty do szczeliny: dziesięć sekund z bliska i jedna więcej za każdy blok odległości. Silny atak przerywa to i otwiera szczelinę. Wciąganie bloków wymaga krwawej sfery albo krwawego kamienia w gnieździe modyfikacji, a każda sekunda kosztuje punkt wytrzymałości. Krwawy kamień nie zużywa się nigdy.","Uma bracelete necromântica. O ataque forte abre uma fenda de armazenamento pessoal - seu tamanho cresce com a sua habilidade de Necromancia. Segure o ataque normal sobre um bloco e ele será puxado para a fenda: dez segundos de perto e mais um por bloco de distância. O ataque forte interrompe isso e abre a fenda. Para absorver blocos é preciso uma esfera de sangue ou uma pedra de sangue no encaixe, e cada segundo de absorção custa um ponto de durabilidade. A pedra de sangue nunca se gasta.","Некромантский браслет. Силовая атака вскрывает личный разлом-хранилище - его размер растёт вместе с навыком Некромантии. Зажмите обычную атаку на блоке - и он втянется в разлом: десять секунд вплотную и ещё по секунде за каждый блок расстояния. Силовая атака прерывает это и открывает хранилище. Чтобы поглощать блоки, в слоте модификаций браслета должна быть кровавая сфера или кровавый камень, и каждая секунда поглощения тратит единицу её прочности. А кровавый камень не тратится вовсе.",Nekromantik bir bileklik. Güçlü saldırı kişisel bir depo yarığı açar; boyutu Nekromantik becerinle birlikte büyür. Normal saldırıyı bir bloka basılı tut ve blok yarığa çekilsin: yakından on saniye ve her blokluk mesafe için bir saniye daha. Güçlü saldırı bunu keser ve yarığı açar. Blok çekmek için mod yuvasında bir kan küresi ya da kan taşı bulunmalı ve çekmenin her saniyesi bir dayanıklılık puanı götürür. Kan taşı ise hiç eksilmez.,亡灵法师的手镯。重击可撕开个人储物裂隙,其容量随亡灵法术技能增长。对准方块按住普通攻击,方块便会被拉入裂隙:贴近时为十秒,每远一格再加一秒。重击可中断并打开裂隙。吸收方块需要在改造槽中装有血之球或血石,且吸收每持续一秒便消耗一点耐久度。而血石则永不消耗。,亡靈法師的手鐠。重擊可撕開個人儲物裂隙,其容量隨亡靈法術技能增長。對準方塊按住普通攻擊,方塊便會被拉入裂隙:貼近時為十秒,每遠一格再加一秒。重擊可中斷並打開裂隙。吸收方塊需要在改造槽中裝有血之球或血石,且吸收每持續一秒便消耗一點耐久度。而血石則永不消耗。
braceletSpatialVaultWindowTitle,items,Item,,,,Spatial Storage,,Raumlager,Almacén espacial,Stockage spatial,Deposito spaziale,空間収納,공간 보관함,Schowek przestrzenny,Armazenamento espacial,Пространственное хранилище,Uzam Deposu,空间储物,空間儲物
braceletSpatialVaultTooWeak,items,Item,,,,Your Necromancy skill isn't strong enough yet to open a storage rift,,"Deine Nekromantie-Fertigkeit reicht noch nicht, um einen Lagerriss zu öffnen",Tu habilidad de Nigromancia aún no es lo bastante fuerte para abrir una grieta de almacenamiento,Ta compétence de Nécromancie n'est pas encore assez forte pour ouvrir une faille de stockage,La tua abilità di Negromanzia non è ancora abbastanza forte per aprire una fessura di stoccaggio,降霊術スキルがまだ足りず、収納の裂け目を開けません,강령술 기술이 아직 부족해 보관 균열을 열 수 없습니다,"Twoja Nekromancja jest jeszcze za słaba, by otworzyć szczelinę na zapasy",Sua habilidade de Necromancia ainda não é forte o bastante para abrir uma fenda de armazenamento,"Ваш навык Некромантии пока недостаточен, чтобы открыть разлом-хранилище",Nekromansi yeteneğin bir depo yarığı açacak kadar güçlü değil,你的亡灵法术技能尚不足以开启储物裂隙,你的亡靈法術技能尚不足以開啟儲物裂隙
braceletSpatialVaultNoTarget,items,Item,,,,There's no zombie in your sight,,Kein Zombie in Sicht,No hay ningún zombi a la vista,Aucun zombie en vue,Non c'è nessuno zombie nel mirino,視界にゾンビがいません,시야에 좀비가 없습니다,W polu widzenia nie ma zombie,Não há nenhum zumbi à vista,В поле зрения нет зомби,Görüş alanında zombi yok,视野中没有僵尸,視野中沒有殭屍
@@ -196,11 +196,29 @@ necroPossessHintBase,items,Item,,,,"LMB - strike, R - leave the body",,"LMT - Sc
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),右键 - 尖叫(召唤亡者),右鍵 - 尖叫(召喚亡者)
+necroPossessHintScreamF,items,Item,,,,F - scream (calls the dead to you),,F - Schrei (ruft die Toten herbei),F: grito (llama a los muertos),F : cri (appelle les morts),F - urlo (richiama i morti),F: 叫び(死者を呼ぶ),F - 비명 (죽은 자를 부름),F - krzyk (wzywa umarłych),F - grito (chama os mortos),F - крик (созвать мертвецов),F - çığlık (ölüleri çağırır),F - 尖叫(召唤亡者),F - 尖叫(召喚亡者)
+necroPossessHintZoom,items,Item,,,,RMB (hold) - keen eye: zoom like a scope,,RMT (halten) - scharfes Auge: Zoom wie ein Zielfernrohr,"RMB (mantener): ojo avizor, zoom como una mira","Clic droit (maintenir) : œil perçant, zoom comme une lunette",RMB (tieni premuto) - occhio acuto: zoom come un mirino,右クリック(長押し): 鋭い目・スコープのようにズーム,우클릭(누르기) - 매의 눈: 조준경처럼 확대,PPM (przytrzymaj) - bystre oko: przybliżenie jak luneta,RMB (segurar) - olho aguçado: zoom como uma luneta,"ПКМ (удерживать) - зоркий глаз: приближение, как у прицела",Sağ tık (basılı tut) - keskin göz: dürbün gibi yakınlaştı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ış,空格 - 远跳,空白鍵 - 遠跳
necroPetRecallHint,items,Item,,,,Recall pet - E,,Diener zurückrufen - E,Devolver al sirviente - E,Rappeler le serviteur - E,Richiama il servitore - E,僕を呼び戻す - E,종복 불러들이기 - E,Odwołaj sługę - E,Chamar o servo de volta - E,Отозвать питомца - E,Hizmetkârı geri çağır - E,召回仆从 - E,召回僕從 - E
necroPetNothingToPossess,items,Item,,,,No servant summoned to possess,,"Kein beschworener Diener, in den du fahren kannst",No hay ningún sirviente invocado que poseer,Aucun serviteur invoqué à posséder,Nessun servitore evocato da possedere,憑依できる僕が呼ばれていません,빙의할 종복이 소환되어 있지 않습니다,Brak przywołanego sługi do opętania,Nenhum servo invocado para possuir,Нет призванного слуги для вселения,Ele geçirilecek çağrılmış hizmetkâr yok,没有可附身的已召唤仆从,沒有可附身的已召喚僕從
necroPossessHintFly,items,Item,,,,"Space - up, C - down, Shift - dive",,"Leertaste - hoch, C - runter, Umschalt - Sturzflug","Espacio: subir, C: bajar, Mayús: picado","Espace : monter, C : descendre, Maj : piqué","Spazio - su, C - giù, Maiusc - picchiata",スペース: 上昇、C: 下降、Shift: 急降下,"스페이스 - 상승, C - 하강, Shift - 급강하","Spacja - w górę, C - w dół, Shift - nurkowanie","Espaço - subir, C - descer, Shift - mergulho","Пробел - вверх, C - вниз, Shift - пике","Boşluk - yukarı, C - aşağı, Shift - dalış",空格 - 上升,C - 下降,Shift - 俯冲,空白鍵 - 上升,C - 下降,Shift - 俯衝
-necroPossessHintEat,items,Item,,,,"F - feed on carrion (sitting by a corpse, heals)",,"F - Aas fressen (neben einer Leiche sitzend, heilt)","F: comer carroña (posado junto a un cadáver, cura)","F : se nourrir de charogne (posé près d'un cadavre, soigne)","F - nutrirsi di carogne (posato accanto a un cadavere, cura)",F: 死肉を食べる(死体のそばに止まって・回復),"F - 썩은 고기 먹기 (시체 옆에 앉아서, 회복)","F - żerowanie na padlinie (siedząc przy zwłokach, leczy)","F - comer carniça (pousado junto a um cadáver, cura)","F - питаться падалью (сидя у трупа, лечит)","F - leş ye (bir cesedin yanına konup, iyileştirir)",F - 食腐(落在尸体旁,治疗),F - 食腐(停在屍體旁,治療)
+necroPossessHintEat,items,Item,,,,"E - feed on carrion (sitting by a corpse, heals)",,"E - Aas fressen (neben einer Leiche sitzend, heilt)","E: comer carroña (posado junto a un cadáver, cura)","E : se nourrir de charogne (posé près d'un cadavre, soigne)","E - nutrirsi di carogne (posato accanto a un cadavere, cura)",E: 死肉を食べる(死体のそばに止まって・回復),"E - 썩은 고기 먹기 (시체 옆에 앉아서, 회복)","E - żerowanie na padlinie (siedząc przy zwłokach, leczy)","E - comer carniça (pousado junto a um cadáver, cura)","E - питаться падалью (сидя у трупа, лечит)","E - leş ye (bir cesedin yanına konup, iyileştirir)",E - 食腐(落在尸体旁,治疗),E - 食腐(停在屍體旁,治療)
+necroPossessHintLook,items,Item,,,,"E - look inside a container (view only, nothing can be taken)",,"E - in einen Behälter schauen (nur ansehen, nichts mitnehmen)","E: mirar dentro de un contenedor (solo ver, no se puede coger nada)","E : regarder dans un conteneur (voir seulement, rien ne peut être pris)","E - guardare in un contenitore (solo vedere, non si può prendere nulla)",E: 容器の中を見る(見るだけ・取れない),"E - 용기 안 살펴보기 (보기만, 가져갈 수 없음)","E - zajrzyj do pojemnika (tylko podgląd, nic nie można zabrać)","E - olhar dentro de um contêiner (só ver, nada pode ser pego)","E - заглянуть в контейнер (только посмотреть, взять нельзя)","E - bir kabın içine bak (sadece bakılır, hiçbir şey alınamaz)",E - 查看容器(只能看,不能拿),E - 查看容器(只能看,不能拿)
+necroPossessHintPhase,items,Item,,,,"RMB (hold) - pass through buildings (not through earth and rock)",,"RMT (halten) - durch Gebäude gehen (nicht durch Erde und Fels)","Clic derecho (mantener): atravesar construcciones (no tierra ni roca)","Clic droit (maintenu) : traverser les constructions (pas la terre ni la roche)","Tasto destro (tieni premuto) - attraversare le costruzioni (non terra e roccia)",右クリック(長押し):建物をすり抜ける(土や岩は不可),"우클릭(누르고 있기) - 건물 통과 (흙과 바위는 불가)","PPM (przytrzymaj) - przechodzenie przez budowle (nie przez ziemię i skały)","Botão direito (segurar) - atravessar construções (não terra e rocha)","ПКМ (держать) - сквозь постройки (не сквозь землю и камень)","Sağ tık (basılı tut) - yapıların içinden geç (toprak ve kayadan değil)",右键(按住)- 穿过建筑(不能穿过泥土和岩石),右鍵(按住)- 穿過建築(不能穿過泥土和岩石)
+necroPossessHintSmell,items,Item,,,,"keen nose: every creature nearby is marked on the compass and map",,"feine Nase: jedes Lebewesen in der Nähe ist auf Kompass und Karte markiert","olfato: toda criatura cercana aparece en la brújula y el mapa","flair : chaque créature proche est marquée sur la boussole et la carte","fiuto: ogni creatura vicina è segnata su bussola e mappa",嗅覚:近くの生き物すべてがコンパスと地図に表示される,"후각: 주변의 모든 생물이 나침반과 지도에 표시됨","węch: każde stworzenie w pobliżu jest zaznaczone na kompasie i mapie","faro: toda criatura por perto aparece na bússola e no mapa","нюх: все существа рядом отмечены на компасе и карте","koku alma: yakındaki her canlı pusulada ve haritada işaretlenir",嗅觉:附近的所有生物都会标在指南针和地图上,嗅覺:附近的所有生物都會標在指南針和地圖上
+necroPossessHintRoar,items,Item,,,,"RMB - roar: zombies nearby come for the bear",,"RMT - Brüllen: Zombies in der Nähe gehen auf den Bären los","Clic derecho: rugido, los zombis cercanos van a por el oso","Clic droit : rugissement, les zombies proches foncent sur l'ours","Tasto destro - ruggito: gli zombi vicini puntano l'orso",右クリック:咆哮、近くのゾンビが熊に向かってくる,"우클릭 - 포효: 주변 좀비가 곰에게 몰려옴","PPM - ryk: pobliskie zombie idą na niedźwiedzia","Botão direito - rugido: zumbis por perto vão atrás do urso","ПКМ - рёв: зомби рядом идут на медведя","Sağ tık - kükreme: yakındaki zombiler ayıya yönelir",右键 - 咆哮:附近的僵尸冲向熊,右鍵 - 咆哮:附近的殭屍衝向熊
+necroPossessHintSmash,items,Item,,,,"LMB on a block - heavy paw blow",,"LMT auf einen Block - schwerer Prankenhieb","Clic izquierdo en un bloque: zarpazo demoledor","Clic gauche sur un bloc : coup de patte dévastateur","Tasto sinistro su un blocco - zampata devastante",ブロックに左クリック:強烈な前足の一撃,"블록에 좌클릭 - 강력한 앞발 일격","LPM w blok - potężne uderzenie łapą","Botão esquerdo num bloco - patada devastadora","ЛКМ по блоку - тяжёлый удар лапой","Bloğa sol tık - ağır pençe darbesi",左键点方块 - 重掌拍击,左鍵點方塊 - 重掌拍擊
+necroPossessHintSkin,items,Item,,,,"thick hide: takes half damage",,"dickes Fell: nimmt halben Schaden","piel gruesa: recibe la mitad de daño","cuir épais : subit moitié moins de dégâts","pelle spessa: subisce metà danni",厚い毛皮:受けるダメージ半減,"두꺼운 가죽: 받는 피해 절반","gruba skóra: otrzymuje o połowę mniej obrażeń","couro grosso: recebe metade do dano","толстая шкура: получает вдвое меньше урона","kalın post: yarı hasar alır",厚皮:受到的伤害减半,厚皮:受到的傷害減半
+necroPossessHintPounce,items,Item,,,,"RMB - pounce on the target in your sights, it falls",,"RMT - Sprung auf das anvisierte Ziel, es stürzt","Clic derecho: abalanzarse sobre el objetivo, cae al suelo","Clic droit : bondir sur la cible visée, elle tombe","Tasto destro - balzo sul bersaglio nel mirino, cade",右クリック:狙った標的に飛びかかり、倒す,"우클릭 - 조준한 대상에게 덮쳐 쓰러뜨림","PPM - skok na cel w celowniku, przewraca go","Botão direito - salto sobre o alvo na mira, ele cai","ПКМ - бросок на цель в прицеле, она падает","Sağ tık - nişandaki hedefe atıl, hedef düşer",右键 - 扑向准星中的目标,将其扑倒,右鍵 - 撲向準星中的目標,將其撲倒
+necroPossessHintBlood,items,Item,,,,"blood trail: wounded enemies are marked on the compass and map",,"Blutspur: verwundete Feinde sind auf Kompass und Karte markiert","rastro de sangre: los enemigos heridos aparecen en la brújula y el mapa","piste de sang : les ennemis blessés sont marqués sur la boussole et la carte","traccia di sangue: i nemici feriti sono segnati su bussola e mappa",血の跡:傷ついた敵がコンパスと地図に表示される,"피의 흔적: 부상당한 적이 나침반과 지도에 표시됨","krwawy trop: ranni wrogowie są zaznaczeni na kompasie i mapie","rastro de sangue: inimigos feridos aparecem na bússola e no mapa","след крови: раненые враги отмечены на компасе и карте","kan izi: yaralı düşmanlar pusulada ve haritada işaretlenir",血迹:受伤的敌人会标在指南针和地图上,血跡:受傷的敵人會標在指南針和地圖上
+necroPossessHintWolfBag,items,Item,,,,"E at a container - loot into the 3-slot bag, E next to your body - unload it",,"E am Behälter - in die Tasche mit 3 Plätzen plündern, E neben deinem Körper - ausladen","E en un contenedor: saquear a la bolsa de 3 huecos; E junto a tu cuerpo: descargarla","E sur un conteneur : piller dans le sac à 3 emplacements ; E près de ton corps : le vider","E su un contenitore - saccheggia nella borsa da 3 posti, E vicino al tuo corpo - svuotala","容器でE:3枠の袋に略奪、体のそばでE:袋を降ろす","용기에서 E - 3칸 가방에 약탈, 몸 옆에서 E - 가방 비우기","E przy pojemniku - łup do torby na 3 miejsca, E przy ciele - rozładuj","E num contêiner - saquear para a bolsa de 3 espaços, E junto ao corpo - descarregar","E у контейнера - лутать в сумку на 3 ячейки, E у тела - выгрузить","Kapta E - 3 yuvalı çantaya yağmala, bedeninin yanında E - boşalt","在容器处按E - 拾取到3格背包,在身体旁按E - 卸下","在容器處按E - 拾取到3格背包,在身體旁按E - 卸下"
+necroPossessHintAfterFear,items,Item,,,,"after you leave, the zombie flees in chilling terror",,"nach dem Verlassen flieht der Zombie in eisigem Schrecken","al salir, el zombi huye presa de un terror helado","quand tu le quittes, le zombie fuit, glacé d'effroi","quando lo lasci, lo zombi fugge in preda al terrore","離れるとゾンビは凍てつく恐怖で逃げ出す","빠져나가면 좀비는 얼어붙는 공포에 질려 달아남","po wyjściu zombie ucieka w lodowatym przerażeniu","ao sair, o zumbi foge em terror gelado","после выхода зомби в леденящем ужасе убегает","çıktığında zombi dondurucu bir dehşetle kaçar","离开后,僵尸会在刺骨的恐惧中逃跑","離開後,殭屍會在刺骨的恐懼中逃跑"
+necroWolfBagEmpty,items,Item,,,,"empty",,"leer","vacía","vide","vuota","空","비어 있음","pusta","vazia","пусто","boş","空","空"
+necroWolfBagTook,items,Item,,,,"Wolf bag ({1}/{2}): {0}",,"Wolfstasche ({1}/{2}): {0}","Bolsa del lobo ({1}/{2}): {0}","Sac du loup ({1}/{2}) : {0}","Borsa del lupo ({1}/{2}): {0}","狼の袋({1}/{2}):{0}","늑대 가방 ({1}/{2}): {0}","Torba wilka ({1}/{2}): {0}","Bolsa do lobo ({1}/{2}): {0}","Сумка волка ({1}/{2}): {0}","Kurt çantası ({1}/{2}): {0}","狼的背包({1}/{2}):{0}","狼的背包({1}/{2}):{0}"
+necroWolfBagFull,items,Item,,,,"The wolf bag is full ({1}/{2}): {0}",,"Die Wolfstasche ist voll ({1}/{2}): {0}","La bolsa del lobo está llena ({1}/{2}): {0}","Le sac du loup est plein ({1}/{2}) : {0}","La borsa del lupo è piena ({1}/{2}): {0}","狼の袋がいっぱい({1}/{2}):{0}","늑대 가방이 가득 찼습니다 ({1}/{2}): {0}","Torba wilka jest pełna ({1}/{2}): {0}","A bolsa do lobo está cheia ({1}/{2}): {0}","Сумка волка полна ({1}/{2}): {0}","Kurt çantası dolu ({1}/{2}): {0}","狼的背包已满({1}/{2}):{0}","狼的背包已滿({1}/{2}):{0}"
+necroWolfBagShow,items,Item,,,,"Wolf bag ({1}/{2}): {0}",,"Wolfstasche ({1}/{2}): {0}","Bolsa del lobo ({1}/{2}): {0}","Sac du loup ({1}/{2}) : {0}","Borsa del lupo ({1}/{2}): {0}","狼の袋({1}/{2}):{0}","늑대 가방 ({1}/{2}): {0}","Torba wilka ({1}/{2}): {0}","Bolsa do lobo ({1}/{2}): {0}","Сумка волка ({1}/{2}): {0}","Kurt çantası ({1}/{2}): {0}","狼的背包({1}/{2}):{0}","狼的背包({1}/{2}):{0}"
+necroWolfBagUnloaded,items,Item,,,,"The wolf bag is unloaded into your backpack",,"Die Wolfstasche ist in deinen Rucksack ausgeladen","La bolsa del lobo se ha descargado en tu mochila","Le sac du loup est vidé dans ton sac à dos","La borsa del lupo è stata svuotata nel tuo zaino","狼の袋をバックパックに降ろした","늑대 가방을 배낭에 비웠습니다","Torba wilka rozładowana do plecaka","A bolsa do lobo foi descarregada na sua mochila","Сумка волка выгружена в рюкзак","Kurt çantası sırt çantana boşaltıldı","狼的背包已卸到你的背包里","狼的背包已卸到你的背包裡"
+necroWolfBagUnloadedPart,items,Item,,,,"The wolf bag is unloaded; what did not fit is in a bag on the ground",,"Die Wolfstasche ist ausgeladen; was nicht passte, liegt in einem Beutel am Boden","La bolsa del lobo se ha descargado; lo que no cabía está en una bolsa en el suelo","Le sac du loup est vidé ; ce qui ne rentrait pas est dans un sac au sol","La borsa del lupo è svuotata; ciò che non entrava è in un sacco a terra","狼の袋を降ろした。入りきらない物は地面の袋の中","늑대 가방을 비웠습니다. 들어가지 않은 것은 바닥의 자루에 있습니다","Torba wilka rozładowana; co się nie zmieściło, leży w worku na ziemi","A bolsa do lobo foi descarregada; o que não coube está num saco no chão","Сумка волка выгружена; что не влезло - в мешке на земле","Kurt çantası boşaltıldı; sığmayanlar yerdeki torbada","狼的背包已卸下;放不下的在地上的袋子里","狼的背包已卸下;放不下的在地上的袋子裡"
necroPossessNoCarrion,items,Item,,,,"No carrion nearby - land next to a corpse",,"Kein Aas in der Nähe - neben einer Leiche landen","No hay carroña cerca: posa junto a un cadáver","Pas de charogne ici - posez-vous près d'un cadavre","Nessuna carogna vicino - posati accanto a un cadavere",近くに死肉がない - 死体のそばに降りろ,"근처에 썩은 고기가 없다 - 시체 옆에 내려앉아라","Brak padliny w pobliżu - wyląduj przy zwłokach","Nenhuma carniça por perto - pouse junto a um cadáver","Падали рядом нет - сядьте возле трупа","Yakında leş yok - bir cesedin yanına kon",附近没有腐肉 - 落在尸体旁边,附近沒有腐肉 - 停在屍體旁邊
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,地脉骨堂,地脈骨堂
@@ -317,11 +335,7 @@ plantedNecroBloodRose3HarvestPlayer,blocks,Block,,,,Blood Rose (in bloom),,Blutr
plantedNecroBloodRose3HarvestPlayerDesc,blocks,Block,,,,The bud has opened into a black flower. Harvest it to get a Black Rose.,,"Die Knospe hat sich zu einer schwarzen Blüte geöffnet. Ernte sie, um eine Schwarze Rose zu erhalten.",El capullo se ha abierto en una flor negra. Coséchala para obtener una Rosa negra.,Le bouton s'est ouvert en une fleur noire. Récoltez-la pour obtenir une Rose noire.,Il bocciolo si è aperto in un fiore nero. Raccoglilo per ottenere una Rosa nera.,つぼみが黒い花を開いた。収穫すると黒いバラが手に入る。,꽃봉오리가 검은 꽃으로 피었다. 수확하면 검은 장미를 얻는다.,"Pąk rozwinął się w czarny kwiat. Zbierz go, aby zdobyć Czarną różę.",O botão se abriu em uma flor negra. Colha-a para obter uma Rosa negra.,"Бутон раскрылся чёрным цветком. Собери его, чтобы получить чёрную розу.",Tomurcuk kara bir çiçek olarak açtı. Kara Gül elde etmek için hasat et.,花苞绽放成了一朵黑花。收获即可获得黑玫瑰。,花苞綻放成了一朵黑花。收穫即可獲得黑玫瑰。
resourceNecroBlackRose,items,Item,,,,Black Rose,,Schwarze Rose,Rosa negra,Rose noire,Rosa nera,黒いバラ,검은 장미,Czarna róża,Rosa negra,Чёрная роза,Kara Gül,黑玫瑰,黑玫瑰
necroSkull,items,Item,,,,Skull,,Schädel,Cráneo,Crâne,Teschio,頭蓋骨,두개골,Czaszka,Crânio,Череп,Kafatası,头骨,頭骨
-necroSkullDesc,items,Item,,,,"Remains torn out of a victim's corpse by the Spatial Bracelet. Something of the dead one's will still clings to it. Placed in a cinerarium, it becomes a spirit worker.",,"Überreste, die das Raumarmband aus der Leiche eines Opfers gerissen hat. Etwas vom Willen des Toten haftet noch daran. In ein Cinerarium gelegt, wird daraus ein Geisterarbeiter.","Restos arrancados del cadáver de una víctima con el brazalete espacial. Aún conservan algo de la voluntad del muerto. Colocado en un cinerario, se convierte en un espíritu trabajador.","Restes arrachés au cadavre d'une victime par le bracelet spatial. Il y subsiste un peu de la volonté du mort. Placé dans un cinéraire, il devient un esprit travailleur.","Resti strappati dal cadavere di una vittima con il bracciale spaziale. Vi resta ancora qualcosa della volontà del morto. Posto in un cinerario, diventa uno spirito lavoratore.",空間の腕輪で生贄の死体から引き抜いた遺骸。死者の意志がまだ宿っている。納骨壺に納めると、働く霊になる。,공간의 팔찌로 제물의 시체에서 뜯어낸 유해. 죽은 자의 의지가 아직 남아 있다. 유골함에 넣으면 일하는 영혼이 된다.,Szczątki wyrwane z ciała ofiary przestrzenną bransoletą. Wciąż tkwi w nich coś z woli zmarłego. Umieszczona w cinerarium staje się duchem-robotnikiem.,"Restos arrancados do cadáver de uma vítima pelo bracelete espacial. Ainda guardam algo da vontade do morto. Colocado num cinerário, torna-se um espírito trabalhador.","Останки, вырванные пространственным браслетом из трупа жертвы. В них ещё держится что-то от воли умершего. Положенный в цинерарий, череп становится духом-работником.",Uzam Bileziği ile bir kurbanın cesedinden koparılmış kalıntılar. Ölünün iradesinden bir şeyler hâlâ üzerinde. Bir küllüğe konduğunda çalışan bir ruha dönüşür.,用空间手环从祭品尸体中扯出的遗骸。死者的意志仍残留其上。放入骨灰瓮后,会化为劳作的灵魂。,用空間手環從祭品屍體中扯出的遺骸。死者的意志仍殘留其上。放入骨灰甕後,會化為勞作的靈魂。
-necroSkullChanneling,items,Item,,,,Tearing out the skull...,,Schädel wird herausgerissen...,Arrancando el cráneo...,Arrachage du crâne...,Strappo del teschio...,頭蓋骨を引き抜いている…,두개골을 뜯어내는 중...,Wyrywanie czaszki...,Arrancando o crânio...,Вырываю череп...,Kafatası koparılıyor...,正在扯出头骨……,正在扯出頭骨……
-necroSkullAlreadyTaken,items,Item,,,,The skull has already been taken from this corpse,,Aus dieser Leiche wurde der Schädel schon genommen,Ya se ha sacado el cráneo de este cadáver,Le crâne a déjà été pris sur ce cadavre,Il teschio è già stato preso da questo cadavere,この死体の頭蓋骨はもう取られている,이 시체의 두개골은 이미 가져갔다,Z tego ciała czaszkę już zabrano,O crânio deste cadáver já foi retirado,Череп из этого трупа уже извлечён,Bu cesedin kafatası zaten alındı,这具尸体的头骨已被取走,這具屍體的頭骨已被取走
-necroSkullExtracted,items,Item,,,,The skull is in the rift,,Der Schädel ist im Riss,El cráneo está en la grieta,Le crâne est dans la faille,Il teschio è nello squarcio,頭蓋骨は裂け目の中へ,두개골이 균열 속으로 들어갔다,Czaszka jest w szczelinie,O crânio está na fenda,Череп в хранилище,Kafatası yarıkta,头骨已进入裂隙,頭骨已進入裂隙
-necroSkullFailed,items,Item,,,,The corpse could not hold together,,Die Leiche hat nicht standgehalten,El cadáver no aguantó,Le cadavre n'a pas tenu,Il cadavere non ha retto,死体は持ちこたえられなかった,시체가 버티지 못했다,Ciało nie wytrzymało,O cadáver não aguentou,Труп не выдержал,Ceset dayanamadı,尸体没能撑住,屍體沒能撐住
+necroSkullDesc,items,Item,,,,"Remains sometimes found when searching a victim's red corpse. Something of the dead one's will still clings to it. Placed in a cinerarium, it becomes a spirit worker.",,"Überreste, die man beim Durchsuchen der roten Leiche eines Opfers manchmal findet. Etwas vom Willen des Toten haftet noch daran. In ein Cinerarium gelegt, wird daraus ein Geisterarbeiter.","Restos que a veces aparecen al registrar el cadáver rojo de una víctima. Aún conservan algo de la voluntad del muerto. Colocado en un cinerario, se convierte en un espíritu trabajador.","Restes que l'on trouve parfois en fouillant le cadavre rouge d'une victime. Il y subsiste un peu de la volonté du mort. Placé dans un cinéraire, il devient un esprit travailleur.","Resti che a volte si trovano perquisendo il cadavere rosso di una vittima. Vi resta ancora qualcosa della volontà del morto. Posto in un cinerario, diventa uno spirito lavoratore.",生贄の赤い死体を漁ると、まれに見つかる遺骸。死者の意志がまだ宿っている。納骨壺に納めると、働く霊になる。,제물의 붉은 시체를 뒤지면 가끔 발견되는 유해. 죽은 자의 의지가 아직 남아 있다. 유골함에 넣으면 일하는 영혼이 된다.,"Szczątki, które czasem znajduje się, przeszukując czerwone ciało ofiary. Wciąż tkwi w nich coś z woli zmarłego. Umieszczona w cinerarium staje się duchem-robotnikiem.","Restos que às vezes aparecem ao revistar o cadáver vermelho de uma vítima. Ainda guardam algo da vontade do morto. Colocado num cinerário, torna-se um espírito trabalhador.","Останки, которые изредка находятся при обыске красного трупа жертвы. В них ещё держится что-то от воли умершего. Положенный в цинерарий, череп становится духом-работником.",Bir kurbanın kırmızı cesedi aranırken bazen bulunan kalıntılar. Ölünün iradesinden bir şeyler hâlâ üzerinde. Bir küllüğe konduğunda çalışan bir ruha dönüşür.,搜刮祭品的红色尸体时偶尔能找到的遗骸。死者的意志仍残留其上。放入骨灰瓮后,会化为劳作的灵魂。,搜刮祭品的紅色屍體時偶爾能找到的遺骸。死者的意志仍殘留其上。放入骨灰甕後,會化為勞作的靈魂。
resourceNecroBlackRoseDesc,items,Item,,,,A black flower grown from a blood seed. It smells of iron and damp earth. Grind four of them in a mortar for a jar of black rose juice.,,"Eine schwarze Blüte, aus einem Blutsamen gewachsen. Sie riecht nach Eisen und feuchter Erde. Vier davon im Mörser ergeben ein Glas Schwarzrosensaft.",Una flor negra nacida de una semilla de sangre. Huele a hierro y a tierra húmeda. Cuatro machacadas en un mortero dan un frasco de jugo de rosa negra.,Une fleur noire née d'une graine de sang. Elle sent le fer et la terre humide. Quatre broyées au mortier donnent un bocal de jus de rose noire.,Un fiore nero cresciuto da un seme di sangue. Odora di ferro e di terra umida. Quattro pestate nel mortaio danno un barattolo di succo di rosa nera.,血の種から育った黒い花。鉄と湿った土の匂いがする。乳鉢で4本すり潰すと黒バラの汁が1瓶できる。,피의 씨앗에서 자란 검은 꽃. 쇠와 축축한 흙 냄새가 난다. 절구에 네 송이를 빻으면 검은 장미 즙 한 병이 된다.,Czarny kwiat wyrosły z krwawego nasiona. Pachnie żelazem i wilgotną ziemią. Cztery utarte w moździerzu dają słoik soku z czarnej róży.,Uma flor negra nascida de uma semente de sangue. Cheira a ferro e terra úmida. Quatro moídas no almofariz rendem um pote de suco de rosa negra.,"Чёрный цветок, выросший из кровавого семени. Пахнет железом и сырой землёй. Четыре цветка, растёртые в ступке, дают банку сока чёрной розы.",Kan tohumundan büyümüş kara bir çiçek. Demir ve nemli toprak kokar. Havanda ezilen dört tanesi bir kavanoz kara gül özü verir.,从血之种子中长出的黑花。散发着铁锈与湿土的气味。在研钵中捣碎四朵可得一罐黑玫瑰汁。,從血之種子中長出的黑花。散發著鐵鏽與濕土的氣味。在研缽中搗碎四朵可得一罐黑玫瑰汁。
necroMortar,blocks,Block,,,,Mortar and Pestle,,Mörser und Stößel,Mortero y maja,Mortier et pilon,Mortaio e pestello,乳鉢と乳棒,절구와 공이,Moździerz z tłuczkiem,Pilão e almofariz,Ступка с пестиком,Havan ve Tokmak,研钵与研杵,研缽與研杵
necroMortarDesc,blocks,Block,,,,A stone mortar for grinding black roses into juice. Place it and open it like any workbench.,,"Ein steinerner Mörser, um schwarze Rosen zu Saft zu zerstoßen. Aufstellen und wie eine Werkbank öffnen.",Un mortero de piedra para machacar rosas negras hasta sacarles el jugo. Colócalo y ábrelo como cualquier banco de trabajo.,Un mortier de pierre pour broyer les roses noires en jus. Posez-le et ouvrez-le comme un établi.,Un mortaio di pietra per pestare le rose nere fino a ricavarne il succo. Posizionalo e aprilo come un banco da lavoro.,黒いバラをすり潰して汁にするための石の乳鉢。設置して作業台のように開く。,검은 장미를 빻아 즙을 내는 돌절구. 설치한 뒤 작업대처럼 연다.,Kamienny moździerz do ucierania czarnych róż na sok. Postaw go i otwórz jak warsztat.,Um almofariz de pedra para moer rosas negras até virar suco. Coloque-o e abra-o como uma bancada.,"Каменная ступка, чтобы растирать чёрные розы в сок. Поставь и открой, как верстак.",Kara gülleri ezip özüne dönüştürmek için taş bir havan. Yerleştir ve bir tezgah gibi aç.,用来把黑玫瑰捣成汁的石研钵。放置后像工作台一样打开。,用來把黑玫瑰搗成汁的石研缽。放置後像工作台一樣打開。
diff --git a/Config/entityclasses.xml b/Config/entityclasses.xml
index 3a5708a..22de7c9 100644
--- a/Config/entityclasses.xml
+++ b/Config/entityclasses.xml
@@ -57,6 +57,10 @@
+
+
+
@@ -356,7 +364,7 @@
-
diff --git a/Config/nav_objects.xml b/Config/nav_objects.xml
index 2c004bc..fb7404e 100644
--- a/Config/nav_objects.xml
+++ b/Config/nav_objects.xml
@@ -14,7 +14,8 @@
животных (перк следопыта), а не через прямое назначение. -->
-
+ и skull (череп, он же значок самой Некромантии), меняется одна строка.
+ С 08.10 - значок дебаффа «Почти смерть» (ui_game_symbol_near_death_trauma, вешается игроку
+ после гибели): «больше похож на смерть с косой» (указание 08.10), как у одержимого. -->
-
+
-
+
-
+
-
+
@@ -85,10 +88,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HarmonySrc/BearControl.cs b/HarmonySrc/BearControl.cs
new file mode 100644
index 0000000..a24e792
--- /dev/null
+++ b/HarmonySrc/BearControl.cs
@@ -0,0 +1,113 @@
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Одержимый медведь - танк (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
+ /// - толстая шкура: пока в нём игрок, урон по нему вдвое меньше (вне одержимости медведь прежний);
+ /// - рёв на ПКМ: зомби ближе RoarRange бросают свои цели и идут на медведя, спящие просыпаются -
+ /// увести толпу от тела или базы. Та же провокация, что каждую секунду делает одержимость
+ /// (Possession.Taunt), только шире, без «видит ли» и на 20 с;
+ /// - удар лапой по блоку: ЛКМ по блоку под прицелом бьёт его на SmashDamage (дерево и двери за 2-3
+ /// удара, бетон дольше).
+ ///
+ public static class BearControl
+ {
+ public const string BearClass = "necroZombieBear";
+ public const float RoarRange = 15f;
+ public const float RoarCooldown = 8f;
+ public const int RoarTicks = 20 * 20;
+ public const string RoarSound = "bearalert";
+ /// Урон блоку за удар лапой. Для сравнения: удар ванильного зомби-медведя по блокам - десятки.
+ public const int SmashDamage = 300;
+ /// Досягаемость лапы от головы медведя, м.
+ public const float SmashReach = 2.6f;
+ public const float SkinFactor = 0.5f;
+ static float _nextRoar;
+
+ public static bool Is(EntityAlive _e)
+ {
+ return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
+ cls.entityClassName == BearClass;
+ }
+
+ public static void Roar(World _world, EntityAlive _bear)
+ {
+ if (Time.time < _nextRoar)
+ {
+ return;
+ }
+ _nextRoar = Time.time + RoarCooldown;
+ _bear.PlayOneShot(RoarSound);
+ _bear.emodel?.avatarController?.StartAnimationAttack();
+ int n = 0;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null || e == _bear || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
+ PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _bear.position).sqrMagnitude > RoarRange * RoarRange)
+ {
+ continue;
+ }
+ if (e.IsSleeping)
+ {
+ e.ConditionalTriggerSleeperWakeUp();
+ }
+ Possession.AllowZombieTarget(e, _bear);
+ e.SetAttackTarget(_bear, RoarTicks);
+ n++;
+ }
+ Debug.Log("[NecromancerTome] BearControl: рёв - на медведя идут " + n);
+ }
+
+ /// ЛКМ: блок под прицелом в досягаемости лапы (не существо) - урон SmashDamage.
+ public static void Smash(World _world, EntityPlayerLocal _player, EntityAlive _bear)
+ {
+ Camera cam = _player.playerCamera;
+ if (cam == null)
+ {
+ return;
+ }
+ Vector3 head = _bear.getHeadPosition();
+ Vector3 from = cam.transform.position + Origin.position;
+ Ray ray = new Ray(from, cam.transform.forward);
+ // С существами: если первым на луче стоит существо - это удар по нему, а не по блоку.
+ if (!Voxel.Raycast(_world, ray, Vector3.Distance(from, head) + SmashReach + 1f, -555528221, 69, 0f))
+ {
+ return;
+ }
+ WorldRayHitInfo hit = Voxel.voxelRayHitInfo;
+ if (hit.transform != null && GameUtils.GetHitRootTransform(hit.tag, hit.transform)?.GetComponent() != null)
+ {
+ return;
+ }
+ if ((hit.hit.pos - head).sqrMagnitude > SmashReach * SmashReach)
+ {
+ return;
+ }
+ Vector3i pos = hit.hit.blockPos;
+ BlockValue bv = _world.GetBlock(pos);
+ if (bv.isair || bv.Block == null)
+ {
+ return;
+ }
+ bv.Block.DamageBlock(_world, new BlockValueRef(pos), bv, SmashDamage, _bear.entityId);
+ Debug.Log("[NecromancerTome] BearControl: лапой по " + bv.Block.GetBlockName() + " " + pos + " - " + SmashDamage +
+ " (прочность " + (bv.Block.MaxDamage - bv.damage) + " из " + bv.Block.MaxDamage + ")");
+ }
+ }
+
+ /// Толстая шкура одержимого медведя - вдвое меньше урона.
+ [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.DamageEntity))]
+ public static class Patch_EntityAlive_DamageEntity_BearSkin
+ {
+ public static void Prefix(EntityAlive __instance, ref int _strength)
+ {
+ if (_strength > 1 && Possession.IsPossessed(__instance) && BearControl.Is(__instance))
+ {
+ _strength = Mathf.Max(1, Mathf.RoundToInt(_strength * BearControl.SkinFactor));
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/CarryVisual.cs b/HarmonySrc/CarryVisual.cs
new file mode 100644
index 0000000..ef4f22e
--- /dev/null
+++ b/HarmonySrc/CarryVisual.cs
@@ -0,0 +1,339 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Ноша в когтях одержимого стервятника (указание 08.10: «сделать курицу в когтях стервятника без хитбокса
+ /// и отдельным элементом, чтобы крылья и шея свисали вниз»).
+ ///
+ /// Раньше под лапы каждый кадр переставлялась сама курица, и было две беды. Рывки: модель сущности
+ /// догоняет её позицию с задержкой (Entity.updateTransform - Lerp), а позиция стервятника меняется
+ /// 20 раз в секунду - курица отставала и прыгала. Твёрдость: её коллайдеры мешали спускаться, и
+ /// стервятник садился на неё.
+ ///
+ /// Теперь при захвате с курицы снимается слепок: кости крыльев и шеи развёрнуты вниз, сетки запечены
+ /// (BakeMesh) в неподвижные MeshRenderer без коллайдеров. Слепок висит под стервятником по его
+ /// видимой (сглаженной) позиции. Сама курица спрятана (forceRenderingOff - его не трогает
+ /// avatarController.SetVisible), коллайдеры выключены, ИИ стоит; её позиция идёт за слепком,
+ /// и когда стервятник её отпускает, она появляется ровно там, где висел слепок.
+ ///
+ public static class CarryVisual
+ {
+ static GameObject _holder;
+ static readonly List _meshes = new List();
+ static readonly List _hidden = new List();
+ static readonly List _off = new List();
+ /// Верх и низ слепка относительно его опоры, м.
+ static float _top, _bottom;
+ /// Сколько крыло отводится в сторону от вертикали и шея - вперёд.
+ const float WingOut = 0.35f;
+ const float NeckForward = 0.35f;
+ static bool _loggedBones;
+
+ public static void Begin(EntityAlive _c)
+ {
+ Clear();
+ Transform model = _c.emodel != null ? _c.emodel.GetModelTransform() : null;
+ if (model == null)
+ {
+ Debug.LogWarning("[NecromancerTome] CarryVisual: у " + _c.EntityName + " нет модели - несём как есть");
+ return;
+ }
+ // Не только свои: физическое тело для толкания (physicsRBT) игра выносит из иерархии сущности
+ // (Entity.PhysicsInit - SetParent наружу), и на нём стервятник «сидел» в воздухе (тест 09.10).
+ foreach (Transform root in new[] { _c.transform, _c.PhysicsTransform, _c.physicsRBT })
+ {
+ if (root == null)
+ {
+ continue;
+ }
+ foreach (Collider col in root.GetComponentsInChildren(true))
+ {
+ if (col != null && col.enabled)
+ {
+ col.enabled = false;
+ _off.Add(col);
+ }
+ }
+ }
+ Limp(_c, model);
+
+ _holder = new GameObject("necroCarry_" + _c.entityId);
+ _holder.transform.SetPositionAndRotation(_c.transform.position, Quaternion.Euler(0f, _c.transform.eulerAngles.y, 0f));
+ bool any = false;
+ _top = float.MinValue;
+ _bottom = float.MaxValue;
+ foreach (Renderer r in model.GetComponentsInChildren(false))
+ {
+ if (r == null || !r.enabled || r.forceRenderingOff)
+ {
+ continue;
+ }
+ Mesh mesh;
+ if (r is SkinnedMeshRenderer smr)
+ {
+ if (smr.sharedMesh == null)
+ {
+ continue;
+ }
+ mesh = new Mesh();
+ smr.BakeMesh(mesh, true);
+ _meshes.Add(mesh);
+ }
+ else if (r is MeshRenderer && r.TryGetComponent(out MeshFilter mf) && mf.sharedMesh != null)
+ {
+ mesh = mf.sharedMesh;
+ }
+ else
+ {
+ continue;
+ }
+ GameObject part = new GameObject(r.name);
+ part.transform.SetPositionAndRotation(r.transform.position, r.transform.rotation);
+ part.transform.SetParent(_holder.transform, true);
+ if (!(r is SkinnedMeshRenderer))
+ {
+ part.transform.localScale = r.transform.lossyScale;
+ }
+ part.AddComponent().sharedMesh = mesh;
+ MeshRenderer mr = part.AddComponent();
+ mr.sharedMaterials = r.sharedMaterials;
+ mr.shadowCastingMode = r.shadowCastingMode;
+ // Высота - по самой запечённой сетке: r.bounds у SkinnedMeshRenderer - рамка с запасом, по ней
+ // курица висела далеко под лапами (тест 09.10, «высота 0.99 м»).
+ foreach (Vector3 v in mesh.vertices)
+ {
+ float y = part.transform.TransformPoint(v).y - _holder.transform.position.y;
+ _top = Mathf.Max(_top, y);
+ _bottom = Mathf.Min(_bottom, y);
+ }
+ any = true;
+ }
+ if (!any)
+ {
+ _top = 0.3f;
+ _bottom = 0f;
+ }
+ foreach (Renderer r in _c.GetComponentsInChildren(true))
+ {
+ if (r != null && !r.forceRenderingOff)
+ {
+ r.forceRenderingOff = true;
+ _hidden.Add(r);
+ }
+ }
+ Debug.Log("[NecromancerTome] CarryVisual: слепок " + _c.EntityName + " - частей " + _holder.transform.childCount +
+ ", высота " + (_top - _bottom).ToString("F2") + " м, выключено коллайдеров " + _off.Count);
+ }
+
+ /// Каждый кадр: слепок под лапами стервятника по его видимой позиции; у земли - стоит на ней.
+ public static void Place(EntityAlive _v, EntityAlive _c, float _gap)
+ {
+ Vector3 feet = Claws(_v);
+ Quaternion yaw = Quaternion.Euler(0f, _v.transform.eulerAngles.y, 0f);
+ float y = feet.y - _top - _gap;
+ if (Physics.Raycast(feet + Vector3.up * 0.1f, Vector3.down, out RaycastHit hit, _top - _bottom + _gap + 0.1f,
+ SnakeControl.WallMask, QueryTriggerInteraction.Ignore) && hit.point.y > y + _bottom)
+ {
+ y = hit.point.y - _bottom;
+ }
+ Vector3 at = new Vector3(feet.x, y, feet.z);
+ if (_holder != null)
+ {
+ _holder.transform.SetPositionAndRotation(at, yaw);
+ }
+ _c.SetPosition(at + Origin.position);
+ }
+
+ static int _clawsOf = -1;
+ static readonly List _claws = new List();
+
+ /// Точка хвата - середина между лапами стервятника (кости с «toe»/«foot» в имени, а без них - «leg»),
+ /// по анимации, каждый кадр (тест 08.10: «курица висит под стервятником, а не в его когтях»). Костей нет -
+ /// низ его модели.
+ static Vector3 Claws(EntityAlive _v)
+ {
+ if (_clawsOf != _v.entityId)
+ {
+ _clawsOf = _v.entityId;
+ _claws.Clear();
+ Transform model = _v.emodel != null ? _v.emodel.GetModelTransform() : null;
+ if (model != null)
+ {
+ List all = new List();
+ foreach (string key in new[] { "toe", "foot", "leg" })
+ {
+ foreach (Transform t in model.GetComponentsInChildren(true))
+ {
+ string n = t.name.ToLowerInvariant();
+ if (key == "toe" || key == "foot" ? n.Contains(key) : n.Contains(key) && !n.Contains("up") && !n.Contains("thigh"))
+ {
+ _claws.Add(t);
+ }
+ }
+ if (_claws.Count > 0)
+ {
+ break;
+ }
+ }
+ foreach (Transform t in model.GetComponentsInChildren(true))
+ {
+ all.Add(t.name);
+ }
+ Debug.Log("[NecromancerTome] CarryVisual: лапы " + _v.EntityName + ": " +
+ (_claws.Count > 0 ? string.Join(", ", _claws.ConvertAll(t => t.name)) : "не найдены") +
+ "; все кости: " + string.Join(", ", all));
+ }
+ }
+ if (_claws.Count == 0)
+ {
+ return _v.transform.position;
+ }
+ Vector3 sum = Vector3.zero;
+ float low = float.MaxValue;
+ foreach (Transform t in _claws)
+ {
+ sum += t.position;
+ low = Mathf.Min(low, t.position.y);
+ }
+ Vector3 c = sum / _claws.Count;
+ c.y = low;
+ return c;
+ }
+
+ /// Отпустить: курица снова видна и тверда там, где висел слепок.
+ public static void End(EntityAlive _c)
+ {
+ if (_c != null && _holder != null && !_c.IsDead())
+ {
+ Vector3 at = _holder.transform.position;
+ _c.SetPosition(at + Origin.position);
+ _c.transform.position = at;
+ }
+ foreach (Renderer r in _hidden)
+ {
+ if (r != null)
+ {
+ r.forceRenderingOff = false;
+ }
+ }
+ foreach (Collider col in _off)
+ {
+ if (col != null)
+ {
+ col.enabled = true;
+ }
+ }
+ _hidden.Clear();
+ _off.Clear();
+ Clear();
+ }
+
+ /// Убрать слепок, ничего не возвращая (выход из мира).
+ public static void Clear()
+ {
+ if (_holder != null)
+ {
+ Object.Destroy(_holder);
+ }
+ _holder = null;
+ foreach (Mesh m in _meshes)
+ {
+ if (m != null)
+ {
+ Object.Destroy(m);
+ }
+ }
+ _meshes.Clear();
+ }
+
+ /// Обвисшая поза: каждая кость крыла и шеи (от корня к концу) разворачивается так, чтобы её
+ /// дочерняя кость оказалась под ней - крылья свисают чуть в стороны, шея с головой - чуть вперёд.
+ /// Аниматор следующим кадром позу вернёт, но слепок уже запечён, а сама курица спрятана.
+ static void Limp(EntityAlive _c, Transform _model)
+ {
+ HashSet bones = new HashSet();
+ foreach (SkinnedMeshRenderer smr in _model.GetComponentsInChildren(true))
+ {
+ if (smr.bones == null)
+ {
+ continue;
+ }
+ foreach (Transform b in smr.bones)
+ {
+ if (b != null)
+ {
+ bones.Add(b);
+ }
+ }
+ }
+ List order = new List(bones);
+ order.Sort((a, b) => Depth(a).CompareTo(Depth(b)));
+ if (!_loggedBones)
+ {
+ _loggedBones = true;
+ List names = new List();
+ foreach (Transform b in order)
+ {
+ names.Add(b.name);
+ }
+ Debug.Log("[NecromancerTome] CarryVisual: кости " + _c.EntityName + ": " + string.Join(", ", names));
+ }
+ Vector3 fwd = _c.transform.forward;
+ Vector3 right = _c.transform.right;
+ Vector3 center = _c.transform.position;
+ int wings = 0, necks = 0;
+ foreach (Transform b in order)
+ {
+ string n = b.name.ToLowerInvariant();
+ Vector3 want;
+ if (n.Contains("wing"))
+ {
+ float side = Mathf.Sign(Vector3.Dot(b.position - center, right));
+ want = (Vector3.down + right * side * WingOut).normalized;
+ wings++;
+ }
+ else if (n.Contains("neck") || n.Contains("head"))
+ {
+ want = (Vector3.down + fwd * NeckForward).normalized;
+ necks++;
+ }
+ else
+ {
+ continue;
+ }
+ Transform child = null;
+ for (int i = 0; i < b.childCount; i++)
+ {
+ if (bones.Contains(b.GetChild(i)))
+ {
+ child = b.GetChild(i);
+ break;
+ }
+ }
+ if (child == null)
+ {
+ continue;
+ }
+ Vector3 dir = child.position - b.position;
+ if (dir.sqrMagnitude < 1e-6f)
+ {
+ continue;
+ }
+ b.rotation = Quaternion.FromToRotation(dir, want) * b.rotation;
+ }
+ Debug.Log("[NecromancerTome] CarryVisual: обвисли костей крыльев " + wings + ", шеи/головы " + necks);
+ }
+
+ static int Depth(Transform _t)
+ {
+ int d = 0;
+ for (Transform p = _t.parent; p != null; p = p.parent)
+ {
+ d++;
+ }
+ return d;
+ }
+ }
+}
diff --git a/HarmonySrc/CorpseLootPatch.cs b/HarmonySrc/CorpseLootPatch.cs
index 4e4fe75..b5c4f29 100644
--- a/HarmonySrc/CorpseLootPatch.cs
+++ b/HarmonySrc/CorpseLootPatch.cs
@@ -646,6 +646,7 @@ namespace NecromancerTome
if (!__instance.bag.Touched)
{
GameManager.Instance.lootManager.LootBagOpened(__instance.bag, __instance, _lockingPlayerID);
+ NecroSkull.Roll(__instance, __instance.bag); // 2% - череп (останки), с 09.10 вместо браслета
}
__instance.timeStayAfterDeath = (int)(CorpseLoot.OpenedCorpseSecondsAfterDeath * 20f);
Debug.Log("[NecromancerTome] CorpseLoot: труп " + __instance.entityId + " открыт");
diff --git a/HarmonySrc/DogSense.cs b/HarmonySrc/DogSense.cs
new file mode 100644
index 0000000..3af92ab
--- /dev/null
+++ b/HarmonySrc/DogSense.cs
@@ -0,0 +1,80 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Нюх одержимой зомбособаки (указание 09.10: «добавь зомбособаке отображение противников, как в Тёмном чутье.
+ /// Типа у собаки нюх»; решено: только в одержимости, чует всех существ). Как Тёмное чутьё змеи
+ /// (SnakeControl.Sense): раз в секунду существа ближе SenseRange к собаке получают метку на компасе и карте,
+ /// дальше SenseDropRange, мёртвые и выгруженные её теряют. В отличие от чутья - не только нежить: звери,
+ /// бандиты, зомби. Не метятся: тело игрока и другие игроки, питомцы мода (призрачные), торговцы.
+ ///
+ public static class DogSense
+ {
+ public const string DogClass = "necroZombieDog";
+ static readonly HashSet _sensed = new HashSet();
+ static float _check;
+
+ public static bool Is(EntityAlive _e)
+ {
+ return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
+ cls.entityClassName == DogClass;
+ }
+
+ static bool Smells(EntityAlive _e, EntityAlive _dog)
+ {
+ return _e != null && _e != _dog && !_e.IsDead() && !(_e is EntityPlayer) && !(_e is EntityTrader) &&
+ !SnakeControl.IsNecroEntity(_e) && !GhostTraderPatch.ShouldBeGhost(_e);
+ }
+
+ public static void Tick(World _world, EntityAlive _dog, float _dt)
+ {
+ _check -= _dt;
+ if (_check > 0f)
+ {
+ return;
+ }
+ _check = 1f;
+ Vector3 at = _dog.position;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null)
+ {
+ continue;
+ }
+ float d2 = (e.position - at).sqrMagnitude;
+ bool smell = Smells(e, _dog);
+ if (smell && d2 < SnakeControl.SenseRange * SnakeControl.SenseRange)
+ {
+ if (_sensed.Add(e.entityId))
+ {
+ e.AddNavObject(SnakeControl.SenseNavObject, null, null);
+ }
+ }
+ else if (_sensed.Contains(e.entityId) && (!smell || d2 > SnakeControl.SenseDropRange * SnakeControl.SenseDropRange))
+ {
+ e.RemoveNavObject(SnakeControl.SenseNavObject);
+ _sensed.Remove(e.entityId);
+ }
+ }
+ _sensed.RemoveWhere(id => _world.GetEntity(id) == null);
+ }
+
+ /// Выход из собаки - снять все метки.
+ public static void Clear()
+ {
+ World world = GameManager.Instance?.World;
+ if (world != null)
+ {
+ foreach (int id in _sensed)
+ {
+ (world.GetEntity(id) as EntityAlive)?.RemoveNavObject(SnakeControl.SenseNavObject);
+ }
+ }
+ _sensed.Clear();
+ _check = 0f;
+ }
+ }
+}
diff --git a/HarmonySrc/EntityLeakProbe.cs b/HarmonySrc/EntityLeakProbe.cs
new file mode 100644
index 0000000..c109027
--- /dev/null
+++ b/HarmonySrc/EntityLeakProbe.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using HarmonyLib;
+using Debug = UnityEngine.Debug;
+
+namespace NecromancerTome
+{
+ ///
+ /// Замер утечки сущностей (указание 09.10: «добавь замер сразу; если не будет тормозить, пригодится для
+ /// анализа»). Повод - лог 09.10: в строке игры «Ent: 7 (31)» живых сущностей 7, а экземпляров в памяти 31
+ /// (Entity.InstanceCount уменьшается только в финализаторе), и разрыв рос за сессию.
+ ///
+ /// Как работает. Каждое удаление сущности из мира проходит через World.unloadEntity (смерть, отзыв,
+ /// выгрузка чанка, чёрный разрыв NecroBurst). Там удалённая сущность запоминается СЛАБОЙ ссылкой
+ /// (WeakReference сама не держит объект), с классом, причиной и источником - первым методом мода в стеке
+ /// вызова (или «игра»). Раз в минуту: если с удаления прошло не меньше двух сборок мусора (GC.CollectionCount),
+ /// а объект всё ещё жив, - его кто-то держит. В лог идёт строка «EntityLeakProbe» со сводкой по классу и
+ /// источнику; ничего не держится - строки нет.
+ ///
+ /// Нагрузка: удаления редки (единицы в минуту), стек снимается только в этот момент; раз в минуту - проход
+ /// по короткому списку. GC принудительно НЕ вызывается (дал бы рывок). Вышедшие из памяти записи
+ /// выбрасываются, список не растёт.
+ ///
+ public static class EntityLeakProbe
+ {
+ class Rec
+ {
+ public WeakReference Ref;
+ public string What;
+ public int Gc;
+ }
+
+ static readonly List _recs = new List();
+ static readonly Dictionary _sum = new Dictionary();
+ static float _next;
+ const float Period = 60f;
+
+ public static void OnUnload(Entity _e, EnumRemoveEntityReason _reason)
+ {
+ if (_e == null || _e is EntityPlayerLocal)
+ {
+ return;
+ }
+ string cls = EntityClass.list.TryGetValue(_e.entityClass, out EntityClass ec) && ec != null ? ec.entityClassName : _e.GetType().Name;
+ _recs.Add(new Rec
+ {
+ Ref = new WeakReference(_e),
+ What = cls + " / " + _reason + " / " + Origin(),
+ Gc = GC.CollectionCount(0)
+ });
+ }
+
+ /// Первый метод мода в стеке (кроме этого класса и патчей Harmony) - кто удалил; иначе «игра».
+ static string Origin()
+ {
+ StackTrace st = new StackTrace(2, false);
+ for (int i = 0; i < st.FrameCount && i < 24; i++)
+ {
+ System.Reflection.MethodBase m = st.GetFrame(i)?.GetMethod();
+ Type t = m?.DeclaringType;
+ if (t == null || t == typeof(EntityLeakProbe) || t.Namespace != nameof(NecromancerTome) || t.Name.StartsWith("Patch_World_unloadEntity"))
+ {
+ continue;
+ }
+ return (t.DeclaringType != null ? t.DeclaringType.Name + "." : "") + t.Name + "." + m.Name;
+ }
+ return "игра";
+ }
+
+ /// Раз в минуту (из ModEvents.UnityUpdate мода).
+ public static void Tick()
+ {
+ float now = UnityEngine.Time.time;
+ if (now < _next)
+ {
+ return;
+ }
+ _next = now + Period;
+ int gc = GC.CollectionCount(0);
+ _sum.Clear();
+ int held = 0;
+ for (int i = _recs.Count - 1; i >= 0; i--)
+ {
+ Rec r = _recs[i];
+ object o = r.Ref.Target;
+ if (o == null)
+ {
+ _recs.RemoveAt(i);
+ continue;
+ }
+ if (gc - r.Gc < 2)
+ {
+ continue; // сборок мусора ещё не было - рано судить
+ }
+ held++;
+ _sum.TryGetValue(r.What, out int n);
+ _sum[r.What] = n + 1;
+ }
+ if (held == 0)
+ {
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.Append("[NecromancerTome] EntityLeakProbe: удалённых, но живых в памяти ").Append(held)
+ .Append(" (Entity.InstanceCount ").Append(Entity.InstanceCount).Append(", сборок мусора ").Append(gc).Append("):");
+ foreach (KeyValuePair kv in _sum)
+ {
+ sb.Append("\n ").Append(kv.Value).Append(" x ").Append(kv.Key);
+ }
+ Debug.Log(sb.ToString());
+ }
+
+ /// Выход из мира - забыть всё.
+ public static void Reset()
+ {
+ _recs.Clear();
+ _next = 0f;
+ }
+ }
+
+ [HarmonyPatch(typeof(World), nameof(World.unloadEntity))]
+ public static class Patch_World_unloadEntity_LeakProbe
+ {
+ public static void Prefix(Entity _e, EnumRemoveEntityReason _reason)
+ {
+ try
+ {
+ EntityLeakProbe.OnUnload(_e, _reason);
+ }
+ catch (Exception)
+ {
+ // Замер не должен ломать удаление сущности ни при каких условиях.
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/GhostTraderPatch.cs b/HarmonySrc/GhostTraderPatch.cs
index 28e285f..9191703 100644
--- a/HarmonySrc/GhostTraderPatch.cs
+++ b/HarmonySrc/GhostTraderPatch.cs
@@ -310,6 +310,9 @@ namespace NecromancerTome
// обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и
// голова, что и выглядело жутко.
"necroZombieGriffin",
+ // Могильная крыса (08.10.2026): «серой, не полупрозрачной» - у неё теперь своя модель
+ // из угольной глины (RatModel.cs), призрачный вид её бы испортил.
+ "necroRatSpirit",
};
public static bool ShouldBeGhost(EntityAlive _entity)
diff --git a/HarmonySrc/NecroMapMarker.cs b/HarmonySrc/NecroMapMarker.cs
index fbbf6c1..6f5351b 100644
--- a/HarmonySrc/NecroMapMarker.cs
+++ b/HarmonySrc/NecroMapMarker.cs
@@ -22,11 +22,16 @@ namespace NecromancerTome
/// (removedNavObjectPool) и раздаёт заново. Если модель исчезла раньше нашего Hide, игра сама сняла
/// метку, и пул мог уже отдать этот же объект другому (метка чутья, транспорт). Поэтому снимаем, только
/// если объект ещё в NavObjectList и следит за НАШЕЙ моделью; иначе просто забываем ссылку.
- /// Класс necroSnakeSpirit (nav_objects.xml) - только карта, фиолетовый; значок - OverrideSpriteName.
+ /// Классы (nav_objects.xml), только карта: necroSnakeSpirit - фиолетовая змея, necroPossessed - зелёный
+ /// значок одержимого. Значок задан в классе: OverrideSpriteName игра берёт, только если у класса
+ /// use_override_icon (NavObject.GetSpriteName) - без него у одержимого была змея (тест 08.10).
+ /// hiddenOnCompass не ставим: карта красит такие метки серым (XUiC_MapArea.updateNavObjects), а на
+ /// компас эти классы и так не попадают - compass_settings у них нет.
///
public static class NecroMapMarker
{
- public const string NavClass = "necroSnakeSpirit";
+ public const string SnakeClass = "necroSnakeSpirit";
+ public const string PossessedClass = "necroPossessed";
/// Метка и модель, за которой она следит.
public class Marker
@@ -35,13 +40,13 @@ namespace NecromancerTome
public Transform Tracked;
}
- public static Marker Show(EntityAlive _e, string _sprite)
+ public static Marker Show(EntityAlive _e, string _navClass)
{
if (_e == null || _e.transform == null || NavObjectManager.Instance == null)
{
return null;
}
- NavObject nav = NavObjectManager.Instance.RegisterNavObject(NavClass, _e.transform, _sprite, true);
+ NavObject nav = NavObjectManager.Instance.RegisterNavObject(_navClass, _e.transform);
Debug.Log("[NecromancerTome] NecroMapMarker: метка на карте для " + _e.EntityName + " (" + _e.entityId + ") - " + nav);
return new Marker { Nav = nav, Tracked = _e.transform };
}
@@ -56,9 +61,5 @@ namespace NecromancerTome
}
_m = null;
}
-
- /// Значок одержимого (зомби или питомца) - значок дебаффа «Почти смерть» (buffNearDeathTrauma,
- /// вешается игроку после гибели), указание 06.10.
- public const string PossessedSprite = "ui_game_symbol_near_death_trauma";
}
}
diff --git a/HarmonySrc/NecroSkull.cs b/HarmonySrc/NecroSkull.cs
index f51979e..f2015dc 100644
--- a/HarmonySrc/NecroSkull.cs
+++ b/HarmonySrc/NecroSkull.cs
@@ -3,155 +3,44 @@ using UnityEngine;
namespace NecromancerTome
{
///
- /// Череп из красного трупа (BACKLOG «Некротехнолог v2», указание 06.10): тот же жест, что снятие
- /// блока браслетом, - зажатая обычная атака с браслетом в руке, круговой таймер. Только целью
- /// здесь не блок, а труп зомби, погибшего под дебаффом жертвы (CorpseLoot.IsSearchable). Труп -
- /// сущность, а не блок, поэтому SpatialVaultPickup.Begin сперва спрашивает этот класс.
+ /// Череп (останки) из красного трупа - сырьё для цинерария (дух-работник).
///
- /// Шанс 2% («так ресурс станет ценнее»): удача - череп в пространственное хранилище, труп остаётся
- /// (его можно обыскать), второй попытки на нём нет (cvar без точки - пишется в сейв вместе с трупом);
- /// неудача - труп уходит в чёрный разрыв (NecroBurst), вместе с недобранной добычей.
+ /// С 09.10 - находка при обычном обыске (указание: «браслет больше не собирает красные трупы,
+ /// выпиливаем; останки дропаются с шансом 2% при обычном лутании красного трупа»). Раньше (06.10) череп
+ /// вырывали пространственным браслетом: зажатая атака по трупу, три секунды канала, при неудаче труп
+ /// уходил в чёрный разрыв. Сняли из-за подозрения на утечку памяти на этом пути (тест 09.10: после
+ /// разрывов в памяти копились экземпляры сущностей - 31 при 7 живых).
///
- /// Цена та же, что у блоков: без сферы или камня в слоте браслет не работает, сфера платит
- /// прочностью за секунды канала. Разрыв делает сервер - в мультиплеере клиенту пока только удача
- /// (труп не исчезает); решать вместе с прочим мультиплеером урн.
+ /// Бросок - один раз на труп, при первом открытии его сумки, на сервере, сразу после того, как игра
+ /// наполнила её добычей (Patch_EntityAlive_OnLockRequestServer_Corpse). Шанс прежний - 2%
+ /// («так ресурс станет ценнее»).
///
public static class NecroSkull
{
public const string ItemName = "necroSkull";
- /// На трупе: попытка уже была удачной.
- public const string TakenCVar = "necroSkullTaken";
public const float Chance = 0.02f;
- /// Короче десяти секунд блока: шанс мал, попытка должна быть дешёвой.
- public const float ChannelSeconds = 3f;
- /// Дальше этого тянуть череп нельзя - как дистанция обыска трупа у игры.
- public static float MaxDistance => Constants.cCollectItemDistance;
- public const string MsgChanneling = "necroSkullChanneling";
- public const string MsgTaken = "necroSkullAlreadyTaken";
- public const string MsgExtracted = "necroSkullExtracted";
- public const string MsgFailed = "necroSkullFailed";
-
- public class SkullJob : SpatialVaultPickup.PickupJob
+ /// Бросок на череп в только что наполненную сумку трупа. Места нет - сумка на ячейку больше.
+ public static void Roll(EntityAlive _corpse, Bag _bag)
{
- public int CorpseId;
- }
-
- /// Красный труп под прицелом (так же, как игра ищет сущность для E -
- /// PlayerMoveController.TryHandleEntity), иначе null.
- public static EntityAlive CorpseAtCrosshair(EntityPlayerLocal _player)
- {
- WorldRayHitInfo hit = _player.HitInfo;
- if (hit == null || !hit.bHitValid || hit.tag == null || !hit.tag.StartsWith("E_") ||
- hit.hit.distanceSq > MaxDistance * MaxDistance)
- {
- return null;
- }
- Transform root = GameUtils.GetHitRootTransform(hit.tag, hit.transform);
- EntityAlive corpse = root != null ? root.GetComponent() as EntityAlive : null;
- return corpse != null && corpse.IsDead() && CorpseLoot.IsSearchable(corpse) ? corpse : null;
- }
-
- /// Обычная атака браслетом: true - под прицелом красный труп, нажатие наше.
- public static bool TryBegin(EntityPlayerLocal _player, ItemValue _bracelet)
- {
- EntityAlive corpse = CorpseAtCrosshair(_player);
- if (corpse == null)
- {
- return false;
- }
- if (corpse.Buffs.GetCustomVar(TakenCVar) > 0f)
- {
- SpatialVaultPickup.Deny(_player, MsgTaken);
- return true;
- }
- Bag bag = SpatialVaultPickup.GetVault(_player);
ItemValue skull = ItemClass.GetItem(ItemName);
- if (bag == null || skull == null || skull.IsEmpty())
- {
- return true;
- }
- if (!bag.CanTakeItem(new ItemStack(skull, 1)))
- {
- SpatialVaultPickup.Deny(_player, SpatialVaultPickup.MsgVaultFull);
- return true;
- }
-
- TimerEventData timerData = new TimerEventData
- {
- Data = new SkullJob
- {
- Player = _player,
- Bracelet = _bracelet,
- ChannelSeconds = ChannelSeconds,
- CorpseId = corpse.entityId
- },
- CloseOnHit = true,
- CancelWithActivateButton = true
- };
- timerData.FullTimeFinishEvent += OnChannelComplete;
- timerData.CloseEvent += delegate
- {
- ChannelVision.End(_player);
- };
- LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
- XUiC_Timer.OpenTimer(playerUI.xui, ChannelSeconds, timerData, -1f, Localization.Get(MsgChanneling));
- ChannelVision.Begin(_player);
- Debug.Log("[NecromancerTome] NecroSkull: " + _player.entityId + " тянет череп из трупа " + corpse.entityId);
- return true;
- }
-
- static void OnChannelComplete(TimerEventData _timerData)
- {
- if (!(_timerData.Data is SkullJob job) || job.Player == null)
+ if (_bag == null || skull == null || skull.IsEmpty())
{
return;
}
- ChannelVision.End(job.Player);
- World world = GameManager.Instance != null ? GameManager.Instance.World : null;
- EntityAlive corpse = world?.GetEntity(job.CorpseId) as EntityAlive;
- // Труп обыскали до дна и он исчез, его разорвали или уже вытянули череп - пока шёл круг.
- if (corpse == null || !corpse.IsDead() || !CorpseLoot.IsSearchable(corpse))
- {
- SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgBlockMissing);
- return;
- }
- if (corpse.Buffs.GetCustomVar(TakenCVar) > 0f)
- {
- SpatialVaultPickup.Deny(job.Player, MsgTaken);
- return;
- }
- Bag bag = SpatialVaultPickup.GetVault(job.Player);
- ItemValue skull = ItemClass.GetItem(ItemName);
- if (bag == null || skull == null || skull.IsEmpty())
- {
- return;
- }
-
float roll = Random.value;
bool success = roll < Chance;
- Debug.Log("[NecromancerTome] NecroSkull: труп " + corpse.entityId + " - бросок " +
- roll.ToString("0.000") + " против " + Chance.ToString("0.00") + " -> " + (success ? "ЧЕРЕП" : "разрыв"));
- if (success)
+ Debug.Log("[NecromancerTome] NecroSkull: труп " + _corpse.entityId + " - бросок " + roll.ToString("0.000") +
+ " против " + Chance.ToString("0.00") + (success ? " -> ЧЕРЕП" : ""));
+ if (!success)
{
- if (!bag.AddItem(new ItemStack(skull, 1)))
- {
- SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgVaultFull);
- return;
- }
- corpse.Buffs.SetCustomVar(TakenCVar, 1f);
- GameManager.ShowTooltip(job.Player, MsgExtracted);
+ return;
}
- else
+ ItemStack stack = new ItemStack(skull, 1);
+ if (!_bag.AddItem(stack.Clone()))
{
- GameManager.ShowTooltip(job.Player, MsgFailed);
- NecroBurst.Burst(corpse, job.Player);
- }
- // Попытка оплачена в обоих исходах: канал отработан.
- SpatialVaultPickup.SpendCharge(job);
- if (success)
- {
- GameManager.Instance.SaveLocalPlayerData();
+ _bag.Resize(new Vector2i(_bag.SlotCount + 1, 1));
+ _bag.AddItem(stack);
}
}
}
diff --git a/HarmonySrc/PetCommandPatch.cs b/HarmonySrc/PetCommandPatch.cs
index b4d46a0..fc6e0cb 100644
--- a/HarmonySrc/PetCommandPatch.cs
+++ b/HarmonySrc/PetCommandPatch.cs
@@ -127,7 +127,10 @@ namespace NecromancerTome
public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen)
{
- if (__instance.inventory == null)
+ // В одержимости тело держит книгу питомца, но цель под прицелом тела не обновляется (управление тела
+ // выключено) - прицел книги застывал красным (тест 09.10: «у стервятника прицел всё время красный»).
+ // Прицел одержимого - обычный, со своей подсветкой (Possession.AimTint).
+ if (__instance.inventory == null || Possession.Active)
{
return true;
}
diff --git a/HarmonySrc/Possession.cs b/HarmonySrc/Possession.cs
index 6645e71..72584c2 100644
--- a/HarmonySrc/Possession.cs
+++ b/HarmonySrc/Possession.cs
@@ -86,6 +86,29 @@ namespace NecromancerTome
/// Звук попадания рывка - ванильный удар рукой зомби по телу (sounds.xml).
public const string FlyDashHitSound = "organichitorganic";
static EntityAlive _flyDash;
+ /// Ноша стервятника (указание 08.10): рывок на курицу или кролика (EntityAnimalRabbit - только
+ /// они; злая курица - другой класс) не клюёт, а хватает. Несёт под лапами, его ИИ стоит; повторная ЛКМ -
+ /// отпускает (падает сам; у земли - стоит на ней). Курицу так приносят в загон, где игрок её ловит
+ /// для курятника (wildChicken).
+ static EntityAlive _carry;
+ /// «Зоркий глаз» (указание 08.10): пока держишь ПКМ, камера уходит к голове, а угол обзора сужается в ZoomFactor раз
+ /// за ZoomTime, как у прицела; мышь во столько же раз медленнее. Свой угол игра ставит только на
+ /// своём приближении (vp_FPCamera), поэтому исходный запоминаем и возвращаем сами.
+ public const float ZoomFactor = 8f; // x8 - указание 08.10 (было x4)
+ public const float ZoomTime = 0.2f;
+ /// Камера в приближении - на столько впереди головы, м (вне собственного клюва).
+ public const float ZoomEyeForward = 0.3f;
+ static bool _flyZoomHeld;
+ static float _zoom;
+ static float _zoomBaseFov = -1f;
+ static Camera _zoomCam;
+ /// Угол обзора «зоркого глаза», -1 - нет. Ставит Patch_EntityPlayerLocal_LateUpdate_Possession:
+ /// игра каждый кадр задаёт свой угол в EntityPlayerLocal.LateUpdate (тест 08.10: «x8 не работает» -
+ /// угол из PlaceCamera затирался).
+ public static float ZoomFov = -1f;
+ /// Зазор между когтями стервятника и спиной ноши, м; минус - когти впиваются в спину
+ /// (тест 08.10: «курица висит под стервятником, а не в его когтях»).
+ public const float CarryGap = -0.06f;
/// Цель рывка под прицелом сейчас (каждый кадр) - обычный прицел игры краснеет (AimTint).
public static EntityAlive FlyAim;
static float _flyDashUntil;
@@ -190,6 +213,12 @@ namespace NecromancerTome
static GameObject _hud;
/// Метка одержимого на карте (указание 06.10, как у змеи) - NecroMapMarker.
static NecroMapMarker.Marker _mapNav;
+ /// Разведка стервятника (указание 08.10: «карта следует за ним как за игроком»; только он, не
+ /// другие одержимые): туман карты открывается вокруг него так же, как вокруг игрока (EntityPlayer.OnUpdateLive: при смене чанка
+ /// ChunkObserver.mapDatabase.Add - 4 чанка во все стороны), а карта открывается на нём
+ /// (Patch_XUiC_MapArea_*_Possession). Это обычная карта игрока - сохраняется как у него.
+ static Vector3i _mapChunk;
+ static bool _mapChunkSet;
public static void Init()
{
@@ -204,6 +233,11 @@ namespace NecromancerTome
public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
+ /// Несёт ли стервятник это существо (его ИИ стоит).
+ public static bool IsCarried(Entity _e) => _carry != null && _e != null && _e.entityId == _carry.entityId;
+
+ static bool Carryable(EntityAlive _e) => _e is EntityAnimalRabbit;
+
/// Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
@@ -273,7 +307,8 @@ namespace NecromancerTome
ReadRanged(_zombie);
Hint(_player0, _zombie);
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
- _mapNav = NecroMapMarker.Show(_zombie, NecroMapMarker.PossessedSprite);
+ _mapNav = NecroMapMarker.Show(_zombie, NecroMapMarker.PossessedClass);
+ _mapChunkSet = false;
if (Flying)
{
_zombie.motion = Vector3.zero;
@@ -289,6 +324,10 @@ namespace NecromancerTome
return;
}
Active = false;
+ RatLoot.CloseIfViewing();
+ RatPhase.Stop(GameManager.Instance?.World, Zombie, "выход из одержимости");
+ DogSense.Clear();
+ WolfControl.Clear();
NecroMapMarker.Hide(ref _mapNav);
bool pet = IsPet;
IsPet = false;
@@ -302,7 +341,10 @@ namespace NecromancerTome
_flyDash = null;
FlyAim = null;
AimTint(false);
+ DropCarry("выход из стервятника");
+ ZoomReset();
StopEat(z, null);
+ VultureNeck.Reset();
if (_flySitting)
{
_flySitting = false;
@@ -395,6 +437,15 @@ namespace NecromancerTome
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
+ // Замер утечки сущностей (09.10) - не связан с одержимостью, просто ежекадровый крючок мода.
+ if (world == null)
+ {
+ EntityLeakProbe.Reset();
+ }
+ else
+ {
+ EntityLeakProbe.Tick();
+ }
if (world == null)
{
if (Active || Zombie != null || _frozen.Count > 0)
@@ -412,6 +463,16 @@ namespace NecromancerTome
_frozenSpeed.Clear();
_flyDash = null;
FlyAim = null;
+ _carry = null;
+ CarryVisual.Clear();
+ RatPhase.On = false;
+ DogSense.Clear();
+ WolfControl.Clear();
+ WolfBag.Forget();
+ _zoomCam = null;
+ _zoomBaseFov = -1f;
+ _zoom = 0f;
+ ZoomFov = -1f;
_eating = false;
_eatCorpse = null;
}
@@ -434,6 +495,18 @@ namespace NecromancerTome
Exit("одержимый погиб");
return;
}
+ if (Flying)
+ {
+ RevealMap(world, z); // разведка - только стервятник (указание 08.10)
+ }
+ // Подсказка тела (тест 08.10: «стоял перед дверью - подсказка осталась в стервятнике»): её гасит
+ // PlayerMoveController.Update, а он на время одержимости выключен. Прочие сообщения (голод и т.п.) -
+ // другие окна, их не трогаем.
+ LocalPlayerUI ui = _player.PlayerUI;
+ if (ui != null && ui.windowManager.IsWindowOpen(XUiC_InteractionPrompt.ID))
+ {
+ XUiC_InteractionPrompt.SetText(ui, null);
+ }
PlayerActionsLocal input = _player.playerInput;
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
@@ -461,20 +534,8 @@ namespace NecromancerTome
_crouchToggled = !_crouchToggled;
}
Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
- if (detonate && IsPet)
- {
- // E у питомца - отзыв (как E с книгой в руке); выход из тела - внутри RecallPet.
- EntityPlayerLocal owner = _player;
- if (Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPossessedPet(owner, z))
- {
- // Звук отзыва - как у E с книгой (soundWarn книги в руке; вселялись с ней же).
- if (owner.inventory?.holdingItem?.Actions?.Length > 0 && owner.inventory.holdingItem.Actions[0] is ItemActionSpawnEntity spawn)
- {
- owner.PlayOneShot(spawn.soundWarn);
- }
- return;
- }
- }
+ // E у питомца больше не отзыв (указание 08.10: «R - всегда вернуться в тело у всех одержимых,
+ // E - для других целей»). Отозвать питомца - E с книгой в руке, уже из тела.
if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
{
Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
@@ -490,7 +551,15 @@ namespace NecromancerTome
_flyUp = input != null && !gui && input.Jump.IsPressed;
_flyDown = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
Crouching = false;
- FlyAim = _flyDash == null ? FlyDashTarget(world, z) : null;
+ if (attack && _carry != null)
+ {
+ // Повторная ЛКМ с ношей - отпустить (не клевок и не новый рывок).
+ DropCarry("ЛКМ");
+ attack = false;
+ _nextAttack = Time.time + FlyAttackDelay;
+ }
+ HoldCarry(z);
+ FlyAim = _flyDash == null && _carry == null ? FlyDashTarget(world, z) : null;
AimTint(FlyAim != null);
if (attack && Time.time >= _nextAttack && FlyAim is EntityAlive dashTo)
{
@@ -519,16 +588,20 @@ namespace NecromancerTome
_nextAttack = Time.time + FlyAttackDelay;
_meleeLookUntil = Time.time + FlyAttackDelay;
}
- // ПКМ - клич-приманка (указание 06.10): крик крикуньи, только голосом паникующей курицы - зомби
- // в ScreamRange идут на него; с воздуха уводит толпу от базы или от тела.
- if (ranged)
+ // F - клич-приманка (указание 06.10; кнопка - 08.10, была ПКМ): крик крикуньи, только голосом
+ // паникующей курицы - зомби в ScreamRange идут на него; с воздуха уводит толпу от базы или от тела.
+ // F у игрока - фонарик, но его читает только выключенный PlayerMoveController.Update.
+ bool scream = input != null && !gui && (input.PermanentActions.ToggleFlashlight.WasPressed || input.ToggleFlashlight.WasPressed);
+ if (scream)
{
Scream(world, z);
}
- // F - питаться падалью (указание 06.10: «сесть на труп - по F стервятник питается»). F у игрока -
- // фонарик, но его читает только PlayerMoveController.Update, а он на время одержимости выключен.
- bool eat = input != null && !gui && (input.PermanentActions.ToggleFlashlight.WasPressed || input.ToggleFlashlight.WasPressed);
- Eat(world, z, eat, attack || ranged);
+ // ПКМ (держать) - «зоркий глаз» (указание 08.10: «приближение как у прицела»), в PlaceCamera.
+ _flyZoomHeld = rangedHeld;
+ // E - питаться падалью (указание 06.10, кнопка - 08.10: «пусть ест по кнопке E» - у игрока E это
+ // взаимодействие, лут; было F). detonate - это и есть нажатие E.
+ bool eat = detonate;
+ Eat(world, z, eat, attack || scream);
if (_meleeLookUntil > 0f && Time.time >= _meleeLookUntil)
{
_meleeLookUntil = 0f;
@@ -559,6 +632,32 @@ namespace NecromancerTome
z.Jumping = true;
z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f);
}
+ if (IsPet && RatModel.Is(z))
+ {
+ // E - осмотреть контейнер без права взять (указание 08.10: крыса - разведчик).
+ RatLoot.Tick(world, _player, z, detonate);
+ // ПКМ (держать) - сквозь постройки, без полёта (BACKLOG, «Могильная крыса» п.4).
+ RatPhase.Tick(world, z, rangedHeld);
+ }
+ if (IsPet && WolfControl.Is(z))
+ {
+ // ПКМ - бросок на цель, след крови раненых (решено 09.10).
+ WolfControl.Tick(world, _player, z, ranged, ref _bodyYaw);
+ // E: у тела - выгрузить сумку в рюкзак, у контейнера - лутать в сумку (3 ячейки).
+ if (!(detonate && WolfBag.TryUnload(_player, z)))
+ {
+ RatLoot.Tick(world, _player, z, detonate, true);
+ }
+ }
+ if (ranged && IsPet && BearControl.Is(z))
+ {
+ BearControl.Roar(world, z); // ПКМ - рёв-провокация (решено 09.10)
+ }
+ if (IsPet && DogSense.Is(z))
+ {
+ // Нюх (указание 09.10): все существа рядом с собакой - на компасе и карте.
+ DogSense.Tick(world, z, Time.deltaTime);
+ }
if (ranged && IsScreamer(z))
{
Scream(world, z);
@@ -569,6 +668,10 @@ namespace NecromancerTome
if (z.Attack(false))
{
z.Attack(true);
+ if (IsPet && BearControl.Is(z))
+ {
+ BearControl.Smash(world, _player, z); // удар лапой и по блоку (решено 09.10)
+ }
float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
_nextAttack = Time.time + timeout;
_meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout);
@@ -975,6 +1078,22 @@ namespace NecromancerTome
_v.motion = Vector3.zero;
_v.SetLookPosition(head);
_v.emodel?.avatarController?.StartAnimationAttack();
+ if (Carryable(t))
+ {
+ // Курица или кролик - не клевок, а захват: ИИ встаёт (updateTasks пропущен), путь сброшен.
+ _carry = t;
+ t.SetAttackTarget(null, 0);
+ t.SetRevengeTarget(null);
+ PathFinderThread.Instance.RemovePathsFor(t.entityId);
+ t.navigator?.clearPath();
+ t.moveHelper?.Stop();
+ t.SetRotation(new Vector3(0f, _bodyYaw, 0f));
+ t.transform.rotation = Quaternion.Euler(0f, _bodyYaw, 0f);
+ CarryVisual.Begin(t);
+ HoldCarry(_v);
+ Debug.Log("[NecromancerTome] Possession: стервятник схватил " + t.EntityName + " (" + t.entityId + ")");
+ return;
+ }
string snd = _v.GetSoundAttack();
if (!string.IsNullOrEmpty(snd))
{
@@ -1001,6 +1120,45 @@ namespace NecromancerTome
(t.IsDead() ? ", убит" : ", здоровье " + t.Health));
}
+ /// Ноша - под лапами стервятника, каждый кадр: верх слепка ноши на CarryGap ниже его ног
+ /// (CarryVisual). Ниже уже земля (схватил у земли, сел) - ноша стоит на земле под ним, но не отпущена.
+ static void HoldCarry(EntityAlive _v)
+ {
+ EntityAlive c = _carry;
+ if (c == null)
+ {
+ return;
+ }
+ if (c.IsDead() || c.IsMarkedForUnload())
+ {
+ _carry = null;
+ CarryVisual.End(c);
+ Debug.Log("[NecromancerTome] Possession: ноша погибла");
+ return;
+ }
+ CarryVisual.Place(_v, c, CarryGap);
+ c.SetRotation(new Vector3(0f, _bodyYaw, 0f));
+ c.motion = Vector3.zero;
+ c.fallDistance = 0f;
+ }
+
+ /// Отпустить ношу: ИИ снова идёт, падает она сама (с высоты - может разбиться).
+ static void DropCarry(string _why)
+ {
+ EntityAlive c = _carry;
+ if (c == null)
+ {
+ return;
+ }
+ _carry = null;
+ CarryVisual.End(c);
+ if (!c.IsDead())
+ {
+ c.motion = Vector3.zero;
+ }
+ Debug.Log("[NecromancerTome] Possession: стервятник отпустил " + c.EntityName + " - " + _why);
+ }
+
/// Цель рывка: ближайшая к стервятнику по лучу прицела, не дальше FlyDashRange от него и
/// не дальше TargetRadius змеи от луча. Свои (питомцы, игроки, зачарованные) - не цель.
static EntityAlive FlyDashTarget(World _world, EntityAlive _v)
@@ -1116,10 +1274,6 @@ namespace NecromancerTome
static void Hint(EntityPlayerLocal _p, EntityAlive _z)
{
string text = Localization.Get("necroPossessHintBase");
- if (IsPet)
- {
- text += ", " + Localization.Get("necroPetRecallHint");
- }
if (Flying)
{
text += ", " + Localization.Get("necroPossessHintFly");
@@ -1128,14 +1282,36 @@ namespace NecromancerTome
{
text += ", " + Localization.Get("necroPossessHintRanged");
}
- if (IsScreamer(_z) || Flying)
+ if (IsScreamer(_z))
{
text += ", " + Localization.Get("necroPossessHintScream");
}
if (Flying)
+ {
+ text += ", " + Localization.Get("necroPossessHintZoom") + ", " + Localization.Get("necroPossessHintScreamF");
+ }
+ if (Flying)
{
text += ", " + Localization.Get("necroPossessHintEat");
}
+ if (IsPet && RatModel.Is(_z))
+ {
+ text += ", " + Localization.Get("necroPossessHintLook") + ", " + Localization.Get("necroPossessHintPhase");
+ }
+ if (IsPet && DogSense.Is(_z))
+ {
+ text += ", " + Localization.Get("necroPossessHintSmell");
+ }
+ if (IsPet && WolfControl.Is(_z))
+ {
+ text += ", " + Localization.Get("necroPossessHintPounce") + ", " + Localization.Get("necroPossessHintBlood") + ", " +
+ Localization.Get("necroPossessHintWolfBag");
+ }
+ if (IsPet && BearControl.Is(_z))
+ {
+ text += ", " + Localization.Get("necroPossessHintRoar") + ", " + Localization.Get("necroPossessHintSmash") + ", " +
+ Localization.Get("necroPossessHintSkin");
+ }
if (_z.jumpMaxDistance >= 5f)
{
text += ", " + Localization.Get("necroPossessHintLeap");
@@ -1144,6 +1320,11 @@ namespace NecromancerTome
{
text += ", " + Localization.Get("necroPossessHintDetonate");
}
+ if (!IsPet)
+ {
+ // Решение 03.10, подтверждено 09.10: после выхода бывший одержимый в леденящем ужасе бежит от тела.
+ text += ", " + Localization.Get("necroPossessHintAfterFear");
+ }
GameManager.ShowTooltip(_p, text);
}
@@ -1355,6 +1536,10 @@ namespace NecromancerTome
PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
+ if (_zoomCam != null && _zoomBaseFov > 0f)
+ {
+ sens *= _zoomCam.fieldOfView / _zoomBaseFov; // в «зорком глазе» мышь медленнее, как в прицеле
+ }
_yaw += Input.GetAxis("Mouse X") * sens.x;
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f);
}
@@ -1404,7 +1589,7 @@ namespace NecromancerTome
/// Разрешить зомби подход к цели класса одержимого: EntityZombie, у питомцев - их класс
/// (EntityZombieDog, EntityVulture).
- static void AllowZombieTarget(EntityAlive _e, EntityAlive _target)
+ internal static void AllowZombieTarget(EntityAlive _e, EntityAlive _target)
{
System.Type type = _target is EntityZombie ? typeof(EntityZombie) : _target.GetType();
List tasks = _e.aiManager.GetTasks();
@@ -1426,6 +1611,54 @@ namespace NecromancerTome
}
}
+ /// Открыть карту вокруг одержимого - как игра открывает её вокруг игрока, при смене чанка.
+ static void RevealMap(World _world, EntityAlive _z)
+ {
+ IMapChunkDatabase map = _player.ChunkObserver?.mapDatabase;
+ if (map == null || !_z.IsSpawned() || _mapChunkSet && _z.chunkPosAddedEntityTo == _mapChunk)
+ {
+ return;
+ }
+ _mapChunk = _z.chunkPosAddedEntityTo;
+ _mapChunkSet = true;
+ map.Add(_mapChunk, _world);
+ }
+
+ /// Шаг «зоркого глаза» за кадр; отпустил - угол плавно возвращается и восстанавливается точно.
+ static void Zoom(bool _held)
+ {
+ Camera cam = _player != null ? _player.playerCamera : null;
+ if (_held && cam != null && _zoomBaseFov < 0f)
+ {
+ _zoomCam = cam;
+ _zoomBaseFov = cam.fieldOfView;
+ }
+ if (_zoomBaseFov < 0f)
+ {
+ return;
+ }
+ _zoom = Mathf.MoveTowards(_zoom, _held ? 1f : 0f, Time.deltaTime / ZoomTime);
+ if (_zoom <= 0f)
+ {
+ ZoomReset();
+ return;
+ }
+ ZoomFov = Mathf.Lerp(_zoomBaseFov, _zoomBaseFov / ZoomFactor, Mathf.SmoothStep(0f, 1f, _zoom));
+ }
+
+ static void ZoomReset()
+ {
+ if (_zoomCam != null && _zoomBaseFov > 0f)
+ {
+ _zoomCam.fieldOfView = _zoomBaseFov;
+ }
+ _zoomCam = null;
+ _zoomBaseFov = -1f;
+ _zoom = 0f;
+ ZoomFov = -1f;
+ _flyZoomHeld = false;
+ }
+
/// Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.
public static void PlaceCamera(Transform _camera)
{
@@ -1434,6 +1667,12 @@ namespace NecromancerTome
{
return;
}
+ if (Flying)
+ {
+ // Шея - до камеры: та берёт положение головы.
+ VultureNeck.Apply(z, _flySitting, !_eating);
+ }
+ Zoom(Flying && _flyZoomHeld);
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);
@@ -1443,6 +1682,12 @@ namespace NecromancerTome
{
eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f);
}
+ if (_zoom > 0f)
+ {
+ // В «зорком глазе» камера уходит к голове (чуть впереди неё): из-за спины при x8 весь кадр
+ // закрыла бы сама птица.
+ eye = Vector3.Lerp(eye, anchor + rot * Vector3.forward * ZoomEyeForward, Mathf.SmoothStep(0f, 1f, _zoom));
+ }
_camera.SetPositionAndRotation(eye, rot);
if (!_loggedCamera)
{
@@ -1458,7 +1703,7 @@ namespace NecromancerTome
{
public static bool Prefix(EntityAlive __instance)
{
- return !Possession.IsPossessed(__instance);
+ return !Possession.IsPossessed(__instance) && !Possession.IsCarried(__instance);
}
}
@@ -1478,6 +1723,48 @@ namespace NecromancerTome
}
}
+ /// Карта в одержимом стервятнике открывается на нём, а не на теле (указание 08.10: разведка -
+ /// только у стервятника).
+ [HarmonyPatch(typeof(XUiC_MapArea), nameof(XUiC_MapArea.OnOpen))]
+ public static class Patch_XUiC_MapArea_OnOpen_Possession
+ {
+ public static void Postfix(XUiC_MapArea __instance)
+ {
+ if (Possession.Active && Possession.Flying && Possession.Zombie != null)
+ {
+ __instance.PositionMapAt(Possession.Zombie.GetPosition());
+ }
+ }
+ }
+
+ /// Кнопка «к игроку» на карте при одержимости - к одержимому.
+ [HarmonyPatch(typeof(XUiC_MapArea), nameof(XUiC_MapArea.onPlayerIconPressed))]
+ public static class Patch_XUiC_MapArea_onPlayerIconPressed_Possession
+ {
+ public static bool Prefix(XUiC_MapArea __instance)
+ {
+ if (!Possession.Active || !Possession.Flying || Possession.Zombie == null)
+ {
+ return true;
+ }
+ __instance.PositionMapAt(Possession.Zombie.GetPosition());
+ return false;
+ }
+ }
+
+ /// «Зоркий глаз» стервятника: свой угол обзора - после того, как игра поставила свой.
+ [HarmonyPatch(typeof(EntityPlayerLocal), "LateUpdate")]
+ public static class Patch_EntityPlayerLocal_LateUpdate_Possession
+ {
+ public static void Postfix(EntityPlayerLocal __instance)
+ {
+ if (Possession.ZoomFov > 0f && __instance == Possession.Player && __instance.playerCamera != null)
+ {
+ __instance.playerCamera.fieldOfView = Possession.ZoomFov;
+ }
+ }
+ }
+
/// Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_Possession
diff --git a/HarmonySrc/RatLoot.cs b/HarmonySrc/RatLoot.cs
new file mode 100644
index 0000000..3bd8244
--- /dev/null
+++ b/HarmonySrc/RatLoot.cs
@@ -0,0 +1,326 @@
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Обыск одержимой крысой (указание 08.10). Крыса - разведчик: «она может осмотреть ящики, и если там
+ /// что-то интересное, персонаж придёт и сам залутает». Поэтому - только смотреть.
+ ///
+ /// Окно ванильное, ровно как у персонажа (таймер первого обыска, сетка, звуки): контейнер открывается
+ /// той же цепочкой LockManager.LockRequestLocal -> OnLockResponseLocal -> XUiC_LootWindowGroup.OpenLooting.
+ /// Отличия на время такого просмотра (Viewing):
+ /// - рюкзак персонажа не показывается («сумку персонажа вообще не показывать») - openContainer
+ /// открывает его сам, закрываем сразу же;
+ /// - ячейки контейнера заперты (XUiC_ItemStack.IsLocked), «Взять всё» (R) и сортировка не работают;
+ /// попытка взять - отказ, как при переполненном рюкзаке (указание: «делал бы вид, что сумки
+ /// переполнены»): звук ui_denied и xuiInventoryFullForPickup;
+ /// - окно не закрывается из-за дальности до ТЕЛА: XUiC_LootWindow.Update меряет её от персонажа.
+ /// Подсказку «E: обыскать» рисует управление игрока, а оно в одержимости выключено - выводим сами.
+ ///
+ public static class RatLoot
+ {
+ /// Открыто (или открывается) окно просмотра крысой.
+ public static bool Viewing;
+ /// Окно открыл волк: щелчок и «Взять всё» кладут в его сумку (WolfBag), а не отказ.
+ public static bool Taking;
+ /// Контейнер, который осматривает крыса, и до какого времени после закрытия его не разрушать.
+ static Vector3i _sparePos;
+ static float _spareUntil;
+ /// Досягаемость контейнера от крысы, м.
+ const float Reach = 2.5f;
+
+ /// Контейнер перед крысой по лучу камеры: блок с хранилищем ближе Reach к крысе; иначе null.
+ public static TEFeatureStorage Find(World _world, EntityPlayerLocal _player, EntityAlive _rat, out Vector3i _pos, out BlockValue _bv)
+ {
+ _pos = Vector3i.zero;
+ _bv = BlockValue.Air;
+ Camera cam = _player.playerCamera;
+ if (cam == null)
+ {
+ return null;
+ }
+ Vector3 eye = _rat.getHeadPosition();
+ Vector3 from = cam.transform.position + Origin.position;
+ float dist = Vector3.Distance(from, eye) + Reach + 1f;
+ Ray ray = new Ray(from, cam.transform.forward);
+ if (!Voxel.Raycast(_world, ray, dist, -555266077, 69, 0f))
+ {
+ return null;
+ }
+ WorldRayHitInfo hit = Voxel.voxelRayHitInfo;
+ if ((hit.hit.pos - eye).sqrMagnitude > Reach * Reach)
+ {
+ return null;
+ }
+ Vector3i pos = hit.hit.blockPos;
+ BlockValue bv = hit.hit.blockValue;
+ Block block = bv.Block;
+ if (block == null || bv.isair)
+ {
+ return null;
+ }
+ if (bv.ischild && block.multiBlockPos != null)
+ {
+ pos = block.multiBlockPos.GetParentPos(pos, bv);
+ bv = _world.GetBlock(pos);
+ }
+ TileEntity te = _world.GetTileEntity(pos);
+ TEFeatureStorage st = te != null ? te.GetSelfOrFeature() : null;
+ if (st == null)
+ {
+ return null;
+ }
+ _pos = pos;
+ _bv = bv;
+ return st;
+ }
+
+ /// Каждый кадр одержимости крысой: подсказка и E - открыть.
+ public static void Tick(World _world, EntityPlayerLocal _player, EntityAlive _rat, bool _activate, bool _take = false)
+ {
+ LocalPlayerUI ui = _player.PlayerUI;
+ if (ui == null || Viewing || ui.windowManager.IsModalWindowOpen())
+ {
+ return;
+ }
+ TEFeatureStorage st = Find(_world, _player, _rat, out Vector3i pos, out BlockValue bv);
+ if (st == null)
+ {
+ return;
+ }
+ PlayerActionsLocal input = _player.playerInput;
+ string key = input.Activate.GetBindingXuiMarkupString() + input.PermanentActions.Activate.GetBindingXuiMarkupString();
+ string text = st.GetActivationText(_world, pos, bv, _rat, key, bv.Block.GetLocalizedBlockName());
+ if (!string.IsNullOrEmpty(text))
+ {
+ XUiC_InteractionPrompt.SetText(ui, text);
+ }
+ if (!_activate)
+ {
+ return;
+ }
+ bool locked = st.lockpickFeature != null && st.lockpickFeature.NeedsLockpicking() ||
+ st.lockFeature != null && st.lockFeature.IsLocked() && !st.lockFeature.IsUserAllowed(Platform.PlatformManager.InternalLocalUserIdentifier) ||
+ st.isJammed;
+ if (locked)
+ {
+ Audio.Manager.BroadcastPlayByLocalPlayer(pos.ToVector3() + Vector3.one * 0.5f, "Misc/locked");
+ return;
+ }
+ Viewing = true;
+ Taking = _take;
+ _sparePos = pos;
+ _spareUntil = float.MaxValue;
+ XUiC_InteractionPrompt.SetText(ui, null);
+ // Через отражение: у LockRequestLocal есть перегрузка с ReadOnlySpan, которой нет в справочных сборках проекта.
+ _lockRequest = _lockRequest ?? AccessTools.Method(typeof(LockManager), nameof(LockManager.LockRequestLocal), new[] { typeof(ILockTarget), typeof(ushort) });
+ _lockRequest.Invoke(LockManager.Instance, new object[] { st, (ushort)0 });
+ Debug.Log("[NecromancerTome] RatLoot: крыса осматривает " + bv.Block.GetBlockName() + " " + pos);
+ }
+
+ static System.Reflection.MethodInfo _lockRequest;
+
+ /// Отказ «рюкзак полон».
+ public static void Deny()
+ {
+ LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
+ if (ui?.entityPlayer != null)
+ {
+ GameManager.ShowTooltip(ui.entityPlayer, Localization.Get("xuiInventoryFullForPickup"), string.Empty, "ui_denied");
+ }
+ }
+
+ /// Выход из одержимости при открытом окне - закрыть его.
+ public static void CloseIfViewing()
+ {
+ if (!Viewing)
+ {
+ return;
+ }
+ LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
+ ui?.windowManager.CloseAllOpenModalWindows();
+ Viewing = false;
+ _spareUntil = Time.time + 5f;
+ }
+
+ static void LockSlots(XUiC_LootWindow _w, bool _lock)
+ {
+ XUiC_LootContainer grid = _w?.lootContainer;
+ if (grid?.itemControllers == null)
+ {
+ return;
+ }
+ foreach (XUiC_ItemStack s in grid.itemControllers)
+ {
+ if (s != null)
+ {
+ s.IsLocked = _lock;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.openContainer))]
+ public static class Patch_openContainer
+ {
+ public static void Postfix(XUiC_LootWindowGroup __instance)
+ {
+ if (!Viewing)
+ {
+ return;
+ }
+ __instance.xui.playerUI.windowManager.Close("backpack");
+ LockSlots(__instance.lootWindow, true);
+ }
+ }
+
+ /// Контейнеры с destroy_on_close (loot.xml: «empty» - 94 вида, мусор, кучи, холодильники, рюкзаки...;
+ /// «true» - аэродроп и коробки Twitch, это сущности, крыса их не открывает) разрушаются при снятии замка
+ /// после закрытия окна. Осмотр крысой их не разрушает (указание 08.10).
+ [HarmonyPatch(typeof(GameManager), nameof(GameManager.CheckDestroyTileEntity))]
+ public static class Patch_CheckDestroyTileEntity
+ {
+ public static bool Prefix(Vector3i _blockPos)
+ {
+ if (_blockPos != _sparePos || Time.time > _spareUntil)
+ {
+ return true;
+ }
+ _spareUntil = 0f;
+ Debug.Log("[NecromancerTome] RatLoot: " + _blockPos + " после осмотра крысой не разрушен");
+ return false;
+ }
+ }
+
+ /// Таймер первого обыска прерван - просмотра нет.
+ [HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.openTimerClosedManually))]
+ public static class Patch_openTimerClosedManually
+ {
+ public static void Postfix()
+ {
+ if (Viewing)
+ {
+ Viewing = false;
+ _spareUntil = Time.time + 5f;
+ }
+ }
+ }
+
+ /// Отказ замка (контейнер занят другим) - просмотра нет.
+ [HarmonyPatch(typeof(TEFeatureStorage), nameof(TEFeatureStorage.OnLockResponseLocal))]
+ public static class Patch_OnLockResponseLocal
+ {
+ public static void Postfix(bool _success)
+ {
+ if (!_success && Viewing)
+ {
+ Viewing = false;
+ _spareUntil = 0f;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.OnClose))]
+ public static class Patch_OnClose
+ {
+ public static void Postfix(XUiC_LootWindowGroup __instance)
+ {
+ LockSlots(__instance.lootWindow, false); // всегда: запертые ячейки не должны достаться персонажу
+ if (Viewing)
+ {
+ Viewing = false;
+ _spareUntil = Time.time + 5f; // снятие замка (и проверка разрушения) - следом, может прийти пакетом
+ }
+ }
+ }
+
+ /// Дальность окна - от крысы, не от тела: XUiC_LootWindow.Update меряет её от entityPlayer.position,
+ /// поэтому на время его Update персонаж «стоит» на месте крысы (поле, ничего больше не трогается), а
+ /// Finalizer возвращает позицию при любом исходе. (Тест 08.10: прежний вариант обнулял te - и закрытие окна
+ /// по E/ESC падало в XUiC_LootWindow.OnClose, окно застревало и после выхода из одержимости.)
+ [HarmonyPatch(typeof(XUiC_LootWindow), nameof(XUiC_LootWindow.Update))]
+ public static class Patch_LootWindow_Update
+ {
+ public static void Prefix(XUiC_LootWindow __instance, out Vector3? __state)
+ {
+ __state = null;
+ EntityPlayerLocal p = __instance.xui.playerUI.entityPlayer;
+ EntityAlive rat = Possession.Zombie;
+ if (!Viewing || p == null || rat == null)
+ {
+ return;
+ }
+ __state = p.position;
+ p.position = rat.position;
+ }
+
+ public static void Finalizer(XUiC_LootWindow __instance, Vector3? __state)
+ {
+ if (__state.HasValue)
+ {
+ __instance.xui.playerUI.entityPlayer.position = __state.Value;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveAll))]
+ public static class Patch_MoveAll
+ {
+ public static bool Prefix(XUiC_ContainerStandardControls __instance)
+ {
+ if (!Viewing)
+ {
+ return true;
+ }
+ TEFeatureStorage te = __instance.GetParentByType()?.te;
+ if (Taking && te != null)
+ {
+ WolfBag.TakeAll(te);
+ }
+ else
+ {
+ Deny();
+ }
+ return false;
+ }
+ }
+
+ [HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.Sort))]
+ public static class Patch_Sort
+ {
+ public static bool Prefix()
+ {
+ return !Viewing;
+ }
+ }
+
+ /// Щелчок по запертой ячейке контейнера - отказ «рюкзак полон».
+ [HarmonyPatch(typeof(XUiC_ItemStack), nameof(XUiC_ItemStack.Update))]
+ public static class Patch_ItemStack_Update
+ {
+ public static void Postfix(XUiC_ItemStack __instance)
+ {
+ if (!Viewing || !__instance.isOver || __instance.ItemStack == null || __instance.ItemStack.IsEmpty() ||
+ __instance.GetParentByType() == null)
+ {
+ return;
+ }
+ CursorControllerAbs cur = __instance.xui.playerUI.CursorController;
+ if (cur.GetMouseButtonDown(UICamera.MouseButton.LeftButton) || cur.GetMouseButtonDown(UICamera.MouseButton.RightButton))
+ {
+ XUiC_LootContainer grid = __instance.GetParentByType();
+ TEFeatureStorage te = __instance.GetParentByType()?.te;
+ int idx = grid?.itemControllers != null ? System.Array.IndexOf(grid.itemControllers, __instance) : -1;
+ if (Taking && te != null && idx >= 0)
+ {
+ WolfBag.TakeFrom(te, idx);
+ }
+ else
+ {
+ Deny();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/RatModel.cs b/HarmonySrc/RatModel.cs
new file mode 100644
index 0000000..166889d
--- /dev/null
+++ b/HarmonySrc/RatModel.cs
@@ -0,0 +1,250 @@
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Своя модель Могильной крысы (08.10.2026). Сущность necroRatSpirit по-прежнему стоит на префабе
+ /// лютоволка (Prefab в entityclasses.xml): его кости, аниматор, рэгдолл и коллайдеры остаются
+ /// ванильными. Меняется только то, что видно: в рендерер DireWolf кладём нашу сетку из бандла
+ /// Resources/necrorat, шерсть (DireWolfHair) прячем.
+ ///
+ /// Сетка сделана с нуля в Blender, веса - по костям волка; привязку (bindposes) и номера костей
+ /// посчитал редактор Unity по самому префабу волка (NecroRatSkin.cs в DonorUnityProject),
+ /// поэтому smr.bones менять не нужно - порядок костей тот же. Проверка ниже на число костей
+ /// ловит случай, если игра однажды поменяет скелет волка: тогда остаётся волчья шкура, а не
+ /// изломанная крыса.
+ ///
+ /// Шейдер - Standard (тот же, что у тела зомбопса). Свечение глаз - _EmissionMap и
+ /// _EmissionColor в материале necroRat_eye, фиолетовое, как у PetEyeGlow.
+ ///
+ public static class RatModel
+ {
+ public const string ClassName = "necroRatSpirit";
+ const string BundleFile = "Resources/necrorat";
+ static readonly string[] MatNames = { "necroRat_body", "necroRat_jaw", "necroRat_eye", "necroRat_incisor", "necroRat_claw" };
+
+ static bool _tried;
+ static Mesh _mesh;
+ static Material[] _mats;
+
+ public static bool Is(Entity _e)
+ {
+ return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) &&
+ cls != null && cls.entityClassName == ClassName;
+ }
+
+ static bool Load()
+ {
+ if (_tried)
+ {
+ return _mesh != null;
+ }
+ _tried = true;
+ if (ModEntry.Instance == null)
+ {
+ return false;
+ }
+ string path = System.IO.Path.Combine(ModEntry.Instance.Path, BundleFile);
+ AssetBundle b = System.IO.File.Exists(path) ? AssetBundle.LoadFromFile(path) : null;
+ if (b == null)
+ {
+ Debug.LogWarning("[NecromancerTome] RatModel: бандл не загружен: " + path);
+ return false;
+ }
+ _mesh = b.LoadAsset("necroRatMesh");
+ _mats = new Material[MatNames.Length];
+ Shader std = Shader.Find("Standard");
+ for (int i = 0; i < MatNames.Length; i++)
+ {
+ _mats[i] = b.LoadAsset(MatNames[i]);
+ if (_mats[i] != null && std != null && _mats[i].shader != std)
+ {
+ _mats[i].shader = std;
+ }
+ }
+ Debug.Log("[NecromancerTome] RatModel: сетка " + (_mesh != null ? _mesh.vertexCount + " вершин, костей " + _mesh.bindposes.Length : "НЕТ") +
+ ", шейдер " + (std != null ? std.name : "нет Standard"));
+ return _mesh != null;
+ }
+
+ /// Прозрачность крысы во время прохода сквозь постройки (указание 09.10: «пока зажата ПКМ, делать
+ /// крысу полупрозрачной»).
+ public const float GhostAlpha = 0.35f;
+ static Material[] _ghostMats;
+
+ /// Полупрозрачные копии материалов (Standard в режиме Fade) - один раз на игру; глаза
+ /// (necroRat_eye) остаются непрозрачными и светятся. Переключение - подмена sharedMaterials у рендерера
+ /// DireWolf, без пересоздания материалов.
+ public static void SetGhost(EntityAlive _rat, bool _ghost)
+ {
+ if (_rat?.emodel == null || _mats == null)
+ {
+ return;
+ }
+ if (_ghost && _ghostMats == null)
+ {
+ _ghostMats = new Material[_mats.Length];
+ for (int i = 0; i < _mats.Length; i++)
+ {
+ Material src = _mats[i];
+ if (src == null || MatNames[i] == "necroRat_eye")
+ {
+ _ghostMats[i] = src;
+ continue;
+ }
+ Material m = new Material(src) { name = src.name + "_ghost" };
+ m.SetFloat("_Mode", 2f);
+ m.SetOverrideTag("RenderType", "Transparent");
+ m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
+ m.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
+ m.SetInt("_ZWrite", 0);
+ m.DisableKeyword("_ALPHATEST_ON");
+ m.EnableKeyword("_ALPHABLEND_ON");
+ m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
+ m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
+ if (m.HasProperty("_Color"))
+ {
+ Color c = m.color;
+ c.a = GhostAlpha;
+ m.color = c;
+ }
+ _ghostMats[i] = m;
+ }
+ }
+ foreach (SkinnedMeshRenderer r in _rat.emodel.GetComponentsInChildren(true))
+ {
+ if (r.name == "DireWolf" && r.sharedMesh == _mesh)
+ {
+ r.sharedMaterials = _ghost ? _ghostMats : _mats;
+ }
+ }
+ }
+
+ public static void Apply(EModelBase _model)
+ {
+ if (!Load())
+ {
+ return;
+ }
+ bool done = false;
+ foreach (SkinnedMeshRenderer r in _model.GetComponentsInChildren(true))
+ {
+ if (r.name == "DireWolf")
+ {
+ if (r.bones == null || r.bones.Length != _mesh.bindposes.Length)
+ {
+ Debug.LogWarning("[NecromancerTome] RatModel: у волка " + (r.bones?.Length ?? 0) + " костей, у крысы " +
+ _mesh.bindposes.Length + " - модель не поставлена");
+ return;
+ }
+ r.sharedMesh = _mesh;
+ r.sharedMaterials = _mats;
+ r.localBounds = _mesh.bounds;
+ done = true;
+ }
+ else if (r.name == "DireWolfHair")
+ {
+ // Не enabled = false: при появлении питомца (renderFade) игра через avatarController.SetVisible
+ // включает все рендереры заново, и шерсть ложилась поверх крысы (тест 08.10: «модель крысы
+ // применилась, но почему-то в шерсти»). forceRenderingOff игра не трогает.
+ r.enabled = false;
+ r.forceRenderingOff = true;
+ }
+ }
+ Debug.Log("[NecromancerTome] RatModel: " + (done ? "модель крысы поставлена на " : "рендерер DireWolf не найден у ") + _model.entity?.entityId);
+ }
+ }
+
+ /// Крыса бегала, не касаясь земли (тест 08.10). Entity.SetCCScale уменьшает высоту капсулы движения
+ /// в SizeScale раз, а радиус - только увеличивает (FastMax(scale, 1)). У крысы (0.28 от волка) высота
+ /// стала меньше двух радиусов, капсула выродилась в шар волчьего радиуса, и его низ ушёл под ноги -
+ /// контроллер поднимал крысу над землёй. Радиус ужимаем так же, как высоту.
+ [HarmonyPatch(typeof(Entity), nameof(Entity.SetScale))]
+ public static class Patch_Entity_SetScale_RatModel
+ {
+ public static void Postfix(Entity __instance, float scale)
+ {
+ CharacterControllerAbstract cc = __instance.m_characterController;
+ if (cc == null || scale >= 1f || !RatModel.Is(__instance))
+ {
+ return;
+ }
+ float h = cc.GetHeight();
+ float r = Mathf.Min(cc.GetRadius() * scale, h * 0.5f);
+ // Центр - ровно половина высоты, иначе низ капсулы уходит под ноги. Замер 08.10 (RatProbe): центр 0.16
+ // при высоте 0.48 - низ на 8 см ниже точки сущности, земля ровно на -0.084, крыса висит. Волку игра
+ // центр выравнивает сама (SetCCScale: высота 1.89-2.2 -> 1.89, центр = половина), крысе - нет.
+ Vector3 c = cc.GetCenter();
+ c.y = h * 0.5f;
+ cc.SetSize(c, h, r);
+ Debug.Log("[NecromancerTome] RatModel: капсула крысы " + __instance.entityId + " - высота " + h.ToString("F2") +
+ ", радиус " + r.ToString("F2") + ", центр " + c.y.ToString("F2"));
+ }
+ }
+
+ /// ЗАМЕР (08.10, временный): крыса всё ещё «не достаёт до земли» после правки капсулы - раз в 3 с пишем,
+ /// где сущность, где её видимая модель, физическое тело, низ костей и земля под ней. По логу видно, что
+ /// именно приподнято. Убрать после починки.
+ [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.LateUpdate))]
+ public static class Patch_EntityAlive_LateUpdate_RatProbe
+ {
+ static int _ratClass = int.MinValue;
+ static float _next;
+
+ public static void Postfix(EntityAlive __instance)
+ {
+ if (_ratClass == int.MinValue)
+ {
+ _ratClass = EntityClass.FromString(RatModel.ClassName);
+ }
+ if (__instance.entityClass != _ratClass || Time.time < _next || __instance.IsDead())
+ {
+ return;
+ }
+ _next = Time.time + 3f;
+ Vector3 p = __instance.position - Origin.position;
+ string ground = Physics.Raycast(p + Vector3.up, Vector3.down, out RaycastHit hit, 4f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore)
+ ? (hit.point.y - p.y).ToString("F3") : "нет";
+ float low = float.MaxValue, rmin = float.MaxValue;
+ string lowName = "";
+ foreach (SkinnedMeshRenderer r in __instance.GetComponentsInChildren(false))
+ {
+ if (r.forceRenderingOff || r.bones == null)
+ {
+ continue;
+ }
+ rmin = Mathf.Min(rmin, r.bounds.min.y - p.y);
+ foreach (Transform b in r.bones)
+ {
+ if (b != null && b.position.y - p.y < low)
+ {
+ low = b.position.y - p.y;
+ lowName = b.name;
+ }
+ }
+ }
+ CharacterControllerAbstract cc = __instance.m_characterController;
+ Transform mt = __instance.emodel != null ? __instance.emodel.GetModelTransform() : null;
+ Debug.Log("[NecromancerTome] RatProbe " + __instance.entityId + ": земля " + ground +
+ ", transform " + (__instance.transform.position.y - p.y).ToString("F3") +
+ ", model " + (mt != null ? (mt.position.y - p.y).ToString("F3") + " scale " + mt.lossyScale.y.ToString("F2") : "нет") +
+ ", низ кости " + low.ToString("F3") + " (" + lowName + "), низ сетки " + rmin.ToString("F3") +
+ ", physicsRB " + (__instance.physicsRBT != null ? (__instance.physicsRBT.position.y - __instance.physicsBasePos.y - p.y).ToString("F3") : "нет") +
+ ", onGround " + __instance.onGround +
+ (cc != null ? ", cc c" + cc.GetCenter().y.ToString("F2") + " h" + cc.GetHeight().ToString("F2") + " r" + cc.GetRadius().ToString("F2") : ""));
+ }
+ }
+
+ [HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))]
+ public static class Patch_EModelBase_createModel_RatModel
+ {
+ public static void Postfix(EModelBase __instance)
+ {
+ if (RatModel.Is(__instance.entity))
+ {
+ RatModel.Apply(__instance);
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/RatPhase.cs b/HarmonySrc/RatPhase.cs
new file mode 100644
index 0000000..10e5995
--- /dev/null
+++ b/HarmonySrc/RatPhase.cs
@@ -0,0 +1,173 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Проход одержимой крысы сквозь постройки (BACKLOG, «Могильная крыса» п.4; тест 09.10: «почему не срабатывает
+ /// проход сквозь текстуры по ПКМ»). Пока держишь ПКМ - как дух змеи, но без полёта:
+ /// - стены, двери, мебель не держат: поднят Entity.IsStuck, и её собственный шаг (тот же бег по WASD)
+ /// идёт мимо контроллера столкновений (Entity.ccEntityCollisionStart);
+ /// - высоту ведём сами: IsStuck снимает и пол, поэтому каждый кадр крыса стоит на поверхности под собой
+ /// (луч вниз с полуметра - ступенька по пути, как у змеи), а над пустотой падает с ускорением на неё;
+ /// - грунт (земля, песок, камень) не пускает - как у змеи: шаг в грунт отменяется;
+ /// - отпустил ПКМ внутри построек - выталкивает на ближайшее свободное место (вбок, вверх, вниз - дороже).
+ ///
+ public static class RatPhase
+ {
+ public static bool On;
+ static Vector3 _last;
+ static float _vy;
+ const float Gravity = 20f;
+ /// Точка тела крысы для проверки «внутри» и радиус шара свободного места.
+ const float BodyUp = 0.2f;
+ const float BodyRadius = 0.12f;
+ /// С какой высоты над ногами ищем пол: всё ниже - ступенька, на которую она взбегает.
+ const float StepUp = 0.5f;
+ static Vector3[] _ejectOffsets;
+
+ public static void Tick(World _world, EntityAlive _rat, bool _held)
+ {
+ if (_held && !On)
+ {
+ On = true;
+ _vy = 0f;
+ _last = _rat.position;
+ _rat.IsStuck = true;
+ RatModel.SetGhost(_rat, true);
+ Debug.Log("[NecromancerTome] RatPhase: проход включён");
+ }
+ else if (!_held && On)
+ {
+ Stop(_world, _rat, "ПКМ отпущена");
+ return;
+ }
+ if (!On)
+ {
+ return;
+ }
+ _rat.IsStuck = true;
+ _rat.fallDistance = 0f;
+ Vector3 p = _rat.position;
+ if (!_world.IsChunkAreaCollidersLoaded(p))
+ {
+ _rat.SetPosition(_last);
+ return;
+ }
+ // В грунт - нельзя: шаг вбок отменяем.
+ if (SnakeControl.Inside(_world, p + Vector3.up * BodyUp, SnakeControl.TerrainMask, out bool ground) && ground)
+ {
+ p.x = _last.x;
+ p.z = _last.z;
+ }
+ // Высота - только наша: собственная гравитация сущности с IsStuck пронесла бы её сквозь пол.
+ float dt = Time.deltaTime;
+ p.y = _last.y;
+ if (SnakeControl.Ground(p + Vector3.up * StepUp, StepUp + 300f, out float floor))
+ {
+ if (floor >= p.y - 0.02f)
+ {
+ p.y = floor;
+ _vy = 0f;
+ }
+ else
+ {
+ _vy -= Gravity * dt;
+ p.y = Mathf.Max(floor, p.y + _vy * dt);
+ if (p.y <= floor)
+ {
+ _vy = 0f;
+ }
+ }
+ }
+ Vector3 m = _rat.motion;
+ m.y = 0f;
+ _rat.motion = m;
+ _rat.SetPosition(p);
+ _last = p;
+ }
+
+ /// Выключить проход; внутри построек - вытолкнуть наружу.
+ public static void Stop(World _world, EntityAlive _rat, string _why)
+ {
+ if (!On)
+ {
+ return;
+ }
+ On = false;
+ if (_rat == null)
+ {
+ return;
+ }
+ _rat.IsStuck = false;
+ _rat.motion = Vector3.zero;
+ RatModel.SetGhost(_rat, false);
+ Vector3 p = _rat.position;
+ if (_world != null && !Free(_world, p))
+ {
+ Vector3? to = Eject(_world, p);
+ if (to.HasValue)
+ {
+ p = to.Value;
+ Debug.Log("[NecromancerTome] RatPhase: вытолкнута из построек, сдвиг " + (p - _rat.position).magnitude.ToString("F1") + " м");
+ }
+ else
+ {
+ p = _world.FindSupportingBlockPos(p);
+ Debug.Log("[NecromancerTome] RatPhase: свободного места рядом нет - на опорный блок");
+ }
+ _rat.SetPosition(p);
+ }
+ Debug.Log("[NecromancerTome] RatPhase: проход выключен - " + _why);
+ }
+
+ static bool Free(World _world, Vector3 _p)
+ {
+ Vector3 body = _p + Vector3.up * BodyUp;
+ return _world.IsChunkAreaCollidersLoaded(_p) &&
+ !SnakeControl.Inside(_world, body, SnakeControl.WallMask, out _) &&
+ !Physics.CheckSphere(body - Origin.position, BodyRadius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
+ }
+
+ /// Ближайшее свободное место с опорой: до 4 м вбок, до 1 м вниз, до 4 м вверх; вниз дороже.
+ static Vector3? Eject(World _world, Vector3 _p)
+ {
+ if (_ejectOffsets == null)
+ {
+ List list = new List();
+ for (float r = 0f; r <= 4f; r += 0.25f)
+ {
+ int dirs = r == 0f ? 1 : 16;
+ for (float h = -1f; h <= 4f; h += 0.5f)
+ {
+ for (int i = 0; i < dirs; i++)
+ {
+ list.Add(Quaternion.Euler(0f, i * 22.5f, 0f) * Vector3.forward * r + Vector3.up * h);
+ }
+ }
+ }
+ list.Sort((x, y) => (x.magnitude + (x.y < 0f ? -x.y : 0f)).CompareTo(y.magnitude + (y.y < 0f ? -y.y : 0f)));
+ _ejectOffsets = list.ToArray();
+ }
+ foreach (Vector3 o in _ejectOffsets)
+ {
+ Vector3 c = _p + o;
+ if (!Free(_world, c))
+ {
+ continue;
+ }
+ // Посадить на пол под этим местом; если пол сам внутри построек - место не годится.
+ if (SnakeControl.Ground(c + Vector3.up * 0.3f, 6f, out float y))
+ {
+ c.y = y;
+ if (!Free(_world, c))
+ {
+ continue;
+ }
+ }
+ return c;
+ }
+ return null;
+ }
+ }
+}
diff --git a/HarmonySrc/SnakeControl.cs b/HarmonySrc/SnakeControl.cs
index 4a95fd5..7d55fb0 100644
--- a/HarmonySrc/SnakeControl.cs
+++ b/HarmonySrc/SnakeControl.cs
@@ -182,7 +182,7 @@ namespace NecromancerTome
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10. С 06.10 - своя
// метка по Transform (NecroMapMarker): через AddNavObject она так и не показалась.
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
- _mapNav = NecroMapMarker.Show(_snake, "ui_game_symbol_tracking_snake");
+ _mapNav = NecroMapMarker.Show(_snake, NecroMapMarker.SnakeClass);
Target = null;
_radiationCheck = 0f;
BodyDanger = false;
@@ -884,7 +884,7 @@ namespace NecromancerTome
return _o.magnitude + (_o.y < 0f ? -_o.y : 0f);
}
- static bool Free(World _world, Vector3 _p, bool _groundOnly)
+ public static bool Free(World _world, Vector3 _p, bool _groundOnly)
{
if (!_world.IsChunkAreaCollidersLoaded(_p))
{
@@ -905,7 +905,7 @@ namespace NecromancerTome
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
/// точке), а не постройка.
- static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
+ public static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
{
_ground = false;
Vector3 o = _p - Origin.position;
@@ -934,7 +934,7 @@ namespace NecromancerTome
return _world.GetBlock(World.worldToBlockPos(_p)).Block.IsCollideMovement;
}
- static bool Ground(Vector3 _from, float _dist, out float _y)
+ public static bool Ground(Vector3 _from, float _dist, out float _y)
{
if (Physics.Raycast(_from - Origin.position, Vector3.down, out RaycastHit hit, _dist, TerrainMask))
{
diff --git a/HarmonySrc/SpatialVaultPickupPatch.cs b/HarmonySrc/SpatialVaultPickupPatch.cs
index 924b59d..b10483c 100644
--- a/HarmonySrc/SpatialVaultPickupPatch.cs
+++ b/HarmonySrc/SpatialVaultPickupPatch.cs
@@ -238,12 +238,6 @@ namespace NecromancerTome
return;
}
- // Красный труп под прицелом - не блок: череп для урны (NecroSkull.cs, 06.10).
- if (NecroSkull.TryBegin(_player, _bracelet))
- {
- return;
- }
-
WorldRayHitInfo hitInfo = _player.HitInfo;
if (hitInfo == null || !hitInfo.bHitValid)
{
diff --git a/HarmonySrc/SummonPatch.cs b/HarmonySrc/SummonPatch.cs
index 16eee7d..15f5c2b 100644
--- a/HarmonySrc/SummonPatch.cs
+++ b/HarmonySrc/SummonPatch.cs
@@ -471,20 +471,6 @@ namespace NecromancerTome
}
}
- /// E в одержимом питомце (тест 06.10: «E не сработал, срабатывает R») - отзыв его самого:
- /// игрок возвращается в тело, питомец уходит в книгу. HandleInteraction во время одержимости не
- /// идёт (PlayerMoveController.Update выключен), поэтому зовёт Possession. True - отозван.
- public static bool RecallPossessedPet(EntityAlive owner, EntityAlive pet)
- {
- if (owner == null || pet == null || !EntityClass.list.TryGetValue(pet.entityClass, out EntityClass ec) ||
- !LimitedPets.TryGetValue(ec.entityClassName, out PetInfo info) || info.RecalledKey == null)
- {
- return false;
- }
- RecallPet(owner, pet.entityId, info.RecalledKey, info.ConsumesBook ? info.SummonItemName : null);
- return true;
- }
-
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
{
if (Possession.Active && Possession.Zombie != null && Possession.Zombie.entityId == petEntityId)
@@ -494,6 +480,7 @@ namespace NecromancerTome
World world = owner.world;
if (world != null)
{
+ WolfBag.DropFrom(world.GetEntity(petEntityId) as EntityAlive, "отозван"); // сумка волка - мешком на месте
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
}
owner.RemoveOwnedEntity(petEntityId);
diff --git a/HarmonySrc/VultureNeck.cs b/HarmonySrc/VultureNeck.cs
new file mode 100644
index 0000000..41f9a65
--- /dev/null
+++ b/HarmonySrc/VultureNeck.cs
@@ -0,0 +1,197 @@
+using System.Collections.Generic;
+using System.Globalization;
+using UnityEngine;
+using UnityEngine.Scripting;
+
+namespace NecromancerTome
+{
+ ///
+ /// Шея одержимого стервятника (указание 08.10). Сидя он стоит в позе 0 ванильного спящего
+ /// (sleeperSit) - голова опущена, как во сне. Это верно для еды, а бодрствующему нужна голова
+ /// вперёд: шея и голова - посередине между полётом и сном.
+ ///
+ /// КАК. Модель и анимации игры не трогаем (в мод не идёт ни одного ассета игры): после аниматора
+ /// доворачиваем кости шеи и головы. В полёте запоминаем их локальные повороты (сглаженно - крылья
+ /// машут), сидя - каждый кадр берём то, что поставил аниматор (поза сна), и ставим долю Mix пути к
+ /// полётным. Вход и выход - плавно за BlendTime. Зовётся из Possession.PlaceCamera (постфикс
+ /// vp_FPCamera.LateUpdate) - после аниматора и до того, как камера читает положение головы.
+ ///
+ /// ПАМЯТЬ. Ничего не сохраняется; ссылки на кости - только пока стервятник одержим (Reset на выходе).
+ ///
+ public static class VultureNeck
+ {
+ /// Доля пути от позы сна к полётной (0.5 - посередине). Подбор - necroneck mix.
+ public static float Mix = 0.5f;
+ public static bool Enabled = true;
+ public const float BlendTime = 0.3f;
+ /// Полётную позу берём, когда стервятник летит хотя бы столько (без переходов анимации).
+ public const float FlightSettle = 0.5f;
+ /// Если костей с «neck» в имени нет - голова и столько её родителей.
+ public const int FallbackChain = 3;
+
+ static EntityAlive _v;
+ static Transform[] _chain;
+ static Quaternion[] _flight;
+ static bool _hasFlight;
+ static float _flyTime;
+ static float _weight;
+
+ public static void Reset()
+ {
+ _v = null;
+ _chain = null;
+ _flight = null;
+ _hasFlight = false;
+ _flyTime = 0f;
+ _weight = 0f;
+ }
+
+ /// Каждый кадр одержимости стервятником, после аниматора. _awake - сидит, не ест.
+ public static void Apply(EntityAlive _vulture, bool _sitting, bool _awake)
+ {
+ if (_vulture != _v)
+ {
+ Reset();
+ _v = _vulture;
+ }
+ if (_chain == null && !FindChain())
+ {
+ return;
+ }
+ if (!_sitting)
+ {
+ _flyTime += Time.deltaTime;
+ if (_flyTime >= FlightSettle)
+ {
+ float k = _hasFlight ? Mathf.Min(1f, Time.deltaTime * 4f) : 1f;
+ for (int i = 0; i < _chain.Length; i++)
+ {
+ if (_chain[i] == null)
+ {
+ _chain = null; // модель пересобрана - найти кости заново
+ return;
+ }
+ _flight[i] = Quaternion.Slerp(_flight[i], _chain[i].localRotation, k);
+ }
+ _hasFlight = true;
+ }
+ }
+ else
+ {
+ _flyTime = 0f;
+ }
+ float want = Enabled && _sitting && _awake && _hasFlight ? 1f : 0f;
+ _weight = Mathf.MoveTowards(_weight, want, Time.deltaTime / BlendTime);
+ if (_weight <= 0f || !_sitting)
+ {
+ return;
+ }
+ float t = Mix * Mathf.SmoothStep(0f, 1f, _weight);
+ for (int i = 0; i < _chain.Length; i++)
+ {
+ if (_chain[i] == null)
+ {
+ _chain = null;
+ return;
+ }
+ _chain[i].localRotation = Quaternion.Slerp(_chain[i].localRotation, _flight[i], t);
+ }
+ }
+
+ /// Цепочка от головы вверх: все кости с «neck» в имени (без них - FallbackChain родителей).
+ static bool FindChain()
+ {
+ Transform head = _v?.emodel != null ? _v.emodel.GetHeadTransform() : null;
+ if (head == null)
+ {
+ return false;
+ }
+ var list = new List { head };
+ for (Transform p = head.parent; p != null && p.name.ToLowerInvariant().Contains("neck"); p = p.parent)
+ {
+ list.Add(p);
+ }
+ if (list.Count == 1)
+ {
+ for (Transform p = head.parent; p != null && list.Count <= FallbackChain; p = p.parent)
+ {
+ list.Add(p);
+ }
+ }
+ _chain = list.ToArray();
+ if (_flight == null || _flight.Length != _chain.Length)
+ {
+ _flight = new Quaternion[_chain.Length];
+ _hasFlight = false;
+ }
+ string names = "";
+ foreach (Transform t in _chain)
+ {
+ names += (names.Length > 0 ? " < " : "") + t.name;
+ }
+ Debug.Log("[NecromancerTome] VultureNeck: кости " + names);
+ return true;
+ }
+
+ public static string Dump()
+ {
+ if (_chain == null)
+ {
+ return "костей нет - вселитесь в стервятника";
+ }
+ string s = "mix " + F(Mix) + (Enabled ? "" : " (выкл)") + ", вес " + F(_weight) + ", полёт " + (_hasFlight ? "снят" : "не снят");
+ for (int i = 0; i < _chain.Length; i++)
+ {
+ if (_chain[i] != null)
+ {
+ s += "\n " + _chain[i].name + " сейчас " + HandPose.F(_chain[i].localEulerAngles) + ", в полёте " + HandPose.F(_flight[i].eulerAngles);
+ }
+ }
+ return s;
+ }
+
+ static string F(float _f) => _f.ToString("0.##", CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// necroneck - подбор шеи сидящего одержимого стервятника:
+ /// mix <0..1> - доля пути от позы сна к полётной (0 - как спит, 1 - как в полёте)
+ /// on | off - включить / выключить доворот
+ /// show - кости и их повороты (в консоль и лог)
+ ///
+ [Preserve]
+ public class ConsoleCmdNecroNeck : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necroneck" };
+ public override string getDescription() => "NecromancerTome: шея сидящего одержимого стервятника - mix <0..1>|on|off|show";
+
+ void Out(string _s)
+ {
+ SdtdConsole.Instance.Output(_s);
+ Debug.Log("[NecromancerTome] necroneck: " + _s);
+ }
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ string cmd = _params.Count > 0 ? _params[0] : "show";
+ switch (cmd)
+ {
+ case "on":
+ case "off":
+ VultureNeck.Enabled = cmd == "on";
+ Out("доворот шеи " + (VultureNeck.Enabled ? "включён" : "выключен"));
+ return;
+ case "mix":
+ if (_params.Count > 1 && float.TryParse(_params[1].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out float m))
+ {
+ VultureNeck.Mix = Mathf.Clamp(m, -0.5f, 1.5f);
+ }
+ Out("mix " + VultureNeck.Mix.ToString("0.##", CultureInfo.InvariantCulture));
+ return;
+ default:
+ Out(VultureNeck.Dump());
+ return;
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/WolfBag.cs b/HarmonySrc/WolfBag.cs
new file mode 100644
index 0000000..cae0bbe
--- /dev/null
+++ b/HarmonySrc/WolfBag.cs
@@ -0,0 +1,258 @@
+using System.Collections.Generic;
+using System.IO;
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Сумка одержимого лютоволка на 3 ячейки (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
+ /// - волк лутает: окно контейнера то же, что у крысы (RatLoot, рюкзак персонажа не показывается),
+ /// но щелчок по предмету (и «Взять всё») кладёт его в сумку волка; места нет - «Сумка полна»;
+ /// - выгрузка - принести к телу: рядом с телом E перекладывает сумку в рюкзак персонажа, что не
+ /// влезло - мешком на землю рядом;
+ /// - волк погиб или отозван - сумка выпадает мешком на месте.
+ ///
+ /// ХРАНЕНИЕ. Сумку сущности игра не сохраняет (Entity.bag нет в Entity.Write), поэтому она своя.
+ /// Упаковка - та же, что у пространственного хранилища (SpatialVaultPersistence.BuildPayload/ParsePayload,
+ /// проверена на сейвах 3.2 и 3.3), но в ОТДЕЛЬНОМ файле папки сейва, как у Чертогов разума
+ /// (NecromancerTome_palaces.txt): файл игрока не меняется (сейвы - главный риск мода). Пишется сразу
+ /// при каждом изменении - переживает и сохранение, и вылет. Волк у игрока один (один спутник за раз),
+ /// поэтому сумка одна, без привязки к номеру сущности.
+ ///
+ public static class WolfBag
+ {
+ public const int Slots = 3;
+ const string FileName = "NecromancerTome_wolfbag.bin";
+ public const float UnloadRange = 3f;
+ static Bag _bag;
+ static string _dir;
+
+ static string FilePath => Path.Combine(GameIO.GetSaveGameDir(), FileName);
+
+ /// Сумка текущего сейва (из файла при первом обращении в этом сейве).
+ public static Bag Get()
+ {
+ string dir = GameIO.GetSaveGameDir();
+ if (_bag != null && _dir == dir)
+ {
+ return _bag;
+ }
+ _dir = dir;
+ _bag = null;
+ try
+ {
+ if (File.Exists(FilePath))
+ {
+ _bag = SpatialVaultPersistence.ParsePayload(File.ReadAllBytes(FilePath));
+ }
+ }
+ catch (System.Exception e)
+ {
+ Debug.LogWarning("[NecromancerTome] WolfBag: файл сумки не прочитан (" + e.Message + ") - оставлен как есть, сумка пустая");
+ _bag = new Bag(new Vector2i(Slots, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
+ return _bag;
+ }
+ if (_bag == null)
+ {
+ _bag = new Bag(new Vector2i(Slots, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
+ }
+ else if (_bag.SlotCount < Slots)
+ {
+ _bag.Resize(new Vector2i(Slots, 1));
+ }
+ Debug.Log("[NecromancerTome] WolfBag: сумка загружена - " + Describe());
+ return _bag;
+ }
+
+ static void Save()
+ {
+ try
+ {
+ if (_bag == null || _bag.IsEmpty())
+ {
+ if (File.Exists(FilePath))
+ {
+ File.Delete(FilePath);
+ }
+ return;
+ }
+ File.WriteAllBytes(FilePath, SpatialVaultPersistence.BuildPayload(_bag));
+ }
+ catch (System.Exception e)
+ {
+ Debug.LogWarning("[NecromancerTome] WolfBag: сумка не записана - " + e.Message);
+ }
+ }
+
+ public static string Describe()
+ {
+ Bag b = Get();
+ List parts = new List();
+ foreach (ItemStack s in b.ItemGrid.items)
+ {
+ if (s != null && !s.IsEmpty())
+ {
+ parts.Add(s.itemValue.ItemClass.GetLocalizedItemName() + (s.count > 1 ? " x" + s.count : ""));
+ }
+ }
+ return parts.Count == 0 ? Localization.Get("necroWolfBagEmpty") : string.Join(", ", parts);
+ }
+
+ static void Show(string _key)
+ {
+ EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
+ if (p != null)
+ {
+ GameManager.ShowTooltip(p, string.Format(Localization.Get(_key), Describe(), Get().GetUsedSlotCount(), Slots));
+ }
+ }
+
+ /// Переложить ячейку контейнера в сумку. Без раздвоения: сперва в неполные стопки, остаток -
+ /// в пустую ячейку; в контейнере остаётся ровно то, что не влезло.
+ public static bool TakeFrom(TEFeatureStorage _te, int _idx)
+ {
+ ItemStack src = _te.ItemGrid.GetItem(_idx);
+ if (src == null || src.IsEmpty())
+ {
+ return false;
+ }
+ Bag b = Get();
+ ItemStack moving = src.Clone();
+ int before = moving.count;
+ b.TryStackItem(0, moving);
+ if (moving.count > 0 && b.AddItem(moving.Clone()))
+ {
+ moving.count = 0;
+ }
+ int moved = before - moving.count;
+ if (moved <= 0)
+ {
+ Audio.Manager.PlayInsidePlayerHead("ui_denied");
+ Show("necroWolfBagFull");
+ return false;
+ }
+ ItemStack rest = src.Clone();
+ rest.count = moving.count;
+ _te.UpdateSlot(_idx, rest.count > 0 ? rest : ItemStack.Empty.Clone());
+ _te.SetModified();
+ _te.Parent?.NotifyListeners(); // окно контейнера перерисует ячейку (как ванильная сортировка)
+ Save();
+ Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
+ Show("necroWolfBagTook");
+ return true;
+ }
+
+ /// «Взять всё» - по ячейкам, пока есть место.
+ public static void TakeAll(TEFeatureStorage _te)
+ {
+ bool any = false;
+ for (int i = 0; i < _te.ItemGrid.items.Length; i++)
+ {
+ if (!_te.ItemGrid.items[i].IsEmpty() && Get().CanTakeItem(_te.ItemGrid.items[i]))
+ {
+ any |= TakeFrom(_te, i);
+ }
+ }
+ if (!any)
+ {
+ Audio.Manager.PlayInsidePlayerHead("ui_denied");
+ Show("necroWolfBagFull");
+ }
+ }
+
+ /// E рядом с телом: сумка - в рюкзак, что не влезло - мешком у тела.
+ public static bool TryUnload(EntityPlayerLocal _body, EntityAlive _wolf)
+ {
+ if (_body == null || (_body.position - _wolf.position).sqrMagnitude > UnloadRange * UnloadRange)
+ {
+ return false;
+ }
+ Bag b = Get();
+ if (b.IsEmpty())
+ {
+ Show("necroWolfBagShow");
+ return true;
+ }
+ List left = new List();
+ ItemStack[] slots = b.ItemGrid.items;
+ for (int i = 0; i < slots.Length; i++)
+ {
+ ItemStack s = slots[i]?.Clone();
+ if (s == null || s.IsEmpty())
+ {
+ continue;
+ }
+ _body.bag.TryStackItem(0, s);
+ if (s.count > 0 && _body.bag.AddItem(s.Clone()))
+ {
+ s.count = 0;
+ }
+ if (s.count > 0)
+ {
+ left.Add(s);
+ }
+ }
+ b.Clear();
+ Save();
+ if (left.Count > 0)
+ {
+ Drop(_body.position + _body.GetForwardVector() * 0.8f, left);
+ }
+ Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
+ GameManager.ShowTooltip(_body, Localization.Get(left.Count > 0 ? "necroWolfBagUnloadedPart" : "necroWolfBagUnloaded"));
+ Debug.Log("[NecromancerTome] WolfBag: выгружена у тела" + (left.Count > 0 ? ", не влезло " + left.Count + " - мешком" : ""));
+ return true;
+ }
+
+ /// Волк погиб или отозван - сумка мешком на месте.
+ public static void DropFrom(EntityAlive _wolf, string _why)
+ {
+ if (_wolf == null || !WolfControl.Is(_wolf))
+ {
+ return;
+ }
+ Bag b = Get();
+ if (b.IsEmpty())
+ {
+ return;
+ }
+ List items = new List();
+ foreach (ItemStack s in b.ItemGrid.items)
+ {
+ if (s != null && !s.IsEmpty())
+ {
+ items.Add(s.Clone());
+ }
+ }
+ b.Clear();
+ Save();
+ Drop(_wolf.position, items);
+ Debug.Log("[NecromancerTome] WolfBag: волк " + _why + " - сумка выпала мешком (" + items.Count + ")");
+ }
+
+ static void Drop(Vector3 _at, List _items)
+ {
+ GameManager.Instance.DropContentInLootContainerServer(-1, "DroppedLootContainer", _at + Vector3.up * 0.5f, _items.ToArray());
+ }
+
+ /// Выход из мира - забыть (следующий сейв прочитает свой файл).
+ public static void Forget()
+ {
+ _bag = null;
+ _dir = null;
+ }
+ }
+
+ [HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.OnEntityDeath))]
+ public static class Patch_EntityAlive_OnEntityDeath_WolfBag
+ {
+ public static void Prefix(EntityAlive __instance)
+ {
+ if (!__instance.world.IsRemote() && WolfControl.Is(__instance))
+ {
+ WolfBag.DropFrom(__instance, "погиб");
+ }
+ }
+ }
+}
diff --git a/HarmonySrc/WolfControl.cs b/HarmonySrc/WolfControl.cs
new file mode 100644
index 0000000..40ab757
--- /dev/null
+++ b/HarmonySrc/WolfControl.cs
@@ -0,0 +1,172 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Одержимый лютоволк - охотник (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
+ /// - бросок на ПКМ: враг под прицелом не дальше LeapRange - волк разворачивается и прыгает прямо на
+ /// него (прыжок самой игры: SetJumpDistance на точную дальность и перепад высоты); приземлился рядом -
+ /// цель падает на LeapStun секунд;
+ /// - след крови: раненые враги (здоровье ниже BloodHealth) отмечены на компасе и карте до BloodRange,
+ /// пока не умрут или не залечатся, - отличие от нюха собаки: та чует всех рядом, волк - подранков издалека.
+ ///
+ public static class WolfControl
+ {
+ public const string WolfClass = "necroZombieWolf";
+ public const float LeapRange = 10f;
+ public const float LeapMin = 2f;
+ public const float LeapConeDeg = 10f;
+ public const float LeapCooldown = 3f;
+ public const float LeapHit = 2.5f;
+ public const float LeapStun = 2.5f;
+ public const float BloodHealth = 0.5f;
+ public const float BloodRange = 150f;
+ public const float BloodDropRange = 200f;
+
+ static EntityAlive _leapTarget;
+ static float _leapUntil, _leapStart, _nextLeap;
+ static readonly HashSet _blood = new HashSet();
+ static float _bloodCheck;
+
+ public static bool Is(EntityAlive _e)
+ {
+ return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
+ cls.entityClassName == WolfClass;
+ }
+
+ /// Каждый кадр одержимости волком.
+ public static void Tick(World _world, EntityPlayerLocal _player, EntityAlive _wolf, bool _leap, ref float _bodyYaw)
+ {
+ if (_leap && _leapTarget == null && Time.time >= _nextLeap && _wolf.onGround && !_wolf.Jumping)
+ {
+ EntityAlive t = Target(_world, _player, _wolf);
+ if (t != null)
+ {
+ Vector3 to = t.position - _wolf.position;
+ Vector3 flat = new Vector3(to.x, 0f, to.z);
+ _bodyYaw = Mathf.Atan2(flat.x, flat.z) * Mathf.Rad2Deg;
+ _wolf.SetRotation(new Vector3(0f, _bodyYaw, 0f));
+ _wolf.Jumping = true;
+ _wolf.SetJumpDistance(flat.magnitude, Mathf.Max(0.5f, to.y + 0.5f));
+ _leapTarget = t;
+ _leapStart = Time.time;
+ _leapUntil = Time.time + 2f;
+ _nextLeap = Time.time + LeapCooldown;
+ Debug.Log("[NecromancerTome] WolfControl: бросок на " + t.EntityName + " (" + t.entityId + "), " + flat.magnitude.ToString("F1") + " м");
+ }
+ }
+ if (_leapTarget != null)
+ {
+ bool landed = Time.time - _leapStart > 0.3f && _wolf.onGround && !_wolf.Jumping;
+ if (_leapTarget.IsDead() || Time.time > _leapUntil)
+ {
+ _leapTarget = null;
+ }
+ else if (landed || (_leapTarget.getChestPosition() - _wolf.getHeadPosition()).sqrMagnitude < LeapHit * LeapHit * 0.5f)
+ {
+ if ((_leapTarget.position - _wolf.position).sqrMagnitude < LeapHit * LeapHit)
+ {
+ KnockDown(_leapTarget, _wolf);
+ }
+ _leapTarget = null;
+ }
+ }
+ BloodTick(_world, _wolf);
+ }
+
+ static void KnockDown(EntityAlive _t, EntityAlive _wolf)
+ {
+ _wolf.emodel?.avatarController?.StartAnimationAttack();
+ if (_t.bodyDamage.CurrentStun == EnumEntityStunType.None && _t.emodel?.avatarController != null)
+ {
+ _t.emodel.avatarController.BeginStun(EnumEntityStunType.Prone, EnumBodyPartHit.Torso, Utils.EnumHitDirection.Front, false, 0.5f);
+ _t.SetStun(EnumEntityStunType.Prone);
+ _t.bodyDamage.StunDuration = LeapStun;
+ }
+ Debug.Log("[NecromancerTome] WolfControl: " + _t.EntityName + " сбит с ног");
+ }
+
+ /// Враг ближе всех к лучу прицела: не дальше LeapRange от волка и в конусе LeapConeDeg.
+ static EntityAlive Target(World _world, EntityPlayerLocal _player, EntityAlive _wolf)
+ {
+ Transform cam = _player.cameraTransform;
+ if (cam == null)
+ {
+ return null;
+ }
+ Vector3 eye = cam.position + Origin.position;
+ EntityAlive best = null;
+ float bestAngle = LeapConeDeg;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (!SnakeControl.IsFoe(e, _wolf))
+ {
+ continue;
+ }
+ float d = (e.position - _wolf.position).magnitude;
+ if (d > LeapRange || d < LeapMin)
+ {
+ continue;
+ }
+ float angle = Vector3.Angle(cam.forward, e.getChestPosition() - eye);
+ if (angle < bestAngle)
+ {
+ bestAngle = angle;
+ best = e;
+ }
+ }
+ return best;
+ }
+
+ static void BloodTick(World _world, EntityAlive _wolf)
+ {
+ _bloodCheck -= Time.deltaTime;
+ if (_bloodCheck > 0f)
+ {
+ return;
+ }
+ _bloodCheck = 1f;
+ for (int i = 0; i < _world.EntityAlives.Count; i++)
+ {
+ EntityAlive e = _world.EntityAlives[i];
+ if (e == null)
+ {
+ continue;
+ }
+ float d2 = (e.position - _wolf.position).sqrMagnitude;
+ bool bleeding = SnakeControl.IsFoe(e, _wolf) && e.Health < e.GetMaxHealth() * BloodHealth;
+ if (bleeding && d2 < BloodRange * BloodRange)
+ {
+ if (_blood.Add(e.entityId))
+ {
+ e.AddNavObject(SnakeControl.SenseNavObject, null, null);
+ }
+ }
+ else if (_blood.Contains(e.entityId) && (!bleeding || d2 > BloodDropRange * BloodDropRange))
+ {
+ e.RemoveNavObject(SnakeControl.SenseNavObject);
+ _blood.Remove(e.entityId);
+ }
+ }
+ _blood.RemoveWhere(id => _world.GetEntity(id) == null);
+ }
+
+ /// Выход из волка - снять метки, забыть бросок.
+ public static void Clear()
+ {
+ World world = GameManager.Instance?.World;
+ if (world != null)
+ {
+ foreach (int id in _blood)
+ {
+ (world.GetEntity(id) as EntityAlive)?.RemoveNavObject(SnakeControl.SenseNavObject);
+ }
+ }
+ _blood.Clear();
+ _bloodCheck = 0f;
+ _leapTarget = null;
+ }
+ }
+}
diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll
index 7ba0d86..7c68ffc 100644
Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ
diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb
index 0b4f839..6cb4649 100644
Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ
diff --git a/Resources/necrorat b/Resources/necrorat
new file mode 100644
index 0000000..39fb99f
Binary files /dev/null and b/Resources/necrorat differ