Compare commits
@@ -122,19 +122,38 @@
|
||||
чертогах (BlockNecroMemoryBook.Open) и консольная команда necrotome. -->
|
||||
<append xpath="/windows">
|
||||
<window name="necroTome" anchor="Center" pos="-650,420" width="1300" height="840" cursor_area="true" depth="1001" lock_navigation="true" controller="NecromancerTome.NecroTome, NecromancerHarmony">
|
||||
<sprite depth="0" name="bg" sprite="menu_empty" color="14,10,10,250" type="sliced" />
|
||||
<sprite depth="1" name="frame" sprite="menu_empty2px" color="120,18,14,255" type="sliced" fillcenter="false" />
|
||||
<sprite depth="0" name="bg" sprite="menu_empty" color="14,10,10,250" type="sliced" globalopacitymod="0" />
|
||||
<sprite depth="1" name="frame" sprite="menu_empty2px" color="120,18,14,255" type="sliced" fillcenter="false" globalopacitymod="0" />
|
||||
<label depth="2" name="lblTitle" pos="0,-22" width="1300" height="50" justify="center" font_size="38" color="205,40,30,255" effect="outline" upper_case="false" />
|
||||
<sprite depth="1" name="rule" pos="200,-82" width="900" height="2" sprite="menu_empty" color="120,18,14,255" />
|
||||
<label depth="2" name="lblText" pos="60,-104" width="1180" height="640" justify="left" font_size="27" color="215,205,190,255" overflow="shrinkcontent" upper_case="false" />
|
||||
<simplebutton name="btnCh1" depth="3" pos="350,-130" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh2" depth="3" pos="350,-190" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh3" depth="3" pos="350,-250" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh4" depth="3" pos="350,-310" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh5" depth="3" pos="350,-370" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh6" depth="3" pos="350,-430" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh7" depth="3" pos="350,-490" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh8" depth="3" pos="350,-550" width="600" height="48" font_size="30" visible="false" />
|
||||
<sprite depth="1" name="rule" pos="200,-82" width="900" height="2" sprite="menu_empty" color="120,18,14,255" globalopacitymod="0" />
|
||||
<label depth="2" name="lblText" pos="60,-104" width="1180" height="640" justify="left" font_size="27" color="215,205,190,255" overflow="shrinkcontent" upper_case="false" effect="outline" effect_color="0,0,0,255" />
|
||||
<!-- Иллюстрация страницы справа (Chapter.Image / Chapter.Video в NecroTome.cs); при ней
|
||||
текст сужается до левой половины. Видео - тот же XUiV_Video, что фон главного меню. -->
|
||||
<!-- globalopacitymod="0" (09.10): иначе фон и иллюстрация берут настройку прозрачности интерфейса и
|
||||
просвечивают обоями чертогов - красные заголовки сливались, картинка была полупрозрачной. -->
|
||||
<texture depth="2" name="imgPage" pos="680,-104" width="560" height="640" visible="false" globalopacitymod="0" />
|
||||
<video depth="2" name="vidPage" pos="680,-104" width="560" height="640" videoaspect="FitInside" loop="true" visible="false" />
|
||||
<!-- Кнопки глав: до 10 - один столбец по центру, больше - два столбца по 10 (10.10); места ставит XUiC_NecroTome.ShowToc. -->
|
||||
<simplebutton name="btnCh1" depth="3" pos="370,-130" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh2" depth="3" pos="370,-190" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh3" depth="3" pos="370,-250" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh4" depth="3" pos="370,-310" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh5" depth="3" pos="370,-370" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh6" depth="3" pos="370,-430" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh7" depth="3" pos="370,-490" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh8" depth="3" pos="370,-550" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh9" depth="3" pos="370,-610" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh10" depth="3" pos="370,-670" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh11" depth="3" pos="370,-130" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh12" depth="3" pos="370,-190" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh13" depth="3" pos="370,-250" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh14" depth="3" pos="370,-310" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh15" depth="3" pos="370,-370" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh16" depth="3" pos="370,-430" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh17" depth="3" pos="370,-490" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh18" depth="3" pos="370,-550" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh19" depth="3" pos="370,-610" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh20" depth="3" pos="370,-670" width="560" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnToc" depth="3" pos="40,-790" width="260" height="46" font_size="26" caption_key="necroTomeToc" />
|
||||
<simplebutton name="btnPrev" depth="3" pos="440,-790" width="160" height="46" font_size="26" caption_key="necroTomePrev" />
|
||||
<label depth="2" name="lblPage" pos="600,-772" width="100" height="40" justify="center" font_size="24" color="160,150,140,255" />
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
<property name="SellableToTrader" value="false" />
|
||||
<drop event="Destroy" name="resourceScrapIron" count="10,20" />
|
||||
</block>
|
||||
<!-- Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере.
|
||||
<!-- Гео-оссуарий (1.6.0, 2026-09-30): станция добычи ресурсов на кровавой сфере.
|
||||
Тело - ванильный коллектор росы (Class=Collector): окно, топливо, таймеры, работа без
|
||||
игрока рядом - всё его. Своего кода два места, оба в HarmonySrc/GeoOssuaryPatch.cs:
|
||||
- каждая из трёх ячеек модификаций принимает любой из пяти ресурсов (ваниль даёт
|
||||
@@ -283,6 +283,83 @@
|
||||
necroposters (на каждый плакат свой префаб с тем же багетом). Исходник рамки:
|
||||
BlenderWork/necroSchemeFramePrefab/build_frame.py. -->
|
||||
<append xpath="/blocks">
|
||||
<block name="necroBookDoor">
|
||||
<!-- Дверь-книжный шкаф 1x2 (09.10.2026): «замаскирована под книжную полку, закрыта, откроется только по событию».
|
||||
Механика, звуки и прочность - ванильной межкомнатной двери; модель своя (бандл necroposters, собирает
|
||||
ItemsUnityProject Assets/Editor/NecroBookDoor.cs): створка 0.98x1.98x0.12 с полками на обеих сторонах, петля
|
||||
слева, Animator IsOpen/OpenTrigger, состояния Open/Close (как ждёт TEFeatureDoor). Чертоги её не открывают
|
||||
(MindPalace.OpenDoors трогает только exteriorHouseDoorWhite). -->
|
||||
<property name="Extends" value="interiorHouseDoorWhite" param1="TintColor,CustomIconTint,MeshDamage"/>
|
||||
<!-- MeshDamage не наследуем: он ищет в модели Door/DMG0..DMG3 (у ванильной двери они есть, у нашей нет) - тест 09.10:
|
||||
NullReferenceException в BlockShapeModelEntity.UpdateDamageState при постановке. -->
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
<property name="CustomIcon" value="necroBookDoor"/>
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroBookDoorPrefab.prefab"/>
|
||||
<property name="ModelOffset" value="0,0,0"/>
|
||||
<property name="MultiBlockDim" value="1,2,1"/>
|
||||
</block>
|
||||
<block name="necroDoorNumber">
|
||||
<!-- Табличка с номером двери чертогов (09.10.2026): ставится в редакторе префабов рядом с дверью или над ней,
|
||||
в ней пишется номер. В чертоги не ставится - вместо неё воздух (MindPalace.Expected), видна только в
|
||||
редакторе. Дверь с номером закрыта, пока событие не откроет её (MindPalace.SetDoorOpen, necropalace door). -->
|
||||
<property name="Extends" value="playerSignWood1x1"/>
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
</block>
|
||||
<block name="necroChalkboard">
|
||||
<!-- Грифельная доска (09.10.2026): «как рамка, только тёмная винтажная доска; вписаться в 2x2; надписи как
|
||||
на ящиках - для сюжета». Модель - бандл necroposters (собирает ItemsUnityProject Assets/Editor/NecroChalkboard.cs):
|
||||
рама 1.90x1.40 м, полочка для мела, дочерний TextMesh (шрифт Neucha, OFL). Надпись - TEFeatureSignable,
|
||||
писать можно только в редакторе префабов / творческом режиме / отладке (ChalkboardPatch.cs).
|
||||
Размер букв: em = FontSize x 0.01 / 10 м; 110 -> ~9 строк по ~30 знаков. -->
|
||||
<property name="Extends" value="pictureFrame_01a"/>
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
<property name="CustomIcon" value="necroChalkboard"/>
|
||||
<property name="Material" value="Mwood_weak"/>
|
||||
<property name="Class" value="CompositeTileEntity"/>
|
||||
<property class="CompositeFeatures">
|
||||
<property class="TEFeatureSignable">
|
||||
<property name="FontSize" value="110"/>
|
||||
<property name="LineCount" value="9"/>
|
||||
<property name="LineWidth" value="1.68"/>
|
||||
<property name="LineSpacing" value="1"/>
|
||||
</property>
|
||||
</property>
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroPosterChalkboardPrefab.prefab"/>
|
||||
<property name="ModelOffset" value="0,0,-0.5"/>
|
||||
<property name="MultiBlockDim" value="2,2,1"/>
|
||||
<property name="OnlySimpleRotations" value="true"/>
|
||||
<property name="CanPickup" value="false"/>
|
||||
</block>
|
||||
<block name="necroChalkboardPlayer">
|
||||
<!-- Меловая доска игрока (указание 10.10: «мне нравится использовать её во время игры, делая записи»).
|
||||
Та же модель и надпись, что у сюжетной necroChalkboard, но крафтится и пишется в обычной игре. Отдельный
|
||||
блок, чтобы сюжетная доска чертогов (на ней пишет мод, MindPalace.UpdateBoards) осталась неизменяемой.
|
||||
Записи - TEFeatureSignable: свои у каждой доски, хранятся в чанке, как надписи на ящиках. -->
|
||||
<property name="Extends" value="necroChalkboard"/>
|
||||
<property name="CreativeMode" value="Player"/>
|
||||
<property name="DescriptionKey" value="necroChalkboardPlayerDesc"/>
|
||||
<property name="Class" value="CompositeTileEntity"/>
|
||||
<property class="CompositeFeatures">
|
||||
<property class="TEFeatureSignable">
|
||||
<property name="FontSize" value="110"/>
|
||||
<property name="LineCount" value="9"/>
|
||||
<property name="LineWidth" value="1.68"/>
|
||||
<property name="LineSpacing" value="1"/>
|
||||
</property>
|
||||
</property>
|
||||
<property name="CanPickup" value="true"/>
|
||||
</block>
|
||||
<block name="necroPosterKnife">
|
||||
<!-- Схема ножа некроманта (09.10.2026): синька в стиле ванильного чертежа пистолета, три вида из Blender
|
||||
(BlenderWork/necroTomePets/make_knife.py poster). Тот же багет; пользователь повесит сам в чертогах. -->
|
||||
<property name="Extends" value="pictureFrame_01a"/>
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
<property name="CustomIcon" value="necroPosterKnife"/>
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroPosterKnifePrefab.prefab"/>
|
||||
<property name="ModelOffset" value="0,0,-0.5"/>
|
||||
<property name="MultiBlockDim" value="2,1,1"/>
|
||||
<property name="CanPickup" value="false"/>
|
||||
</block>
|
||||
<block name="necroPosterAnatomy">
|
||||
<property name="Extends" value="pictureFrame_01a"/>
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
|
||||
@@ -96,6 +96,21 @@
|
||||
death too (onSelfDied) even though the buff FLAG itself survives death on purpose
|
||||
(remove_on_death="false" above) - the glow is cosmetic and shouldn't linger on a
|
||||
corpse; VictimPatch.cs's own HasBuff check doesn't care whether the particle is gone. -->
|
||||
<!-- Кровавый прилив (указание 10.10): ставит и снимает BloodTide.cs - на игроке, пока идёт ночь кровавой
|
||||
луны (SnakeMode.IsBloodTide). Сам по себе ничего не меняет; нож читает его в effect_group
|
||||
necroKnifeBloodTide (items.xml). Здесь - только то, что зависит от ножа в руке: бег на 50% быстрее.
|
||||
Восстановление здоровья и выносливости вдвое (только положительное, кровотечение не трогаем),
|
||||
пульс полосок и сердцебиение - BloodTide.cs. -->
|
||||
<buff name="buffNecroBloodTide" icon="ui_game_symbol_skull" icon_color="170,60,255" name_key="buffNecroBloodTideName" description_key="buffNecroBloodTideDesc" remove_on_death="false">
|
||||
<stack_type value="ignore"/>
|
||||
<effect_group>
|
||||
<requirement name="HoldingItemHasTags" tags="necroKnife"/>
|
||||
<passive_effect name="WalkSpeed" operation="perc_add" value=".5"/>
|
||||
<passive_effect name="RunSpeed" operation="perc_add" value=".5"/>
|
||||
<passive_effect name="CrouchSpeed" operation="perc_add" value=".5"/>
|
||||
</effect_group>
|
||||
</buff>
|
||||
|
||||
<buff name="buffNecroVictim" hidden="true" remove_on_death="false">
|
||||
<stack_type value="ignore"/>
|
||||
<duration value="0"/>
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
<objective type="Craft" item="thrownStonePortalBlue"/>
|
||||
</challenge>
|
||||
|
||||
<!-- ИСПЫТАНИЯ 1.7.0 (01.10.2026, указание: «добавь испытания по линии некромантии, сам
|
||||
<!-- ИСПЫТАНИЯ 1.6.0 (01.10.2026, указание: «добавь испытания по линии некромантии, сам
|
||||
придумай; часть - первое создание новых предметов»). Пять - первое создание нового
|
||||
(скакун, оссуарий, запас сфер, камень, свиток крысы), два - вещи, на которые испытаний
|
||||
ещё не было (браслет, пирамида). Награды по тому же счёту, что выше: 1000 - быстрые
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
<!-- User request 2026-08-28: bite applies buffInjurySlow, same debuff the Insect Swarm
|
||||
originally had before its own rework - see necroMeleeHandZombieDog in items.xml. -->
|
||||
<property name="HandItem" value="necroMeleeHandZombieDog"/>
|
||||
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
|
||||
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
|
||||
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
|
||||
<property name="NavObject" value="necroRatSpiritPet"/>
|
||||
|
||||
<!-- ИИ ПЕРЕПИСАН 2026-09-18 ПОД ОБРАЗЕЦ ДУХА КРЫСЫ, прямое указание: "поведение
|
||||
других призванных животных зомби тоже подгони под крысу. По сути это те же
|
||||
@@ -292,6 +296,10 @@
|
||||
<property name="IsEnemyEntity" value="false"/>
|
||||
<property name="Faction" value="none"/>
|
||||
<property name="HandItem" value="necroMeleeHandZombieGriffin"/>
|
||||
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
|
||||
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
|
||||
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
|
||||
<property name="NavObject" value="necroRatSpiritPet"/>
|
||||
<effect_group name="Base Effects">
|
||||
<!-- У ванильного зомбостервятника 30 HP - для питомца, которого не отозвать до
|
||||
следующего призыва, это ничто. 750 оставлено с прежней, наземной версии. -->
|
||||
@@ -356,7 +364,7 @@
|
||||
<property name="SoundAttack" value="rabbitpain"/>
|
||||
<property name="SoundStepType" value="animallightstep"/>
|
||||
|
||||
<!-- Жёлтая метка на карте и компасе - указание 2026-09-18. Класс метки наш,
|
||||
<!-- Метка на карте и компасе - указание 2026-09-18 (жёлтая, с 08.10 зелёная, как у всех питомцев). Класс метки наш,
|
||||
Config/nav_objects.xml; ванильные animaltracking_* не подошли, они завязаны
|
||||
на перк следопыта, а не на прямое назначение. -->
|
||||
<property name="NavObject" value="necroRatSpiritPet"/>
|
||||
@@ -431,7 +439,7 @@
|
||||
</entity_class>
|
||||
</append>
|
||||
|
||||
<!-- Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей. Модель - бандл necrobonesteed
|
||||
<!-- Костяной скакун (1.6.0, 01.10.2026): мотоцикл из костей. Модель - бандл necrobonesteed
|
||||
(копия ванильного VMotorcycleP, переделанная в Blender); теги коллайдеров и шейдер
|
||||
ставит BoneSteedPatch с ванильного мотоцикла, топливо - кровавая сфера (там же). -->
|
||||
<append xpath="/entity_classes">
|
||||
@@ -474,5 +482,21 @@
|
||||
</property>
|
||||
</property>
|
||||
</entity_class>
|
||||
<!-- Останки красного трупа (10.10.2026): через 2 минуты (раньше - если игрок ушёл далеко или трупов больше
|
||||
предела) красный труп «Жертвы» истлевает в лёгкий контейнер со своей моделью - остов, обтянутый обугленной
|
||||
плёнкой (бандл necroremains). Лут и остаток срока переходят от трупа (RemainsDecay.cs). Сохраняется с
|
||||
участком силами игры, как мешок. TimeStayAfterDeath ставит код (остаток срока трупа). -->
|
||||
<entity_class name="necroRemains">
|
||||
<property name="Class" value="EntityLootContainer"/>
|
||||
<property name="UserSpawnType" value="Console"/>
|
||||
<property name="Mesh" value="#@modfolder(NecromancerTome):Resources/necroremains?necroRemainsPrefab.prefab"/>
|
||||
<property name="ModelType" value="Custom"/>
|
||||
<property name="Prefab" value="Backpack"/>
|
||||
<property name="Parent" value="Backpack"/>
|
||||
<property name="IsEnemyEntity" value="false"/>
|
||||
<property name="TimeStayAfterDeath" value="300"/>
|
||||
<property name="LootList" value="zPackVictimRemains"/>
|
||||
<property name="Faction" value="none"/>
|
||||
</entity_class>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -274,8 +274,9 @@
|
||||
<!-- Вдвое дороже по стамине, только силовая. -->
|
||||
<passive_effect name="StaminaLoss" operation="perc_add" value="1" tags="secondary"/>
|
||||
|
||||
<!-- Вторая половина платы: базовые 5 + эти 5 = 10 HP. -->
|
||||
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="5">
|
||||
<!-- Вторая половина платы: базовые 20 + эти 20 = 40 HP (было 5 + 5; указание 09.10). Не хватает 40 -
|
||||
KnifePowerCostPatch.cs меняет силовую на обычный удар. -->
|
||||
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="20">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<!-- "Health GT 0" на цели - тот же гейт на трупы, что у самого ножа, чтобы
|
||||
силовой удар по трупу не брал 5 HP впустую; подробный разбор в items.xml
|
||||
@@ -283,6 +284,14 @@
|
||||
отсеивает мёртвых. -->
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="5"/>
|
||||
<requirement name="!HasBuff" buff="buffNecroBloodTide"/>
|
||||
</triggered_effect>
|
||||
<!-- Кровавый прилив (10.10): плата вдвое меньше - 10 + 10 = 20. -->
|
||||
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="10">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="5"/>
|
||||
<requirement name="HasBuff" buff="buffNecroBloodTide"/>
|
||||
</triggered_effect>
|
||||
|
||||
<!-- AOE вокруг задетой цели. Базовое сбивание с ног у ножа одиночное, это его
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
открытая банка. Бандл necrocan; текстура пока ванильная cannedfood01 - до релиза
|
||||
перерисовать (гигиена ассетов). Банки с водой - свои модели с водой внутри. -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanPrefab.prefab"/>
|
||||
<property name="HoldType" value="14"/>
|
||||
<!-- HoldType 87 = хват пустой стеклянной банки (drinkJarEmpty), единственный с анимацией набора воды;
|
||||
консервный 14 её не имеет - банка набирала воду без движения рук (05.10.2026). -->
|
||||
<property name="HoldType" value="87"/>
|
||||
<property name="Material" value="Mmetal"/>
|
||||
<property class="Action1"> <!-- UseAction -->
|
||||
<property name="Class" value="CollectWater"/>
|
||||
@@ -132,6 +134,14 @@
|
||||
<property name="Create_item" value="tinCanEmpty"/>
|
||||
</append>
|
||||
|
||||
<!-- Письмо архимага (ванильная записка noteDuke01) прочитано -> открыта глава «Письмо архимага»
|
||||
в книге с оглавлением (HarmonySrc/NecroTome.cs). Ваниль сама ставит .dukeNoteRead, но cvar
|
||||
с точкой в сейв не пишется (EntityBuffs.Write пропускает key[0]=='.', декомпиляция 3.3) -
|
||||
поэтому свой cvar без точки. -->
|
||||
<append xpath="/items/item[@name='noteDuke01']/effect_group">
|
||||
<triggered_effect trigger="onSelfPrimaryActionEnd" action="ModifyCVar" cvar="necroTomeLetterRead" operation="set" value="1"/>
|
||||
</append>
|
||||
|
||||
<!-- "Камень духов" (Spirit Stone): the first necromancer item, replacing the earlier
|
||||
"Девиатор" (Deviator) placeholder book with a real thrown object - a vanilla small rock,
|
||||
reused via resourceRockSmall's own mesh/icon, tinted green (TintColor for the 3D mesh,
|
||||
@@ -1533,10 +1543,39 @@
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
</triggered_effect>
|
||||
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="5">
|
||||
<!-- Плата 20 (было 5; указание 09.10: лечение удара - убийства/20, прежние 5 окупались одним ударом).
|
||||
Не хватает здоровья - KnifePowerCostPatch.cs меняет силовую на обычный удар ещё до замаха. -->
|
||||
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="20">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="5"/>
|
||||
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="20"/>
|
||||
<requirement name="!HasBuff" buff="buffNecroBloodTide"/>
|
||||
</triggered_effect>
|
||||
</effect_group>
|
||||
|
||||
<!-- КРОВАВЫЙ ПРИЛИВ (указание 10.10). Некроэнергии в воздухе столько, что нож пьёт её сам: пока на
|
||||
игроке buffNecroBloodTide (ставит BloodTide.cs на ночь кровавой луны) - урон и скорость ударов
|
||||
вдвое, вампиризм вдвое (второе такое же лечение), плата за силовую вдвое меньше (10 вместо 20;
|
||||
обычная плата выше выключена !HasBuff), выносливость на удар вчетверо меньше, дальность -
|
||||
как у меча (у мачете 2.3, у ножа 2.0). Хват и модель меча, скорость бега и восстановление -
|
||||
BloodTide.cs и сам бафф. -->
|
||||
<effect_group name="necroKnifeBloodTide">
|
||||
<requirement name="HasBuff" buff="buffNecroBloodTide"/>
|
||||
<passive_effect name="EntityDamage" operation="perc_add" value="1"/>
|
||||
<passive_effect name="AttacksPerMinute" operation="perc_add" value="1"/>
|
||||
<passive_effect name="StaminaLoss" operation="perc_add" value="-.75"/>
|
||||
<passive_effect name="MaxRange" operation="base_set" value="2.8"/>
|
||||
<!-- Расчленение (10.10: «почти джедайская светошашка»). У игры шанс = DismemberChance x доля урона от
|
||||
здоровья x множитель конечности зомби (EntityAlive.GetDismemberChance); у мачете 1, у ножа 0 - здесь втрое больше мачете. -->
|
||||
<passive_effect name="DismemberChance" operation="base_add" value="3"/>
|
||||
<triggered_effect trigger="onSelfPrimaryActionRayHit" action="ModifyStats" stat="Health" operation="add" value="@$necroKnifeHealCVar">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
</triggered_effect>
|
||||
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="10">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="10"/>
|
||||
</triggered_effect>
|
||||
</effect_group>
|
||||
</item>
|
||||
@@ -1899,7 +1938,7 @@
|
||||
</item>
|
||||
</append>
|
||||
|
||||
<!-- Костяной скакун - предмет-транспорт (1.7.0), ванильная система: в руке призрачный
|
||||
<!-- Костяной скакун - предмет-транспорт (1.6.0), ванильная система: в руке призрачный
|
||||
предпросмотр (ItemActionSpawnVehicle грузит его из Meshfile - поэтому Meshfile = модель
|
||||
скакуна), применение ставит, подбор возвращает предмет с прочностью и модами. Решение
|
||||
01.10: «не книга призыва, а та же система, что у ванильного транспорта». -->
|
||||
@@ -1951,6 +1990,22 @@
|
||||
<property name="SoundPlace" value="plantfibers_place"/>
|
||||
</item>
|
||||
</append>
|
||||
<!-- Череп (06.10.2026, «Некротехнолог v2»): находка в красном трупе (2%, HarmonySrc/NecroSkull.cs), кладётся в
|
||||
цинерарий (урна) - дух-работник на грядках. С 10.10 - своя модель (CC0-череп Blender Studio, окрашен в стиле
|
||||
останков: копоть, засохшая кровь; бандл necroskull, Unity NecroSkullSetup.cs) и своя иконка; хват книги (21). -->
|
||||
<append xpath="/items">
|
||||
<item name="necroSkull">
|
||||
<property name="DescriptionKey" value="necroSkullDesc"/>
|
||||
<property name="HoldType" value="21"/>
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroskull?necroSkullPrefab.prefab"/>
|
||||
<property name="CustomIcon" value="necroSkull"/>
|
||||
<property name="Material" value="MresourceBone"/>
|
||||
<property name="Stacknumber" value="50"/>
|
||||
<property name="EconomicValue" value="500"/>
|
||||
<property name="SellableToTrader" value="false"/>
|
||||
<property name="Group" value="Resources"/>
|
||||
</item>
|
||||
</append>
|
||||
<!-- Сок чёрной розы (05.10.2026): банка, на ступке из 4 роз. Заменяет кровь некроманта в копиях
|
||||
рецептов (recipes.xml). Наследует кровь (стак, звуки, хват банки); модель - necroJuicePrefab в бандле
|
||||
necroblood (копия банки крови, текстуры make_blood_jar_textures.py juice; Unity: NecroJuiceSetup.cs).
|
||||
|
||||
@@ -39,5 +39,10 @@
|
||||
<lootcontainer name="zPackVictim" count="1" size="6,3" sound_open="UseActions/open_backpack" sound_close="UseActions/close_backpack" open_time="1" loot_quality_template="qualBaseTemplate">
|
||||
<item group="groupZpackVictim"/>
|
||||
</lootcontainer>
|
||||
<!-- Останки красного трупа (10.10): та же добыча, но контейнер исчезает, когда опустошён (труп убирает
|
||||
CorpseLoot.RemoveEmptiedCorpses, а останки - обычный контейнер игры). -->
|
||||
<lootcontainer name="zPackVictimRemains" count="1" size="6,3" sound_open="UseActions/open_backpack" sound_close="UseActions/close_backpack" open_time="1" destroy_on_close="empty" loot_quality_template="qualBaseTemplate">
|
||||
<item group="groupZpackVictim"/>
|
||||
</lootcontainer>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
животных (перк следопыта), а не через прямое назначение. -->
|
||||
|
||||
<append xpath="/nav_object_classes">
|
||||
<!-- Сам питомец - ЖЁЛТЫМ, по прямому указанию.
|
||||
<!-- Сам питомец - ЗЕЛЁНЫМ (указание 08.10: «пусть значки питомцев на карте будут зелёными»;
|
||||
до 08.10 был жёлтым 255,220,0 - тоже по указанию).
|
||||
|
||||
СПРАЙТ: "воскрешение" (пробуем, указание 2026-09-18 - "для крысы попробуй иконку
|
||||
воскрешения, проверим как смотрится"). Кроличий, стоявший тут сначала, не подошёл по
|
||||
@@ -22,22 +23,24 @@
|
||||
имён вида ui_game_symbol_* из Data/Config и из самой сборки; ближайшее по названию
|
||||
было specters_grace (бафф "Призрачная грация", перк Ловкости), но у воскрешения
|
||||
смысл к некромантии ближе. Если в игре окажется невнятно, запасные - specters_grace
|
||||
и skull (череп, он же значок самой Некромантии), меняется одна строка. -->
|
||||
и skull (череп, он же значок самой Некромантии), меняется одна строка.
|
||||
С 08.10 - значок дебаффа «Почти смерть» (ui_game_symbol_near_death_trauma, вешается игроку
|
||||
после гибели): «больше похож на смерть с косой» (указание 08.10), как у одержимого. -->
|
||||
<nav_object_class name="necroRatSpiritPet">
|
||||
<map_settings>
|
||||
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/>
|
||||
<property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
|
||||
<property name="min_distance" value="0"/>
|
||||
<property name="max_distance" value="-1"/>
|
||||
<property name="color" value="255,220,0,255"/>
|
||||
<property name="color" value="70,220,70,255"/>
|
||||
<property name="has_pulse" value="false"/>
|
||||
</map_settings>
|
||||
|
||||
<compass_settings>
|
||||
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/>
|
||||
<property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
|
||||
<property name="min_distance" value="0"/>
|
||||
<property name="max_distance" value="1024"/>
|
||||
<property name="max_scale_distance" value="32"/>
|
||||
<property name="color" value="255,220,0,255"/>
|
||||
<property name="color" value="70,220,70,255"/>
|
||||
<property name="has_pulse" value="false"/>
|
||||
<property name="icon_clamped" value="false"/>
|
||||
</compass_settings>
|
||||
@@ -85,9 +88,26 @@
|
||||
</nav_object_class>
|
||||
</append>
|
||||
|
||||
<!-- Одержимый на карте (08.10): питомец или зомби, в которого вселился игрок, - ЗЕЛЁНЫЙ значок
|
||||
«Почти смерть» (ui_game_symbol_near_death_trauma, выбран 06.10). Раньше одержимый носил класс
|
||||
змеи с подменой значка, а подмена у класса не была включена (нужен use_override_icon) - на карте
|
||||
была змея. Своему классу подмена не нужна: значок прямо здесь. Только карта, как у змеи. -->
|
||||
<append xpath="/nav_object_classes">
|
||||
<nav_object_class name="necroPossessed">
|
||||
<map_settings>
|
||||
<property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
|
||||
<property name="min_distance" value="0"/>
|
||||
<property name="max_distance" value="-1"/>
|
||||
<property name="color" value="70,220,70,255"/>
|
||||
<property name="has_pulse" value="false"/>
|
||||
</map_settings>
|
||||
</nav_object_class>
|
||||
</append>
|
||||
|
||||
<!-- Дух змеи на карте (02.10.2026, просьба пользователя): фиолетовая змея - иконка следа змеи
|
||||
(ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же
|
||||
змеи бессмысленна. Вешается в SnakeControl.Begin, снимается в End. -->
|
||||
змеи бессмысленна. (С 06.10 по 08.10 этот класс носил и одержимый - теперь у него necroPossessed.) Вешает NecroMapMarker.cs отдельной меткой на Transform модели, как игра
|
||||
вешает метку транспорта (через Entity.AddNavObject метка змеи так и не показалась). -->
|
||||
<append xpath="/nav_object_classes">
|
||||
<nav_object_class name="necroSnakeSpirit">
|
||||
<map_settings>
|
||||
|
||||
@@ -473,7 +473,7 @@
|
||||
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||
</recipe>
|
||||
</append>
|
||||
<!-- Гео-оссуарий (1.7.0, 2026-09-30). Рецепт придуман по указанию «туда явно нужен флакон
|
||||
<!-- Гео-оссуарий (1.6.0, 2026-09-30). Рецепт придуман по указанию «туда явно нужен флакон
|
||||
крови некроманта и много костей»; дерево и волокна - корни, которыми
|
||||
оплетены кости (добавлено тем же вечером). Порог - «Подмастерье» (уровень 25, как Гримуар
|
||||
девиации): к этому времени игрок строит базу и кузницу, и камень с железом ему нужны
|
||||
@@ -489,7 +489,7 @@
|
||||
</recipe>
|
||||
</append>
|
||||
|
||||
<!-- Костяной скакун (1.7.0, 01.10.2026). Порог - «Подмастерье» (уровень 25), как у
|
||||
<!-- Костяной скакун (1.6.0, 01.10.2026). Порог - «Подмастерье» (уровень 25), как у
|
||||
гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по
|
||||
указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. -->
|
||||
<append xpath="/recipes">
|
||||
@@ -518,6 +518,15 @@
|
||||
<ingredient name="resourceRockSmall" count="20"/>
|
||||
<ingredient name="resourceWood" count="2"/>
|
||||
</recipe>
|
||||
<!-- Меловая доска игрока (10.10): чёрная краска, дерево, гвозди, немного травяных волокон, кости. Краска - обычная
|
||||
resourcePaint: чёрный краситель modDyeBlack в игре не крафтится (только находка и торговцы). С начала игры. -->
|
||||
<recipe name="necroChalkboardPlayer" count="1" craft_time="20" tags="packMuleCrafting">
|
||||
<ingredient name="resourcePaint" count="2"/>
|
||||
<ingredient name="resourceWood" count="8"/>
|
||||
<ingredient name="resourceNail" count="4"/>
|
||||
<ingredient name="resourceYuccaFibers" count="3"/>
|
||||
<ingredient name="resourceBone" count="2"/>
|
||||
</recipe>
|
||||
<recipe name="resourceNecroBlackRoseJuice" count="1" craft_time="10" craft_area="necroMortar">
|
||||
<ingredient name="resourceNecroBlackRose" count="4"/>
|
||||
<ingredient name="drinkJarEmpty" count="1"/>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<config>
|
||||
|
||||
<!-- ПЕРВЫЙ СОБСТВЕННЫЙ ЗВУК МОДА (2026-09-10). До этого весь мод переиспользовал ванильные
|
||||
звуковые id: разбор 2026-08-30 показал, что новый звук нельзя просто положить файлом
|
||||
рядом с XML. Аудио в этой версии игры - единственный тип ассета, который идёт через
|
||||
DataLoader.LoadAsset<AudioClip> -> Resources.Load, а тот читает только запечённое в
|
||||
сборку самой игры; ванильные "@:Sounds/..." идут вообще третьим путём, через
|
||||
Addressables, чей каталог модам недоступен. Единственная работающая дорога для НОВОГО
|
||||
аудио - синтаксис "#bundlePath?assetName", то есть настоящий Unity AssetBundle.
|
||||
|
||||
Бандл собирается из Unity-проекта в _private/Extracted/ItemsUnityProject скриптом
|
||||
Assets/Editor/NecroSoundSetup.cs (копия - _private/tools/UnityEditorScripts/) и кладётся
|
||||
в Resources/necrosounds. Исходники звуков и настройки импорта - там же, здесь только
|
||||
ссылка. Пересобрать: Unity.exe -batchmode -nographics -quit -projectPath <проект>
|
||||
-executeMethod NecroSoundSetup.SetupAndBuild -logFile <лог>.
|
||||
|
||||
ЕСЛИ ЗВУКА НЕТ В ИГРЕ: отказ здесь ТИХИЙ - ни краша, ни ошибки загрузки XML, только
|
||||
строка "AudioManager LoadAudio failed to load audio clip" в логе игры. Проверять надо
|
||||
именно лог, а не "ну наверное не слышно за другими звуками".
|
||||
-->
|
||||
<append xpath="/Sounds">
|
||||
|
||||
<!-- Камень духов: попадание по зомби (то есть попадание, которое реально накладывает
|
||||
charm). Вешается на triggered_effect в items.xml, там же и отсечка "только зомби".
|
||||
|
||||
AudioSource_Impact.prefab - ровно тот же ванильный источник, что у сиблинга
|
||||
stonehitorganic, который звучит на этом же событии: одинаковые кривые затухания и
|
||||
та же группа микшера, поэтому два звука удара не разъезжаются по громкости.
|
||||
|
||||
Имя клипа С РАСШИРЕНИЕМ (soulstone.mp3) - не опечатка. Игра ищет ассет в бандле по
|
||||
имени файла, а не по пути внутри Unity-проекта; ровно эта форма уже подтверждена в
|
||||
игре на ноже ("...necroknife?necroKnifePrefab.prefab").
|
||||
|
||||
Узла <Noise> здесь сознательно НЕТ. Шум для стелса на этом попадании уже создаёт
|
||||
stonehitorganic (Noise ID=1, noise=7), и второй узел просто удвоил бы привлечение
|
||||
зомби на ровном месте - звук чармa не должен быть громче в смысле AI, чем сам удар
|
||||
камня. -->
|
||||
<SoundDataNode name="necroSpiritStoneHit">
|
||||
<AudioSource name="@:Sounds/Prefabs/AudioSource_Impact.prefab"/>
|
||||
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?soulstone.mp3"/>
|
||||
<MaxVoices value="3"/>
|
||||
<MaxRepeatRate value="0.1"/>
|
||||
</SoundDataNode>
|
||||
|
||||
</append>
|
||||
|
||||
</config>
|
||||
@@ -1,5 +1,5 @@
|
||||
<config>
|
||||
<!-- Костяной скакун (1.7.0, 01.10.2026). vehicles.xml наследования не знает - это копия
|
||||
<!-- Костяной скакун (1.6.0, 01.10.2026). vehicles.xml наследования не знает - это копия
|
||||
ванильного vehicleMotorcycle (игра 3.3) под именем сущности necroBoneSteed. Отличия:
|
||||
recipeName -> предмет necroBoneSteedPlaceable (его и возвращает подбор скакуна), без
|
||||
honkEvent (гудок открывает двери торговца - скакуну незачем). Мотор молчит
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Одержимый медведь - танк (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
|
||||
/// - толстая шкура: пока в нём игрок, урон по нему вдвое меньше (вне одержимости медведь прежний);
|
||||
/// - рёв на ПКМ: зомби ближе RoarRange бросают свои цели и идут на медведя, спящие просыпаются -
|
||||
/// увести толпу от тела или базы. Та же провокация, что каждую секунду делает одержимость
|
||||
/// (Possession.Taunt), только шире, без «видит ли» и на 20 с;
|
||||
/// - удар лапой по блоку: ЛКМ по блоку под прицелом бьёт его на SmashDamage (дерево и двери за 2-3
|
||||
/// удара, бетон дольше);
|
||||
/// - на дыбы на E (указание 09.10: «на E - действие»): враги ближе RearRange сбиты с ног на RearStun с
|
||||
/// и отброшены от медведя - передышка в толпе. Откат RearCooldown.
|
||||
/// </summary>
|
||||
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";
|
||||
/// <summary>Урон блоку за удар лапой. Для сравнения: удар ванильного зомби-медведя по блокам - десятки.</summary>
|
||||
public const int SmashDamage = 300;
|
||||
/// <summary>Досягаемость лапы от головы медведя, м.</summary>
|
||||
public const float SmashReach = 2.6f;
|
||||
public const float SkinFactor = 0.5f;
|
||||
static float _nextRoar;
|
||||
public const float RearRange = 3.5f;
|
||||
public const float RearStun = 2f;
|
||||
public const float RearCooldown = 6f;
|
||||
/// <summary>Толчок от медведя, м за тик (как motion существ; гаснет сам).</summary>
|
||||
public const float RearPush = 0.5f;
|
||||
public const string RearSound = "bearattack";
|
||||
static float _nextRear;
|
||||
|
||||
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)
|
||||
{
|
||||
// Рёв в толпе не отличить от удара (тест 09.10) - пишем, сколько ждать и сколько пошло на медведя.
|
||||
Tip(string.Format(Localization.Get("necroBearRoarCooldown"), Mathf.CeilToInt(_nextRoar - Time.time)));
|
||||
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);
|
||||
Tip(string.Format(Localization.Get("necroBearRoarResult"), n));
|
||||
}
|
||||
|
||||
/// <summary>Сразу, мимо очереди подсказок: очередь игры (XUiC_PopupToolTip) отсеивает только одинаковый текст,
|
||||
/// и «через 7 с», «через 6 с»... при частых ПКМ копились бы и всплывали с опозданием.</summary>
|
||||
static void Tip(string _text)
|
||||
{
|
||||
if (Possession.Player != null)
|
||||
{
|
||||
GameManager.ShowTooltip(Possession.Player, _text, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>E: на дыбы - враги рядом падают (тот же приём, что у броска волка: BeginStun Prone) и
|
||||
/// отлетают от медведя.</summary>
|
||||
public static void RearUp(World _world, EntityAlive _bear)
|
||||
{
|
||||
if (Time.time < _nextRear)
|
||||
{
|
||||
Tip(string.Format(Localization.Get("necroBearRearCooldown"), Mathf.CeilToInt(_nextRear - Time.time)));
|
||||
return;
|
||||
}
|
||||
_nextRear = Time.time + RearCooldown;
|
||||
_bear.PlayOneShot(RearSound);
|
||||
_bear.emodel?.avatarController?.StartAnimationAttack();
|
||||
int n = 0;
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (!SnakeControl.IsFoe(e, _bear) || (e.position - _bear.position).sqrMagnitude > RearRange * RearRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3 away = e.position - _bear.position;
|
||||
away.y = 0f;
|
||||
away = away.sqrMagnitude > 0.01f ? away.normalized : _bear.GetForwardVector();
|
||||
e.motion += away * RearPush + Vector3.up * 0.15f;
|
||||
if (e.bodyDamage.CurrentStun == EnumEntityStunType.None && e.emodel?.avatarController != null)
|
||||
{
|
||||
e.emodel.avatarController.BeginStun(EnumEntityStunType.Prone, EnumBodyPartHit.Torso, Utils.EnumHitDirection.Back, false, 0.5f);
|
||||
e.SetStun(EnumEntityStunType.Prone);
|
||||
e.bodyDamage.StunDuration = RearStun;
|
||||
}
|
||||
n++;
|
||||
}
|
||||
Tip(string.Format(Localization.Get("necroBearRearResult"), n));
|
||||
Debug.Log("[NecromancerTome] BearControl: на дыбы - сбиты с ног " + n);
|
||||
}
|
||||
|
||||
/// <summary>ЛКМ: блок под прицелом в досягаемости лапы (не существо) - урон SmashDamage.</summary>
|
||||
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<Entity>() != 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 + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Толстая шкура одержимого медведя - вдвое меньше урона.</summary>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Кровавый прилив и нож (указание 10.10): «раз некроэнергии становится много, она должна воздействовать не
|
||||
/// только на зомби и некроманта». Пока идёт прилив (SnakeMode.IsBloodTide - ночь кровавой луны):
|
||||
/// - на игроке бафф buffNecroBloodTide (buffs.xml): по нему нож (effect_group necroKnifeBloodTide, items.xml) бьёт
|
||||
/// вдвое сильнее и вдвое чаще, лечит вдвое, плата за силовую вдвое меньше, выносливость на удар вчетверо
|
||||
/// меньше, дальность - как у меча; с ножом в руке бег на 50% быстрее (сам бафф);
|
||||
/// - хват ножа - как у мачете, модель - нож в фиолетовом мече (ItemClass ножа подменяется на всю ночь,
|
||||
/// нож в руке пересоздаётся);
|
||||
/// и, только если нож в руке:
|
||||
/// - восстановление здоровья и выносливости вдвое (лишь положительное - кровотечение и прочий урон не трогаем);
|
||||
/// - полоски здоровья и выносливости пульсируют фиолетовым в такт сердцу;
|
||||
/// - фоном бьётся сердце, 120 ударов в минуту (синтез в коде: два низких удара синусом, «лаб» и «даб»,
|
||||
/// с затуханием и спадом тона - по образцу, одобренному пользователем 10.10).
|
||||
/// Всё - у локального игрока (одиночная игра и хост).
|
||||
/// </summary>
|
||||
public static class BloodTide
|
||||
{
|
||||
public const string BuffName = "buffNecroBloodTide";
|
||||
/// <summary>Хват мачете (items.xml игры, meleeWpnBladeT3Machete HoldType=47); у ножа - 36.</summary>
|
||||
public const int TideHoldType = 47;
|
||||
/// <summary>Модель меча на прилив (10.10): префаб в том же бандле necroknife (ItemsUnityProject, NecroTideSetup.cs) -
|
||||
/// путь берётся из пути обычного ножа (уже разобранного игрой, @modfolder) заменой имени префаба.</summary>
|
||||
public const string KnifePrefab = "necroKnifePrefab.prefab", TidePrefab = "necroKnifeTidePrefab.prefab";
|
||||
static string TideMeshFile => _origMesh != null && _origMesh.Contains(KnifePrefab) ? _origMesh.Replace(KnifePrefab, TidePrefab) : null;
|
||||
public const float HeartBpm = 120f;
|
||||
public const float HeartVolume = 0.55f;
|
||||
/// <summary>Цвет пульса полосок - фиолетовый меча.</summary>
|
||||
public static readonly Color PulseColor = new Color(0.67f, 0.24f, 1f);
|
||||
|
||||
/// <summary>Идёт прилив (кэш на кадр).</summary>
|
||||
public static bool Active;
|
||||
/// <summary>Прилив и нож в руке.</summary>
|
||||
public static bool Armed;
|
||||
|
||||
static bool _swapped;
|
||||
static int _origHoldType = -1;
|
||||
static string _origMesh;
|
||||
static float _nextBuffCheck;
|
||||
static GameObject _heartGo;
|
||||
static AudioSource _heart;
|
||||
static AudioClip _heartClip;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||
}
|
||||
|
||||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
EntityPlayerLocal player = world?.GetPrimaryPlayer();
|
||||
if (world == null || player == null)
|
||||
{
|
||||
Active = Armed = false;
|
||||
_swapped = false; // классы предметов игра грузит заново с миром
|
||||
_recreate = false;
|
||||
_origHoldType = -1;
|
||||
Heart(false);
|
||||
Patch_XUiC_HUDStatBar_Update_BloodTide.Forget();
|
||||
Patch_Stat_Tick_BloodTide.Next = null; // не держать стат (а через него игрока) после выхода из мира
|
||||
return;
|
||||
}
|
||||
bool tide = SnakeMode.IsBloodTide();
|
||||
if (tide != Active)
|
||||
{
|
||||
Active = tide;
|
||||
Debug.Log("[NecromancerTome] BloodTide: " + (tide ? "прилив начался" : "прилив схлынул"));
|
||||
}
|
||||
SwapKnife(player, Active);
|
||||
TryRecreate(player);
|
||||
if (Time.time >= _nextBuffCheck && !player.IsDead())
|
||||
{
|
||||
_nextBuffCheck = Time.time + 0.5f;
|
||||
bool has = player.Buffs.HasBuff(BuffName);
|
||||
if (Active && !has)
|
||||
{
|
||||
player.Buffs.AddBuff(BuffName);
|
||||
}
|
||||
else if (!Active && has)
|
||||
{
|
||||
player.Buffs.RemoveBuff(BuffName);
|
||||
}
|
||||
}
|
||||
Armed = Active && !player.IsDead() && SnakeMode.IsKnife(player.inventory?.holdingItem);
|
||||
Heart(Armed && !GameManager.Instance.IsPaused());
|
||||
}
|
||||
|
||||
/// <summary>Хват и модель ножа на прилив и обратно. Подмена - в общем ItemClass, поэтому видна у любого
|
||||
/// ножа; нож в руке пересоздаём (снять и достать тот же слот), иначе он до смены оружия остался бы прежним.</summary>
|
||||
static void SwapKnife(EntityPlayerLocal _player, bool _tide)
|
||||
{
|
||||
if (_tide == _swapped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ItemClass knife = ItemClass.GetItemClass(SnakeMode.KnifeName);
|
||||
if (knife == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_origHoldType < 0)
|
||||
{
|
||||
_origHoldType = knife.HoldType.Value;
|
||||
_origMesh = knife.MeshFile;
|
||||
}
|
||||
knife.HoldType.Value = _tide ? TideHoldType : _origHoldType;
|
||||
knife.MeshFile = _tide && !string.IsNullOrEmpty(TideMeshFile) ? TideMeshFile : _origMesh;
|
||||
_swapped = _tide;
|
||||
Hand hand = _player.Hand;
|
||||
// Ножи в других слотах пояса: «собранная» копия помечается устаревшей - соберутся заново (с новыми хватом и
|
||||
// моделью), когда их возьмут. Нож в руке - в TryRecreate, когда рука свободна.
|
||||
if (hand?.slotDatas != null)
|
||||
{
|
||||
foreach (ItemInventoryData d in hand.slotDatas)
|
||||
{
|
||||
if (d != null && d.item == knife && d != hand.equipped && d.physicalItemValue != null)
|
||||
{
|
||||
MarkStale(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
_recreate = true;
|
||||
Debug.Log("[NecromancerTome] BloodTide: нож - хват " + knife.HoldType.Value);
|
||||
}
|
||||
|
||||
/// <summary>Нож в руке ждёт пересоздания (сменились хват/модель).</summary>
|
||||
static bool _recreate;
|
||||
|
||||
/// <summary>«Собранный» предмет перестаёт совпадать со слотом (NeedsRebuild): копия того же ножа с другим Seed.
|
||||
/// Не ItemValue.None (тест 10.10): Hand.Unequip читает класс предмета у собранного - NRE.</summary>
|
||||
static void MarkStale(ItemInventoryData _d)
|
||||
{
|
||||
ItemValue pv = (_d.physicalItemValue ?? _d.itemValue).Clone();
|
||||
pv.Seed = (ushort)(pv.Seed + 1);
|
||||
_d.physicalItemValue = pv;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пересоздать нож в руке: повторный выбор того же слота снимает его и собирает заново (Hand.SelectHoldingMode).
|
||||
/// Только когда рука свободна - не идёт смена предмета и удар; иначе ждём кадр. Тест 10.10: при сбое внутри
|
||||
/// SelectHoldingMode игра оставляла Hand.isSwitching = true навсегда, и рука переставала брать что-либо до
|
||||
/// перезапуска («после ошибки не получается переключиться на что-либо ещё»). Поэтому сбой перехватываем и
|
||||
/// возвращаем руку в рабочее состояние.
|
||||
/// </summary>
|
||||
static void TryRecreate(EntityPlayerLocal _player)
|
||||
{
|
||||
if (!_recreate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Inventory inv = _player.inventory;
|
||||
Hand hand = _player.Hand;
|
||||
ItemInventoryData held = inv?.holdingItemData;
|
||||
if (hand == null || held == null || !SnakeMode.IsKnife(held.item))
|
||||
{
|
||||
_recreate = false; // ножа в руке нет - возьмут, тогда и соберётся (слоты помечены)
|
||||
return;
|
||||
}
|
||||
if (hand.isSwitching || inv.IsHoldingItemActionRunning() || _player.IsDead())
|
||||
{
|
||||
return;
|
||||
}
|
||||
_recreate = false;
|
||||
ItemValue before = held.physicalItemValue;
|
||||
try
|
||||
{
|
||||
MarkStale(held);
|
||||
inv.SetHoldingItemIdx(inv.holdingItemIdx);
|
||||
Debug.Log("[NecromancerTome] BloodTide: нож в руке пересоздан, хват " + held.item.HoldType.Value);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] BloodTide: нож не пересоздан - " + ex.Message + "; рука восстановлена");
|
||||
held.physicalItemValue = before;
|
||||
hand.isSwitching = false;
|
||||
try
|
||||
{
|
||||
hand.Reconcile();
|
||||
}
|
||||
catch (System.Exception ex2)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] BloodTide: Reconcile - " + ex2.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- сердце
|
||||
|
||||
static void Heart(bool _on)
|
||||
{
|
||||
if (!_on)
|
||||
{
|
||||
if (_heart != null && _heart.isPlaying)
|
||||
{
|
||||
_heart.Stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_heart == null)
|
||||
{
|
||||
_heartGo = new GameObject("NecroBloodTideHeart");
|
||||
Object.DontDestroyOnLoad(_heartGo);
|
||||
_heart = _heartGo.AddComponent<AudioSource>();
|
||||
_heart.clip = _heartClip ?? (_heartClip = MakeHeartClip());
|
||||
_heart.loop = true;
|
||||
_heart.spatialBlend = 0f;
|
||||
_heart.playOnAwake = false;
|
||||
}
|
||||
_heart.volume = HeartVolume * GamePrefs.GetFloat(EnumGamePrefs.OptionsOverallAudioVolumeLevel);
|
||||
if (!_heart.isPlaying)
|
||||
{
|
||||
_heart.Play();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Доля удара сердца для пульса полосок: 1 в момент «лаба», дальше спад; без звука - 0.</summary>
|
||||
public static float Beat()
|
||||
{
|
||||
if (_heart == null || !_heart.isPlaying)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
float t = _heart.time;
|
||||
float dub = 0.17f;
|
||||
return Mathf.Max(Mathf.Exp(-9f * t), t >= dub ? 0.6f * Mathf.Exp(-12f * (t - dub)) : 0f);
|
||||
}
|
||||
|
||||
/// <summary>Один цикл сердца (60/HeartBpm с): «лаб» 62 Гц и «даб» 52 Гц через 0.17 с, каждый - синус с
|
||||
/// второй гармоникой, атака 4 мс, экспоненциальное затухание, тон к концу падает на треть.</summary>
|
||||
static AudioClip MakeHeartClip()
|
||||
{
|
||||
const int sr = 44100;
|
||||
int n = Mathf.RoundToInt(sr * 60f / HeartBpm);
|
||||
float[] buf = new float[n];
|
||||
Thump(buf, 0, 62f, 0.16f, 1f, 22f, sr);
|
||||
Thump(buf, Mathf.RoundToInt(sr * 0.17f), 52f, 0.12f, 0.6f, 28f, sr);
|
||||
float max = 0f;
|
||||
foreach (float v in buf)
|
||||
{
|
||||
max = Mathf.Max(max, Mathf.Abs(v));
|
||||
}
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
buf[i] = buf[i] / max * 0.9f;
|
||||
}
|
||||
AudioClip clip = AudioClip.Create("necroHeartbeat", n, 1, sr, false);
|
||||
clip.SetData(buf, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static void Thump(float[] _buf, int _start, float _freq, float _dur, float _amp, float _decay, int _sr)
|
||||
{
|
||||
int n = Mathf.Min(Mathf.RoundToInt(_sr * _dur), _buf.Length - _start);
|
||||
double ph = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
float t = (float)i / _sr;
|
||||
float env = Mathf.Min(t / 0.004f, 1f) * Mathf.Exp(-_decay * t);
|
||||
ph += 2 * Mathf.PI * _freq * (1f - 0.35f * t / _dur) / _sr;
|
||||
_buf[_start + i] += _amp * env * (float)(System.Math.Sin(ph) + 0.25 * System.Math.Sin(2 * ph));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Восстановление вдвое (прилив и нож в руке). Игра сама задаёт RegenerationAmount и вызывает Stat.Tick в
|
||||
/// PlayerEntityStats.UpdatePlayerHealthOT/StaminaOT; удваиваем только свежее положительное восстановление этого
|
||||
/// вызова (у здоровья оно считается, лишь когда здоровье не полное и HealthChangeOT больше нуля - иначе в поле
|
||||
/// остаётся прошлая разница, её трогать нельзя).</summary>
|
||||
[HarmonyPatch(typeof(PlayerEntityStats), nameof(PlayerEntityStats.UpdatePlayerHealthOT))]
|
||||
public static class Patch_PlayerEntityStats_UpdatePlayerHealthOT_BloodTide
|
||||
{
|
||||
public static void Prefix(PlayerEntityStats __instance)
|
||||
{
|
||||
Patch_Stat_Tick_BloodTide.Next = null;
|
||||
if (BloodTide.Armed && __instance.m_entity is EntityPlayerLocal p && __instance.Health.ValuePercent < 1f &&
|
||||
EffectManager.GetValue(PassiveEffects.HealthChangeOT, null, 0f, p) > 0f)
|
||||
{
|
||||
Patch_Stat_Tick_BloodTide.Next = __instance.Health;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(PlayerEntityStats), nameof(PlayerEntityStats.UpdatePlayerStaminaOT))]
|
||||
public static class Patch_PlayerEntityStats_UpdatePlayerStaminaOT_BloodTide
|
||||
{
|
||||
public static void Prefix(PlayerEntityStats __instance)
|
||||
{
|
||||
Patch_Stat_Tick_BloodTide.Next = BloodTide.Armed && __instance.m_entity is EntityPlayerLocal ? __instance.Stamina : null;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Stat), nameof(Stat.Tick))]
|
||||
public static class Patch_Stat_Tick_BloodTide
|
||||
{
|
||||
/// <summary>Стат, чей следующий Tick удвоить (ставят префиксы выше).</summary>
|
||||
public static Stat Next;
|
||||
|
||||
public static void Prefix(Stat __instance)
|
||||
{
|
||||
if (Next == null || Next != __instance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Next = null;
|
||||
if (__instance.regenAmount > 0f)
|
||||
{
|
||||
__instance.regenAmount *= 2f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Крит меча (указание 10.10: «шанс на критический удар бы тоже повысить»). Случайного крита у ближнего боя в игре
|
||||
/// нет: ItemActionDynamic помечает критическим каждый силовой удар (действие 1) и ни одного обычного, а
|
||||
/// CriticalChance уходит в параметр _criticalHitChanceOLD функции Hit, которую та не читает. Сам крит урон не
|
||||
/// поднимает - только чаще сбивает с ног и ставит на колено, вдвое быстрее копит урон по ногам, красит цифру урона.
|
||||
/// Наш крит: в прилив обычный удар ножом по существу с шансом CritChance - критический, урон x CritMultiplier.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemActionAttack), nameof(ItemActionAttack.Hit))]
|
||||
public static class Patch_ItemActionAttack_Hit_BloodTideCrit
|
||||
{
|
||||
public const float CritChance = 0.25f;
|
||||
public const float CritMultiplier = 2f;
|
||||
|
||||
public static void Prefix(WorldRayHitInfo hitInfo, int _attackerEntityId, ref float _entityDamage,
|
||||
ItemActionAttack.AttackHitInfo _attackDetails, ItemValue damagingItemValue, bool _isGrazingHit)
|
||||
{
|
||||
if (!BloodTide.Armed || _isGrazingHit || _attackDetails == null || _attackDetails.isCriticalHit || _entityDamage <= 0f ||
|
||||
hitInfo?.tag == null || !hitInfo.tag.StartsWith("E_") || damagingItemValue?.ItemClass?.Name != SnakeMode.KnifeName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null || world.GetPrimaryPlayer()?.entityId != _attackerEntityId || Random.value >= CritChance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_entityDamage *= CritMultiplier;
|
||||
_attackDetails.isCriticalHit = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Пульс полосок здоровья и выносливости (прилив и нож в руке): цвет полосы (BarContent в HUDLeftStatBars
|
||||
/// игры, у здоровья 255,0,0,160, у выносливости 0,54,255,160) уходит в фиолетовый на каждом ударе сердца.</summary>
|
||||
[HarmonyPatch(typeof(XUiC_HUDStatBar), nameof(XUiC_HUDStatBar.Update))]
|
||||
public static class Patch_XUiC_HUDStatBar_Update_BloodTide
|
||||
{
|
||||
static readonly Dictionary<XUiC_HUDStatBar, (XUiV_Sprite bar, Color orig)> _bars = new Dictionary<XUiC_HUDStatBar, (XUiV_Sprite, Color)>();
|
||||
|
||||
public static void Forget()
|
||||
{
|
||||
_bars.Clear();
|
||||
}
|
||||
|
||||
public static void Postfix(XUiC_HUDStatBar __instance)
|
||||
{
|
||||
if (__instance.statType != HUDStatTypes.Health && __instance.statType != HUDStatTypes.Stamina)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_bars.TryGetValue(__instance, out var e) && e.bar.UiTransform == null)
|
||||
{
|
||||
_bars.Remove(__instance); // окно пересобрано - старая полоса удалена
|
||||
}
|
||||
if (!_bars.TryGetValue(__instance, out e))
|
||||
{
|
||||
if (!BloodTide.Armed || !(__instance.GetChildById("BarContent")?.ViewComponent is XUiV_Sprite s))
|
||||
{
|
||||
return;
|
||||
}
|
||||
e = (s, s.Color);
|
||||
_bars[__instance] = e;
|
||||
}
|
||||
Color c = BloodTide.Armed
|
||||
? Color.Lerp(e.orig, new Color(BloodTide.PulseColor.r, BloodTide.PulseColor.g, BloodTide.PulseColor.b, Mathf.Max(e.orig.a, 0.8f)), BloodTide.Beat())
|
||||
: e.orig;
|
||||
if (e.bar.Color != c)
|
||||
{
|
||||
e.bar.Color = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using UnityEngine;
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей, призывается книгой, ездит на
|
||||
/// Костяной скакун (1.6.0, 01.10.2026): мотоцикл из костей, призывается книгой, ездит на
|
||||
/// кровавых сферах. Сущность necroBoneSteed в entityclasses.xml - наследник vehicleMotorcycle
|
||||
/// со своей моделью (бандл necrobonesteed, собран из копии ванильного VMotorcycleP.prefab).
|
||||
///
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Шейдер Standard из наших бандлов (10.10.2026). Тест в игре: останки красного трупа вышли бледно-бежевыми, хотя
|
||||
/// в Unity тот же префаб тёмный и багровый (снимок NecroPreviewOne) - игра рисует копию Standard, приехавшую
|
||||
/// в бандле, не так, как свой Standard. То же раньше было у крысы (RatModel.cs подменяет шейдер на свой) и у
|
||||
/// пирамиды (маджента издалека). Здесь - общая подмена для новых моделей: останки (сущность necroRemains),
|
||||
/// череп и нож-меч прилива (модели предметов). Материалы бандла общие - правится один раз на материал.
|
||||
/// Очередь отрисовки сохраняется (у меча прилива - прозрачная, Fade).
|
||||
/// </summary>
|
||||
public static class BundleShaderFix
|
||||
{
|
||||
static Shader _std;
|
||||
static readonly HashSet<int> _done = new HashSet<int>();
|
||||
|
||||
/// <summary>Выход из мира: бандлы при следующей загрузке дадут новые материалы - старые номера не нужны
|
||||
/// (вызывает RemainsDecay, когда мира нет).</summary>
|
||||
public static void Forget()
|
||||
{
|
||||
_done.Clear();
|
||||
}
|
||||
|
||||
public static void Fix(Transform _root)
|
||||
{
|
||||
if (_root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_std == null)
|
||||
{
|
||||
_std = Shader.Find("Standard");
|
||||
if (_std == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
foreach (Renderer r in _root.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
foreach (Material m in r.sharedMaterials)
|
||||
{
|
||||
if (m == null || m.shader == null || m.shader == _std || m.shader.name != "Standard" || !_done.Add(m.GetInstanceID()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int queue = m.renderQueue;
|
||||
m.shader = _std;
|
||||
m.renderQueue = queue;
|
||||
Debug.Log("[NecromancerTome] BundleShaderFix: материал '" + m.name + "' - встроенный Standard (очередь " + queue + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Останки красного трупа - модель сущности.</summary>
|
||||
[HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))]
|
||||
public static class Patch_EModelBase_createModel_BundleShaderFix
|
||||
{
|
||||
static int _cls = int.MinValue;
|
||||
|
||||
public static void Postfix(EModelBase __instance)
|
||||
{
|
||||
if (_cls == int.MinValue)
|
||||
{
|
||||
_cls = EntityClass.FromString(RemainsDecay.EntityClassName);
|
||||
}
|
||||
Entity e = __instance.entity;
|
||||
if (e != null && e.entityClass == _cls)
|
||||
{
|
||||
BundleShaderFix.Fix(__instance.GetModelTransform());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Череп и нож (меч прилива) - модели предметов: в руке и на земле.</summary>
|
||||
[HarmonyPatch(typeof(ItemClass), nameof(ItemClass.CloneModel))]
|
||||
public static class Patch_ItemClass_CloneModel_BundleShaderFix
|
||||
{
|
||||
public static void Postfix(ItemClass __instance, AssetManagement.InstanceHandle<GameObject> __result)
|
||||
{
|
||||
string n = __instance?.Name;
|
||||
if (n != "necroSkull" && n != SnakeMode.KnifeName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
GameObject go = __result.Instance;
|
||||
if (go != null)
|
||||
{
|
||||
BundleShaderFix.Fix(go.transform);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Ноша в когтях одержимого стервятника (указание 08.10: «сделать курицу в когтях стервятника без хитбокса
|
||||
/// и отдельным элементом, чтобы крылья и шея свисали вниз»).
|
||||
///
|
||||
/// Раньше под лапы каждый кадр переставлялась сама курица, и было две беды. Рывки: модель сущности
|
||||
/// догоняет её позицию с задержкой (Entity.updateTransform - Lerp), а позиция стервятника меняется
|
||||
/// 20 раз в секунду - курица отставала и прыгала. Твёрдость: её коллайдеры мешали спускаться, и
|
||||
/// стервятник садился на неё.
|
||||
///
|
||||
/// Теперь при захвате с курицы снимается слепок: кости крыльев и шеи развёрнуты вниз, сетки запечены
|
||||
/// (BakeMesh) в неподвижные MeshRenderer без коллайдеров. Слепок висит под стервятником по его
|
||||
/// видимой (сглаженной) позиции. Сама курица спрятана (forceRenderingOff - его не трогает
|
||||
/// avatarController.SetVisible), коллайдеры выключены, ИИ стоит; её позиция идёт за слепком,
|
||||
/// и когда стервятник её отпускает, она появляется ровно там, где висел слепок.
|
||||
/// </summary>
|
||||
public static class CarryVisual
|
||||
{
|
||||
static GameObject _holder;
|
||||
static readonly List<Mesh> _meshes = new List<Mesh>();
|
||||
static readonly List<Renderer> _hidden = new List<Renderer>();
|
||||
static readonly List<Collider> _off = new List<Collider>();
|
||||
/// <summary>Верх и низ слепка относительно его опоры, м.</summary>
|
||||
static float _top, _bottom;
|
||||
/// <summary>Сколько крыло отводится в сторону от вертикали и шея - вперёд.</summary>
|
||||
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<Collider>(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<Renderer>(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<MeshFilter>().sharedMesh = mesh;
|
||||
MeshRenderer mr = part.AddComponent<MeshRenderer>();
|
||||
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<Renderer>(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);
|
||||
}
|
||||
|
||||
/// <summary>Каждый кадр: слепок под лапами стервятника по его видимой позиции; у земли - стоит на ней.</summary>
|
||||
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<Transform> _claws = new List<Transform>();
|
||||
|
||||
/// <summary>Точка хвата - середина между лапами стервятника (кости с «toe»/«foot» в имени, а без них - «leg»),
|
||||
/// по анимации, каждый кадр (тест 08.10: «курица висит под стервятником, а не в его когтях»). Костей нет -
|
||||
/// низ его модели.</summary>
|
||||
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<string> all = new List<string>();
|
||||
foreach (string key in new[] { "toe", "foot", "leg" })
|
||||
{
|
||||
foreach (Transform t in model.GetComponentsInChildren<Transform>(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<Transform>(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;
|
||||
}
|
||||
|
||||
/// <summary>Отпустить: курица снова видна и тверда там, где висел слепок.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Убрать слепок, ничего не возвращая (выход из мира).</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Обвисшая поза: каждая кость крыла и шеи (от корня к концу) разворачивается так, чтобы её
|
||||
/// дочерняя кость оказалась под ней - крылья свисают чуть в стороны, шея с головой - чуть вперёд.
|
||||
/// Аниматор следующим кадром позу вернёт, но слепок уже запечён, а сама курица спрятана.</summary>
|
||||
static void Limp(EntityAlive _c, Transform _model)
|
||||
{
|
||||
HashSet<Transform> bones = new HashSet<Transform>();
|
||||
foreach (SkinnedMeshRenderer smr in _model.GetComponentsInChildren<SkinnedMeshRenderer>(true))
|
||||
{
|
||||
if (smr.bones == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (Transform b in smr.bones)
|
||||
{
|
||||
if (b != null)
|
||||
{
|
||||
bones.Add(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<Transform> order = new List<Transform>(bones);
|
||||
order.Sort((a, b) => Depth(a).CompareTo(Depth(b)));
|
||||
if (!_loggedBones)
|
||||
{
|
||||
_loggedBones = true;
|
||||
List<string> names = new List<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Грифельная доска (necroChalkboard, 09.10.2026): надписи мелом - как на табличках (TEFeatureSignable), но
|
||||
/// «игрок этого делать не будет, а нам для сюжета пригодится». Поэтому команда «написать» у доски есть
|
||||
/// только в редакторе префабов, в творческом режиме и в режиме отладки (так мы и подписываем её - текст
|
||||
/// сохраняется в префабе локации, IFeatureSavedInPrefab). В обычной игре доску только читают: без команды
|
||||
/// нет и подсказки «E - использовать».
|
||||
/// </summary>
|
||||
public static class Chalkboard
|
||||
{
|
||||
/// <summary>Сюжетная доска (чертоги, локации): пишет мод или автор префаба.</summary>
|
||||
public const string BlockName = "necroChalkboard";
|
||||
/// <summary>Доска игрока (10.10): крафтится, пишется в обычной игре - записи свои у каждой доски.</summary>
|
||||
public const string PlayerBlockName = "necroChalkboardPlayer";
|
||||
|
||||
static string NameOf(TEFeatureSignable _sign)
|
||||
{
|
||||
return _sign?.Parent?.TeData?.Block?.GetBlockName();
|
||||
}
|
||||
|
||||
/// <summary>Любая из досок - для материала надписи.</summary>
|
||||
public static bool Is(TEFeatureSignable _sign)
|
||||
{
|
||||
string n = NameOf(_sign);
|
||||
return n == BlockName || n == PlayerBlockName;
|
||||
}
|
||||
|
||||
/// <summary>Сюжетная доска - писать только в редакторе, творческом режиме, отладке.</summary>
|
||||
public static bool IsLocked(TEFeatureSignable _sign)
|
||||
{
|
||||
return NameOf(_sign) == BlockName && !CanWrite();
|
||||
}
|
||||
|
||||
/// <summary>Кто может писать: редактор префабов, творческий режим, отладка.</summary>
|
||||
public static bool CanWrite()
|
||||
{
|
||||
return GameManager.Instance != null && GameManager.Instance.IsEditMode() ||
|
||||
GameStats.GetBool(EnumGameStats.IsCreativeMenuEnabled) ||
|
||||
GamePrefs.GetBool(EnumGamePrefs.DebugMenuEnabled);
|
||||
}
|
||||
|
||||
/// <summary>Материал надписи - из бандла, свой шейдер NecroTome/ChalkText с ZTest LEqual (тест 09.10: надпись
|
||||
/// просвечивала сквозь стены - встроенный GUI/Text Shader берёт ZTest из глобального unity_GUIZTestMode, который
|
||||
/// интерфейс держит «Always», и переопределение в материале не помогло). Шрифт динамический: его текстура
|
||||
/// растёт и пересоздаётся при новых буквах - держим её в материале надписи (один материал на все доски).</summary>
|
||||
static Material _mat;
|
||||
static Font _font;
|
||||
static bool _hooked;
|
||||
|
||||
public static void BindText(TextMesh _tm, MeshRenderer _r)
|
||||
{
|
||||
_mat = _r.sharedMaterial;
|
||||
_font = _tm.font;
|
||||
if (_mat == null || _font == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_mat.mainTexture = _font.material.mainTexture;
|
||||
if (!_hooked)
|
||||
{
|
||||
_hooked = true;
|
||||
Font.textureRebuilt += _f =>
|
||||
{
|
||||
if (_mat != null && _f == _font)
|
||||
{
|
||||
_mat.mainTexture = _f.material.mainTexture;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(TEFeatureSignable), nameof(TEFeatureSignable.SetBlockEntityData))]
|
||||
public static class Patch_TEFeatureSignable_SetBlockEntityData_Chalkboard
|
||||
{
|
||||
public static void Postfix(TEFeatureSignable __instance, BlockEntityData _blockEntityData)
|
||||
{
|
||||
if (_blockEntityData == null || !_blockEntityData.bHasTransform || !Chalkboard.Is(__instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (TextMesh tm in _blockEntityData.transform.GetComponentsInChildren<TextMesh>())
|
||||
{
|
||||
MeshRenderer r = tm.GetComponent<MeshRenderer>();
|
||||
if (r != null && tm.font != null)
|
||||
{
|
||||
Chalkboard.BindText(tm, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(TEFeatureSignable), nameof(TEFeatureSignable.AllowBlockActivationCommand))]
|
||||
public static class Patch_TEFeatureSignable_AllowBlockActivationCommand_Chalkboard
|
||||
{
|
||||
public static void Postfix(TEFeatureSignable __instance, ITileEntityFeature _module, ReadOnlySpan<char> _commandName, ref bool __result)
|
||||
{
|
||||
if (__result && __instance.Equals(_module) && _commandName.SequenceEqual("edit".AsSpan()) &&
|
||||
Chalkboard.IsLocked(__instance))
|
||||
{
|
||||
__result = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(TEFeatureSignable), nameof(TEFeatureSignable.GetActivationText))]
|
||||
public static class Patch_TEFeatureSignable_GetActivationText_Chalkboard
|
||||
{
|
||||
public static void Postfix(TEFeatureSignable __instance, ref string __result)
|
||||
{
|
||||
if (Chalkboard.IsLocked(__instance))
|
||||
{
|
||||
__result = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 + " открыт");
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace NecromancerTome
|
||||
|
||||
static bool Ours(string _name)
|
||||
{
|
||||
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster");
|
||||
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster") || _name == "necroBookDoorPrefab";
|
||||
}
|
||||
|
||||
public static void Postfix(Transform __result)
|
||||
@@ -37,6 +37,21 @@ namespace NecromancerTome
|
||||
}
|
||||
__result.tag = vanilla.tag;
|
||||
__result.gameObject.layer = vanilla.gameObject.layer;
|
||||
// Дверь-шкаф (09.10): коллайдер на створке (ходит с петлёй), а не на корне - тег и ей.
|
||||
foreach (Collider c in __result.GetComponentsInChildren<Collider>(true))
|
||||
{
|
||||
c.gameObject.tag = vanilla.tag;
|
||||
c.gameObject.layer = vanilla.gameObject.layer;
|
||||
}
|
||||
// По попаданию в дочерний коллайдер игра ищет блок через RootTransformRefParent.FindRoot (Voxel ->
|
||||
// GameUtils.FindMasterBlockForEntityModelBlock): без него луч по створке «не находил» блок - двери не было
|
||||
// хитбокса (тест 09.10). Как у ванильной двери - компонент на корне со ссылкой на корень; при копировании
|
||||
// модели Unity переводит ссылку на корень копии.
|
||||
if (__result.GetComponentInChildren<Collider>(true) != null && __result.GetComponent<Collider>() == null)
|
||||
{
|
||||
RootTransformRefParent rr = __result.GetComponent<RootTransformRefParent>() ?? __result.gameObject.AddComponent<RootTransformRefParent>();
|
||||
rr.RootTransform = __result;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] DecorCollider: '" + __result.name + "' тег " + vanilla.tag + ", слой " + vanilla.gameObject.layer +
|
||||
", коллайдер " + (__result.GetComponent<Collider>() != null ? "есть" : "НЕТ"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Нюх одержимой зомбособаки (указание 09.10: «добавь зомбособаке отображение противников, как в Тёмном чутье.
|
||||
/// Типа у собаки нюх»; решено: только в одержимости, чует всех существ). Как Тёмное чутьё змеи
|
||||
/// (SnakeControl.Sense): раз в секунду существа ближе SenseRange к собаке получают метку на компасе и карте,
|
||||
/// дальше SenseDropRange, мёртвые и выгруженные её теряют. В отличие от чутья - не только нежить: звери,
|
||||
/// бандиты, зомби. Не метятся: тело игрока и другие игроки, питомцы мода (призрачные), торговцы.
|
||||
/// </summary>
|
||||
public static class DogSense
|
||||
{
|
||||
public const string DogClass = "necroZombieDog";
|
||||
static readonly HashSet<int> _sensed = new HashSet<int>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Выход из собаки - снять все метки.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using HarmonyLib;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Замер утечки сущностей (указание 09.10: «добавь замер сразу; если не будет тормозить, пригодится для
|
||||
/// анализа»). Повод - лог 09.10: в строке игры «Ent: 7 (31)» живых сущностей 7, а экземпляров в памяти 31
|
||||
/// (Entity.InstanceCount уменьшается только в финализаторе), и разрыв рос за сессию.
|
||||
///
|
||||
/// Как работает. Каждое удаление сущности из мира проходит через World.unloadEntity (смерть, отзыв,
|
||||
/// выгрузка чанка, чёрный разрыв NecroBurst). Там удалённая сущность запоминается СЛАБОЙ ссылкой
|
||||
/// (WeakReference сама не держит объект), с классом, причиной и источником - первым методом мода в стеке
|
||||
/// вызова (или «игра»). Раз в минуту: если с удаления прошло не меньше двух сборок мусора (GC.CollectionCount),
|
||||
/// а объект всё ещё жив, - его кто-то держит. В лог идёт строка «EntityLeakProbe» со сводкой по классу и
|
||||
/// источнику; ничего не держится - строки нет.
|
||||
///
|
||||
/// Нагрузка: удаления редки (единицы в минуту), стек снимается только в этот момент; раз в минуту - проход
|
||||
/// по короткому списку. GC принудительно НЕ вызывается (дал бы рывок). Вышедшие из памяти записи
|
||||
/// выбрасываются, список не растёт.
|
||||
/// </summary>
|
||||
public static class EntityLeakProbe
|
||||
{
|
||||
class Rec
|
||||
{
|
||||
public WeakReference Ref;
|
||||
public string What;
|
||||
public int Gc;
|
||||
}
|
||||
|
||||
static readonly List<Rec> _recs = new List<Rec>();
|
||||
static readonly Dictionary<string, int> _sum = new Dictionary<string, int>();
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Первый метод мода в стеке (кроме этого класса и патчей Harmony) - кто удалил; иначе «игра».</summary>
|
||||
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 "игра";
|
||||
}
|
||||
|
||||
/// <summary>Раз в минуту (из ModEvents.UnityUpdate мода).</summary>
|
||||
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<string, int> kv in _sum)
|
||||
{
|
||||
sb.Append("\n ").Append(kv.Value).Append(" x ").Append(kv.Key);
|
||||
}
|
||||
Debug.Log(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Выход из мира - забыть всё.</summary>
|
||||
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)
|
||||
{
|
||||
// Замер не должен ломать удаление сущности ни при каких условиях.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,10 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
|
||||
// ОТКЛЮЧЕНО 10.10 (новая концепция кровавой луны - «кровавый прилив», SnakeMode.IsBloodTide): в эту ночь
|
||||
// дух не выходит из тела, а кто был змеёй - выброшен в тело. Игрока-змеи в орде больше не бывает, и
|
||||
// античит не нужен. Сущность и её вид (выше и ниже) оставлены - вернуть подмешивание можно, сняв комментарий.
|
||||
/*
|
||||
/// <summary>
|
||||
/// Подмешивание в орду. Ванильный образец - AIDirectorBloodMoonParty.SpawnZombie: игрок в
|
||||
/// транспорте получает в орду стервятников с шансом 0.5. Класс зомби там берётся из
|
||||
@@ -139,6 +143,7 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/// <summary>
|
||||
/// Вид Стирателя (02.10): тело - ванильная модель толстого гавайца (скелет, физика, теги,
|
||||
|
||||
@@ -4,7 +4,7 @@ using UnityEngine;
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере. Блок -
|
||||
/// Гео-оссуарий (1.6.0, 2026-09-30): станция добычи ресурсов на кровавой сфере. Блок -
|
||||
/// necroGeoOssuary в blocks.xml, наследник ванильного коллектора росы; всё, что коллектор
|
||||
/// умеет сам (окно, топливо, таймеры, работа без игрока рядом), остаётся его. Здесь только то,
|
||||
/// чего ваниль не умеет, - разобрано по декомпилятору 3.3:
|
||||
|
||||
@@ -310,6 +310,9 @@ namespace NecromancerTome
|
||||
// обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и
|
||||
// голова, что и выглядело жутко.
|
||||
"necroZombieGriffin",
|
||||
// Могильная крыса (08.10.2026): «серой, не полупрозрачной» - у неё теперь своя модель
|
||||
// из угольной глины (RatModel.cs), призрачный вид её бы испортил.
|
||||
"necroRatSpirit",
|
||||
};
|
||||
|
||||
public static bool ShouldBeGhost(EntityAlive _entity)
|
||||
|
||||
@@ -157,6 +157,12 @@ namespace NecromancerTome
|
||||
HandPose.Jab(); // толчок к прицелу, как у «пиф-пафа» (05.10)
|
||||
_player.PlayOneShot(DenySound);
|
||||
World world = _player.world;
|
||||
if (SnakeMode.IsBloodTide())
|
||||
{
|
||||
// Кровавый прилив (10.10): леденящий ужас тонет в насыщенном воздухе - жест и звук есть, бегства нет.
|
||||
SnakeMode.BloodTideHint(_player);
|
||||
return;
|
||||
}
|
||||
if (world.IsRemote())
|
||||
{
|
||||
return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно
|
||||
@@ -317,6 +323,11 @@ namespace NecromancerTome
|
||||
public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
|
||||
{
|
||||
__state = null;
|
||||
// В одержимости прицел красит Possession.AimTint по цели одержимого, а не хват тела.
|
||||
if (Possession.Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int hold = HandHolds.Current(__instance);
|
||||
EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
|
||||
// «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
|
||||
|
||||
@@ -6,7 +6,7 @@ using UnityEngine.Scripting;
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.7.0). Свой хват -
|
||||
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.6.0). Свой хват -
|
||||
/// это неподвижная поза рук от первого лица, как у браслета (кулак к противнику, без движения
|
||||
/// при действии): кости рига ставятся в нужную комбинацию после аниматора. Сначала надо знать
|
||||
/// кости: команда пишет в лог дерево рига рук от первого лица (EModelSDCS.baseRigFP, его создаёт
|
||||
|
||||
@@ -7,7 +7,7 @@ using UnityEngine.Scripting;
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.7.0). Хват - неподвижная
|
||||
/// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.6.0). Хват - неподвижная
|
||||
/// поза: после аниматора (LateUpdate) кости рига EModelSDCS.baseRigFP ставятся в заданные локальные
|
||||
/// повороты. Аниматор игры не трогаем. Снимки necrobones 03.10: кулак браслета (FP_Block_Hold,
|
||||
/// HoldType 7) - суставы пальцев повёрнуты по локальной Z на 77-92°, у пустой руки 8-38°;
|
||||
@@ -323,6 +323,7 @@ namespace NecromancerTome
|
||||
{
|
||||
HandHolds.Update();
|
||||
HandPose.Apply();
|
||||
HeldTweak.Apply();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Подбор посадки модели предмета в руке прямо в игре (10.10.2026, череп): как necropose для жестов. Сдвиг, поворот
|
||||
/// и масштаб применяются к дочерним узлам модели в руке (корень модели держит игра) каждый кадр - переживают смену
|
||||
/// слота. Итог - строкой в лог, затем переносится в настройку модели (NecroSkullSetup и т.п.) и бандл пересобирается
|
||||
/// при закрытой игре.
|
||||
/// </summary>
|
||||
public static class HeldTweak
|
||||
{
|
||||
public static bool Enabled;
|
||||
public static Vector3 Pos; // метры, в осях модели
|
||||
public static Vector3 Rot; // градусы, добавка к исходному повороту
|
||||
public static float Scale = 1f;
|
||||
/// <summary>Предмет, для которого подбирается посадка (тот, что был в руке при первой команде). Другие предметы
|
||||
/// не трогаются: 10.10 без этого поворот черепа применился и к книге, взятой следом.</summary>
|
||||
public static string Item;
|
||||
|
||||
/// <summary>Исходные локальные значения детей модели (по экземпляру узла).</summary>
|
||||
static readonly Dictionary<int, (Vector3 p, Quaternion r, Vector3 s)> _base = new Dictionary<int, (Vector3, Quaternion, Vector3)>();
|
||||
|
||||
public static Transform Model()
|
||||
{
|
||||
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
return pl?.inventory?.GetHoldingItemTransform();
|
||||
}
|
||||
|
||||
public static string HeldName()
|
||||
{
|
||||
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
return pl?.inventory?.holdingItem?.Name;
|
||||
}
|
||||
|
||||
public static void Apply()
|
||||
{
|
||||
Transform m = Model();
|
||||
if (m == null || (Enabled && HeldName() != Item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < m.childCount; i++)
|
||||
{
|
||||
Transform c = m.GetChild(i);
|
||||
int id = c.GetInstanceID();
|
||||
if (!_base.TryGetValue(id, out var b))
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
b = (c.localPosition, c.localRotation, c.localScale);
|
||||
_base[id] = b;
|
||||
}
|
||||
if (Enabled)
|
||||
{
|
||||
c.localPosition = Scale * b.p + Pos;
|
||||
c.localRotation = Quaternion.Euler(Rot) * b.r;
|
||||
c.localScale = b.s * Scale;
|
||||
}
|
||||
else
|
||||
{
|
||||
c.localPosition = b.p;
|
||||
c.localRotation = b.r;
|
||||
c.localScale = b.s;
|
||||
_base.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string F(float _v) => _v.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// necroheld - посадка модели в руке:
|
||||
/// pos x y z (см) | add x y z (см) | rot x y z (градусы) | scale s | off | show
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroHeld : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necroheld" };
|
||||
public override string getDescription() => "NecromancerTome: посадка модели в руке - pos x y z (см)|add x y z (см)|rot x y z|scale s|off|show";
|
||||
|
||||
static bool Num(List<string> _p, int _i, out float _v)
|
||||
{
|
||||
_v = 0f;
|
||||
return _p.Count > _i && float.TryParse(_p[_i].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
|
||||
}
|
||||
|
||||
static bool Vec(List<string> _p, out Vector3 _v)
|
||||
{
|
||||
bool ok = Num(_p, 1, out float x) & Num(_p, 2, out float y) & Num(_p, 3, out float z);
|
||||
_v = new Vector3(x, y, z);
|
||||
return ok;
|
||||
}
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
string cmd = _params.Count > 0 ? _params[0] : "show";
|
||||
Vector3 v;
|
||||
switch (cmd)
|
||||
{
|
||||
case "pos" when Vec(_params, out v):
|
||||
HeldTweak.Pos = v / 100f;
|
||||
break;
|
||||
case "add" when Vec(_params, out v):
|
||||
HeldTweak.Pos += v / 100f;
|
||||
break;
|
||||
case "rot" when Vec(_params, out v):
|
||||
HeldTweak.Rot = v;
|
||||
break;
|
||||
case "scale" when Num(_params, 1, out float s) && s > 0f:
|
||||
HeldTweak.Scale = s;
|
||||
break;
|
||||
case "off":
|
||||
HeldTweak.Enabled = false;
|
||||
HeldTweak.Pos = Vector3.zero;
|
||||
HeldTweak.Rot = Vector3.zero;
|
||||
HeldTweak.Scale = 1f;
|
||||
Out("посадка сброшена");
|
||||
return;
|
||||
case "show":
|
||||
break;
|
||||
default:
|
||||
Out(getDescription());
|
||||
return;
|
||||
}
|
||||
if (cmd != "show")
|
||||
{
|
||||
if (!HeldTweak.Enabled)
|
||||
{
|
||||
HeldTweak.Item = HeldTweak.HeldName();
|
||||
}
|
||||
HeldTweak.Enabled = true;
|
||||
}
|
||||
Transform m = HeldTweak.Model();
|
||||
Out("предмет " + HeldTweak.Item + ", модель " + (m != null ? m.name : "нет") + ": сдвиг (см) " + HeldTweak.F(HeldTweak.Pos * 100f) + ", поворот " +
|
||||
HeldTweak.F(HeldTweak.Rot) + ", масштаб " + HeldTweak.F(HeldTweak.Scale) + (HeldTweak.Enabled ? "" : " (выключено)"));
|
||||
}
|
||||
|
||||
static void Out(string _s)
|
||||
{
|
||||
SdtdConsole.Instance.Output(_s);
|
||||
Debug.Log("[NecromancerTome] necroheld: " + _s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Плата здоровьем за силовую атаку Ножа некроманта (указание 09.10.2026: «обычная силовая - 20 хп, с модификацией
|
||||
/// 40; если здоровья на штраф не хватает - звук сломавшегося инвентаря, а силовая заменится обычной»).
|
||||
/// Саму плату снимает XML (items.xml - 20 при попадании силовой, item_modifiers.xml «Мёртвая буря» - ещё 20).
|
||||
/// Здесь - только проверка до удара: ItemClass.ExecuteAction запускает и обычный удар (действие 0), и силовую
|
||||
/// (действие 1); не хватает здоровья - номер действия меняется на 0 (тот же удар с той же анимацией обычного),
|
||||
/// а при нажатии звучит ванильный itembreak и надпись «Недостаточно здоровья».
|
||||
/// Почему был нужен штраф побольше: лечение обычным ударом - убийства/20, и уже со 100 убийств один удар
|
||||
/// окупал прежнюю плату в 5 здоровья.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemClass), nameof(ItemClass.ExecuteAction))]
|
||||
public static class Patch_ItemClass_ExecuteAction_KnifePowerCost
|
||||
{
|
||||
public const string KnifeName = "necroWpnBladeNecroKnife";
|
||||
public const string StormMod = "necroModKnifeDeadStorm";
|
||||
/// <summary>Должно совпадать с items.xml и item_modifiers.xml.</summary>
|
||||
public const int Cost = 20, StormCost = 40;
|
||||
|
||||
public static void Prefix(ItemClass __instance, ref int _actionIdx, ItemInventoryData _data, bool _bReleased)
|
||||
{
|
||||
if (_actionIdx != 1 || __instance.Name != KnifeName || !(_data?.holdingEntity is EntityPlayerLocal player))
|
||||
{
|
||||
return;
|
||||
}
|
||||
int cost = HasStorm(_data.itemValue) ? StormCost : Cost;
|
||||
if (BloodTide.Active)
|
||||
{
|
||||
cost /= 2; // кровавый прилив (10.10): плата вдвое меньше - как в items.xml/item_modifiers.xml
|
||||
}
|
||||
if (player.Health > cost)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_actionIdx = 0;
|
||||
if (!_bReleased)
|
||||
{
|
||||
Audio.Manager.PlayInsidePlayerHead("itembreak");
|
||||
// Надпись (указание 09.10) - сразу, мимо очереди подсказок, чтобы при частых нажатиях не копилась.
|
||||
GameManager.ShowTooltip(player, Localization.Get("necroKnifeNoHealth"), true);
|
||||
Debug.Log("[NecromancerTome] KnifePowerCost: здоровья " + player.Health + ", плата " + cost + " - вместо силовой обычный удар");
|
||||
}
|
||||
}
|
||||
|
||||
static bool HasStorm(ItemValue _iv)
|
||||
{
|
||||
if (_iv?.modifications == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (ItemValue m in _iv.modifications)
|
||||
{
|
||||
if (m != null && !m.IsEmpty() && m.ItemClass?.Name == StormMod)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
@@ -33,6 +34,10 @@ namespace NecromancerTome
|
||||
static Vector3 _feet;
|
||||
static Vector3 _spawn;
|
||||
static float _yaw, _pitch, _vy;
|
||||
/// <summary>Высота камеры (ног) со сглаживанием: ступенька поднимает ноги рывком, камера догоняет
|
||||
/// плавно (тест 09.10 на лестнице чертогов: «двигается рывками»).</summary>
|
||||
static float _camY;
|
||||
public const float CamSmooth = 18f;
|
||||
static bool _grounded;
|
||||
public static bool HasFocus;
|
||||
static EntityPlayerLocal _player;
|
||||
@@ -44,6 +49,7 @@ namespace NecromancerTome
|
||||
_player = _p;
|
||||
_feet = _at;
|
||||
_spawn = _at;
|
||||
_camY = _at.y;
|
||||
_yaw = _atYaw;
|
||||
_pitch = 0f;
|
||||
_vy = 0f;
|
||||
@@ -86,6 +92,27 @@ namespace NecromancerTome
|
||||
return Physics.CheckCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
|
||||
static float _blockLogAt;
|
||||
|
||||
/// <summary>Диагностика (09.10): во что упёрся дух - раз в 2 с, имена коллайдеров.</summary>
|
||||
static void LogBlocker(Vector3 _p)
|
||||
{
|
||||
if (Time.time < _blockLogAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_blockLogAt = Time.time + 2f;
|
||||
Vector3 a = _p + Vector3.up * (Radius + 0.05f) - Origin.position;
|
||||
Vector3 b = _p + Vector3.up * (Height - Radius) - Origin.position;
|
||||
var names = new List<string>();
|
||||
foreach (Collider col in Physics.OverlapCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
Transform r = col.transform.root;
|
||||
names.Add(col.name + "@" + (r != null ? r.name : "?") + " " + col.bounds.min.y.ToString("0.00") + ".." + col.bounds.max.y.ToString("0.00"));
|
||||
}
|
||||
Debug.Log("[NecromancerTome] MindControl: упёрся у " + _p + " (ноги " + (_p.y - Origin.position.y).ToString("0.00") + "): " + string.Join(", ", names));
|
||||
}
|
||||
|
||||
static bool TryMove(Vector3 _d)
|
||||
{
|
||||
Vector3 c = _feet + _d;
|
||||
@@ -94,12 +121,27 @@ namespace NecromancerTome
|
||||
_feet = c;
|
||||
return true;
|
||||
}
|
||||
// Ступенька: на полблока выше свободно - шагаем вверх.
|
||||
if (_grounded && !Blocked(c + Vector3.up * StepUp))
|
||||
// Ступенька: шагаем вверх на наименьшую высоту (до StepUp), где свободно, ноги - на верх ступеньки
|
||||
// (не выше: иначе на следующем кадре проседали - рывок). Сразу на StepUp нельзя: в проёме с порогом
|
||||
// и низкой притолокой (сейфовая дверь, тест 09.10: «не получается пройти в проём») голова упиралась.
|
||||
if (_grounded)
|
||||
{
|
||||
_feet = c + Vector3.up * StepUp;
|
||||
return true;
|
||||
for (float h = StepUp / 4f; h <= StepUp + 0.001f; h += StepUp / 4f)
|
||||
{
|
||||
if (Blocked(c + Vector3.up * h))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3 up = c + Vector3.up * h;
|
||||
if (Physics.Raycast(up + Vector3.up * 0.05f - Origin.position, Vector3.down, out RaycastHit hit, h + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
up.y = hit.point.y + Origin.position.y;
|
||||
}
|
||||
_feet = up;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
LogBlocker(c);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -161,7 +203,9 @@ namespace NecromancerTome
|
||||
_vy = 0f;
|
||||
dy = 0f;
|
||||
}
|
||||
if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
|
||||
// Стоял на полу и сошёл со ступеньки - прилипаем к нижней (до StepUp), а не падаем с неё скачками.
|
||||
float snap = _grounded && _vy <= 0f ? StepUp : 0f;
|
||||
if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f + snap, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
|
||||
{
|
||||
_feet.y = hit.point.y + Origin.position.y;
|
||||
_vy = 0f;
|
||||
@@ -184,7 +228,9 @@ namespace NecromancerTome
|
||||
/// <summary>Из патчей камеры режима змеи: глаза духа и поиск книги под прицелом.</summary>
|
||||
public static void PlaceCamera(Transform _camera)
|
||||
{
|
||||
Vector3 eye = _feet + Vector3.up * EyeHeight - Origin.position;
|
||||
// Камера догоняет ноги плавно; большой перепад (падение, возврат на точку появления) - сразу.
|
||||
_camY = Mathf.Abs(_feet.y - _camY) > 1.2f ? _feet.y : Mathf.Lerp(_camY, _feet.y, 1f - Mathf.Exp(-CamSmooth * Time.deltaTime));
|
||||
Vector3 eye = new Vector3(_feet.x, _camY, _feet.z) + Vector3.up * EyeHeight - Origin.position;
|
||||
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
|
||||
_camera.SetPositionAndRotation(eye, rot);
|
||||
HasFocus = FindBook(_camera.position + Origin.position, _camera.forward);
|
||||
|
||||
@@ -27,7 +27,9 @@ namespace NecromancerTome
|
||||
/// (woodShapes:cube) -> terrBedrock (в меню творчества его нет, рисовали деревом),
|
||||
/// одиночная книга на столе -> necroMemoryBook, на стол с реактивами - нож-декорация,
|
||||
/// кровать (SleepingBag) -> necroPalaceBed: иначе игра делает её точкой возрождения игрока.
|
||||
/// Построены ли - по книге: нет её - ставим заново (самопочинка).
|
||||
/// Построены ли - сверка с префабом каждой клетки (Mismatches: тип, плотность, покраска) при каждом входе:
|
||||
/// расхождение - ставим заново (самопочинка; заодно новый, более широкий префаб сам перестраивает старые
|
||||
/// чертоги в сейвах). Надписи на досках из префаба НЕ переносятся (указание 09.10: «текст будет добавлять мод по событиям»).
|
||||
///
|
||||
/// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
|
||||
/// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
|
||||
@@ -45,6 +47,18 @@ namespace NecromancerTome
|
||||
public const string BedBlock = "necroPalaceBed";
|
||||
public const string ShellFrom = "woodShapes:cube";
|
||||
public const string ShellTo = "terrBedrock";
|
||||
/// <summary>Межкомнатные двери - их чертоги открывают сами при входе (кроме входной и дверей с номером).</summary>
|
||||
public const string PassageDoor = "exteriorHouseDoorWhite";
|
||||
/// <summary>Табличка с номером двери (указание 09.10): ставится в редакторе рядом с дверью или над ней,
|
||||
/// в ней пишется номер (до двух цифр). В чертоги не ставится (вместо неё воздух) - видна только в редакторе.
|
||||
/// Дверь с номером закрыта, пока событие не откроет её (SetDoorOpen).</summary>
|
||||
public const string DoorTagBlock = "necroDoorNumber";
|
||||
/// <summary>Двери с номером, открытые с самого начала (указание 09.10): 2 туалет, 4 ванная, 5 прихожая,
|
||||
/// 6 лаборатория. Остальные закрыты до события: 1 входная - бутафорская, не открывается никогда; 3 лестница,
|
||||
/// 7 шкаф-тайник, 8 за ним, 9 решётка, 10-11 сейфовые, 12, 14-17 второй этаж.</summary>
|
||||
public static readonly HashSet<int> DefaultOpenDoors = new HashSet<int> { 2, 4, 5, 6 };
|
||||
/// <summary>Как далеко от таблички искать её дверь (клеток по каждой оси; над дверью - 2).</summary>
|
||||
public const int DoorTagReach = 2;
|
||||
/// <summary>Одиночная книга на обеденном столе (cntBookPile09 в префабе).</summary>
|
||||
public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
|
||||
/// <summary>Место ножа на столе с реактивами (указание пользователя).</summary>
|
||||
@@ -313,6 +327,12 @@ namespace NecromancerTome
|
||||
_bv = bed;
|
||||
_d = MarchingCubes.DensityAir;
|
||||
}
|
||||
else if (!_bv.isair && _bv.Block.GetBlockName() == DoorTagBlock)
|
||||
{
|
||||
// Табличка номера двери - только для редактора.
|
||||
_bv = BlockValue.Air;
|
||||
_d = MarchingCubes.DensityAir;
|
||||
}
|
||||
else if (_bv.isair || !_bv.Block.shape.IsTerrain())
|
||||
{
|
||||
_d = MarchingCubes.DensityAir;
|
||||
@@ -408,31 +428,242 @@ namespace NecromancerTome
|
||||
return Mismatches(_world, _o) == 0;
|
||||
}
|
||||
|
||||
/// <summary>Все двери чертогов - открыты (в редакторе сохранились закрытыми, а дух дверей не
|
||||
/// открывает). Состояние двери - в её TEFeatureDoor, не в блоке.</summary>
|
||||
/// <summary>Номера дверей из табличек префаба: локальная клетка таблички -> номер.</summary>
|
||||
static Dictionary<Vector3i, int> DoorTags(Prefab _p)
|
||||
{
|
||||
var tags = new Dictionary<Vector3i, int>();
|
||||
foreach (var kv in _p.tileEntities)
|
||||
{
|
||||
Vector3i lp = kv.Key;
|
||||
if (_p.GetBlock(lp.x, lp.y, lp.z).Block?.GetBlockName() != DoorTagBlock ||
|
||||
!(kv.Value is TileEntityComposite tec) || !tec.TryGetSelfOrFeature(out TEFeatureSignable sign))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string text = (sign.GetAuthoredText()?.Text ?? "").Trim();
|
||||
if (int.TryParse(text, out int n))
|
||||
{
|
||||
tags[lp] = n;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: табличка двери " + lp + " без номера: «" + text + "»");
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>Двери с номерами: номер -> локальная клетка двери (ближайшая к табличке дверь в пределах DoorTagReach).</summary>
|
||||
public static Dictionary<int, Vector3i> NumberedDoors(World _world, Vector3i _o)
|
||||
{
|
||||
var doors = new Dictionary<int, Vector3i>();
|
||||
Prefab p = LoadPrefab();
|
||||
if (p == null)
|
||||
{
|
||||
return doors;
|
||||
}
|
||||
foreach (var kv in DoorTags(p))
|
||||
{
|
||||
Vector3i best = default;
|
||||
int bestD = int.MaxValue;
|
||||
for (int dy = -DoorTagReach; dy <= DoorTagReach; dy++)
|
||||
for (int dz = -DoorTagReach; dz <= DoorTagReach; dz++)
|
||||
for (int dx = -DoorTagReach; dx <= DoorTagReach; dx++)
|
||||
{
|
||||
Vector3i lp = kv.Key + new Vector3i(dx, dy, dz);
|
||||
int d = dx * dx + dy * dy + dz * dz;
|
||||
if (d < bestD && _world.GetTileEntity(_o + lp) is TileEntityComposite tec && tec.GetFeature<TEFeatureDoor>() != null)
|
||||
{
|
||||
best = lp;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
if (bestD == int.MaxValue)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: у таблички " + kv.Key + " (дверь " + kv.Value + ") рядом нет двери");
|
||||
}
|
||||
else if (doors.ContainsKey(kv.Value))
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: номер двери " + kv.Value + " повторяется (" + doors[kv.Value] + " и " + best + ")");
|
||||
}
|
||||
else
|
||||
{
|
||||
doors[kv.Value] = best;
|
||||
}
|
||||
}
|
||||
return doors;
|
||||
}
|
||||
|
||||
/// <summary>Состояние дверей чертогов при каждом входе. Дверь с номером (табличка DoorTagBlock) открыта, только
|
||||
/// если событие её открыло (SetDoorOpen). Межкомнатные двери без номера (PassageDoor) открыты (в редакторе
|
||||
/// сохранились закрытыми, а дух дверей не открывает), входная - закрыта. Прочие двери без номера не трогаем.
|
||||
/// Состояние двери - в её TEFeatureDoor.</summary>
|
||||
public static void OpenDoors(World _world, Vector3i _o)
|
||||
{
|
||||
var numbered = new Dictionary<Vector3i, int>();
|
||||
foreach (var kv in NumberedDoors(_world, _o))
|
||||
{
|
||||
numbered[kv.Value] = kv.Key;
|
||||
}
|
||||
HashSet<int> opened = OpenedDoors(_world.GetPrimaryPlayer());
|
||||
Vector3i s = Size;
|
||||
int opened = 0;
|
||||
int switched = 0;
|
||||
for (int y = 0; y < s.y; y++)
|
||||
for (int z = 0; z < s.z; z++)
|
||||
for (int x = 0; x < s.x; x++)
|
||||
{
|
||||
if (_world.GetTileEntity(_o + new Vector3i(x, y, z)) is TileEntityComposite tec)
|
||||
Vector3i lp = new Vector3i(x, y, z);
|
||||
if (!(_world.GetTileEntity(_o + lp) is TileEntityComposite tec) || !(tec.GetFeature<TEFeatureDoor>() is TEFeatureDoor door))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
bool wantOpen;
|
||||
if (numbered.TryGetValue(lp, out int n))
|
||||
{
|
||||
// В файле: n - открыта событием, -n - закрыта событием (для открытых с начала).
|
||||
wantOpen = opened.Contains(n) || DefaultOpenDoors.Contains(n) && !opened.Contains(-n);
|
||||
}
|
||||
else if (_world.GetBlock(_o + lp).Block.GetBlockName() == PassageDoor)
|
||||
{
|
||||
TEFeatureDoor door = tec.GetFeature<TEFeatureDoor>();
|
||||
// Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
|
||||
bool wantOpen = new Vector3i(x, y, z) != EntranceDoorAt;
|
||||
if (door != null && door.IsOpen() != wantOpen)
|
||||
{
|
||||
door.SetOpen(wantOpen);
|
||||
opened++;
|
||||
}
|
||||
wantOpen = lp != EntranceDoorAt;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (door.IsOpen() != wantOpen)
|
||||
{
|
||||
door.SetOpen(wantOpen);
|
||||
switched++;
|
||||
}
|
||||
}
|
||||
if (opened > 0)
|
||||
if (switched > 0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + opened);
|
||||
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + switched + ", с номерами " + numbered.Count + ", открыты по событиям: " + string.Join(",", opened));
|
||||
}
|
||||
}
|
||||
|
||||
// Открытые событиями двери - по игрокам, в файле в папке сохранения мира: «игрок номер номер ...».
|
||||
static string DoorsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaceDoors.txt");
|
||||
static readonly Dictionary<string, HashSet<int>> _openedDoors = new Dictionary<string, HashSet<int>>();
|
||||
static string _openedFor;
|
||||
|
||||
static HashSet<int> OpenedDoors(EntityPlayer _player)
|
||||
{
|
||||
string dir = GameIO.GetSaveGameDir();
|
||||
if (_openedFor != dir)
|
||||
{
|
||||
_openedFor = dir;
|
||||
_openedDoors.Clear();
|
||||
try
|
||||
{
|
||||
if (File.Exists(DoorsFile))
|
||||
{
|
||||
foreach (string line in File.ReadAllLines(DoorsFile))
|
||||
{
|
||||
string[] parts = line.Split(' ');
|
||||
var set = new HashSet<int>();
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
if (int.TryParse(parts[i], out int n))
|
||||
{
|
||||
set.Add(n);
|
||||
}
|
||||
}
|
||||
_openedDoors[parts[0]] = set;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать двери - " + ex.Message);
|
||||
}
|
||||
}
|
||||
string key = _player != null ? PlayerKey(_player) : "local";
|
||||
if (!_openedDoors.TryGetValue(key, out HashSet<int> opened))
|
||||
{
|
||||
opened = new HashSet<int>();
|
||||
_openedDoors[key] = opened;
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
/// <summary>Открыть/закрыть дверь чертогов с номером (для событий). Запоминается в сейве; если игрок сейчас
|
||||
/// в чертогах - дверь переключается сразу.</summary>
|
||||
public static void SetDoorOpen(EntityPlayer _player, int _number, bool _open)
|
||||
{
|
||||
HashSet<int> opened = OpenedDoors(_player);
|
||||
bool changed = _open ? opened.Remove(-_number) | opened.Add(_number) : opened.Remove(_number) | DefaultOpenDoors.Contains(_number) && opened.Add(-_number);
|
||||
if (!changed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var kv in _openedDoors)
|
||||
{
|
||||
lines.Add(kv.Key + (kv.Value.Count > 0 ? " " + string.Join(" ", kv.Value) : ""));
|
||||
}
|
||||
File.WriteAllLines(DoorsFile, lines);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: не записать двери - " + ex.Message);
|
||||
}
|
||||
World world = GameManager.Instance.World;
|
||||
if (_stage == Stage.Ready && world != null && _player == world.GetPrimaryPlayer())
|
||||
{
|
||||
OpenDoors(world, _origin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Надписи на досках чертогов (указание 09.10). Лор: некроманта бросили в этот мир умирать - раздели,
|
||||
/// лишили артефактов (и вживлённых тоже) и ментальной магией стёрли память, «закрыв знания, опасные для общества».
|
||||
/// Доска показывает информацию из подсознания, которую герой пока не помнит (указание 09.10); строки прибавляются по событиям игры. Пока событие -
|
||||
/// только день: (с какого дня, ключ локализации). Остальные строки - позднее.</summary>
|
||||
public static readonly (int day, string key)[] BoardLines =
|
||||
{
|
||||
(1, "necroPalaceBoardRevenge"),
|
||||
(5, "necroPalaceBoardTide"), // было «излучение красной луны»; 10.10 - кровавый прилив
|
||||
};
|
||||
|
||||
/// <summary>При каждом входе: на каждой доске (necroChalkboard) чертогов - все наступившие строки, на языке игрока.
|
||||
/// Текст из префаба в чертоги не идёт (Stamp ставит только блоки) - пишет только мод.</summary>
|
||||
public static void UpdateBoards(World _world, Vector3i _o)
|
||||
{
|
||||
int day = GameUtils.WorldTimeToDays(_world.worldTime);
|
||||
var lines = new List<string>();
|
||||
foreach (var l in BoardLines)
|
||||
{
|
||||
if (day >= l.day)
|
||||
{
|
||||
lines.Add(Localization.Get(l.key));
|
||||
}
|
||||
}
|
||||
string text = string.Join("\n", lines);
|
||||
Vector3i s = Size;
|
||||
int n = 0;
|
||||
for (int y = 0; y < s.y; y++)
|
||||
for (int z = 0; z < s.z; z++)
|
||||
for (int x = 0; x < s.x; x++)
|
||||
{
|
||||
Vector3i wp = _o + new Vector3i(x, y, z);
|
||||
if (_world.GetBlock(wp).Block?.GetBlockName() != Chalkboard.BlockName ||
|
||||
!(_world.GetTileEntity(wp) is TileEntityComposite tec) || !tec.TryGetSelfOrFeature(out TEFeatureSignable sign))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if ((sign.GetAuthoredText()?.Text ?? "") != text)
|
||||
{
|
||||
sign.SetText(text, true);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n > 0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: надпись на досках (день " + day + ", строк " + lines.Count + "), досок " + n);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +808,12 @@ namespace NecromancerTome
|
||||
Debug.Log("[NecromancerTome] MindPalace: днём чертоги недоступны");
|
||||
return;
|
||||
}
|
||||
if (SnakeMode.IsBloodTide())
|
||||
{
|
||||
SnakeMode.BloodTideHint(_player);
|
||||
Debug.Log("[NecromancerTome] MindPalace: кровавый прилив - дух не покидает тело");
|
||||
return;
|
||||
}
|
||||
World world = GameManager.Instance.World;
|
||||
if (world.IsRemote())
|
||||
{
|
||||
@@ -592,6 +829,7 @@ namespace NecromancerTome
|
||||
// Дальность как у змеи: при радиусе 2 игра не строила коллизии участка (тест 03.10:
|
||||
// «чертоги не построились вовремя» - ждали коллизий 30 с).
|
||||
_observer = GameManager.Instance.AddChunkObserver(center, true, GameUtils.GetViewDistance(), -1);
|
||||
ShrinkBody(_player);
|
||||
_stage = Stage.Loading;
|
||||
_deadline = Time.time + 30f;
|
||||
Active = true;
|
||||
@@ -631,6 +869,27 @@ namespace NecromancerTome
|
||||
GameManager.Instance.RemoveChunkObserver(_observer);
|
||||
}
|
||||
_observer = null;
|
||||
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
if (_bodyViewDim > 0 && p?.ChunkObserver != null)
|
||||
{
|
||||
p.ChunkObserver.viewDim = _bodyViewDim;
|
||||
}
|
||||
_bodyViewDim = -1;
|
||||
}
|
||||
|
||||
/// <summary>Область мира у тела до входа (-1 - не сжата). Фризы в чертогах (жалоба 10.10): игра держала две
|
||||
/// полные области - у чертогов на краю карты и у лежащего тела; подгрузка и пересборка участков - мусор
|
||||
/// и паузы его сборки. Тело сжимаем до Possession.BodyViewDim, как у змеи и одержимого.</summary>
|
||||
static int _bodyViewDim = -1;
|
||||
|
||||
static void ShrinkBody(EntityPlayerLocal _player)
|
||||
{
|
||||
ChunkManager.ChunkObserver body = _player?.ChunkObserver;
|
||||
if (body != null && _bodyViewDim < 0 && body.viewDim > Possession.BodyViewDim)
|
||||
{
|
||||
_bodyViewDim = body.viewDim;
|
||||
body.viewDim = Possession.BodyViewDim;
|
||||
}
|
||||
}
|
||||
|
||||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
@@ -643,6 +902,7 @@ namespace NecromancerTome
|
||||
_observer = null;
|
||||
MindControl.End(null);
|
||||
_slotsFor = null;
|
||||
_bodyViewDim = -1;
|
||||
return;
|
||||
}
|
||||
EntityPlayerLocal player = world.GetPrimaryPlayer();
|
||||
@@ -672,6 +932,13 @@ namespace NecromancerTome
|
||||
Exit(player);
|
||||
return;
|
||||
}
|
||||
if (SnakeMode.IsBloodTide())
|
||||
{
|
||||
SnakeMode.BloodTideHint(player);
|
||||
Debug.Log("[NecromancerTome] MindPalace: кровавый прилив - дух выброшен в тело");
|
||||
Exit(player);
|
||||
return;
|
||||
}
|
||||
Body(player);
|
||||
switch (_stage)
|
||||
{
|
||||
@@ -707,6 +974,7 @@ namespace NecromancerTome
|
||||
Debug.Log("[NecromancerTome] MindPalace: коллизии " + (world.IsChunkAreaCollidersLoaded(spawn) ? "готовы" : "НЕ готовы, пускаем по таймеру"));
|
||||
_stage = Stage.Ready;
|
||||
OpenDoors(world, _origin);
|
||||
UpdateBoards(world, _origin);
|
||||
LogCell(world, _origin + KnifeAt, "нож");
|
||||
LogCell(world, _origin + BookAt, "книга");
|
||||
MindControl.Begin(player, spawn, SpawnYaw);
|
||||
@@ -842,7 +1110,7 @@ namespace NecromancerTome
|
||||
public class ConsoleCmdNecroPalace : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necropalace" };
|
||||
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N";
|
||||
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N | door [N open|close]";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
@@ -878,6 +1146,20 @@ namespace NecromancerTome
|
||||
GameManager.Instance.StartCoroutine(MindPalace.StressTest(world, o));
|
||||
SdtdConsole.Instance.Output("проверка: заливка гравием, через 2 с - постановка (итог - в логе)");
|
||||
}
|
||||
else if (cmd == "door" && _params.Count > 2 && int.TryParse(_params[1], out int dn))
|
||||
{
|
||||
MindPalace.SetDoorOpen(player, dn, _params[2] == "open");
|
||||
SdtdConsole.Instance.Output("дверь " + dn + ": " + (_params[2] == "open" ? "открыта" : "закрыта"));
|
||||
}
|
||||
else if (cmd == "door")
|
||||
{
|
||||
var doors = MindPalace.NumberedDoors(world, o);
|
||||
SdtdConsole.Instance.Output("дверей с номерами: " + doors.Count);
|
||||
foreach (var kv in doors)
|
||||
{
|
||||
SdtdConsole.Instance.Output(" " + kv.Key + " - " + world.GetBlock(o + kv.Value).Block.GetBlockName() + " " + kv.Value);
|
||||
}
|
||||
}
|
||||
else if (cmd == "rebuild")
|
||||
{
|
||||
MindPalace.Stamp(world, o);
|
||||
|
||||
@@ -65,6 +65,8 @@ namespace NecromancerTome
|
||||
HandPose.Init();
|
||||
NecroMailbox.Init();
|
||||
Possession.Init();
|
||||
BloodTide.Init();
|
||||
RemainsDecay.Init();
|
||||
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// necromodel <блок> - выписать в лог устройство модели блока (09.10.2026, для своей двери-книжного
|
||||
/// шкафа: где у ванильной двери петля, куда она открывается, какие коллайдеры). Иерархия с местом, поворотом,
|
||||
/// масштабом, мешами и коллайдерами; у аниматоров - параметры и клипы, а створка в состояниях «Open» и
|
||||
/// «Close» - пробой клипа на копии префаба. Копия создаётся и тут же уничтожается.
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroModel : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necromodel" };
|
||||
public override string getDescription() => "NecromancerTome: устройство модели блока в лог (necromodel <имя блока>)";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
if (_params.Count < 1)
|
||||
{
|
||||
SdtdConsole.Instance.Output("necromodel <имя блока>");
|
||||
return;
|
||||
}
|
||||
Block block = Block.GetBlockByName(_params[0]);
|
||||
string model = block?.Properties.GetString("Model");
|
||||
if (string.IsNullOrEmpty(model))
|
||||
{
|
||||
SdtdConsole.Instance.Output("Нет блока или у него нет Model: " + _params[0]);
|
||||
return;
|
||||
}
|
||||
if (!model.StartsWith("@") && !model.StartsWith("#"))
|
||||
{
|
||||
model = "@:" + model;
|
||||
}
|
||||
GameObject prefab = DataLoader.LoadAsset<GameObject>(model);
|
||||
if (prefab == null)
|
||||
{
|
||||
SdtdConsole.Instance.Output("Модель не загрузилась: " + model);
|
||||
return;
|
||||
}
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[NecromancerTome] necromodel " + _params[0] + " = " + model + ", ModelOffset " +
|
||||
block.Properties.GetString("ModelOffset") + ", MultiBlockDim " + block.Properties.GetString("MultiBlockDim"));
|
||||
Dump(prefab.transform, 0, sb);
|
||||
GameObject copy = Object.Instantiate(prefab);
|
||||
try
|
||||
{
|
||||
foreach (Animator a in copy.GetComponentsInChildren<Animator>(true))
|
||||
{
|
||||
RuntimeAnimatorController rc = a.runtimeAnimatorController;
|
||||
sb.AppendLine("Animator на " + a.name + ": " + (rc != null ? rc.name : "нет контроллера"));
|
||||
foreach (AnimatorControllerParameter p in a.parameters)
|
||||
{
|
||||
sb.AppendLine(" параметр " + p.name + " " + p.type);
|
||||
}
|
||||
if (rc == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (AnimationClip clip in rc.animationClips)
|
||||
{
|
||||
sb.AppendLine(" клип " + clip.name + ", " + clip.length.ToString("F2") + " с");
|
||||
clip.SampleAnimation(copy, clip.length);
|
||||
foreach (Transform t in copy.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
sb.AppendLine(" в конце: " + Path(t, copy.transform) + " pos " + t.localPosition.ToString("F3") +
|
||||
" rot " + t.localEulerAngles.ToString("F1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.Destroy(copy);
|
||||
}
|
||||
Debug.Log(sb.ToString());
|
||||
SdtdConsole.Instance.Output("necromodel: выписано в лог");
|
||||
}
|
||||
|
||||
static void Dump(Transform _t, int _depth, StringBuilder _sb)
|
||||
{
|
||||
var line = new StringBuilder(new string(' ', _depth * 2));
|
||||
line.Append(_t.name).Append(" pos ").Append(_t.localPosition.ToString("F3")).Append(" rot ")
|
||||
.Append(_t.localEulerAngles.ToString("F1")).Append(" scale ").Append(_t.localScale.ToString("F2"))
|
||||
.Append(" tag ").Append(_t.tag).Append(" layer ").Append(_t.gameObject.layer);
|
||||
MeshFilter mf = _t.GetComponent<MeshFilter>();
|
||||
if (mf != null && mf.sharedMesh != null)
|
||||
{
|
||||
line.Append(" mesh ").Append(mf.sharedMesh.name).Append(" bounds c").Append(mf.sharedMesh.bounds.center.ToString("F3"))
|
||||
.Append(" size").Append(mf.sharedMesh.bounds.size.ToString("F3"));
|
||||
}
|
||||
foreach (Collider c in _t.GetComponents<Collider>())
|
||||
{
|
||||
line.Append(" | ").Append(c.GetType().Name).Append(c.isTrigger ? "(trigger)" : "");
|
||||
if (c is BoxCollider b)
|
||||
{
|
||||
line.Append(" c").Append(b.center.ToString("F3")).Append(" s").Append(b.size.ToString("F3"));
|
||||
}
|
||||
}
|
||||
foreach (Component comp in _t.GetComponents<Component>())
|
||||
{
|
||||
if (!(comp is Transform) && !(comp is MeshFilter) && !(comp is MeshRenderer) && !(comp is Collider))
|
||||
{
|
||||
line.Append(" [").Append(comp != null ? comp.GetType().Name : "null").Append("]");
|
||||
}
|
||||
}
|
||||
_sb.AppendLine(line.ToString());
|
||||
foreach (Transform ch in _t)
|
||||
{
|
||||
Dump(ch, _depth + 1, _sb);
|
||||
}
|
||||
}
|
||||
|
||||
static string Path(Transform _t, Transform _root)
|
||||
{
|
||||
string p = _t.name;
|
||||
while (_t.parent != null && _t != _root)
|
||||
{
|
||||
_t = _t.parent;
|
||||
p = _t.name + "/" + p;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// necroobj <префаб> <имя> - выгрузить модель префаба в OBJ (09.10.2026, для чертежей питомцев в книге:
|
||||
/// медведь, волк, собака, стервятник - ванильные модели). Скелетные меши «запекаются» в позе префаба (BakeMesh),
|
||||
/// у LODGroup - только LOD0. Координаты - от корня префаба, оси Unity переведены в правые (x -> -x, обход граней
|
||||
/// развёрнут). Файл - %APPDATA%\7DaysToDie\NecroMailbox\obj\<имя>.obj, группы «o» по рендерерам, «usemtl» -
|
||||
/// имя материала. Это сырьё для рендера в Blender: в мод и в репозиторий модели игры не идут (гигиена ассетов).
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroObj : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necroobj" };
|
||||
public override string getDescription() => "NecromancerTome: модель префаба в OBJ (necroobj <префаб> <имя>)";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
if (_params.Count < 2)
|
||||
{
|
||||
SdtdConsole.Instance.Output("necroobj <префаб, например @:Entities/Animals/Bear/animalBearZombiePrefab.prefab> <имя>");
|
||||
return;
|
||||
}
|
||||
GameObject prefab = DataLoader.LoadAsset<GameObject>(_params[0]);
|
||||
if (prefab == null)
|
||||
{
|
||||
SdtdConsole.Instance.Output("Префаб не загрузился: " + _params[0]);
|
||||
return;
|
||||
}
|
||||
GameObject copy = Object.Instantiate(prefab);
|
||||
copy.transform.position = Vector3.zero;
|
||||
copy.transform.rotation = Quaternion.identity;
|
||||
try
|
||||
{
|
||||
var renderers = new List<Renderer>();
|
||||
var lod = copy.GetComponentInChildren<LODGroup>(true);
|
||||
if (lod != null && lod.GetLODs().Length > 0)
|
||||
{
|
||||
renderers.AddRange(lod.GetLODs()[0].renderers);
|
||||
}
|
||||
if (renderers.Count == 0)
|
||||
{
|
||||
foreach (Renderer r in copy.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
if (r is SkinnedMeshRenderer || r is MeshRenderer)
|
||||
{
|
||||
renderers.Add(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
var sb = new StringBuilder();
|
||||
var ci = CultureInfo.InvariantCulture;
|
||||
int vBase = 1, tris = 0;
|
||||
Matrix4x4 toRoot = copy.transform.worldToLocalMatrix;
|
||||
foreach (Renderer r in renderers)
|
||||
{
|
||||
if (r == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Mesh mesh;
|
||||
Matrix4x4 m;
|
||||
if (r is SkinnedMeshRenderer smr)
|
||||
{
|
||||
mesh = new Mesh();
|
||||
smr.BakeMesh(mesh, true);
|
||||
// Запечённый меш - в пространстве рендерера без его масштаба (useScale учтён в вершинах).
|
||||
m = toRoot * Matrix4x4.TRS(smr.transform.position, smr.transform.rotation, Vector3.one);
|
||||
}
|
||||
else
|
||||
{
|
||||
MeshFilter mf = r.GetComponent<MeshFilter>();
|
||||
if (mf == null || mf.sharedMesh == null || !mf.sharedMesh.isReadable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
mesh = mf.sharedMesh;
|
||||
m = toRoot * r.transform.localToWorldMatrix;
|
||||
}
|
||||
if (!mesh.isReadable && !(r is SkinnedMeshRenderer))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3[] v = mesh.vertices;
|
||||
Vector2[] uv = mesh.uv;
|
||||
sb.Append("o ").Append(r.name).Append('\n');
|
||||
foreach (Vector3 p0 in v)
|
||||
{
|
||||
Vector3 p = m.MultiplyPoint3x4(p0);
|
||||
sb.Append("v ").Append((-p.x).ToString("F5", ci)).Append(' ').Append(p.y.ToString("F5", ci)).Append(' ')
|
||||
.Append(p.z.ToString("F5", ci)).Append('\n');
|
||||
}
|
||||
bool hasUv = uv != null && uv.Length == v.Length;
|
||||
if (hasUv)
|
||||
{
|
||||
foreach (Vector2 t in uv)
|
||||
{
|
||||
sb.Append("vt ").Append(t.x.ToString("F5", ci)).Append(' ').Append(t.y.ToString("F5", ci)).Append('\n');
|
||||
}
|
||||
}
|
||||
Material[] mats = r.sharedMaterials;
|
||||
for (int s = 0; s < mesh.subMeshCount; s++)
|
||||
{
|
||||
sb.Append("usemtl ").Append(s < mats.Length && mats[s] != null ? mats[s].name.Replace(' ', '_') : "none").Append('\n');
|
||||
int[] idx = mesh.GetTriangles(s);
|
||||
for (int i = 0; i + 2 < idx.Length; i += 3)
|
||||
{
|
||||
// обход развёрнут из-за смены руки осей
|
||||
int a = idx[i] + vBase, b = idx[i + 2] + vBase, c = idx[i + 1] + vBase;
|
||||
if (hasUv)
|
||||
{
|
||||
sb.Append("f ").Append(a).Append('/').Append(a).Append(' ').Append(b).Append('/').Append(b).Append(' ')
|
||||
.Append(c).Append('/').Append(c).Append('\n');
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("f ").Append(a).Append(' ').Append(b).Append(' ').Append(c).Append('\n');
|
||||
}
|
||||
tris++;
|
||||
}
|
||||
}
|
||||
vBase += v.Length;
|
||||
}
|
||||
string dir = Path.Combine(GameIO.GetUserGameDataDir(), "NecroMailbox", "obj");
|
||||
Directory.CreateDirectory(dir);
|
||||
string path = Path.Combine(dir, _params[1] + ".obj");
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
string msg = "necroobj: " + _params[1] + " - рендереров " + renderers.Count + ", вершин " + (vBase - 1) + ", треуг. " + tris + " -> " + path;
|
||||
Debug.Log("[NecromancerTome] " + msg);
|
||||
SdtdConsole.Instance.Output(msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.Destroy(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Метка на карте для того, кем сейчас управляет игрок: дух змеи и одержимый (зомби или питомец).
|
||||
/// Указание 06.10: «Змея всё ещё не появляется на карте. Подозреваю, что питомцы в режиме одержимости тоже».
|
||||
///
|
||||
/// ПОЧЕМУ НЕ Entity.AddNavObject (так было у змеи с 02.10). У сущности одна общая NavObject на все её
|
||||
/// классы меток; у змеи от ванильного animalSnake там уже лежат twitch_spawn, animaltracking_snake,
|
||||
/// clear_sleeper. AddNavObject только дописывает класс в этот список, а какой из них показывать,
|
||||
/// игра решает сама (NavObject.HandleActiveNavClass), и карта (XUiC_MapArea.updateNavObjectList)
|
||||
/// заводит спрайт метки один раз по её ключу. Метка змеи так и не появилась ни разу.
|
||||
///
|
||||
/// ЗДЕСЬ - как у транспорта (EntityVehicle.HandleNavObject: RegisterNavObject(класс,
|
||||
/// vehicle.GetMeshTransform()), разбор 06.10): своя, отдельная NavObject, привязанная к Transform (перегрузка
|
||||
/// NavObjectManager.RegisterNavObject(className, Transform)): своя запись, свой ключ, один класс.
|
||||
/// Позиция - transform.position (NavObject.GetPosition для TrackTypes.Transform), т.е. сама модель.
|
||||
/// Снимается UnRegisterNavObject; если сущность исчезла, игра снимет метку сама (IsValid).
|
||||
///
|
||||
/// ЧУЖУЮ МЕТКУ НЕ ТРОГАТЬ (проверка на утечки 06.10). Снятые метки игра кладёт в пул
|
||||
/// (removedNavObjectPool) и раздаёт заново. Если модель исчезла раньше нашего Hide, игра сама сняла
|
||||
/// метку, и пул мог уже отдать этот же объект другому (метка чутья, транспорт). Поэтому снимаем, только
|
||||
/// если объект ещё в NavObjectList и следит за НАШЕЙ моделью; иначе просто забываем ссылку.
|
||||
/// Классы (nav_objects.xml), только карта: necroSnakeSpirit - фиолетовая змея, necroPossessed - зелёный
|
||||
/// значок одержимого. Значок задан в классе: OverrideSpriteName игра берёт, только если у класса
|
||||
/// use_override_icon (NavObject.GetSpriteName) - без него у одержимого была змея (тест 08.10).
|
||||
/// hiddenOnCompass не ставим: карта красит такие метки серым (XUiC_MapArea.updateNavObjects), а на
|
||||
/// компас эти классы и так не попадают - compass_settings у них нет.
|
||||
/// </summary>
|
||||
public static class NecroMapMarker
|
||||
{
|
||||
public const string SnakeClass = "necroSnakeSpirit";
|
||||
public const string PossessedClass = "necroPossessed";
|
||||
|
||||
/// <summary>Метка и модель, за которой она следит.</summary>
|
||||
public class Marker
|
||||
{
|
||||
public NavObject Nav;
|
||||
public Transform Tracked;
|
||||
}
|
||||
|
||||
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);
|
||||
Debug.Log("[NecromancerTome] NecroMapMarker: метка на карте для " + _e.EntityName + " (" + _e.entityId + ") - " + nav);
|
||||
return new Marker { Nav = nav, Tracked = _e.transform };
|
||||
}
|
||||
|
||||
public static void Hide(ref Marker _m)
|
||||
{
|
||||
NavObjectManager manager = NavObjectManager.Instance;
|
||||
if (_m != null && _m.Nav != null && manager != null && _m.Tracked != null &&
|
||||
manager.NavObjectList.Contains(_m.Nav) && _m.Nav.IsTrackedTransform(_m.Tracked))
|
||||
{
|
||||
manager.UnRegisterNavObject(_m.Nav);
|
||||
}
|
||||
_m = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Череп (останки) из красного трупа - сырьё для цинерария (дух-работник).
|
||||
///
|
||||
/// С 09.10 - находка при обычном обыске (указание: «браслет больше не собирает красные трупы,
|
||||
/// выпиливаем; останки дропаются с шансом 2% при обычном лутании красного трупа»). Раньше (06.10) череп
|
||||
/// вырывали пространственным браслетом: зажатая атака по трупу, три секунды канала, при неудаче труп
|
||||
/// уходил в чёрный разрыв. Сняли из-за подозрения на утечку памяти на этом пути (тест 09.10: после
|
||||
/// разрывов в памяти копились экземпляры сущностей - 31 при 7 живых).
|
||||
///
|
||||
/// Бросок - один раз на труп, при первом открытии его сумки, на сервере, сразу после того, как игра
|
||||
/// наполнила её добычей (Patch_EntityAlive_OnLockRequestServer_Corpse). Шанс прежний - 2%
|
||||
/// («так ресурс станет ценнее»).
|
||||
/// </summary>
|
||||
public static class NecroSkull
|
||||
{
|
||||
public const string ItemName = "necroSkull";
|
||||
public const float Chance = 0.02f;
|
||||
|
||||
/// <summary>Бросок на череп в только что наполненную сумку трупа. Места нет - сумка на ячейку больше.</summary>
|
||||
public static void Roll(EntityAlive _corpse, Bag _bag)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ItemStack stack = new ItemStack(skull, 1);
|
||||
if (!_bag.AddItem(stack.Clone()))
|
||||
{
|
||||
_bag.Resize(new Vector2i(_bag.SlotCount + 1, 1));
|
||||
_bag.AddItem(stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
using UnityEngine.Video;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
@@ -24,19 +25,114 @@ namespace NecromancerTome
|
||||
public string TitleKey;
|
||||
public string[] PageKeys;
|
||||
public Func<EntityPlayerLocal, bool> Unlocked;
|
||||
/// <summary>Иллюстрация справа от текста на всех страницах главы: картинка (путь вида
|
||||
/// "@modfolder(NecromancerTome):Textures/x.png") или зацикленное видео без звука. Ни того,
|
||||
/// ни другого - текст на всю ширину.</summary>
|
||||
public string Image, Video;
|
||||
/// <summary>Картинка полосой сверху во всю ширину, текст под ней (главы питомцев, указание 09.10:
|
||||
/// «под картинкой - управление схематично, под ним - описание питомца»).</summary>
|
||||
public bool ImageTop;
|
||||
}
|
||||
|
||||
/// <summary>cvar «письмо архимага прочитано» - ставит записка noteDuke01 (Config/items.xml).</summary>
|
||||
public const string LetterReadCVar = "necroTomeLetterRead";
|
||||
|
||||
public static readonly List<Chapter> Chapters = new List<Chapter>
|
||||
{
|
||||
// Глава 1 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
|
||||
// Глава 1 (указание 06.10): письмо архимага - та самая стартовая записка, текст берётся из её
|
||||
// же ключа. Открывается ТОЛЬКО когда записку прочли («появляется в книге, когда прочитано
|
||||
// письмо», 06.10): cvar ставит сама записка по «ОК» (Config/items.xml). Запасного условия по
|
||||
// уровню нет - в старых сейвах, где записку прочли до 1.6.0, главы не будет.
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeLetterTitle",
|
||||
PageKeys = new[] { "noteDuke01Desc" },
|
||||
Video = Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback.NoteFlashbackVideoPath,
|
||||
Unlocked = _p => _p.Buffs.GetCustomVar(LetterReadCVar) > 0f
|
||||
},
|
||||
// Глава 2 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeCh1Title",
|
||||
PageKeys = new[] { "necroTomeCh1P1", "necroTomeCh1P2", "necroTomeCh1P3", "necroTomeCh1P4" },
|
||||
Unlocked = _p => true
|
||||
},
|
||||
// Нож некроманта (указание 09.10): лист-чертёж ножа (тот же рецепт, что у крысы), управление ножом и духом
|
||||
// змеи, описание. Перед питомцами - «основной инструмент», с него всё начинается.
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeKnifeTitle",
|
||||
PageKeys = new[] { "necroTomeKnifeP1", "necroTomeKnifeP2", "necroTomeKnifeP3" },
|
||||
Image = "@modfolder(NecromancerTome):Textures/necroTomeKnife.png",
|
||||
ImageTop = true,
|
||||
Unlocked = _p => true
|
||||
},
|
||||
// Главы питомцев (указание 09.10): лист-чертёж модели (три вида из Blender, подписи на эсперанто) сверху, на первой
|
||||
// странице - управление, дальше - описание. Крыса - учебная модель, глава открыта сразу; прочие - с рецептом гримуара.
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeRatTitle",
|
||||
PageKeys = new[] { "necroTomeRatP1", "necroTomeRatP2", "necroTomeRatP3" },
|
||||
Image = "@modfolder(NecromancerTome):Textures/necroTomeRat.png",
|
||||
ImageTop = true,
|
||||
Unlocked = _p => true
|
||||
},
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeDogTitle",
|
||||
PageKeys = new[] { "necroTomeDogP1", "necroTomeDogP2", "necroTomeDogP3" },
|
||||
Image = "@modfolder(NecromancerTome):Textures/necroTomeDog.png",
|
||||
ImageTop = true,
|
||||
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieDog")
|
||||
},
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeVultureTitle",
|
||||
PageKeys = new[] { "necroTomeVultureP1", "necroTomeVultureP2", "necroTomeVultureP3" },
|
||||
Image = "@modfolder(NecromancerTome):Textures/necroTomeVulture.png",
|
||||
ImageTop = true,
|
||||
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieGriffin")
|
||||
},
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeWolfTitle",
|
||||
PageKeys = new[] { "necroTomeWolfP1", "necroTomeWolfP2", "necroTomeWolfP3" },
|
||||
Image = "@modfolder(NecromancerTome):Textures/necroTomeWolf.png",
|
||||
ImageTop = true,
|
||||
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieWolf")
|
||||
},
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeBearTitle",
|
||||
PageKeys = new[] { "necroTomeBearP1", "necroTomeBearP2", "necroTomeBearP3" },
|
||||
Image = "@modfolder(NecromancerTome):Textures/necroTomeBear.png",
|
||||
ImageTop = true,
|
||||
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieBear")
|
||||
},
|
||||
// Кровавый прилив (указание 10.10): предупреждение - в ночь кровавой луны дух не выходит из тела
|
||||
// (SnakeMode.IsBloodTide). Лист-схема: луна, горы, некропыль из земли; подписи на эсперанто.
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeTideTitle",
|
||||
PageKeys = new[] { "necroTomeTideP1", "necroTomeTideP2", "necroTomeTideP3" },
|
||||
Image = "@modfolder(NecromancerTome):Textures/necroTomeTide.png",
|
||||
ImageTop = true,
|
||||
Unlocked = _p => GameUtils.WorldTimeToDays(_p.world.worldTime) >= TideChapterDay
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>С какого дня видна глава о кровавом приливе. По замыслу - 5 (тогда же надпись на доске
|
||||
/// чертогов, MindPalace.BoardLines); на время теста было 1, возвращено к релизу 1.6.0 (10.10).</summary>
|
||||
public const int TideChapterDay = 5;
|
||||
|
||||
/// <summary>Глава питомца видна, когда игроку открыт рецепт его гримуара (указание 09.10, после проверки глав).
|
||||
/// Крыса - учебная модель, её глава открыта сразу.</summary>
|
||||
static bool RecipeUnlocked(EntityPlayerLocal _p, string _item)
|
||||
{
|
||||
Recipe r = CraftingManager.GetRecipe(_item);
|
||||
return r != null && r.IsUnlocked(_p);
|
||||
}
|
||||
|
||||
public static void Open(EntityPlayerLocal _player)
|
||||
{
|
||||
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
|
||||
@@ -47,14 +143,31 @@ namespace NecromancerTome
|
||||
[Preserve]
|
||||
public class XUiC_NecroTome : XUiController
|
||||
{
|
||||
const int MaxButtons = 8;
|
||||
/// <summary>Кнопки глав в оглавлении (btnCh1..20 в windows.xml). Глав будет много (указание 10.10): до Rows - один
|
||||
/// столбец по центру, больше - два столбца по Rows («я бы их сделал в две колонки»), ещё больше - страницы,
|
||||
/// листаются теми же «Назад/Далее», что и главы.</summary>
|
||||
const int MaxButtons = 20, Rows = 10;
|
||||
/// <summary>Места кнопок (windows.xml: ширина 560, шаг строк 60 от y -130): столбец по центру окна 1300 и два столбца.</summary>
|
||||
const int ColCenterX = 370, ColLeftX = 60, ColRightX = 680, RowTopY = -130, RowStep = 60;
|
||||
|
||||
XUiV_Label _title, _text, _page;
|
||||
XUiV_Texture _img;
|
||||
XUiV_Video _vid;
|
||||
/// <summary>Ширина текста без иллюстрации и с ней (windows.xml: lblText 1180, картинка с x 680).</summary>
|
||||
const int TextWidthFull = 1180, TextWidthHalf = 590;
|
||||
/// <summary>Раскладка «картинка сверху»: лист-чертёж 1180x400 (как Textures/necroTomeRat.png), текст ниже.</summary>
|
||||
static readonly Vector2i ImgSidePos = new Vector2i(680, -104), ImgSideSize = new Vector2i(560, 640);
|
||||
static readonly Vector2i ImgTopPos = new Vector2i(60, -104), ImgTopSize = new Vector2i(1180, 400);
|
||||
static readonly Vector2i TextPos = new Vector2i(60, -104), TextTopPos = new Vector2i(60, -518);
|
||||
const int TextHeightFull = 640, TextHeightTop = 245;
|
||||
XUiC_SimpleButton _toc, _prev, _next, _close;
|
||||
readonly XUiC_SimpleButton[] _chapterButtons = new XUiC_SimpleButton[MaxButtons];
|
||||
/// <summary>Глава по кнопке оглавления (только открытые главы, по порядку).</summary>
|
||||
readonly int[] _buttonChapter = new int[MaxButtons];
|
||||
int _chapter = -1, _pageIndex;
|
||||
/// <summary>Страница оглавления; «Оглавление» из главы возвращает на неё.</summary>
|
||||
int _tocPage;
|
||||
readonly List<int> _open = new List<int>();
|
||||
|
||||
public override void Init()
|
||||
{
|
||||
@@ -62,11 +175,13 @@ namespace NecromancerTome
|
||||
_title = GetChildById("lblTitle")?.ViewComponent as XUiV_Label;
|
||||
_text = GetChildById("lblText")?.ViewComponent as XUiV_Label;
|
||||
_page = GetChildById("lblPage")?.ViewComponent as XUiV_Label;
|
||||
_img = GetChildById("imgPage")?.ViewComponent as XUiV_Texture;
|
||||
_vid = GetChildById("vidPage")?.ViewComponent as XUiV_Video;
|
||||
_toc = GetChildById("btnToc") as XUiC_SimpleButton;
|
||||
_prev = GetChildById("btnPrev") as XUiC_SimpleButton;
|
||||
_next = GetChildById("btnNext") as XUiC_SimpleButton;
|
||||
_close = GetChildById("btnClose") as XUiC_SimpleButton;
|
||||
if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc();
|
||||
if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc(_tocPage);
|
||||
if (_prev != null) _prev.OnPressed += (_s, _m) => Turn(-1);
|
||||
if (_next != null) _next.OnPressed += (_s, _m) => Turn(1);
|
||||
if (_close != null) _close.OnPressed += (_s, _m) => xui.playerUI.windowManager.Close(NecroTomeBook.WindowGroup);
|
||||
@@ -84,27 +199,41 @@ namespace NecromancerTome
|
||||
public override void OnOpen()
|
||||
{
|
||||
base.OnOpen();
|
||||
ShowToc();
|
||||
ShowToc(0);
|
||||
}
|
||||
|
||||
void ShowToc()
|
||||
void ShowToc(int _p)
|
||||
{
|
||||
_chapter = -1;
|
||||
SetLabel(_title, Localization.Get("necroTomeToc"));
|
||||
SetLabel(_text, "");
|
||||
SetLabel(_page, "");
|
||||
SetMedia(null, null);
|
||||
EntityPlayerLocal player = xui.playerUI.entityPlayer;
|
||||
int b = 0;
|
||||
for (int c = 0; c < NecroTomeBook.Chapters.Count && b < MaxButtons; c++)
|
||||
// Открытые главы по порядку; на странице - MaxButtons из них.
|
||||
_open.Clear();
|
||||
for (int c = 0; c < NecroTomeBook.Chapters.Count; c++)
|
||||
{
|
||||
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
|
||||
if (ch.Unlocked != null && !ch.Unlocked(player))
|
||||
if (ch.Unlocked == null || ch.Unlocked(player))
|
||||
{
|
||||
continue;
|
||||
_open.Add(c);
|
||||
}
|
||||
}
|
||||
bool twoCols = _open.Count > Rows;
|
||||
int perPage = twoCols ? MaxButtons : Rows;
|
||||
int pages = Mathf.Max(1, (_open.Count + perPage - 1) / perPage);
|
||||
_tocPage = Mathf.Clamp(_p, 0, pages - 1);
|
||||
int b = 0;
|
||||
for (int i = _tocPage * perPage; i < _open.Count && b < perPage; i++)
|
||||
{
|
||||
int c = _open[i];
|
||||
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
|
||||
_buttonChapter[b] = c;
|
||||
if (_chapterButtons[b] != null)
|
||||
{
|
||||
// Сначала левый столбец сверху вниз, потом правый.
|
||||
int x = !twoCols ? ColCenterX : b < Rows ? ColLeftX : ColRightX;
|
||||
_chapterButtons[b].ViewComponent.Position = new Vector2i(x, RowTopY - RowStep * (b % Rows));
|
||||
_chapterButtons[b].Text = Localization.Get(ch.TitleKey);
|
||||
_chapterButtons[b].ViewComponent.IsVisible = true;
|
||||
}
|
||||
@@ -118,8 +247,13 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
SetVisible(_toc, false);
|
||||
SetVisible(_prev, false);
|
||||
SetVisible(_next, false);
|
||||
// Листать оглавление - только если страниц больше одной.
|
||||
bool many = pages > 1;
|
||||
SetLabel(_page, many ? (_tocPage + 1) + " / " + pages : "");
|
||||
SetVisible(_prev, many);
|
||||
SetVisible(_next, many);
|
||||
if (_prev != null) _prev.Enabled = _tocPage > 0;
|
||||
if (_next != null) _next.Enabled = _tocPage < pages - 1;
|
||||
}
|
||||
|
||||
void ShowChapter(int _c, int _p)
|
||||
@@ -138,6 +272,7 @@ namespace NecromancerTome
|
||||
SetLabel(_title, Localization.Get(ch.TitleKey));
|
||||
SetLabel(_text, Localization.Get(ch.PageKeys[_pageIndex]));
|
||||
SetLabel(_page, (_pageIndex + 1) + " / " + ch.PageKeys.Length);
|
||||
SetMedia(ch.Image, ch.Video, ch.ImageTop);
|
||||
SetVisible(_toc, true);
|
||||
SetVisible(_prev, true);
|
||||
SetVisible(_next, true);
|
||||
@@ -145,12 +280,99 @@ namespace NecromancerTome
|
||||
if (_next != null) _next.Enabled = _pageIndex < ch.PageKeys.Length - 1;
|
||||
}
|
||||
|
||||
public override void OnClose()
|
||||
{
|
||||
SetMedia(null, null);
|
||||
base.OnClose();
|
||||
}
|
||||
|
||||
/// <summary>Показать картинку или видео справа (или убрать обе). Видео задаётся только здесь,
|
||||
/// а не в вёрстке: XUiV_Video сам запускает ролик при открытии окна, и в оглавлении он играл
|
||||
/// бы невидимым. Пустой uri - XUiV_Video.startVideo сам освобождает свою текстуру.</summary>
|
||||
/// <summary>Путь загруженной картинки (null - не загружена). Свой, а не _img.Path - см. ниже.</summary>
|
||||
string _imgPath;
|
||||
|
||||
void ReleaseImage()
|
||||
{
|
||||
if (_img == null || _imgPath == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Texture t = _img.Texture;
|
||||
_img.Path = null; // TextureUris = пусто, Texture = null - без выгрузки (autounload выключен)
|
||||
if (t != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(t);
|
||||
}
|
||||
_imgPath = null;
|
||||
}
|
||||
|
||||
void SetMedia(string _image, string _video, bool _top = false)
|
||||
{
|
||||
bool img = !string.IsNullOrEmpty(_image), vid = !img && !string.IsNullOrEmpty(_video);
|
||||
if (_img != null)
|
||||
{
|
||||
// Без чтения _img.Path: у пустой картинки (в вёрстке texture не задан) его геттер берёт
|
||||
// textureUris[0] из пустого массива - IndexOutOfRange (тест 09.10). Сеттер сам сравнивает
|
||||
// с текущим путём и не перезагружает тот же файл.
|
||||
// Утечка (проверка 10.10): картинка из файла мода - новая текстура (XUiV_Texture.FetchWwwTexture, клон),
|
||||
// и при смене пути старая сама не удаляется (autounload в вёрстке не включён - в этом режиме игра на
|
||||
// загрузке стирает путь новой картинки). Каждая смена главы оставляла ~2 МБ. Удаляем сами: при смене
|
||||
// картинки, у главы без картинки и при закрытии окна (SetMedia(null, null)).
|
||||
if (_imgPath != _image)
|
||||
{
|
||||
ReleaseImage();
|
||||
}
|
||||
if (img && _imgPath == null)
|
||||
{
|
||||
_img.Path = _image;
|
||||
_imgPath = _image;
|
||||
}
|
||||
_img.IsVisible = img;
|
||||
}
|
||||
if (_vid != null)
|
||||
{
|
||||
string uri = vid ? _video : "";
|
||||
if (_vid.VideoUri != uri)
|
||||
{
|
||||
_vid.VideoUri = uri;
|
||||
}
|
||||
if (_vid.videoPlayer != null)
|
||||
{
|
||||
_vid.videoPlayer.audioOutputMode = VideoAudioOutputMode.None;
|
||||
// Проверка на утечки 06.10: XUiV_Video гасит ролик при смене uri, только если тот уже
|
||||
// играет; ушли в оглавление во время подготовки - догрузился бы и играл невидимым до
|
||||
// закрытия окна. Останавливаем сами (Stop освобождает декодер).
|
||||
if (!vid)
|
||||
{
|
||||
_vid.videoPlayer.Stop();
|
||||
}
|
||||
}
|
||||
_vid.IsVisible = vid;
|
||||
}
|
||||
if (_img != null)
|
||||
{
|
||||
_img.Position = _top ? ImgTopPos : ImgSidePos;
|
||||
_img.Size = _top ? ImgTopSize : ImgSideSize;
|
||||
}
|
||||
if (_text != null)
|
||||
{
|
||||
bool top = _top && img;
|
||||
_text.Position = top ? TextTopPos : TextPos;
|
||||
_text.Size = new Vector2i(top || !(img || vid) ? TextWidthFull : TextWidthHalf, top ? TextHeightTop : TextHeightFull);
|
||||
}
|
||||
}
|
||||
|
||||
void Turn(int _d)
|
||||
{
|
||||
if (_chapter >= 0)
|
||||
{
|
||||
ShowChapter(_chapter, _pageIndex + _d);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowToc(_tocPage + _d);
|
||||
}
|
||||
}
|
||||
|
||||
static void SetLabel(XUiV_Label _l, string _s)
|
||||
|
||||
@@ -46,9 +46,20 @@
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.PhysicsModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- AudioSource/AudioClip.Create - сердцебиение кровавого прилива (BloodTide.cs, 10.10). -->
|
||||
<Reference Include="UnityEngine.AudioModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AudioModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- EntityAlive.PlayOneShot(string)'s own overload set touches AnimationEvent (2026-08-29,
|
||||
BansheePatch.cs) - needed even though this mod never uses AnimationEvent directly, just
|
||||
to satisfy the compiler's reference-resolution for that overload. -->
|
||||
<!-- NecroTome.cs (2026-10-06): видео-иллюстрация в книге играет без звука -
|
||||
VideoPlayer.audioOutputMode живёт в VideoModule. -->
|
||||
<Reference Include="UnityEngine.VideoModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.VideoModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.AnimationModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -519,6 +519,13 @@ namespace NecromancerTome
|
||||
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
|
||||
}
|
||||
|
||||
// Одержимый питомец (указание 06.10) ведётся игроком: без охраны, поводка, «дома» и
|
||||
// телепорта - удаляться можно на любое расстояние. После выхода всё это вернёт его к хозяину.
|
||||
if (Possession.IsPossessed(pet))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Охрана и поводок 40 м - до блока про «дом» летающих: тот в этом же тике должен
|
||||
// увидеть уже назначенную или снятую цель. Рой (не спутник) сюда не попадает.
|
||||
if (tracked.Guards || tracked.OwnFollowTask || tracked.FlyingHomeRadius > 0)
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// УБИЙСТВА ПИТОМЦЕМ ТЕПЕРЬ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ - 2026-09-18.
|
||||
/// Одержимый питомец (06.10) - тоже питомец: его убийства идут хозяину этим же префиксом.
|
||||
/// А его СОБСТВЕННАЯ гибель не засчитывается никому (06.10, первая проверка префикса).
|
||||
///
|
||||
/// ПОЧЕМУ РАНЬШЕ НЕ ЗАСЧИТЫВАЛИСЬ. Ваниль решает, чей это фраг, ровно в одном месте:
|
||||
///
|
||||
@@ -53,6 +55,14 @@ namespace NecromancerTome
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance, DamageSource _source, ref EntityAlive _killingEntity)
|
||||
{
|
||||
// Гибель СВОЕГО существа (питомцы, рой, дух змеи - все классы necro*) игроку в плюс не идёт
|
||||
// никогда (указание 06.10): ни опыт, ни некромантия - даже если добил его сам игрок. Оригинал
|
||||
// пропускаем целиком, он раздал бы опыт убийце.
|
||||
if (__instance != null && SnakeControl.IsNecroEntity(__instance))
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PetKillCredit: гибель своего существа " + __instance.EntityName + " (" + __instance.entityId + ") - не засчитывается");
|
||||
return false;
|
||||
}
|
||||
if (_source == null)
|
||||
{
|
||||
return true;
|
||||
|
||||
@@ -319,6 +319,7 @@ namespace NecromancerTome
|
||||
// светящееся фиолетовое кольцо по земле - LineRenderer, точки опущены на рельеф лучом.
|
||||
// Дочерний объект модели блока - поэтому сдвиги Origin и выгрузка чанка его не теряют.
|
||||
GameObject ring;
|
||||
static Material _ringMat;
|
||||
|
||||
public void SpawnZoneRing()
|
||||
{
|
||||
@@ -332,8 +333,13 @@ namespace NecromancerTome
|
||||
const int n = 96;
|
||||
lr.positionCount = n;
|
||||
lr.widthMultiplier = 0.14f;
|
||||
var mat = new Material(Shader.Find("Sprites/Default"));
|
||||
lr.material = mat;
|
||||
// Один материал на все кольца (проверка утечек 10.10): материал - ассет и с объектом кольца не удаляется,
|
||||
// а кольцо пересоздаётся при каждой перезагрузке чанка (модель блока - новая). Цвет - вершинный (startColor).
|
||||
if (_ringMat == null)
|
||||
{
|
||||
_ringMat = new Material(Shader.Find("Sprites/Default")) { name = "NecroWardRing" };
|
||||
}
|
||||
lr.sharedMaterial = _ringMat;
|
||||
Color col = new Color(0.75f, 0.25f, 1f, 0.85f);
|
||||
lr.startColor = col; lr.endColor = col;
|
||||
lr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; lr.receiveShadows = false;
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Обыск одержимой крысой (указание 08.10). Крыса - разведчик: «она может осмотреть ящики, и если там
|
||||
/// что-то интересное, персонаж придёт и сам залутает». Поэтому - только смотреть.
|
||||
///
|
||||
/// Окно ванильное, ровно как у персонажа (таймер первого обыска, сетка, звуки): контейнер открывается
|
||||
/// той же цепочкой LockManager.LockRequestLocal -> OnLockResponseLocal -> XUiC_LootWindowGroup.OpenLooting.
|
||||
/// Отличия на время такого просмотра (Viewing):
|
||||
/// - рюкзак персонажа не показывается («сумку персонажа вообще не показывать») - openContainer
|
||||
/// открывает его сам, закрываем сразу же;
|
||||
/// - ячейки контейнера заперты (XUiC_ItemStack.IsLocked), «Взять всё» (R) и сортировка не работают;
|
||||
/// попытка взять - отказ, как при переполненном рюкзаке (указание: «делал бы вид, что сумки
|
||||
/// переполнены»): звук ui_denied и xuiInventoryFullForPickup;
|
||||
/// - окно не закрывается из-за дальности до ТЕЛА: XUiC_LootWindow.Update меряет её от персонажа.
|
||||
/// Подсказку «E: обыскать» рисует управление игрока, а оно в одержимости выключено - выводим сами.
|
||||
///
|
||||
/// МЕШКИ (указание 09.10: «лутать жёлтые мешочки, и синие, и красные, падающие с зомби»). Мешок - сущность
|
||||
/// EntityLootContainer, открывается той же цепочкой замка (LockRequestLocal -> Entity.OnLockResponseLocal), но
|
||||
/// окно другое - XUiC_BagStorageWindowGroup; для него те же отличия: рюкзак закрыт, ячейки заперты, дальность
|
||||
/// от крысы. Рюкзак погибшего игрока (EntityBackpack) - не мешок, его не трогаем.
|
||||
/// </summary>
|
||||
public static class RatLoot
|
||||
{
|
||||
/// <summary>Открыто (или открывается) окно просмотра крысой.</summary>
|
||||
public static bool Viewing;
|
||||
/// <summary>Окно открыл волк: щелчок и «Взять всё» кладут в его сумку (WolfBag), а не отказ.</summary>
|
||||
public static bool Taking;
|
||||
/// <summary>Контейнер, который осматривает крыса, и до какого времени после закрытия его не разрушать.</summary>
|
||||
static Vector3i _sparePos;
|
||||
static float _spareUntil;
|
||||
/// <summary>Досягаемость контейнера от крысы, м.</summary>
|
||||
const float Reach = 2.5f;
|
||||
/// <summary>Мешок - не дальше стольких градусов от луча камеры (мешок мал, точно навести трудно).</summary>
|
||||
const float BagAimDeg = 25f;
|
||||
/// <summary>Открытый на просмотр мешок - для щелчков по ячейкам и «Взять всё».</summary>
|
||||
static EntityLootContainer _viewBag;
|
||||
|
||||
/// <summary>Кто осматривает - для лога (у волка тот же обыск).</summary>
|
||||
static string Who => Possession.Zombie != null && WolfControl.Is(Possession.Zombie) ? "волк" : "крыса";
|
||||
|
||||
/// <summary>Мешок перед крысой: ближе Reach к ней и ближе всех к лучу камеры, не дальше BagAimDeg.</summary>
|
||||
public static EntityLootContainer FindBag(World _world, EntityPlayerLocal _player, EntityAlive _rat)
|
||||
{
|
||||
Transform cam = _player.cameraTransform;
|
||||
if (cam == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Vector3 from = cam.position + Origin.position;
|
||||
EntityLootContainer best = null;
|
||||
float bestDeg = BagAimDeg;
|
||||
for (int i = 0; i < _world.Entities.list.Count; i++)
|
||||
{
|
||||
if (!(_world.Entities.list[i] is EntityLootContainer bag) || bag.IsMarkedForUnload() || bag.bag == null ||
|
||||
(bag.position - _rat.position).sqrMagnitude > Reach * Reach)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float deg = Vector3.Angle(cam.forward, bag.position + Vector3.up * 0.2f - from);
|
||||
if (deg < bestDeg)
|
||||
{
|
||||
bestDeg = deg;
|
||||
best = bag;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Контейнер перед крысой по лучу камеры: блок с хранилищем ближе Reach к крысе; иначе null.</summary>
|
||||
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<TEFeatureStorage>() : null;
|
||||
if (st == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
_pos = pos;
|
||||
_bv = bv;
|
||||
return st;
|
||||
}
|
||||
|
||||
/// <summary>Каждый кадр одержимости крысой: подсказка и E - открыть. true - нажатие E ушло на контейнер или
|
||||
/// мешок (волку тогда не открывать свою сумку).</summary>
|
||||
public static bool Tick(World _world, EntityPlayerLocal _player, EntityAlive _rat, bool _activate, bool _take = false)
|
||||
{
|
||||
LocalPlayerUI ui = _player.PlayerUI;
|
||||
if (ui == null || Viewing || ui.windowManager.IsModalWindowOpen())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
PlayerActionsLocal input = _player.playerInput;
|
||||
string key = input.Activate.GetBindingXuiMarkupString() + input.PermanentActions.Activate.GetBindingXuiMarkupString();
|
||||
TEFeatureStorage st = Find(_world, _player, _rat, out Vector3i pos, out BlockValue bv);
|
||||
if (st == null)
|
||||
{
|
||||
EntityLootContainer bag = FindBag(_world, _player, _rat);
|
||||
if (bag == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
XUiC_InteractionPrompt.SetText(ui, string.Format(Localization.Get("necroPossessSearchBag"), key, bag.LocalizedEntityName));
|
||||
if (!_activate)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Viewing = true;
|
||||
Taking = _take;
|
||||
_viewBag = bag;
|
||||
XUiC_InteractionPrompt.SetText(ui, null);
|
||||
_lockRequest = _lockRequest ?? AccessTools.Method(typeof(LockManager), nameof(LockManager.LockRequestLocal), new[] { typeof(ILockTarget), typeof(ushort) });
|
||||
_lockRequest.Invoke(LockManager.Instance, new object[] { bag, (ushort)0 });
|
||||
Debug.Log("[NecromancerTome] RatLoot: " + Who + " осматривает мешок " + EntityClass.GetEntityClassName(bag.entityClass) + " (" + bag.entityId + ")");
|
||||
return true;
|
||||
}
|
||||
string text = st.GetActivationText(_world, pos, bv, _rat, key, bv.Block.GetLocalizedBlockName());
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
XUiC_InteractionPrompt.SetText(ui, text);
|
||||
}
|
||||
if (!_activate)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
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 true;
|
||||
}
|
||||
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: " + Who + " осматривает " + bv.Block.GetBlockName() + " " + pos);
|
||||
return true;
|
||||
}
|
||||
|
||||
static System.Reflection.MethodInfo _lockRequest;
|
||||
|
||||
/// <summary>Отказ «рюкзак полон».</summary>
|
||||
public static void Deny()
|
||||
{
|
||||
LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
|
||||
if (ui?.entityPlayer != null)
|
||||
{
|
||||
GameManager.ShowTooltip(ui.entityPlayer, Localization.Get("xuiInventoryFullForPickup"), string.Empty, "ui_denied");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Выход из одержимости при открытом окне - закрыть его.</summary>
|
||||
public static void CloseIfViewing()
|
||||
{
|
||||
if (!Viewing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
|
||||
ui?.windowManager.CloseAllOpenModalWindows();
|
||||
Viewing = false;
|
||||
_viewBag = null;
|
||||
_spareUntil = Time.time + 5f;
|
||||
}
|
||||
|
||||
static void LockSlots(XUiC_LootWindow _w, bool _lock)
|
||||
{
|
||||
LockSlots(_w?.lootContainer, _lock);
|
||||
}
|
||||
|
||||
static void LockSlots(XUiC_ItemStackGrid grid, bool _lock)
|
||||
{
|
||||
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;
|
||||
}
|
||||
if (!Taking)
|
||||
{
|
||||
__instance.xui.playerUI.windowManager.Close("backpack"); // у волка рюкзак показывает его сумку (WolfBagBackpack)
|
||||
}
|
||||
LockSlots(__instance.lootWindow, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Контейнеры с destroy_on_close (loot.xml: «empty» - 94 вида, мусор, кучи, холодильники, рюкзаки...;
|
||||
/// «true» - аэродроп и коробки Twitch, это сущности, крыса их не открывает) разрушаются при снятии замка
|
||||
/// после закрытия окна. Осмотр крысой их не разрушает (указание 08.10).</summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Таймер первого обыска прерван - просмотра нет.</summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Отказ замка (контейнер занят другим) - просмотра нет.</summary>
|
||||
[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; // снятие замка (и проверка разрушения) - следом, может прийти пакетом
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Дальность окна - от крысы, не от тела: XUiC_LootWindow.Update меряет её от entityPlayer.position,
|
||||
/// поэтому на время его Update персонаж «стоит» на месте крысы (поле, ничего больше не трогается), а
|
||||
/// Finalizer возвращает позицию при любом исходе. (Тест 08.10: прежний вариант обнулял te - и закрытие окна
|
||||
/// по E/ESC падало в XUiC_LootWindow.OnClose, окно застревало и после выхода из одержимости.)</summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Окно мешка открылось на просмотр - рюкзак закрыть, ячейки запереть (как у ящика).</summary>
|
||||
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnOpen))]
|
||||
public static class Patch_Bag_OnOpen
|
||||
{
|
||||
public static void Postfix(XUiC_BagStorageWindowGroup __instance)
|
||||
{
|
||||
if (!Viewing || _viewBag == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!Taking)
|
||||
{
|
||||
__instance.xui.playerUI.windowManager.Close("backpack");
|
||||
}
|
||||
LockSlots(__instance.containerWindow, true);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnClose))]
|
||||
public static class Patch_Bag_OnClose
|
||||
{
|
||||
public static void Prefix(XUiC_BagStorageWindowGroup __instance)
|
||||
{
|
||||
LockSlots(__instance.containerWindow, false); // всегда: запертые ячейки не должны достаться персонажу
|
||||
if (Viewing && _viewBag != null)
|
||||
{
|
||||
Viewing = false;
|
||||
_viewBag = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Таймер обыска мешка прерван - просмотра нет.</summary>
|
||||
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.CancelOpen))]
|
||||
public static class Patch_Bag_CancelOpen
|
||||
{
|
||||
public static void Postfix()
|
||||
{
|
||||
if (Viewing && _viewBag != null)
|
||||
{
|
||||
Viewing = false;
|
||||
_viewBag = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Замок мешка не дан (занят другим) - просмотра нет.</summary>
|
||||
[HarmonyPatch(typeof(Entity), nameof(Entity.OnLockResponseLocal))]
|
||||
public static class Patch_Entity_OnLockResponseLocal
|
||||
{
|
||||
public static void Postfix(Entity __instance, bool __result)
|
||||
{
|
||||
if (!__result && Viewing && _viewBag != null && __instance == _viewBag)
|
||||
{
|
||||
Viewing = false;
|
||||
_viewBag = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Дальность окна мешка - от крысы: проверка окна меряет её до entityPlayer, поэтому на время Update
|
||||
/// персонаж «стоит» на месте крысы, как у окна ящика.</summary>
|
||||
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.Update))]
|
||||
public static class Patch_Bag_Update
|
||||
{
|
||||
public static void Prefix(XUiC_BagStorageWindowGroup __instance, out Vector3? __state)
|
||||
{
|
||||
__state = null;
|
||||
EntityPlayerLocal p = __instance.xui.playerUI.entityPlayer;
|
||||
EntityAlive rat = Possession.Zombie;
|
||||
if (!Viewing || _viewBag == null || p == null || rat == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
__state = p.position;
|
||||
p.position = rat.position;
|
||||
}
|
||||
|
||||
public static void Finalizer(XUiC_BagStorageWindowGroup __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;
|
||||
}
|
||||
if (WolfBagButton(__instance, false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
TEFeatureStorage te = __instance.GetParentByType<XUiC_LootWindow>()?.te;
|
||||
if (Taking && te != null)
|
||||
{
|
||||
WolfBag.TakeAll(te);
|
||||
}
|
||||
else if (Taking && _viewBag != null)
|
||||
{
|
||||
WolfBag.TakeAll(_viewBag);
|
||||
}
|
||||
else
|
||||
{
|
||||
Deny();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.Sort))]
|
||||
public static class Patch_Sort
|
||||
{
|
||||
public static bool Prefix(XUiC_ContainerStandardControls __instance)
|
||||
{
|
||||
if (Viewing && WolfBagBackpack.On && __instance.GetParentByType<XUiC_BackpackWindow>() != null)
|
||||
{
|
||||
WolfBag.Sort();
|
||||
}
|
||||
return !Viewing;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Кнопки окна рюкзака, показывающего сумку волка: «Положить всё» / «Положить похожие» - в открытый
|
||||
/// ящик или мешок (как щелчок по ячейке сумки). True - кнопка сумки волка, обработана.</summary>
|
||||
static bool WolfBagButton(XUiC_ContainerStandardControls _c, bool _similarOnly)
|
||||
{
|
||||
if (!WolfBagBackpack.On || _c.GetParentByType<XUiC_BackpackWindow>() == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
XUiC_LootWindow lw = _c.xui.GetChildByType<XUiC_LootWindow>();
|
||||
TEFeatureStorage dst = lw != null && lw.ViewComponent.IsVisible ? lw.te : null;
|
||||
if (dst != null || _viewBag != null)
|
||||
{
|
||||
WolfBag.PutAll(dst, dst == null ? _viewBag : null, _similarOnly);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>«Положить похожие» у сумки волка; прочие переносы и замок ячеек при обыске - выключены (ячейки окна -
|
||||
/// копии сумки, ванильный перенос их раздвоил бы).</summary>
|
||||
[HarmonyPatch(typeof(XUiC_ContainerStandardControls))]
|
||||
public static class Patch_OtherMoves
|
||||
{
|
||||
static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillAndSmart));
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillStacks));
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveSmart));
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.ToggleLockMode));
|
||||
}
|
||||
|
||||
public static bool Prefix(XUiC_ContainerStandardControls __instance, System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!Viewing)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (__originalMethod.Name == nameof(XUiC_ContainerStandardControls.MoveFillAndSmart))
|
||||
{
|
||||
WolfBagButton(__instance, true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Щелчок по запертой ячейке контейнера - отказ «рюкзак полон».</summary>
|
||||
[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())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Ячейка сумки волка (окно рюкзака при обыске) - в ящик или мешок (указание 10.10).
|
||||
XUiC_Backpack bp = __instance.GetParentByType<XUiC_Backpack>();
|
||||
if (bp != null)
|
||||
{
|
||||
CursorControllerAbs c = __instance.xui.playerUI.CursorController;
|
||||
if (WolfBagBackpack.On && (c.GetMouseButtonDown(UICamera.MouseButton.LeftButton) || c.GetMouseButtonDown(UICamera.MouseButton.RightButton)))
|
||||
{
|
||||
int bi = bp.itemControllers != null ? System.Array.IndexOf(bp.itemControllers, __instance) : -1;
|
||||
XUiC_LootWindow lw = __instance.xui.GetChildByType<XUiC_LootWindow>();
|
||||
TEFeatureStorage dst = lw != null && lw.ViewComponent.IsVisible ? lw.te : null;
|
||||
if (bi >= 0 && dst != null)
|
||||
{
|
||||
WolfBag.PutTo(dst, bi);
|
||||
}
|
||||
else if (bi >= 0 && _viewBag != null)
|
||||
{
|
||||
WolfBag.PutTo(_viewBag, bi);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ячейка контейнера-блока (XUiC_LootContainer) или мешка (XUiC_BagContainer), не рюкзака.
|
||||
XUiC_ItemStackGrid grid = (XUiC_ItemStackGrid)__instance.GetParentByType<XUiC_LootContainer>() ?? __instance.GetParentByType<XUiC_BagContainer>();
|
||||
if (grid == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
CursorControllerAbs cur = __instance.xui.playerUI.CursorController;
|
||||
if (cur.GetMouseButtonDown(UICamera.MouseButton.LeftButton) || cur.GetMouseButtonDown(UICamera.MouseButton.RightButton))
|
||||
{
|
||||
TEFeatureStorage te = __instance.GetParentByType<XUiC_LootWindow>()?.te;
|
||||
int idx = grid.itemControllers != null ? System.Array.IndexOf(grid.itemControllers, __instance) : -1;
|
||||
if (Taking && te != null && idx >= 0)
|
||||
{
|
||||
WolfBag.TakeFrom(te, idx);
|
||||
}
|
||||
else if (Taking && _viewBag != null && grid is XUiC_BagContainer && idx >= 0)
|
||||
{
|
||||
WolfBag.TakeFrom(_viewBag, idx);
|
||||
}
|
||||
else
|
||||
{
|
||||
Deny();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Своя модель Могильной крысы (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.
|
||||
/// </summary>
|
||||
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<Mesh>("necroRatMesh");
|
||||
_mats = new Material[MatNames.Length];
|
||||
Shader std = Shader.Find("Standard");
|
||||
for (int i = 0; i < MatNames.Length; i++)
|
||||
{
|
||||
_mats[i] = b.LoadAsset<Material>(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;
|
||||
}
|
||||
|
||||
/// <summary>Прозрачность крысы во время прохода сквозь постройки (указание 09.10: «пока зажата ПКМ, делать
|
||||
/// крысу полупрозрачной»).</summary>
|
||||
public const float GhostAlpha = 0.35f;
|
||||
static Material[] _ghostMats;
|
||||
|
||||
/// <summary>Полупрозрачные копии материалов (Standard в режиме Fade) - один раз на игру; глаза
|
||||
/// (necroRat_eye) остаются непрозрачными и светятся. Переключение - подмена sharedMaterials у рендерера
|
||||
/// DireWolf, без пересоздания материалов.</summary>
|
||||
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<SkinnedMeshRenderer>(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<SkinnedMeshRenderer>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Крыса бегала, не касаясь земли (тест 08.10). Entity.SetCCScale уменьшает высоту капсулы движения
|
||||
/// в SizeScale раз, а радиус - только увеличивает (FastMax(scale, 1)). У крысы (0.28 от волка) высота
|
||||
/// стала меньше двух радиусов, капсула выродилась в шар волчьего радиуса, и его низ ушёл под ноги -
|
||||
/// контроллер поднимал крысу над землёй. Радиус ужимаем так же, как высоту.</summary>
|
||||
[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"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ЗАМЕР (08.10, временный): крыса всё ещё «не достаёт до земли» после правки капсулы - раз в 3 с пишем,
|
||||
/// где сущность, где её видимая модель, физическое тело, низ костей и земля под ней. По логу видно, что
|
||||
/// именно приподнято. Убрать после починки.</summary>
|
||||
[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<SkinnedMeshRenderer>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Проход одержимой крысы сквозь постройки (BACKLOG, «Могильная крыса» п.4; тест 09.10: «почему не срабатывает
|
||||
/// проход сквозь текстуры по ПКМ»). Пока держишь ПКМ - как дух змеи, но без полёта:
|
||||
/// - стены, двери, мебель не держат: поднят Entity.IsStuck, и её собственный шаг (тот же бег по WASD)
|
||||
/// идёт мимо контроллера столкновений (Entity.ccEntityCollisionStart);
|
||||
/// - высоту ведём сами: IsStuck снимает и пол, поэтому каждый кадр крыса стоит на поверхности под собой
|
||||
/// (луч вниз с полуметра - ступенька по пути, как у змеи), а над пустотой падает с ускорением на неё;
|
||||
/// - грунт (земля, песок, камень) не пускает - как у змеи: шаг в грунт отменяется;
|
||||
/// - отпустил ПКМ внутри построек - выталкивает на ближайшее свободное место (вбок, вверх, вниз - дороже).
|
||||
/// </summary>
|
||||
public static class RatPhase
|
||||
{
|
||||
public static bool On;
|
||||
static Vector3 _last;
|
||||
static float _vy;
|
||||
const float Gravity = 20f;
|
||||
/// <summary>Точка тела крысы для проверки «внутри» и радиус шара свободного места.</summary>
|
||||
const float BodyUp = 0.2f;
|
||||
const float BodyRadius = 0.12f;
|
||||
/// <summary>С какой высоты над ногами ищем пол: всё ниже - ступенька, на которую она взбегает.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Выключить проход; внутри построек - вытолкнуть наружу.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Ближайшее свободное место с опорой: до 4 м вбок, до 1 м вниз, до 4 м вверх; вниз дороже.</summary>
|
||||
static Vector3? Eject(World _world, Vector3 _p)
|
||||
{
|
||||
if (_ejectOffsets == null)
|
||||
{
|
||||
List<Vector3> list = new List<Vector3>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Истлевание красного трупа в останки (указание 10.10.2026). Красный труп «Жертвы» - полноценный мёртвый зомби
|
||||
/// (модель со скелетом, физика, перекрашенные текстуры): много таких трупов в загруженной зоне - много памяти, а при
|
||||
/// выгрузке участка игра трупы не сохраняет (лут пропадал). Поэтому труп истлевает в лёгкий контейнер necroRemains
|
||||
/// (entityclasses.xml, своя модель: остов, обтянутый обугленной плёнкой; бандл necroremains):
|
||||
/// - через DecaySeconds после того, как стал красным трупом (пользователь: «2 минуты будет более чем достаточно»);
|
||||
/// - раньше, если до ближайшего игрока дальше FarDistance - участок скоро выгрузится;
|
||||
/// - раньше, если красных трупов больше MaxCorpses - сначала самые старые.
|
||||
/// Лут переходит как у ванильного DropBagServer (Entity.cs): нетронутый - список zPackVictimRemains (наполнится при
|
||||
/// первом открытии), открытый - предметы и отметка «трогали». Остаток срока трупа переходит в срок останков.
|
||||
/// Звук - разбитое пятно крови (блоки bloodDecor из материала Morganic: organicdestroy). Только сервер/одиночная игра.
|
||||
/// Последние BurnSeconds труп горит (ванильное пламя p_onFire по форме тела, как у горящего зомби) и обжигает
|
||||
/// стоящих вплотную (ванильный buffBurningEnvironment - как костёр). Ради нагрузки горят только трупы ближе BurnNear
|
||||
/// к игроку и не больше MaxBurning сразу (ближайшие); ушёл игрок - пламя гаснет. Пламя видно у хоста (сервер).
|
||||
/// </summary>
|
||||
public static class RemainsDecay
|
||||
{
|
||||
public const string EntityClassName = "necroRemains";
|
||||
public const string RemainsLootList = "zPackVictimRemains";
|
||||
public const float DecaySeconds = 120f;
|
||||
public const float FarDistance = 80f;
|
||||
public const int MaxCorpses = 30;
|
||||
public const string Sound = "organicdestroy";
|
||||
public const float BurnSeconds = 30f;
|
||||
public const float BurnNear = 25f;
|
||||
public const int MaxBurning = 6;
|
||||
public const float BurnRadius = 1.3f;
|
||||
const string FireParticle = "p_onFire";
|
||||
const string FireName = "Ptl_" + FireParticle;
|
||||
const string BurnBuff = "buffBurningEnvironment";
|
||||
|
||||
/// <summary>Когда мод впервые увидел красный труп (Time.time).</summary>
|
||||
static readonly Dictionary<int, float> _since = new Dictionary<int, float>();
|
||||
static float _next;
|
||||
static readonly List<int> _tmp = new List<int>();
|
||||
static readonly HashSet<int> _burning = new HashSet<int>();
|
||||
static readonly List<KeyValuePair<float, EntityAlive>> _cand = new List<KeyValuePair<float, EntityAlive>>();
|
||||
static GameObject _fire;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||
}
|
||||
|
||||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null)
|
||||
{
|
||||
_since.Clear();
|
||||
_burning.Clear();
|
||||
BundleShaderFix.Forget();
|
||||
return;
|
||||
}
|
||||
if (Time.time < _next || !SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_next = Time.time + 1f;
|
||||
// забыть ушедшие
|
||||
_tmp.Clear();
|
||||
foreach (int id in _since.Keys)
|
||||
{
|
||||
if (!CorpseLoot.SearchableCorpses.Contains(id))
|
||||
{
|
||||
_tmp.Add(id);
|
||||
}
|
||||
}
|
||||
foreach (int id in _tmp)
|
||||
{
|
||||
_since.Remove(id);
|
||||
_burning.Remove(id);
|
||||
}
|
||||
foreach (int id in CorpseLoot.SearchableCorpses)
|
||||
{
|
||||
if (!_since.ContainsKey(id))
|
||||
{
|
||||
_since[id] = Time.time;
|
||||
}
|
||||
}
|
||||
// кого истлеть
|
||||
_tmp.Clear();
|
||||
foreach (var kv in _since)
|
||||
{
|
||||
if (!(world.GetEntity(kv.Key) is EntityAlive c) || !c.IsDead())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (Time.time - kv.Value >= DecaySeconds || FarFromPlayers(world, c.position))
|
||||
{
|
||||
_tmp.Add(kv.Key);
|
||||
}
|
||||
}
|
||||
if (_since.Count - _tmp.Count > MaxCorpses)
|
||||
{
|
||||
var rest = new List<KeyValuePair<int, float>>();
|
||||
foreach (var kv in _since)
|
||||
{
|
||||
if (!_tmp.Contains(kv.Key))
|
||||
{
|
||||
rest.Add(kv);
|
||||
}
|
||||
}
|
||||
rest.Sort((a, b) => a.Value.CompareTo(b.Value));
|
||||
for (int i = 0; i < rest.Count - MaxCorpses; i++)
|
||||
{
|
||||
_tmp.Add(rest[i].Key);
|
||||
}
|
||||
}
|
||||
foreach (int id in _tmp)
|
||||
{
|
||||
if (world.GetEntity(id) is EntityAlive c)
|
||||
{
|
||||
Convert(world, c);
|
||||
}
|
||||
_since.Remove(id);
|
||||
_burning.Remove(id);
|
||||
}
|
||||
Burn(world);
|
||||
}
|
||||
|
||||
/// <summary>Тест: все красные трупы сразу в последние BurnSeconds (necroremains burn).</summary>
|
||||
public static int IgniteAll()
|
||||
{
|
||||
_tmp.Clear();
|
||||
_tmp.AddRange(_since.Keys);
|
||||
foreach (int id in _tmp)
|
||||
{
|
||||
_since[id] = Mathf.Min(_since[id], Time.time - (DecaySeconds - BurnSeconds));
|
||||
}
|
||||
_next = 0f;
|
||||
return _tmp.Count;
|
||||
}
|
||||
|
||||
static void Burn(World _world)
|
||||
{
|
||||
_cand.Clear();
|
||||
foreach (var kv in _since)
|
||||
{
|
||||
if (Time.time - kv.Value < DecaySeconds - BurnSeconds || !(_world.GetEntity(kv.Key) is EntityAlive c) || !c.IsDead())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float d = NearestPlayer(_world, c.position);
|
||||
if (d < BurnNear)
|
||||
{
|
||||
_cand.Add(new KeyValuePair<float, EntityAlive>(d, c));
|
||||
}
|
||||
}
|
||||
_cand.Sort((a, b) => a.Key.CompareTo(b.Key));
|
||||
_tmp.Clear();
|
||||
for (int i = 0; i < _cand.Count && i < MaxBurning; i++)
|
||||
{
|
||||
_tmp.Add(_cand[i].Value.entityId);
|
||||
}
|
||||
// погасить лишние
|
||||
foreach (int id in new List<int>(_burning))
|
||||
{
|
||||
if (!_tmp.Contains(id))
|
||||
{
|
||||
(_world.GetEntity(id) as EntityAlive)?.RemoveParticle(FireName);
|
||||
_burning.Remove(id);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < _cand.Count && i < MaxBurning; i++)
|
||||
{
|
||||
EntityAlive c = _cand[i].Value;
|
||||
if (!_burning.Contains(c.entityId) && Ignite(c))
|
||||
{
|
||||
_burning.Add(c.entityId);
|
||||
}
|
||||
if (!_burning.Contains(c.entityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// обжечь вплотную стоящих (как костёр: от 3 касаний подряд - горит сам)
|
||||
Vector3 hip = c.emodel != null ? c.emodel.GetHipPosition() : c.position;
|
||||
float r2 = BurnRadius * BurnRadius;
|
||||
foreach (EntityPlayer pl in _world.Players.list)
|
||||
{
|
||||
if (pl != null && !pl.IsDead() && (pl.position - hip).sqrMagnitude < r2)
|
||||
{
|
||||
pl.Buffs.AddBuff(BurnBuff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static float NearestPlayer(World _world, Vector3 _p)
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
foreach (EntityPlayer pl in _world.Players.list)
|
||||
{
|
||||
if (pl != null)
|
||||
{
|
||||
best = Mathf.Min(best, (pl.position - _p).magnitude);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Пламя по форме тела - как MinEventActionAttachParticleEffectToEntity (parent_transform LOD0, shape_mesh).</summary>
|
||||
static bool Ignite(EntityAlive _c)
|
||||
{
|
||||
Transform parent = _c.emodel?.meshTransform;
|
||||
if (parent == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (_fire == null)
|
||||
{
|
||||
_fire = LoadManager.LoadAssetFromAddressables<GameObject>("ParticleEffects/" + FireParticle + ".prefab", null, null, _deferLoading: false, _loadSync: true).Asset;
|
||||
if (_fire == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (parent.Find(FireName) != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
GameObject go = Object.Instantiate(_fire);
|
||||
go.name = FireName;
|
||||
Utils.SetLayerRecursively(go, parent.gameObject.layer);
|
||||
Transform t = go.transform;
|
||||
t.SetParent(parent, false);
|
||||
t.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity);
|
||||
_c.AddParticle(FireName, t);
|
||||
SkinnedMeshRenderer smr = parent.GetComponentInChildren<SkinnedMeshRenderer>();
|
||||
foreach (ParticleSystem ps in go.GetComponentsInChildren<ParticleSystem>())
|
||||
{
|
||||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
ParticleSystem.ShapeModule sh = ps.shape;
|
||||
if (sh.shapeType == ParticleSystemShapeType.SkinnedMeshRenderer || sh.shapeType == ParticleSystemShapeType.Mesh)
|
||||
{
|
||||
if (smr != null)
|
||||
{
|
||||
sh.skinnedMeshRenderer = smr;
|
||||
}
|
||||
else
|
||||
{
|
||||
sh.shapeType = ParticleSystemShapeType.Sphere;
|
||||
}
|
||||
}
|
||||
ParticleSystem.MainModule main = ps.main;
|
||||
main.duration = 900000f;
|
||||
ps.Play();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool FarFromPlayers(World _world, Vector3 _p)
|
||||
{
|
||||
float far2 = FarDistance * FarDistance;
|
||||
for (int i = 0; i < _world.Players.list.Count; i++)
|
||||
{
|
||||
EntityPlayer pl = _world.Players.list[i];
|
||||
if (pl != null && (pl.position - _p).sqrMagnitude < far2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return _world.Players.list.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>Красный труп -> останки. True - получилось (труп убран).</summary>
|
||||
public static bool Convert(World _world, EntityAlive _corpse)
|
||||
{
|
||||
int cls = EntityClass.FromString(EntityClassName);
|
||||
if (cls == -1 || _corpse == null || _corpse.IsMarkedForUnload())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// где и как лежит тело: таз и направление таз -> голова (у модели останков голова - в сторону -Z)
|
||||
Vector3 pos = _corpse.emodel != null ? _corpse.emodel.GetHipPosition() : _corpse.position;
|
||||
Vector3 head = _corpse.emodel?.GetHeadTransform() != null ? _corpse.emodel.GetHeadTransform().position + Origin.position : pos + _corpse.transform.forward;
|
||||
Vector3 dir = head - pos; dir.y = 0f;
|
||||
float yaw = dir.sqrMagnitude > 1e-4f ? Mathf.Atan2(-dir.x, -dir.z) * Mathf.Rad2Deg : _corpse.rotation.y;
|
||||
// на землю: луч вниз от таза
|
||||
Vector3 g = pos;
|
||||
if (Physics.Raycast(pos + Vector3.up * 0.6f - Origin.position, Vector3.down, out RaycastHit hit, 3f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
g.y = hit.point.y + Origin.position.y;
|
||||
}
|
||||
// центр модели останков сдвинут от таза к груди: отодвинуть назад на полкорпуса, чтобы таз остался на месте
|
||||
Vector3 back = Quaternion.Euler(0f, yaw, 0f) * Vector3.back;
|
||||
g += back * 0.19f; // модель x1.2 (NecroRemainsSetup.Scale)
|
||||
|
||||
if (!(EntityFactory.CreateEntity(cls, g, new Vector3(0f, yaw, 0f)) is EntityLootContainer rem))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rem.OverrideLootList = RemainsLootList;
|
||||
Bag bag = _corpse.bag;
|
||||
bool touched = bag != null && bag.Touched;
|
||||
if (touched)
|
||||
{
|
||||
rem.bag.Resize(new Vector2i(bag.SlotCount, 1));
|
||||
rem.bag.ItemGrid.CopyItemsFrom(bag.ItemGrid);
|
||||
rem.bag.ItemGrid.WorldTimeTouched = bag.ItemGrid.WorldTimeTouched;
|
||||
}
|
||||
_world.SpawnEntityInWorld(rem);
|
||||
int left = Mathf.Max(20 * 30, _corpse.timeStayAfterDeath - _corpse.deathUpdateTime);
|
||||
rem.timeStayAfterDeath = left;
|
||||
rem.deathUpdateTime = 0;
|
||||
Audio.Manager.BroadcastPlay(g, Sound);
|
||||
CorpseLoot.SearchableCorpses.Remove(_corpse.entityId);
|
||||
_world.RemoveEntity(_corpse.entityId, EnumRemoveEntityReason.Despawned);
|
||||
Debug.Log("[NecromancerTome] RemainsDecay: труп " + _corpse.entityId + " -> останки " + rem.entityId +
|
||||
(touched ? " (лут перенесён)" : " (нетронутый)") + ", срок " + left / 20 + " с");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>necroremains - мгновенно истлеть все красные трупы рядом (проверка без ожидания 2 минут).</summary>
|
||||
[UnityEngine.Scripting.Preserve]
|
||||
public class ConsoleCmdNecroRemains : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necroremains" };
|
||||
public override string getDescription() => "NecromancerTome: красные трупы сразу истлевают в останки; burn - сразу последние 30 с (горят)";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_params.Count > 0 && _params[0] == "burn")
|
||||
{
|
||||
SdtdConsole.Instance.Output("подожжено трупов (последние " + RemainsDecay.BurnSeconds + " с): " + RemainsDecay.IgniteAll());
|
||||
return;
|
||||
}
|
||||
var ids = new List<int>(CorpseLoot.SearchableCorpses);
|
||||
int n = 0;
|
||||
foreach (int id in ids)
|
||||
{
|
||||
if (world.GetEntity(id) is EntityAlive c && c.IsDead() && RemainsDecay.Convert(world, c))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
SdtdConsole.Instance.Output("истлело трупов: " + n);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,7 @@ namespace NecromancerTome
|
||||
static readonly System.Collections.Generic.Queue<int> _fearQueue = new System.Collections.Generic.Queue<int>();
|
||||
static readonly System.Collections.Generic.HashSet<int> _fearQueued = new System.Collections.Generic.HashSet<int>();
|
||||
static float _fearCheck;
|
||||
public const string SnakeMapNavObject = "necroSnakeSpirit";
|
||||
static EntityAlive _mapSnake;
|
||||
static NecroMapMarker.Marker _mapNav;
|
||||
public const float FearInterval = 0.25f;
|
||||
/// <summary>Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
|
||||
/// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.</summary>
|
||||
@@ -180,9 +179,10 @@ namespace NecromancerTome
|
||||
_fearQueue.Clear();
|
||||
_fearQueued.Clear();
|
||||
_fearCheck = 0f;
|
||||
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10.
|
||||
_snake.AddNavObject(SnakeMapNavObject, null, null);
|
||||
_mapSnake = _snake;
|
||||
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10. С 06.10 - своя
|
||||
// метка по Transform (NecroMapMarker): через AddNavObject она так и не показалась.
|
||||
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
|
||||
_mapNav = NecroMapMarker.Show(_snake, NecroMapMarker.SnakeClass);
|
||||
Target = null;
|
||||
_radiationCheck = 0f;
|
||||
BodyDanger = false;
|
||||
@@ -248,11 +248,7 @@ namespace NecromancerTome
|
||||
public static void End()
|
||||
{
|
||||
ClearSense();
|
||||
if (_mapSnake != null)
|
||||
{
|
||||
_mapSnake.RemoveNavObject(SnakeMapNavObject);
|
||||
_mapSnake = null;
|
||||
}
|
||||
NecroMapMarker.Hide(ref _mapNav);
|
||||
_fearQueue.Clear();
|
||||
_fearQueued.Clear();
|
||||
Controlling = false;
|
||||
@@ -432,7 +428,7 @@ namespace NecromancerTome
|
||||
_fearQueued.Remove(id);
|
||||
EntityAlive e = _world.GetEntity(id) as EntityAlive;
|
||||
// Ушёл из чутья, умер, стал своим, началась луна - пропускаем и берём следующего.
|
||||
if (e == null || bloodMoon || _world.IsRemote() || !IsFoe(e, _snake) ||
|
||||
if (e == null || bloodMoon || _world.IsRemote() || !IsFoe(e, _snake) || !IsUndead(e) ||
|
||||
(e.position - _pos).sqrMagnitude > SenseRange * SenseRange)
|
||||
{
|
||||
continue;
|
||||
@@ -598,7 +594,8 @@ namespace NecromancerTome
|
||||
continue;
|
||||
}
|
||||
float d2 = (e.position - _pos).sqrMagnitude;
|
||||
bool foe = IsFoe(e, _snake);
|
||||
// Чутьё и ужас - власть над мёртвыми (указание 06.10): только нежить, живых змея не чует и не пугает.
|
||||
bool foe = IsFoe(e, _snake) && IsUndead(e);
|
||||
if (foe && d2 < SenseRange * SenseRange)
|
||||
{
|
||||
// Только наземные: зомби и звери-зомби. Стервятникам пафайндер не нужен.
|
||||
@@ -654,6 +651,13 @@ namespace NecromancerTome
|
||||
!GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
|
||||
}
|
||||
|
||||
/// <summary>Нежить - поднятые мертвецы: всё с меткой zombie (зомби-люди, зомби-собаки, -медведи,
|
||||
/// -кабаны, стервятники, Стиратель). Живые звери, бандиты, игроки и торговцы - не нежить. Та же
|
||||
/// метка, по которой считаются убийства в Некромантию (NecromancyKillCreditPatch.ZombieTag).</summary>
|
||||
static readonly FastTags<TagGroup.Global> UndeadTag = FastTags<TagGroup.Global>.Parse("zombie");
|
||||
|
||||
public static bool IsUndead(EntityAlive _e) => _e != null && _e.HasAnyTags(UndeadTag);
|
||||
|
||||
/// <summary>Питомцы и змея мода - тоже EntityEnemy (зомби-основа), и без этой проверки
|
||||
/// охрана выбирала целью самого питомца: он ближе всех к хозяину (тест 30.09, лог
|
||||
/// «pet 3838 идёт на 3838»). Все наши классы сущностей начинаются с necro.</summary>
|
||||
@@ -880,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))
|
||||
{
|
||||
@@ -901,7 +905,7 @@ namespace NecromancerTome
|
||||
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
|
||||
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
|
||||
/// точке), а не постройка.</summary>
|
||||
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;
|
||||
@@ -930,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))
|
||||
{
|
||||
@@ -1002,7 +1006,8 @@ namespace NecromancerTome
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (!IsFoe(e, _snake))
|
||||
// Атака духа змеи - только по нежити (указание 06.10): живые звери и люди ей не подвластны.
|
||||
if (!IsFoe(e, _snake) || !IsUndead(e))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1340,7 +1345,8 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»). Игра каждый
|
||||
/// <summary>Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»), так же в
|
||||
/// чертогах и у одержимого питомца (06.10). Игра каждый
|
||||
/// кадр в EntityPlayerLocal.OnHUD открывает окно "toolbelt", если оно закрыто, - поэтому не
|
||||
/// закрываем его раз за разом, а не даём открыться: все Open сходятся в openInternal.</summary>
|
||||
[HarmonyPatch(typeof(GUIWindowManager), "openInternal")]
|
||||
@@ -1348,7 +1354,7 @@ namespace NecromancerTome
|
||||
{
|
||||
public static bool Prefix(GUIWindow _w)
|
||||
{
|
||||
return !((SnakeControl.Controlling || MindControl.Controlling) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
|
||||
return !((SnakeControl.Controlling || MindControl.Controlling || (Possession.Active && Possession.IsPet)) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,37 @@ namespace NecromancerTome
|
||||
return world != null && world.IsDark();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Кровавый прилив» (указание 10.10, новая концепция ночи кровавой луны). Луна в этой фазе тянет из
|
||||
/// земли некротлен - прах умерших за годы эпидемии, - и плотность некроэнергии в воздухе скачком
|
||||
/// сравнивается с плотностью в теле некроманта. Дух выходит из тела по перепаду плотностей; перепада
|
||||
/// нет - выйти нечем. Поэтому до утра: нет духа змеи, чертогов разума, одержимости (и зомби, и своих
|
||||
/// питомцев), а кто был вне тела к началу - выброшен в тело. «Коза» (леденящий ужас) тонет в
|
||||
/// насыщенном воздухе: жест есть, эффекта нет.
|
||||
/// Время - ванильное GameUtils.IsBloodMoonTime (от заката дня луны до рассвета), оно считается по часам
|
||||
/// мира и одинаково у хоста и клиента; AIDirector с его BloodMoonActive есть только у сервера.
|
||||
/// </summary>
|
||||
public static bool IsBloodTide()
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
return world != null && GameUtils.IsBloodMoonTime(world.worldTime, (world.DuskHour, world.DawnHour),
|
||||
GameStats.GetInt(EnumGameStats.BloodMoonDay));
|
||||
}
|
||||
|
||||
/// <summary>Надпись «кровавого прилива» - не чаще раза в BloodTideHintPeriod с.</summary>
|
||||
public const float BloodTideHintPeriod = 2f;
|
||||
static float _bloodTideHintAt = -100f;
|
||||
|
||||
public static void BloodTideHint(EntityPlayerLocal _player)
|
||||
{
|
||||
if (_player == null || Time.time - _bloodTideHintAt < BloodTideHintPeriod)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_bloodTideHintAt = Time.time;
|
||||
GameManager.ShowTooltip(_player, Localization.Get("necroBloodTide"));
|
||||
}
|
||||
|
||||
public static bool IsKnife(ItemClass _item)
|
||||
{
|
||||
return _item != null && _item.Name == KnifeName;
|
||||
@@ -81,6 +112,12 @@ namespace NecromancerTome
|
||||
Debug.Log("[NecromancerTome] SnakeMode: днём дух змеи недоступен");
|
||||
return;
|
||||
}
|
||||
if (IsBloodTide())
|
||||
{
|
||||
BloodTideHint(_player);
|
||||
Debug.Log("[NecromancerTome] SnakeMode: кровавый прилив - дух не покидает тело");
|
||||
return;
|
||||
}
|
||||
World world = GameManager.Instance.World;
|
||||
EntityClass snakeClass = EntityClass.list[EntityClass.FromString(SnakeClassName)];
|
||||
if (snakeClass == null)
|
||||
@@ -247,6 +284,13 @@ namespace NecromancerTome
|
||||
Exit(player);
|
||||
return;
|
||||
}
|
||||
if (IsBloodTide())
|
||||
{
|
||||
BloodTideHint(player);
|
||||
Debug.Log("[NecromancerTome] SnakeMode: кровавый прилив - дух выброшен в тело");
|
||||
Exit(player);
|
||||
return;
|
||||
}
|
||||
if (_placeSnake && PlaceSnake(player, snake))
|
||||
{
|
||||
_placeSnake = false;
|
||||
|
||||
@@ -68,8 +68,10 @@ namespace NecromancerTome
|
||||
public const float ShoveDistance = 6f;
|
||||
|
||||
/// <summary>Session cache only - the durable copy lives on disk, see
|
||||
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
|
||||
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
|
||||
/// SpatialVaultPersistence.cs. NOT cleared on WorldShuttingDown any more (that event fires before
|
||||
/// the final player save and wiped vaults - see SpatialVaultPersistence.cs); a different save cannot
|
||||
/// inherit this world's vault because ToPlayer overwrites or removes the entry on load. Leftover entries
|
||||
/// are at most one per world loaded in a session - not a growing leak (checked 10.10).</summary>
|
||||
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||||
|
||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||
|
||||
@@ -316,14 +316,9 @@ namespace NecromancerTome
|
||||
|
||||
if (_actionData.indexInEntityOfAction == RecallActionIndex)
|
||||
{
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null);
|
||||
}
|
||||
else if (holdingEntity.world != null)
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey);
|
||||
}
|
||||
// С 06.10 ПКМ - одержимость питомца (Patch_..._ExecuteAction_InstantRecall делает её на
|
||||
// нажатии, сюда доходит только если та пропустила); отзыв - на E (PetRecallKey).
|
||||
TryPossessPet(holdingEntity, owned, tooltips);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -436,11 +431,56 @@ namespace NecromancerTome
|
||||
|
||||
/// <summary>summonItemName is null when this species doesn't consume its book on summon
|
||||
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
|
||||
/// <summary>ПКМ с книгой призыва (указание 06.10): вселиться в призванного питомца этой книги.
|
||||
/// Нет питомца - надпись necroPetNothingToPossess.</summary>
|
||||
public static void TryPossessPet(EntityAlive holdingEntity, List<OwnedEntityData> owned, PetInfo tooltips)
|
||||
{
|
||||
EntityPlayerLocal local = holdingEntity as EntityPlayerLocal;
|
||||
if (local == null || tooltips.RecalledKey == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityAlive pet = owned.Count > 0 && holdingEntity.world != null ? holdingEntity.world.GetEntity(owned[0].Id) as EntityAlive : null;
|
||||
if (pet == null || pet.IsDead())
|
||||
{
|
||||
GameManager.ShowTooltip(local, "necroPetNothingToPossess");
|
||||
return;
|
||||
}
|
||||
if (!Possession.EnterPet(local, pet))
|
||||
{
|
||||
Debug.Log("[NecromancerTome] SummonPatch: вселиться в питомца " + pet.entityId + " нельзя (змея, чертоги, транспорт или уже одержимость)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Отозвать питомца книги, которую держит игрок (E, PetRecallKey).</summary>
|
||||
public static void RecallHeldBookPet(EntityAlive holdingEntity, string entityToSpawn, PetInfo tooltips)
|
||||
{
|
||||
int petClassId = EntityClass.GetId(entityToSpawn);
|
||||
if (petClassId == -1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null);
|
||||
}
|
||||
else if (holdingEntity.world != null)
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
|
||||
{
|
||||
if (Possession.Active && Possession.Zombie != null && Possession.Zombie.entityId == petEntityId)
|
||||
{
|
||||
Possession.Exit("питомец отозван");
|
||||
}
|
||||
World world = owner.world;
|
||||
if (world != null)
|
||||
{
|
||||
WolfBag.DropFrom(world.GetEntity(petEntityId) as EntityAlive, "отозван"); // сумка волка - мешком на месте
|
||||
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
|
||||
}
|
||||
owner.RemoveOwnedEntity(petEntityId);
|
||||
@@ -551,17 +591,9 @@ namespace NecromancerTome
|
||||
}
|
||||
lastRecallTime = Time.time;
|
||||
|
||||
// С 06.10 ПКМ - одержимость питомца, отзыв переехал на E (PetRecallKey).
|
||||
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(holdingEntity, owned[0].Id,
|
||||
petInfo.RecalledKey, petInfo.ConsumesBook ? petInfo.SummonItemName : null);
|
||||
holdingEntity.PlayOneShot(__instance.soundWarn);
|
||||
}
|
||||
else
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), petInfo.NothingToRecallKey);
|
||||
}
|
||||
Patch_ItemActionSpawnEntity_Spawn_PetLimit.TryPossessPet(holdingEntity, owned, petInfo);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -619,6 +651,40 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ОТЗЫВ НА E - указание 06.10: «Отозвать питомца снимаем с ПКМ и ставим на E» (ПКМ отдана
|
||||
/// одержимости питомца). E у игрока - «взаимодействие»: PlayerMoveController.HandleInteraction
|
||||
/// получает _activate и открывает то, что под прицелом (блок, предмет, сущность). Отзываем только
|
||||
/// когда под прицелом взаимодействовать не с чем - иначе E с книгой в руке перестала бы открывать
|
||||
/// двери и ящики. «Не с чем» = у игры нет подсказки «Нажмите E…» (strTextLabelPointingTo пуст: на
|
||||
/// кадре нажатия она не обновляется и держит прошлый кадр - то, куда игрок смотрел).
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.HandleInteraction))]
|
||||
public static class Patch_PlayerMoveController_HandleInteraction_PetRecallKey
|
||||
{
|
||||
public static void Postfix(PlayerMoveController __instance, bool _activate)
|
||||
{
|
||||
if (!_activate || Possession.Active || !string.IsNullOrEmpty(__instance.strTextLabelPointingTo))
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityPlayerLocal player = __instance.entityPlayerLocal;
|
||||
if (player == null || player.AttachedToEntity != null || player.IsDead() || player.inventory == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ItemClass held = player.inventory.holdingItem;
|
||||
if (held?.Actions == null || held.Actions.Length == 0 || !(held.Actions[0] is ItemActionSpawnEntity spawn) ||
|
||||
!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(spawn.entityToSpawn, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo info) ||
|
||||
info.RecalledKey == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallHeldBookPet(player, spawn.entityToSpawn, info);
|
||||
player.PlayOneShot(spawn.soundWarn);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
|
||||
public static class Patch_EntityFactory_CreateEntity_PetOwnership
|
||||
{
|
||||
@@ -683,6 +749,11 @@ namespace NecromancerTome
|
||||
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
|
||||
petInfo.FlyingHomeRadius, petInfo.Guards);
|
||||
AttachFollowTask(owner, __result, petInfo);
|
||||
// Указание 06.10: при призыве ненадолго - «Отозвать питомца - E». Рой не отзывается.
|
||||
if (petInfo.RecalledKey != null && owner is EntityPlayerLocal ownerLocal)
|
||||
{
|
||||
GameManager.ShowTooltip(ownerLocal, "necroPetRecallHint");
|
||||
}
|
||||
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
|
||||
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
|
||||
// поддерживают (на Рое это выяснилось ещё 28.08 - он только засорял лог). Призрачный
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Шея одержимого стервятника (указание 08.10). Сидя он стоит в позе 0 ванильного спящего
|
||||
/// (sleeperSit) - голова опущена, как во сне. Это верно для еды, а бодрствующему нужна голова
|
||||
/// вперёд: шея и голова - посередине между полётом и сном.
|
||||
///
|
||||
/// КАК. Модель и анимации игры не трогаем (в мод не идёт ни одного ассета игры): после аниматора
|
||||
/// доворачиваем кости шеи и головы. В полёте запоминаем их локальные повороты (сглаженно - крылья
|
||||
/// машут), сидя - каждый кадр берём то, что поставил аниматор (поза сна), и ставим долю Mix пути к
|
||||
/// полётным. Вход и выход - плавно за BlendTime. Зовётся из Possession.PlaceCamera (постфикс
|
||||
/// vp_FPCamera.LateUpdate) - после аниматора и до того, как камера читает положение головы.
|
||||
///
|
||||
/// ПАМЯТЬ. Ничего не сохраняется; ссылки на кости - только пока стервятник одержим (Reset на выходе).
|
||||
/// </summary>
|
||||
public static class VultureNeck
|
||||
{
|
||||
/// <summary>Доля пути от позы сна к полётной (0.5 - посередине). Подбор - necroneck mix.</summary>
|
||||
public static float Mix = 0.5f;
|
||||
public static bool Enabled = true;
|
||||
public const float BlendTime = 0.3f;
|
||||
/// <summary>Полётную позу берём, когда стервятник летит хотя бы столько (без переходов анимации).</summary>
|
||||
public const float FlightSettle = 0.5f;
|
||||
/// <summary>Если костей с «neck» в имени нет - голова и столько её родителей.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Каждый кадр одержимости стервятником, после аниматора. _awake - сидит, не ест.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Цепочка от головы вверх: все кости с «neck» в имени (без них - FallbackChain родителей).</summary>
|
||||
static bool FindChain()
|
||||
{
|
||||
Transform head = _v?.emodel != null ? _v.emodel.GetHeadTransform() : null;
|
||||
if (head == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var list = new List<Transform> { 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// necroneck - подбор шеи сидящего одержимого стервятника:
|
||||
/// mix <0..1> - доля пути от позы сна к полётной (0 - как спит, 1 - как в полёте)
|
||||
/// on | off - включить / выключить доворот
|
||||
/// show - кости и их повороты (в консоль и лог)
|
||||
/// </summary>
|
||||
[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<string> _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Сумка одержимого лютоволка на 3 ячейки (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
|
||||
/// - волк лутает: окно контейнера то же, что у крысы (RatLoot, рюкзак персонажа не показывается),
|
||||
/// но щелчок по предмету (и «Взять всё») кладёт его в сумку волка; места нет - «Сумка полна»;
|
||||
/// - выгрузка - принести к телу: рядом с телом E перекладывает сумку в рюкзак персонажа, что не
|
||||
/// влезло - мешком на землю рядом;
|
||||
/// - волк погиб или отозван - сумка выпадает мешком на месте; при отзыве (указание 09.10) на карте метка
|
||||
/// «Сумка волка», чтобы за ним прийти; мешок подобран или истлел - метка снята (Patch_removeBackpack);
|
||||
/// - «багажник» (указание 09.10: «почти как у пространственного хранилища»): E не на контейнер и не у тела -
|
||||
/// окно сумки, то же ванильное окно, что у браслета (XUiC_BagStorageWindowGroup, шаблон roboticDrone), но
|
||||
/// без рюкзака персонажа: внутри перекладывать, вытащить за окно - выбросить у волка (Patch_GetDropPosition);
|
||||
/// - мешки с зомби волк лутает так же, как ящики (RatLoot, TakeFrom(EntityLootContainer));
|
||||
/// - тест 10.10: «волк - это ещё одно пространственное хранилище: берёт игрок, но положить может только в эти
|
||||
/// 3 ячейки» - при обыске рядом с ящиком окно рюкзака показывает сумку волка (WolfBagBackpack: первые 3
|
||||
/// ячейки, остальные скрыты, ячейки заперты - перекладка щелчком: по ящику - в сумку, по сумке - в ящик,
|
||||
/// PutTo), E на предмет на земле кладёт его в сумку (TryPickup), Tab в волке - окно сумки, не рюкзак
|
||||
/// персонажа (Possession: windowpaging закрывается).
|
||||
///
|
||||
/// ХРАНЕНИЕ. Сумку сущности игра не сохраняет (Entity.bag нет в Entity.Write), поэтому она своя.
|
||||
/// Упаковка - та же, что у пространственного хранилища (SpatialVaultPersistence.BuildPayload/ParsePayload,
|
||||
/// проверена на сейвах 3.2 и 3.3), но в ОТДЕЛЬНОМ файле папки сейва, как у Чертогов разума
|
||||
/// (NecromancerTome_palaces.txt): файл игрока не меняется (сейвы - главный риск мода). Пишется сразу
|
||||
/// при каждом изменении - переживает и сохранение, и вылет. Волк у игрока один (один спутник за раз),
|
||||
/// поэтому сумка одна, без привязки к номеру сущности.
|
||||
/// </summary>
|
||||
public static class WolfBag
|
||||
{
|
||||
public const int Slots = 3;
|
||||
const string FileName = "NecromancerTome_wolfbag.bin";
|
||||
public const float UnloadRange = 3f;
|
||||
/// <summary>Метка выпавшей сумки: имя - ключ локализации, по нему и находим свою метку среди меток игрока.</summary>
|
||||
public const string WaypointName = "necroWolfBagWaypoint";
|
||||
public const string WaypointIcon = "ui_game_symbol_backpack";
|
||||
/// <summary>Открыто окно сумки («багажник»).</summary>
|
||||
public static bool WindowOpen;
|
||||
/// <summary>Счётчик изменений сумки - окно рюкзака перерисовывает её по нему.</summary>
|
||||
public static int Version;
|
||||
/// <summary>Предмет на земле - не дальше стольких метров от волка и градусов от луча камеры.</summary>
|
||||
const float PickupReach = 2.5f;
|
||||
const float PickupAimDeg = 25f;
|
||||
static Bag _bag;
|
||||
static string _dir;
|
||||
|
||||
static string FilePath => Path.Combine(GameIO.GetSaveGameDir(), FileName);
|
||||
|
||||
/// <summary>Сумка текущего сейва (из файла при первом обращении в этом сейве).</summary>
|
||||
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()
|
||||
{
|
||||
Version++;
|
||||
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<string> parts = new List<string>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Переложить ячейку контейнера в сумку. Без раздвоения: сперва в неполные стопки, остаток -
|
||||
/// в пустую ячейку; в контейнере остаётся ровно то, что не влезло.</summary>
|
||||
public static bool TakeFrom(TEFeatureStorage _te, int _idx)
|
||||
{
|
||||
ItemStack rest = Take(_te.ItemGrid.GetItem(_idx));
|
||||
if (rest == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_te.UpdateSlot(_idx, rest);
|
||||
_te.SetModified();
|
||||
_te.Parent?.NotifyListeners(); // окно контейнера перерисует ячейку (как ванильная сортировка)
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>То же из мешка, упавшего с зомби. Окно мешка перерисуется по событию сумки (SetSlot).</summary>
|
||||
public static bool TakeFrom(EntityLootContainer _src, int _idx)
|
||||
{
|
||||
if (_idx >= _src.bag.SlotCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ItemStack rest = Take(_src.bag.ItemGrid.items[_idx]);
|
||||
if (rest == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_src.bag.SetSlot(_idx, rest);
|
||||
_src.OnBagModified(); // на клиенте - отправить сумку мешка серверу, как делает окно
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Переложить стопку в сумку; что осталось в источнике (пустая стопка, если всё), или null - не влезло.</summary>
|
||||
static ItemStack Take(ItemStack src)
|
||||
{
|
||||
if (src == null || src.IsEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
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 null;
|
||||
}
|
||||
ItemStack rest = src.Clone();
|
||||
rest.count = moving.count;
|
||||
Save();
|
||||
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
|
||||
Show("necroWolfBagTook");
|
||||
Debug.Log("[NecromancerTome] WolfBag: в сумку " + src.itemValue.ItemClass.GetItemName() + " x" + moved + " - " + Describe());
|
||||
return rest.count > 0 ? rest : ItemStack.Empty.Clone();
|
||||
}
|
||||
|
||||
/// <summary>Ячейку сумки - в ящик (щелчок по сумке в окне рюкзака при обыске, тест 10.10: «выложить в ящик не
|
||||
/// получается - это неправильно»). Сперва в неполные стопки, остаток - в пустые ячейки; что не влезло -
|
||||
/// остаётся в сумке.</summary>
|
||||
public static bool PutTo(TEFeatureStorage _te, int _idx)
|
||||
{
|
||||
ItemStack[] g = _te.ItemGrid.items;
|
||||
List<int> changed = new List<int>();
|
||||
if (!Give(_idx, g, changed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (int i in changed)
|
||||
{
|
||||
_te.UpdateSlot(i, g[i].Clone());
|
||||
}
|
||||
_te.SetModified();
|
||||
_te.Parent?.NotifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>То же в мешок с зомби.</summary>
|
||||
public static bool PutTo(EntityLootContainer _dst, int _idx)
|
||||
{
|
||||
ItemStack[] g = _dst.bag.ItemGrid.items;
|
||||
List<int> changed = new List<int>();
|
||||
if (!Give(_idx, g, changed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (int i in changed)
|
||||
{
|
||||
_dst.bag.SetSlot(i, g[i].Clone());
|
||||
}
|
||||
_dst.OnBagModified();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Кнопки окна сумки при обыске (указание 12.10): «Положить всё» и «Положить похожие» - в открытый
|
||||
/// ящик или мешок; похожие - только то, что в контейнере уже лежит.</summary>
|
||||
public static void PutAll(TEFeatureStorage _te, EntityLootContainer _bag, bool _similarOnly)
|
||||
{
|
||||
bool any = false;
|
||||
ItemStack[] slots = Get().ItemGrid.items;
|
||||
for (int i = 0; i < slots.Length; i++)
|
||||
{
|
||||
if (slots[i] == null || slots[i].IsEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
ItemStack[] dst = _te != null ? _te.ItemGrid.items : _bag?.bag.ItemGrid.items;
|
||||
if (dst == null || (_similarOnly && System.Array.FindIndex(dst, s => s != null && !s.IsEmpty() && s.itemValue.type == slots[i].itemValue.type) < 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
any |= _te != null ? PutTo(_te, i) : PutTo(_bag, i);
|
||||
}
|
||||
if (!any)
|
||||
{
|
||||
Audio.Manager.PlayInsidePlayerHead("ui_denied");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>«Сортировать» - по самой сумке.</summary>
|
||||
public static void Sort()
|
||||
{
|
||||
Get().ItemGrid.Sort();
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <summary>Переложить ячейку сумки в массив ячеек контейнера (сами ячейки меняются, номера - в _changed).</summary>
|
||||
static bool Give(int _idx, ItemStack[] _g, List<int> _changed)
|
||||
{
|
||||
Bag b = Get();
|
||||
ItemStack src = _idx < b.SlotCount ? b.ItemGrid.items[_idx] : null;
|
||||
if (src == null || src.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
int left = src.count;
|
||||
int max = src.itemValue.ItemClass?.Stacknumber.Value ?? 1;
|
||||
for (int i = 0; i < _g.Length && left > 0 && max > 1; i++)
|
||||
{
|
||||
if (_g[i] != null && !_g[i].IsEmpty() && _g[i].itemValue.type == src.itemValue.type && _g[i].count < max)
|
||||
{
|
||||
int n = Mathf.Min(left, max - _g[i].count);
|
||||
_g[i] = _g[i].Clone();
|
||||
_g[i].count += n;
|
||||
left -= n;
|
||||
_changed.Add(i);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < _g.Length && left > 0; i++)
|
||||
{
|
||||
if (_g[i] == null || _g[i].IsEmpty())
|
||||
{
|
||||
_g[i] = new ItemStack(src.itemValue.Clone(), left);
|
||||
left = 0;
|
||||
_changed.Add(i);
|
||||
}
|
||||
}
|
||||
int moved = src.count - left;
|
||||
if (moved <= 0)
|
||||
{
|
||||
Audio.Manager.PlayInsidePlayerHead("ui_denied");
|
||||
return false;
|
||||
}
|
||||
string name = src.itemValue.ItemClass?.GetItemName(); // до SetSlot: src - сама ячейка сумки, очистится
|
||||
ItemStack rest = src.Clone();
|
||||
rest.count = left;
|
||||
b.SetSlot(_idx, left > 0 ? rest : ItemStack.Empty.Clone());
|
||||
Save();
|
||||
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
|
||||
Debug.Log("[NecromancerTome] WolfBag: из сумки в контейнер " + name + " x" + moved + " - " + Describe());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>E на предмет, лежащий на земле, - в сумку (как игрок подбирает: что не влезло, остаётся лежать).
|
||||
/// True - предмет перед волком был (взят или «сумка полна»).</summary>
|
||||
public static bool TryPickup(World _world, EntityPlayerLocal _player, EntityAlive _wolf)
|
||||
{
|
||||
Transform cam = _player.cameraTransform;
|
||||
if (cam == null || _world.IsRemote())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Vector3 from = cam.position + Origin.position;
|
||||
EntityItem best = null;
|
||||
float bestDeg = PickupAimDeg;
|
||||
for (int i = 0; i < _world.Entities.list.Count; i++)
|
||||
{
|
||||
if (!(_world.Entities.list[i] is EntityItem it) || it.IsDead() || it.IsMarkedForUnload() || it.itemStack == null ||
|
||||
it.itemStack.IsEmpty() || !it.CanCollect() || (it.position - _wolf.position).sqrMagnitude > PickupReach * PickupReach)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float deg = Vector3.Angle(cam.forward, it.position - from);
|
||||
if (deg < bestDeg)
|
||||
{
|
||||
bestDeg = deg;
|
||||
best = it;
|
||||
}
|
||||
}
|
||||
if (best == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ItemStack rest = Take(best.itemStack);
|
||||
if (rest == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (rest.IsEmpty())
|
||||
{
|
||||
_world.RemoveEntity(best.entityId, EnumRemoveEntityReason.Killed);
|
||||
}
|
||||
else
|
||||
{
|
||||
best.itemStack.count = rest.count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>«Взять всё» - по ячейкам, пока есть место.</summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>«Взять всё» из мешка - по ячейкам, пока есть место.</summary>
|
||||
public static void TakeAll(EntityLootContainer _src)
|
||||
{
|
||||
bool any = false;
|
||||
ItemStack[] slots = _src.bag.ItemGrid.items;
|
||||
for (int i = 0; i < slots.Length; i++)
|
||||
{
|
||||
if (!slots[i].IsEmpty() && Get().CanTakeItem(slots[i]))
|
||||
{
|
||||
any |= TakeFrom(_src, i);
|
||||
}
|
||||
}
|
||||
if (!any)
|
||||
{
|
||||
Audio.Manager.PlayInsidePlayerHead("ui_denied");
|
||||
Show("necroWolfBagFull");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>«Багажник»: окно сумки, как у пространственного браслета (шаблон дрона), без рюкзака персонажа.
|
||||
/// Перекладка внутри пишется сразу (Save на каждое изменение и на закрытие).</summary>
|
||||
public static void OpenWindow(EntityPlayerLocal _player, EntityAlive _wolf)
|
||||
{
|
||||
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
|
||||
if (ui == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
WindowOpen = true;
|
||||
XUiC_BagStorageWindowGroup.Open(ui.xui, _wolf, Get(), LootContainer.GetLootContainer("roboticDrone"),
|
||||
Localization.Get("necroWolfBagWindowTitle"), Save, () =>
|
||||
{
|
||||
WindowOpen = false;
|
||||
Save();
|
||||
});
|
||||
Debug.Log("[NecromancerTome] WolfBag: окно сумки - " + Describe());
|
||||
}
|
||||
|
||||
/// <summary>Выход из одержимости при открытом окне сумки - закрыть его.</summary>
|
||||
public static void CloseWindow()
|
||||
{
|
||||
if (WindowOpen)
|
||||
{
|
||||
LocalPlayerUI.GetUIForPrimaryPlayer()?.windowManager.CloseAllOpenModalWindows();
|
||||
WindowOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>E рядом с телом: сумка - в рюкзак, что не влезло - мешком у тела.</summary>
|
||||
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<ItemStack> left = new List<ItemStack>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Волк погиб или отозван - сумка мешком на месте.</summary>
|
||||
public static void DropFrom(EntityAlive _wolf, string _why)
|
||||
{
|
||||
if (_wolf == null || !WolfControl.Is(_wolf))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Bag b = Get();
|
||||
if (b.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<ItemStack> items = new List<ItemStack>();
|
||||
foreach (ItemStack s in b.ItemGrid.items)
|
||||
{
|
||||
if (s != null && !s.IsEmpty())
|
||||
{
|
||||
items.Add(s.Clone());
|
||||
}
|
||||
}
|
||||
b.Clear();
|
||||
Save();
|
||||
Drop(_wolf.position, items);
|
||||
if (_why == "отозван")
|
||||
{
|
||||
AddWaypoint(_wolf.position);
|
||||
}
|
||||
Debug.Log("[NecromancerTome] WolfBag: волк " + _why + " - сумка выпала мешком (" + items.Count + ")");
|
||||
}
|
||||
|
||||
/// <summary>Метка на карте и компасе - как игра ставит метку транспорта (XUiC_MapArea.RefreshVehiclePositionWaypoint),
|
||||
/// но обычная, не «последнее место сущности»: номер мешка после перезахода другой. Метка - в списке меток игрока,
|
||||
/// сохраняется игрой вместе с ним; удалить можно и вручную.</summary>
|
||||
static void AddWaypoint(Vector3 _at)
|
||||
{
|
||||
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
if (p?.Waypoints == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Waypoint w = new Waypoint();
|
||||
w.pos = World.worldToBlockPos(_at);
|
||||
w.icon = WaypointIcon;
|
||||
w.ownerId = Platform.PlatformManager.InternalLocalUserIdentifier;
|
||||
w.name.Update(WaypointName, Platform.PlatformManager.MultiPlatform.User.PlatformUserId);
|
||||
w.bUsingLocalizationId = true;
|
||||
p.Waypoints.Collection.Add(w);
|
||||
w.navObject = NavObjectManager.Instance.RegisterNavObject("waypoint", w.pos.ToVector3(), w.icon);
|
||||
w.navObject.IsActive = false;
|
||||
w.navObject.name = w.name.Text;
|
||||
w.navObject.usingLocalizationId = true;
|
||||
Debug.Log("[NecromancerTome] WolfBag: метка сумки на карте " + w.pos);
|
||||
}
|
||||
|
||||
/// <summary>Мешок исчез (опустел или истлел) - снять метку сумки, стоящую рядом с ним.</summary>
|
||||
public static void RemoveWaypointAt(Vector3 _at)
|
||||
{
|
||||
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
if (p?.Waypoints == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<Waypoint> list = p.Waypoints.Collection.list;
|
||||
for (int i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Waypoint w = list[i];
|
||||
if (w.name.Text == WaypointName && (w.pos.ToVector3() - _at).sqrMagnitude < 3f * 3f)
|
||||
{
|
||||
p.Waypoints.Collection.Remove(w);
|
||||
if (w.navObject != null)
|
||||
{
|
||||
NavObjectManager.Instance.UnRegisterNavObject(w.navObject);
|
||||
}
|
||||
Debug.Log("[NecromancerTome] WolfBag: мешок сумки подобран - метка снята " + w.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void Drop(Vector3 _at, List<ItemStack> _items)
|
||||
{
|
||||
GameManager.Instance.DropContentInLootContainerServer(-1, "DroppedLootContainer", _at + Vector3.up * 0.5f, _items.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Выход из мира - забыть (следующий сейв прочитает свой файл).</summary>
|
||||
public static void Forget()
|
||||
{
|
||||
_bag = null;
|
||||
_dir = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Мешок исчез (пустой после обыска - «empty», или истлел - «old») - снять метку сумки волка рядом.</summary>
|
||||
[HarmonyPatch(typeof(EntityContainerAbs), nameof(EntityContainerAbs.removeBackpack))]
|
||||
public static class Patch_EntityContainerAbs_removeBackpack_WolfBag
|
||||
{
|
||||
public static void Postfix(EntityContainerAbs __instance)
|
||||
{
|
||||
if (__instance is EntityLootContainer)
|
||||
{
|
||||
WolfBag.RemoveWaypointAt(__instance.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>«Багажник» без рюкзака персонажа: окно мешка открывает его само (OnOpen), закрываем сразу же.</summary>
|
||||
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnOpen))]
|
||||
public static class Patch_XUiC_BagStorageWindowGroup_OnOpen_WolfBag
|
||||
{
|
||||
public static void Postfix(XUiC_BagStorageWindowGroup __instance)
|
||||
{
|
||||
bool wolf = WolfBag.WindowOpen && Possession.Active;
|
||||
if (wolf)
|
||||
{
|
||||
__instance.xui.playerUI.windowManager.Close("backpack");
|
||||
}
|
||||
// «Взять всё» / «Взять похожие» кладут в рюкзак тела, а оно далеко - у волка кнопок нет; у дрона и браслета - вернуть
|
||||
XUiC_ContainerStandardControls c = __instance.GetChildByType<XUiC_BagContainer>()?.standardControls;
|
||||
foreach (string id in WolfBagControls.Hidden)
|
||||
{
|
||||
XUiController b = c?.GetChildById(id);
|
||||
if (b?.ViewComponent != null)
|
||||
{
|
||||
b.ViewComponent.IsVisible = !wolf;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Окно сумки волка: работает только «Сортировать» (по самой сумке, пишется через onModified).
|
||||
/// Перенос в рюкзак (кнопки и клавиша R) и замки ячеек (нужны лишь для переноса) - выключены.</summary>
|
||||
public static class WolfBagControls
|
||||
{
|
||||
public static readonly string[] Hidden = { "btnMoveAll", "btnMoveFillAndSmart", "btnMoveFillStacks", "btnMoveSmart" };
|
||||
|
||||
public static bool Blocked(XUiController _c)
|
||||
{
|
||||
return WolfBag.WindowOpen && Possession.Active && _c?.GetParentByType<XUiC_BagContainer>() != null;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_ContainerStandardControls))]
|
||||
public static class Patch_ContainerStandardControls_Move_WolfBag
|
||||
{
|
||||
static IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveAll));
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillAndSmart));
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillStacks));
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveSmart));
|
||||
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.ToggleLockMode));
|
||||
}
|
||||
|
||||
public static bool Prefix(XUiC_ContainerStandardControls __instance)
|
||||
{
|
||||
return !WolfBagControls.Blocked(__instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Кнопка замка видна по привязке {hasslotlocks} - у волка её нет.</summary>
|
||||
[HarmonyPatch(typeof(XUiC_BagContainer), nameof(XUiC_BagContainer.GetBindingValueInternal))]
|
||||
public static class Patch_BagContainer_Binding_WolfBag
|
||||
{
|
||||
public static void Postfix(XUiC_BagContainer __instance, ref string _value, string _bindingName, ref bool __result)
|
||||
{
|
||||
if (_bindingName == "hasslotlocks" && WolfBagControls.Blocked(__instance))
|
||||
{
|
||||
_value = "false";
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Предмет, вытащенный за окно сумки волка, падает у волка, а не у тела (XUiM_PlayerInventory.DropItem
|
||||
/// берёт место у игрока - GetDropPosition).</summary>
|
||||
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.GetDropPosition))]
|
||||
public static class Patch_EntityPlayerLocal_GetDropPosition_WolfBag
|
||||
{
|
||||
public static void Postfix(EntityPlayerLocal __instance, ref Vector3 __result)
|
||||
{
|
||||
EntityAlive wolf = Possession.Zombie;
|
||||
if (WolfBag.WindowOpen && Possession.Active && __instance == Possession.Player && wolf != null)
|
||||
{
|
||||
__result = wolf.position + wolf.GetForwardVector() * 0.8f + Vector3.up * 0.5f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Окно рюкзака при обыске волком показывает сумку волка (указание 10.10). Рюкзак персонажа при этом
|
||||
/// не читается и не пишется: UpdateBackend пропущен - в сейв игрока сумка не попадёт.</summary>
|
||||
public static class WolfBagBackpack
|
||||
{
|
||||
public static bool On => RatLoot.Viewing && RatLoot.Taking && Possession.Active;
|
||||
static int _seen = -1;
|
||||
static readonly List<XUiC_ItemStack> _hidden = new List<XUiC_ItemStack>();
|
||||
|
||||
public static ItemStack[] Slots(int _n)
|
||||
{
|
||||
ItemStack[] a = new ItemStack[_n];
|
||||
ItemStack[] b = WolfBag.Get().ItemGrid.items;
|
||||
for (int i = 0; i < _n; i++)
|
||||
{
|
||||
a[i] = i < b.Length && b[i] != null ? b[i].Clone() : ItemStack.Empty.Clone();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
public static void Tick(XUiC_Backpack _bp)
|
||||
{
|
||||
if (_seen != WolfBag.Version || _hidden.Count == 0)
|
||||
{
|
||||
_seen = WolfBag.Version;
|
||||
_bp.RefreshBackpackSlots();
|
||||
}
|
||||
XUiC_ItemStack[] c = _bp.itemControllers;
|
||||
for (int i = 0; c != null && i < c.Length; i++)
|
||||
{
|
||||
c[i].IsLocked = true;
|
||||
if (i >= WolfBag.Slots && c[i].ViewComponent.IsVisible)
|
||||
{
|
||||
c[i].ViewComponent.IsVisible = false;
|
||||
_hidden.Add(c[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Обыск закончен: ячейки рюкзака снова видны и отперты, рюкзак - снова свой.</summary>
|
||||
public static void Unlock(XUiC_Backpack _bp)
|
||||
{
|
||||
foreach (XUiC_ItemStack s in _hidden)
|
||||
{
|
||||
if (s != null)
|
||||
{
|
||||
s.ViewComponent.IsVisible = true;
|
||||
}
|
||||
}
|
||||
bool was = _hidden.Count > 0 || _seen != -1;
|
||||
_hidden.Clear();
|
||||
_seen = -1;
|
||||
if (_bp?.itemControllers == null || !was)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (XUiC_ItemStack s in _bp.itemControllers)
|
||||
{
|
||||
if (s != null)
|
||||
{
|
||||
s.IsLocked = false;
|
||||
}
|
||||
}
|
||||
_bp.RefreshBackpackSlots();
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.GetSlots))]
|
||||
public static class Patch_Backpack_GetSlots_WolfBag
|
||||
{
|
||||
public static bool Prefix(XUiC_Backpack __instance, ref ItemStack[] __result)
|
||||
{
|
||||
if (!WolfBagBackpack.On)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
__result = WolfBagBackpack.Slots(__instance.itemControllers?.Length ?? WolfBag.Slots);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.UpdateBackend))]
|
||||
public static class Patch_Backpack_UpdateBackend_WolfBag
|
||||
{
|
||||
public static bool Prefix()
|
||||
{
|
||||
return !WolfBagBackpack.On;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.Update))]
|
||||
public static class Patch_Backpack_Update_WolfBag
|
||||
{
|
||||
static bool _was;
|
||||
|
||||
public static void Postfix(XUiC_Backpack __instance)
|
||||
{
|
||||
bool on = WolfBagBackpack.On;
|
||||
if (on)
|
||||
{
|
||||
WolfBagBackpack.Tick(__instance);
|
||||
}
|
||||
else if (_was)
|
||||
{
|
||||
WolfBagBackpack.Unlock(__instance);
|
||||
}
|
||||
_was = on;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.OnClose))]
|
||||
public static class Patch_Backpack_OnClose_WolfBag
|
||||
{
|
||||
public static void Postfix(XUiC_Backpack __instance)
|
||||
{
|
||||
WolfBagBackpack.Unlock(__instance);
|
||||
}
|
||||
}
|
||||
|
||||
[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, "погиб");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Одержимый лютоволк - охотник (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
|
||||
/// - бросок на ПКМ: враг под прицелом не дальше LeapRange - волк разворачивается и прыгает прямо на
|
||||
/// него (прыжок самой игры: SetJumpDistance на точную дальность и перепад высоты); приземлился рядом -
|
||||
/// цель падает на LeapStun секунд;
|
||||
/// - след крови: раненые враги (здоровье ниже BloodHealth) отмечены на компасе и карте до BloodRange,
|
||||
/// пока не умрут или не залечатся, - отличие от нюха собаки: та чует всех рядом, волк - подранков издалека.
|
||||
/// </summary>
|
||||
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<int> _blood = new HashSet<int>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Каждый кадр одержимости волком.</summary>
|
||||
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 + " сбит с ног");
|
||||
}
|
||||
|
||||
/// <summary>Враг ближе всех к лучу прицела: не дальше LeapRange от волка и в конусе LeapConeDeg.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Выход из волка - снять метки, забыть бросок.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
<DisplayName value="Necromancer's Tome" />
|
||||
<Description value="A dark necromancy progression for 7 Days to Die 3.3: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, undead companions that keep station with you and attack what you point at, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
||||
<Author value="Alex Cube" />
|
||||
<Version value="1.6.1" />
|
||||
<Version value="1.6.0" />
|
||||
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
||||
</xml>
|
||||
|
||||
@@ -63,3 +63,4 @@ Thanks to [b]The Fun Pimps[/b] for a game that survives this much rewriting, and
|
||||
Source code, full change history and issue tracker: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
|
||||
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
|
||||
My YouTube channel: [url=https://www.youtube.com/@alex_cube]@alex_cube[/url]
|
||||
News and previews on Telegram: [url=https://t.me/necromancerstome]t.me/necromancerstome[/url]
|
||||
|
||||
@@ -8,25 +8,39 @@
|
||||
<property name="DifficultyTier" value="0" />
|
||||
<property name="ShowQuestClearCount" value="1" />
|
||||
<property name="TraderArea" value="False" />
|
||||
<property name="PrefabSize" value="17, 8, 17" />
|
||||
<property name="PrefabSize" value="25, 13, 25" />
|
||||
<property name="RotationToFaceNorth" value="2" />
|
||||
<property name="Zoning" value="none" />
|
||||
<property class="IndexedBlockOffsets">
|
||||
<property class="TraderOnOff">
|
||||
<property name="0" value="12, 1, 13" />
|
||||
<property name="1" value="18, 6, 13" />
|
||||
<property name="2" value="14, 1, 16" />
|
||||
<property name="3" value="18, 6, 18" />
|
||||
<property name="4" value="14, 1, 19" />
|
||||
</property>
|
||||
<property class="POILight">
|
||||
<property name="0" value="11, 3, 9" />
|
||||
<property name="1" value="12, 3, 9" />
|
||||
<property name="2" value="8, 4, 10" />
|
||||
<property name="3" value="12, 4, 10" />
|
||||
<property name="4" value="12, 4, 12" />
|
||||
<property name="5" value="9, 3, 13" />
|
||||
<property name="0" value="14, 9, 12" />
|
||||
<property name="1" value="16, 4, 12" />
|
||||
<property name="2" value="18, 4, 12" />
|
||||
<property name="3" value="20, 4, 12" />
|
||||
<property name="4" value="18, 9, 13" />
|
||||
<property name="5" value="20, 9, 13" />
|
||||
<property name="6" value="16, 4, 14" />
|
||||
<property name="7" value="18, 4, 14" />
|
||||
<property name="8" value="20, 4, 14" />
|
||||
<property name="9" value="16, 4, 16" />
|
||||
<property name="10" value="18, 4, 16" />
|
||||
<property name="11" value="20, 4, 16" />
|
||||
<property name="12" value="15, 9, 18" />
|
||||
<property name="13" value="18, 9, 18" />
|
||||
<property name="14" value="20, 9, 18" />
|
||||
<property name="15" value="15, 4, 19" />
|
||||
<property name="16" value="15, 9, 19" />
|
||||
</property>
|
||||
<property class="challenge_sinks">
|
||||
<property name="0" value="11, 1, 3" />
|
||||
<property name="1" value="13, 1, 4" />
|
||||
</property>
|
||||
<property class="TraderOnOff">
|
||||
<property name="0" value="8, 1, 8" />
|
||||
<property name="1" value="10, 1, 11" />
|
||||
<property name="0" value="15, 1, 18" />
|
||||
<property name="1" value="15, 1, 21" />
|
||||
</property>
|
||||
<property class="challenge_gore">
|
||||
<property name="0" value="3, 2, 9" />
|
||||
@@ -34,18 +48,28 @@
|
||||
<property class="challenge_mech_parts">
|
||||
<property name="0" value="6, 1, 13" />
|
||||
</property>
|
||||
<property class="challenge_polymers">
|
||||
<property name="0" value="17, 1, 19" />
|
||||
<property name="1" value="18, 1, 19" />
|
||||
<property name="2" value="20, 1, 19" />
|
||||
<property name="3" value="21, 1, 19" />
|
||||
<property name="4" value="17, 1, 20" />
|
||||
<property name="5" value="18, 1, 20" />
|
||||
<property name="6" value="20, 1, 20" />
|
||||
<property name="7" value="21, 1, 20" />
|
||||
</property>
|
||||
</property>
|
||||
<property class="Stats">
|
||||
<property name="LightsVolume" value="5516.637" />
|
||||
<property name="TotalVertices" value="91813" />
|
||||
<property name="TotalTriangles" value="61958" />
|
||||
<property name="LightsVolume" value="42763.35" />
|
||||
<property name="TotalVertices" value="218641" />
|
||||
<property name="TotalTriangles" value="114274" />
|
||||
<property class="BlockEntities">
|
||||
<property name="Vertices" value="73400" />
|
||||
<property name="Triangles" value="52089" />
|
||||
<property name="Vertices" value="169120" />
|
||||
<property name="Triangles" value="88691" />
|
||||
</property>
|
||||
<property class="ChunkMeshes0">
|
||||
<property name="Vertices" value="18121" />
|
||||
<property name="Triangles" value="9645" />
|
||||
<property name="Vertices" value="49229" />
|
||||
<property name="Triangles" value="25359" />
|
||||
</property>
|
||||
<property class="ChunkMeshes1">
|
||||
<property name="Vertices" value="0" />
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2008-2010 by Jovanny Lemonad (http://www.jovanny.ru)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
After Width: | Height: | Size: 652 KiB |
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 655 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |