Compare commits
@@ -116,6 +116,33 @@
|
||||
<sprite name="blackout" depth="0" sprite="menu_empty" type="sliced" color="[black]" anchor_left="#cam,0,-10" anchor_right="#cam,1,10" anchor_bottom="#cam,0,-10" anchor_top="#cam,1,10" globalopacitymod="0"/>
|
||||
</window>
|
||||
</append>
|
||||
<!-- Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»): оглавление кнопками,
|
||||
глава по страницам, «Назад/Далее», «Оглавление», «Закрыть». Содержание и условия открытия глав -
|
||||
в HarmonySrc/NecroTome.cs, тексты - Localization.csv (necroTome*). Открывает книга памяти в
|
||||
чертогах (BlockNecroMemoryBook.Open) и консольная команда necrotome. -->
|
||||
<append xpath="/windows">
|
||||
<window name="necroTome" anchor="Center" pos="-650,420" width="1300" height="840" cursor_area="true" depth="1001" lock_navigation="true" controller="NecromancerTome.NecroTome, NecromancerHarmony">
|
||||
<sprite depth="0" name="bg" sprite="menu_empty" color="14,10,10,250" type="sliced" />
|
||||
<sprite depth="1" name="frame" sprite="menu_empty2px" color="120,18,14,255" type="sliced" fillcenter="false" />
|
||||
<label depth="2" name="lblTitle" pos="0,-22" width="1300" height="50" justify="center" font_size="38" color="205,40,30,255" effect="outline" upper_case="false" />
|
||||
<sprite depth="1" name="rule" pos="200,-82" width="900" height="2" sprite="menu_empty" color="120,18,14,255" />
|
||||
<label depth="2" name="lblText" pos="60,-104" width="1180" height="640" justify="left" font_size="27" color="215,205,190,255" overflow="shrinkcontent" upper_case="false" />
|
||||
<simplebutton name="btnCh1" depth="3" pos="350,-130" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh2" depth="3" pos="350,-190" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh3" depth="3" pos="350,-250" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh4" depth="3" pos="350,-310" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh5" depth="3" pos="350,-370" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh6" depth="3" pos="350,-430" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh7" depth="3" pos="350,-490" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnCh8" depth="3" pos="350,-550" width="600" height="48" font_size="30" visible="false" />
|
||||
<simplebutton name="btnToc" depth="3" pos="40,-790" width="260" height="46" font_size="26" caption_key="necroTomeToc" />
|
||||
<simplebutton name="btnPrev" depth="3" pos="440,-790" width="160" height="46" font_size="26" caption_key="necroTomePrev" />
|
||||
<label depth="2" name="lblPage" pos="600,-772" width="100" height="40" justify="center" font_size="24" color="160,150,140,255" />
|
||||
<simplebutton name="btnNext" depth="3" pos="700,-790" width="160" height="46" font_size="26" caption_key="necroTomeNext" />
|
||||
<simplebutton name="btnClose" depth="3" pos="1000,-790" width="260" height="46" font_size="26" caption_key="necroTomeClose" />
|
||||
</window>
|
||||
</append>
|
||||
|
||||
<!-- ФИОЛЕТОВАЯ ШКАЛА НЕКРОМАНТИИ, 2026-09-17. Продиктовано: "Есть индикатор опыта. Это шкала
|
||||
до уровня. А мы сделаем ещё одну шкалу, которая заполняется каждые 20 зомби... Каждое
|
||||
заполнение индикатора пусть добавляет уровень некромантии, а индикатор обнуляет."
|
||||
|
||||
@@ -42,4 +42,21 @@
|
||||
<window name="necroFinalBlack" />
|
||||
</window_group>
|
||||
</append>
|
||||
<!-- Книга с оглавлением (см. windows.xml, necroTome). -->
|
||||
<append xpath="/xui">
|
||||
<window_group name="necroTome">
|
||||
<window name="necroTome" />
|
||||
</window_group>
|
||||
</append>
|
||||
<!-- Окно ступки (05.10.2026): копия ванильной workstation_cementMixer. Имя группы = workstation_ +
|
||||
имя блока - по нему игра и открывает окно верстака. -->
|
||||
<append xpath="/xui">
|
||||
<window_group name="workstation_necroMortar" controller="XUiC_WorkstationWindowGroup" open_backpack_on_open="true" close_compass_on_open="true" defaultselected="bp.content">
|
||||
<window name="windowCraftingList"/>
|
||||
<window name="craftingInfoPanel"/>
|
||||
<window name="windowCraftingQueue"/>
|
||||
<window name="windowOutput" />
|
||||
<window name="windowNonPagingHeader" />
|
||||
</window_group>
|
||||
</append>
|
||||
</xui>
|
||||
|
||||
@@ -80,10 +80,15 @@
|
||||
_private/tools/make_pyramid_textures.py, сборка _private/Extracted/ShapesUnityProject,
|
||||
бандл Resources/necropyramid. Чтобы вернуться к нему, достаточно заменить Shape на
|
||||
ModelEntity и раскомментировать строку Model ниже. -->
|
||||
<property name="Shape" value="New" />
|
||||
<!-- СВОЯ КОСТЯНАЯ МОДЕЛЬ, 01.10.2026 («пирамида отрицания»): ступени из костей, черепа с
|
||||
тлеющими глазницами, ось насквозь, наверху цветок из рваной плоти на шипастом стебле
|
||||
из жил, в нём кристалл. Путь тот же, что у гео-оссуария (там он отлажен): ModelEntity,
|
||||
префаб - копия оссуария (бандл necroward), шейдер и теги коллайдера ставит
|
||||
GeoOssuaryPatch по ванильному коллектору росы. Свой коллайдер-коробка 0.95x0.5x0.95. -->
|
||||
<property name="Shape" value="ModelEntity" />
|
||||
<!-- Своя модель отключена вместе с возвратом на Shape="New" (см. комментарий выше).
|
||||
Строка сохранена: она рабочая, ею подключается наш бандл. -->
|
||||
<property name="Model" value="@:Shapes/pyramid.fbx" />
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroward?necroWardPrefab.prefab" />
|
||||
<!-- <property name="Model" value="#@modfolder(NecromancerTome):Resources/necropyramid?necroPyramidPrefab.prefab" /> -->
|
||||
<!-- ModelOffset - ПОЛОЖЕНИЕ МОДЕЛИ, и задавать его надо именно здесь.
|
||||
|
||||
@@ -179,5 +184,203 @@
|
||||
<property name="SellableToTrader" value="false" />
|
||||
<drop event="Destroy" name="resourceScrapIron" count="10,20" />
|
||||
</block>
|
||||
<!-- Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере.
|
||||
Тело - ванильный коллектор росы (Class=Collector): окно, топливо, таймеры, работа без
|
||||
игрока рядом - всё его. Своего кода два места, оба в HarmonySrc/GeoOssuaryPatch.cs:
|
||||
- каждая из трёх ячеек модификаций принимает любой из пяти ресурсов (ваниль даёт
|
||||
ячейке ровно один предмет из RequiredMods);
|
||||
- ячейка продукции i выдаёт полный стак того ресурса, что лежит в ячейке
|
||||
модификации i (ваниль смотрит только "занята/пуста", а не на предмет), пустая
|
||||
модификация = ячейка стоит и сферу не ест; проверка неба снята.
|
||||
Отсюда три OutputType os1..os3 на три ячейки продукции: у каждого свой таймер и своя
|
||||
ячейка. OutputItem в них - заглушка, настоящий ресурс подставляет патч.
|
||||
Топливо общее: 1 сфера = 1 стак (FuelCost 1, AdditionalFuelCost 0). Время как у росы.
|
||||
ModTypes=Modify - флаг, который ваниль ни на что не тратит: Speed/Count/Expand
|
||||
изменили бы скорость, выход или размер сетки.
|
||||
Модель своя (Blender: кости, корни, сосуды, клыки, черепа с глазами-углями), бандл
|
||||
necroossuary, собрана из префаба коллектора росы: LODGroup, слои и коллайдер ванильные.
|
||||
Шейдер материалу ставит GeoOssuaryPatch (шейдер ванильного коллектора). -->
|
||||
<block name="necroGeoOssuary">
|
||||
<property name="Extends" value="cntDewCollector" />
|
||||
<property name="UnlockedBy" value="craftingNecroNecromancy" />
|
||||
<property name="DescriptionKey" value="necroGeoOssuaryDesc" />
|
||||
<property name="LootLabelKey" value="xuiNecroGeoOssuary" />
|
||||
<property name="ItemIconBackdrop" value="ui_game_symbol_mining" />
|
||||
<property name="ItemBackgroundColor" value="140,20,20,255" />
|
||||
<property name="FuelTypes" value="{blood,resourceBloodSphere,resourceBloodStone}" /><!-- камень - вечное топливо (GeoOssuaryPatch.removeFuel) -->
|
||||
<property name="OutputTypes" value="{os1,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}{os2,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}{os3,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}" />
|
||||
<property name="Outputs" value="os1,os2,os3" />
|
||||
<property name="OutputGridHeight" value="1" />
|
||||
<property name="FuelGridHeight" value="2" />
|
||||
<property name="RequiredMods" value="resourceRockSmall,resourceRockSmall,resourceRockSmall" />
|
||||
<property name="RequiredModsOnly" value="true" />
|
||||
<property name="ModTypes" value="Modify,Modify,Modify" />
|
||||
<property name="ModTransformEnableNames" value=",," />
|
||||
<property name="EmptyText" value="necroGeoOssuaryEmpty" />
|
||||
<property name="HasItem1Text" value="necroGeoOssuaryHasItems" />
|
||||
<property name="HasItem2Text" value="necroGeoOssuaryHasItems" />
|
||||
<property name="FuelTitleText" value="necroGeoOssuaryFuel" />
|
||||
<property name="FuelTitleSprite" value="ui_game_symbol_skull" />
|
||||
<property name="FuelTitleSpriteAtlas" value="UIAtlas" />
|
||||
<property name="FuelTypesSprites" value="BloodSphere,ItemIconAtlasGreyscale" />
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroossuary?necroOssuaryPrefab.prefab" />
|
||||
<property name="CustomIcon" value="necroGeoOssuary" />
|
||||
<property name="EconomicValue" value="0" />
|
||||
<property name="SellableToTrader" value="false" />
|
||||
<property class="RepairItems">
|
||||
<property name="resourceBone" value="50" />
|
||||
</property>
|
||||
</block>
|
||||
</append>
|
||||
|
||||
<!-- «Книга памяти» (03.10.2026): единственный интерактивный предмет чертогов разума, по E - окно
|
||||
«Память» (HarmonySrc/BlockNecroMemoryBook.cs). Вид - одиночная книга игры, без хранилища.
|
||||
CreativeMode Dev - чтобы можно было поставить в редакторе префабов. -->
|
||||
<append xpath="/blocks">
|
||||
<block name="necroMemoryBook">
|
||||
<property name="Extends" value="decoEntityClothMaster"/>
|
||||
<property name="Class" value="NecromancerTome.NecroMemoryBook, NecromancerHarmony"/>
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
<property name="CustomIcon" value="necroMemoryBook"/> <!-- 03.10: свой рендер книги памяти -->
|
||||
<!-- 03.10: своя модель - книга некроманта: чёрная кожа, кроваво-алый рисунок (z-mind), крупнее и толще книг призыва -->
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookMindPrefab.prefab"/>
|
||||
<property name="ModelOffset" value="0,0,0"/>
|
||||
<property name="GndAlign" value="1"/>
|
||||
<property name="WaterFlow" value="permitted"/>
|
||||
<property name="CanPickup" value="false"/>
|
||||
</block>
|
||||
</append>
|
||||
|
||||
<!-- Нож-декорация в чертогах разума (03.10.2026): копия ножа некроманта на столе с реактивами,
|
||||
просто декорация, руны светятся (эмиссия материала ножа). Модель - свой бандл necroknife.
|
||||
Поворот задаёт MindPalace.KnifeRotation (подбирается командой necropalace knife N). -->
|
||||
<append xpath="/blocks">
|
||||
<block name="necroKnifeDecor">
|
||||
<property name="Extends" value="decoEntityClothMaster"/>
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
<property name="CustomIcon" value="NecromancersKnife"/> <!-- 03.10: иконка ножа-предмета (имя предмета иконкой не работает) -->
|
||||
<!-- 03.10: своя модель-декорация - нож уже лежит плашмя, низом на дне клетки (как нижняя обложка
|
||||
книги памяти). Модель ножа-предмета стоит вертикально (для руки), а блоки-модели в 3.x ставятся
|
||||
«реквизитом», для которого код блока не вызывается - поэтому плашмя в самой модели, не в коде. -->
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroknife?necroKnifeDecorPrefab.prefab"/>
|
||||
<property name="ModelOffset" value="0,0,0"/>
|
||||
<property name="WaterFlow" value="permitted"/>
|
||||
<property name="CanPickup" value="false"/>
|
||||
</block>
|
||||
</append>
|
||||
<!-- Кровать чертогов (03.10.2026): копия bunkBedMadeWhite без Class="SleepingBag" - просто мебель.
|
||||
Чертоги ставятся без игрока, и игра делала кровать префаба точкой возрождения: синий камень
|
||||
портала отправлял в чертоги. MindPalace.Expected подменяет ею кровать префаба при постановке. -->
|
||||
<append xpath="/blocks">
|
||||
<block name="necroPalaceBed">
|
||||
<property name="Extends" value="bunkBedMadeWhite" param1="Class,DescriptionKey,DowngradeBlock"/>
|
||||
<property name="CreativeMode" value="None"/>
|
||||
<property name="CanPickup" value="false"/>
|
||||
</block>
|
||||
</append>
|
||||
<!-- Плакаты-схемы в чертогах (03.10.2026): по образцу ванильной pictureFrameGunSchematic, крупнее.
|
||||
Блок 2×1×1, рамка-плакат 1.405×0.95 м (полотно 3:2 под плакат), тонкий багет 2 см; модель - бандл
|
||||
necroposters (на каждый плакат свой префаб с тем же багетом). Исходник рамки:
|
||||
BlenderWork/necroSchemeFramePrefab/build_frame.py. -->
|
||||
<append xpath="/blocks">
|
||||
<block name="necroPosterAnatomy">
|
||||
<property name="Extends" value="pictureFrame_01a"/>
|
||||
<property name="CreativeMode" value="Dev"/>
|
||||
<property name="CustomIcon" value="necroPosterAnatomy"/>
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroPosterAnatomyPrefab.prefab"/>
|
||||
<property name="ModelOffset" value="0,0,-0.5"/>
|
||||
<property name="MultiBlockDim" value="2,1,1"/>
|
||||
<property name="CanPickup" value="false"/>
|
||||
</block>
|
||||
</append>
|
||||
<!-- Кровавая роза (05.10.2026): «кровавые семена» из 50 семян травы и банки крови некроманта.
|
||||
Три этапа по образцу ванильного хлопка: росток -> бутон -> чёрный цветок (урожай).
|
||||
Сажается только на грядку (FertileLevel 15 от cropsGrowingMaster), каждый этап - GrowthRate
|
||||
мастера (63 мин). Росток - ванильный хлопок; бутоны и цветущий куст - бандл necrorose (BlenderWork/necroBloodRosePrefab:
|
||||
build_rose.py, set_quality('game'); Unity: NecroRoseSetup.cs). Тег коллайдера - DecorColliderTagPatch.
|
||||
Иконка BloodSeeds - рендер _private/Extracted/BlenderWork/necroBloodSeed (make_icon.py). -->
|
||||
<append xpath="/blocks">
|
||||
<block name="plantedNecroBloodRose1">
|
||||
<property name="Extends" value="cropsGrowingMaster" param1="CustomIcon"/>
|
||||
<property name="CreativeMode" value="Player"/>
|
||||
<property name="CustomIcon" value="BloodSeeds"/>
|
||||
<property name="DescriptionKey" value="plantedNecroBloodRose1Desc"/>
|
||||
<property name="Shape" value="ModelEntity"/>
|
||||
<property name="Model" value="@:Entities/Plants/plantCottonSproutPrefab.prefab"/>
|
||||
<property class="PlantGrowing">
|
||||
<property name="Next" value="plantedNecroBloodRose2"/>
|
||||
</property>
|
||||
<property name="PlaceAsRandomRotation" value="true"/>
|
||||
<drop event="Destroy" name="plantedNecroBloodRose1" count="1"/>
|
||||
<property name="SortOrder1" value="B953"/>
|
||||
<property name="SortOrder2" value="0100"/>
|
||||
<property name="Group" value="Resources"/>
|
||||
<property name="SoundPickup" value="seeds_generic1_grab"/>
|
||||
<property name="SoundPlace" value="seeds_generic1_place"/>
|
||||
</block>
|
||||
<block name="plantedNecroBloodRose2">
|
||||
<property name="Extends" value="cropsGrowingMaster"/>
|
||||
<property name="CreativeMode" value="None"/>
|
||||
<property name="CustomIcon" value="BloodSeeds"/>
|
||||
<property name="Shape" value="ModelEntity"/>
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrorose?necroBloodRose2Prefab.prefab"/>
|
||||
<property class="PlantGrowing">
|
||||
<property name="Next" value="plantedNecroBloodRose3HarvestPlayer"/>
|
||||
</property>
|
||||
<drop event="Destroy" name="plantedNecroBloodRose1" count="1"/>
|
||||
<property name="SortOrder1" value="B953"/>
|
||||
<property name="SortOrder2" value="0101"/>
|
||||
</block>
|
||||
<block name="plantedNecroBloodRose3HarvestPlayer">
|
||||
<property name="Extends" value="cropsHarvestableMaster"/>
|
||||
<property name="CreativeMode" value="None"/>
|
||||
<property name="CustomIcon" value="BloodSeeds"/>
|
||||
<property name="DescriptionKey" value="plantedNecroBloodRose3HarvestPlayerDesc"/>
|
||||
<property name="Shape" value="ModelEntity"/>
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrorose?necroBloodRose3Prefab.prefab"/>
|
||||
<drop event="Harvest" name="resourceNecroBlackRose" count="3" tag="cropHarvest"/>
|
||||
<drop event="Harvest" name="resourceNecroBlackRose" count="1" tag="farmerBonusHarvest"/>
|
||||
<drop event="Harvest" name="resourceNecroBlackRose" prob="0.5" count="1" tag="bonusCropHarvest"/>
|
||||
<drop event="Harvest" name="plantedNecroBloodRose1" count="1" tag="farmerBonusSeed"/>
|
||||
<drop event="Destroy" name="plantedNecroBloodRose1" count="1" prob="0.5"/>
|
||||
<property name="SortOrder1" value="B953"/>
|
||||
<property name="SortOrder2" value="0102"/>
|
||||
</block>
|
||||
</append>
|
||||
<!-- Ступка (05.10.2026): верстак 1 куб, на нём сок чёрной розы. Устроен как cementMixer (Workstation
|
||||
с одним модулем output, без топлива); окно - workstation_necroMortar в XUi_InGame/xui.xml.
|
||||
Модель - бандл necromortar (BlenderWork/necroMortarPrefab, руны запечены в текстуру; Unity:
|
||||
NecroMortarSetup.cs + NecroAuto.ImportMeshAndBuild). BoxCollider на корне, тег - DecorColliderTagPatch. -->
|
||||
<append xpath="/blocks">
|
||||
<block name="necroMortar">
|
||||
<property name="Class" value="Workstation"/>
|
||||
<property class="Workstation">
|
||||
<property name="Modules" value="output"/>
|
||||
</property>
|
||||
<property name="CreativeMode" value="Player"/>
|
||||
<property name="DescriptionKey" value="necroMortarDesc"/>
|
||||
<property name="CustomIcon" value="necroMortar"/>
|
||||
<property name="WorkstationIcon" value="ui_game_symbol_skull"/>
|
||||
<property name="CraftIcon" value="ui_game_symbol_skull"/>
|
||||
<property name="CraftActionName" value="lblContextActionCraft"/>
|
||||
<property name="Stacknumber" value="1"/>
|
||||
<property name="Material" value="Mstone"/>
|
||||
<property name="Shape" value="ModelEntity"/>
|
||||
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necromortar?necroMortarPrefab.prefab"/>
|
||||
<property name="ImposterDontBlock" value="true"/>
|
||||
<property name="WaterFlow" value="permitted"/>
|
||||
<property name="Place" value="TowardsPlacer"/>
|
||||
<property name="OnlySimpleRotations" value="true"/>
|
||||
<property name="IsTerrainDecoration" value="true"/>
|
||||
<property name="StabilitySupport" value="false"/>
|
||||
<property name="MaxDamage" value="300"/>
|
||||
<property name="EconomicValue" value="50"/>
|
||||
<property name="OpenSound" value="open_workbench"/>
|
||||
<property name="CloseSound" value="close_workbench"/>
|
||||
<property name="TakeDelay" value="5"/>
|
||||
<property name="Group" value="Building,advBuilding"/>
|
||||
<property name="FilterTags" value="MC_playerBlocks,SC_decor"/>
|
||||
<drop event="Destroy" name="resourceRockSmall" count="10"/>
|
||||
</block>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -343,4 +343,19 @@
|
||||
</buff>
|
||||
|
||||
</append>
|
||||
|
||||
<!-- «Стиратель» (02.10.2026): свечение маджента - свободный цвет (у игры зелёный/голубой/огонь,
|
||||
у мода зелёный, фиолетовый, жёлто-зелёный, бирюзовый). Цвет даёт ParticlePatch.cs (EraserGlow).
|
||||
Вешается свойством Buffs сущности zombieEraser. -->
|
||||
<append xpath="/buffs">
|
||||
<buff name="buffNecroEraser" hidden="true" remove_on_death="false">
|
||||
<stack_type value="ignore"/>
|
||||
<duration value="0"/>
|
||||
<effect_group>
|
||||
<triggered_effect trigger="onSelfBuffStart" action="AttachParticleEffectToEntity" particle="RadiatedParticlesOnMesh" local_offset="0,0.75,0"/>
|
||||
<triggered_effect trigger="onSelfBuffRemove" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||
<triggered_effect trigger="onSelfDied" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
|
||||
</effect_group>
|
||||
</buff>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -141,5 +141,47 @@
|
||||
<objective type="Craft" item="thrownStonePortalBlue"/>
|
||||
</challenge>
|
||||
|
||||
<!-- ИСПЫТАНИЯ 1.7.0 (01.10.2026, указание: «добавь испытания по линии некромантии, сам
|
||||
придумай; часть - первое создание новых предметов»). Пять - первое создание нового
|
||||
(скакун, оссуарий, запас сфер, камень, свиток крысы), два - вещи, на которые испытаний
|
||||
ещё не было (браслет, пирамида). Награды по тому же счёту, что выше: 1000 - быстрые
|
||||
вещи (10 сфер, свиток), 2000 - станция/браслет/пирамида, 2500 - скакун и камень
|
||||
(дорогие рецепты на верстаке и химстанции). Иконки - только ui_game_symbol_*. -->
|
||||
<challenge name="necroBoneSteed" title_key="challengeNecroBoneSteedTitle" icon="ui_game_symbol_motorcycle" group="NecroPath"
|
||||
short_description_key="challengeNecroBoneSteedShort" description_key="challengeNecroBoneSteedDesc"
|
||||
reward_text_key="challenge_reward_2500xp" reward_event="challenge_reward_2500">
|
||||
<objective type="Craft" item="necroBoneSteedPlaceable"/>
|
||||
</challenge>
|
||||
<challenge name="necroGeoOssuary" title_key="challengeNecroGeoOssuaryTitle" icon="ui_game_symbol_challenge_harvesting_cobblestone" group="NecroPath"
|
||||
short_description_key="challengeNecroGeoOssuaryShort" description_key="challengeNecroGeoOssuaryDesc"
|
||||
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
|
||||
<objective type="BlockPlace" block="necroGeoOssuary" count="1"/>
|
||||
</challenge>
|
||||
<challenge name="necroBloodReserve" title_key="challengeNecroBloodReserveTitle" icon="ui_game_symbol_laceration" group="NecroPath"
|
||||
short_description_key="challengeNecroBloodReserveShort" description_key="challengeNecroBloodReserveDesc"
|
||||
reward_text_key="challenge_reward_1000xp" reward_event="challenge_reward_1000">
|
||||
<objective type="Craft" item="resourceBloodSphere" count="10"/>
|
||||
</challenge>
|
||||
<challenge name="necroBloodStone" title_key="challengeNecroBloodStoneTitle" icon="ui_game_symbol_resource" group="NecroPath"
|
||||
short_description_key="challengeNecroBloodStoneShort" description_key="challengeNecroBloodStoneDesc"
|
||||
reward_text_key="challenge_reward_2500xp" reward_event="challenge_reward_2500">
|
||||
<objective type="Craft" item="resourceBloodStone"/>
|
||||
</challenge>
|
||||
<challenge name="necroRatKing" title_key="challengeNecroRatKingTitle" icon="ui_game_symbol_book" group="NecroPath"
|
||||
short_description_key="challengeNecroRatKingShort" description_key="challengeNecroRatKingDesc"
|
||||
reward_text_key="challenge_reward_1000xp" reward_event="challenge_reward_1000">
|
||||
<objective type="Craft" item="bookSummonRatSpirit"/>
|
||||
</challenge>
|
||||
<challenge name="necroPocketVault" title_key="challengeNecroPocketVaultTitle" icon="ui_game_symbol_backpack" group="NecroPath"
|
||||
short_description_key="challengeNecroPocketVaultShort" description_key="challengeNecroPocketVaultDesc"
|
||||
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
|
||||
<objective type="Craft" item="braceletSpatialVault"/>
|
||||
</challenge>
|
||||
<challenge name="necroHeresy" title_key="challengeNecroHeresyTitle" icon="ui_game_symbol_7th_curse" group="NecroPath"
|
||||
short_description_key="challengeNecroHeresyShort" description_key="challengeNecroHeresyDesc"
|
||||
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
|
||||
<objective type="BlockPlace" block="necroHeresyPyramid" count="1"/>
|
||||
</challenge>
|
||||
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -430,4 +430,49 @@
|
||||
<property name="ExperienceGain" value="0"/>
|
||||
</entity_class>
|
||||
</append>
|
||||
|
||||
<!-- Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей. Модель - бандл necrobonesteed
|
||||
(копия ванильного VMotorcycleP, переделанная в Blender); теги коллайдеров и шейдер
|
||||
ставит BoneSteedPatch с ванильного мотоцикла, топливо - кровавая сфера (там же). -->
|
||||
<append xpath="/entity_classes">
|
||||
<entity_class name="necroBoneSteed" extends="vehicleMotorcycle">
|
||||
<property name="Prefab" value="#@modfolder(NecromancerTome):Resources/necrobonesteed?necroBoneSteedP.prefab"/>
|
||||
<property name="Tags" value="vehicle,bikerSetBonus,necroSteed"/>
|
||||
<property name="SurfaceCategory" value="bone"/>
|
||||
<property name="LootList" value="vehicleMotorcycle"/>
|
||||
</entity_class>
|
||||
</append>
|
||||
|
||||
<!-- «Стиратель» (02.10.2026, HarmonySrc/EraserPatch.cs): античит к режиму змеи - приходит в орду
|
||||
кровавой луны игрока-змеи. Маленький, быстрый; взрывается, когда пытается сломать блок или
|
||||
когда его разрывает змея. Имя НЕ на necro (IsNecroEntity - иначе змея не считает врагом).
|
||||
Временный вид: тело толстого гавайца по ссылке игры + свечение маджента (buffNecroEraser);
|
||||
свои вздутия и морда спайдера - потом. -->
|
||||
<append xpath="/entity_classes">
|
||||
<entity_class name="zombieEraser" extends="zombieFatHawaiian">
|
||||
<property name="UserSpawnType" value="Menu"/>
|
||||
<property name="Tags" value="entity,zombie,walker,eraser"/>
|
||||
<property name="SizeScale" value="0.75"/>
|
||||
<property name="MoveSpeedAggro" value="1.0, 1.6"/>
|
||||
<!-- Звуки спайдера (указание 02.10) -->
|
||||
<property name="SoundRandom" value="spiderroam"/>
|
||||
<property name="SoundAlert" value="spideralert"/>
|
||||
<property name="SoundAttack" value="spiderattack"/>
|
||||
<property name="SoundDeath" value="spiderdeath"/>
|
||||
<property name="SoundHurt" value="spiderpain"/>
|
||||
<property name="SoundSense" value="spidersense"/>
|
||||
<property name="SoundStepType" value="barestep"/>
|
||||
<property name="Buffs" value="zombieBuffStatusCheck01,buffNecroEraser"/>
|
||||
<property class="Explosion">
|
||||
<property name="ParticleIndex" value="2"/>
|
||||
<property name="RadiusBlocks" value="4"/>
|
||||
<property name="RadiusEntities" value="5"/>
|
||||
<property name="BlockDamage" value="2500"/>
|
||||
<property name="EntityDamage" value="300"/>
|
||||
<property class="DamageBonus">
|
||||
<property name="earth" value=".1"/>
|
||||
</property>
|
||||
</property>
|
||||
</entity_class>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -414,6 +414,10 @@
|
||||
<!-- true: опустевшая сфера должна исчезать, а не лежать "сломанной" в ожидании
|
||||
ремонта. Само удаление из слота делает SpendCharge - см. SpatialVaultPickupPatch. -->
|
||||
<property name="DegradationBreaksAfter" value="true"/>
|
||||
<!-- Ремонт обычным пакетом крови (01.10: «пусть сферы можно будет чинить обычной ванильной
|
||||
кровью»): пункт «Починить» в инвентаре; сколько чинит пакет - RepairAmount у
|
||||
medicalBloodBag в items.xml. Пустая сфера исчезает, чинить можно только початую. -->
|
||||
<property name="RepairTools" value="medicalBloodBag"/>
|
||||
<effect_group name="resourceBloodSphere" tiered="false">
|
||||
<passive_effect name="DegradationMax" operation="base_set" value="500"/>
|
||||
</effect_group>
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
<property name="Extends" value="drinkJarEmpty"/>
|
||||
<property name="CustomIcon" value="tinCanEmpty"/>
|
||||
<property name="DescriptionKey" value="tinCanEmptyDesc"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Food/can_emptyPrefab.prefab"/>
|
||||
<!-- Своя модель (01.10.2026): ванильная консервная банка без отогнутой крышки - пустая
|
||||
открытая банка. Бандл necrocan; текстура пока ванильная cannedfood01 - до релиза
|
||||
перерисовать (гигиена ассетов). Банки с водой - свои модели с водой внутри. -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanPrefab.prefab"/>
|
||||
<property name="HoldType" value="14"/>
|
||||
<property name="Material" value="Mmetal"/>
|
||||
<property class="Action1"> <!-- UseAction -->
|
||||
@@ -48,8 +51,10 @@
|
||||
<property name="Extends" value="drinkJarRiverWater"/>
|
||||
<property name="CustomIcon" value="tinCanRiverWater"/>
|
||||
<property name="DescriptionKey" value="tinCanRiverWaterDesc"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Food/can_emptyPrefab.prefab"/>
|
||||
<property name="HoldType" value="14"/>
|
||||
<!-- Своя модель (01.10.2026): банка без крышки, внутри мутная бурая вода (бандл necrocan). -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanMurkyPrefab.prefab"/>
|
||||
<!-- HoldType 3 = хват стеклянной банки (drinkJar*): сразу питьё, без фазы вскрытия, которая есть у консервного хвата 14 (05.10.2026). -->
|
||||
<property name="HoldType" value="3"/>
|
||||
<property name="Material" value="Mmetal"/>
|
||||
<property class="Action0">
|
||||
<property name="Class" value="Eat"/>
|
||||
@@ -90,8 +95,9 @@
|
||||
<property name="Extends" value="drinkJarBoiledWater"/>
|
||||
<property name="CustomIcon" value="tinCanBoiledWater"/>
|
||||
<property name="DescriptionKey" value="tinCanBoiledWaterDesc"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Food/can_emptyPrefab.prefab"/>
|
||||
<property name="HoldType" value="14"/>
|
||||
<!-- Своя модель (01.10.2026): банка без крышки, внутри чистая прозрачная голубоватая вода (бандл necrocan). -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanCleanPrefab.prefab"/>
|
||||
<property name="HoldType" value="3"/> <!-- хват стеклянной банки, без вскрытия - см. tinCanRiverWater -->
|
||||
<property name="Material" value="Mmetal"/>
|
||||
<property class="Action0">
|
||||
<property name="Class" value="Eat"/>
|
||||
@@ -534,12 +540,10 @@
|
||||
how bookPrefab.prefab was actually authored/rigged to be held. No flight mesh
|
||||
needed here (unlike the thrown books above) since this item is never thrown. -->
|
||||
<property name="HoldType" value="21"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
|
||||
<!-- Своя модель книги (01.10.2026): кожа в тон шкуре зомбопса (серо-бурая), светящийся фиолетовый рисунок
|
||||
из референса; та же модель, что у книги крысы (бандл necrobooks), корешком к игроку. -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookDogPrefab.prefab"/>
|
||||
<property name="Material" value="Mpaper"/>
|
||||
<!-- Yellow-green (per user request), distinct from the pure green of the Spirit
|
||||
Stone/Grimoire above - keeps the "summon" books visually distinguishable from the
|
||||
"throw" books at a glance even before real icon art exists. -->
|
||||
<property name="TintColor" value="160, 220, 40"/>
|
||||
<property name="Weight" value="0"/>
|
||||
<property name="Stacknumber" value="10"/>
|
||||
<property name="EconomicValue" value="0"/>
|
||||
@@ -832,9 +836,10 @@
|
||||
<property name="DescriptionKey" value="bookSummonZombieGriffinDesc"/>
|
||||
<property name="DisplayType" value="ammoGrenadeFire"/>
|
||||
<property name="HoldType" value="21"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
|
||||
<!-- Своя модель книги (01.10.2026): кожа в тон перьям зомбостервятника (буро-красная), светящийся фиолетовый рисунок
|
||||
из референса; та же модель, что у книги крысы (бандл necrobooks), корешком к игроку. -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookVulturePrefab.prefab"/>
|
||||
<property name="Material" value="Mpaper"/>
|
||||
<property name="TintColor" value="160, 220, 40"/>
|
||||
<property name="Weight" value="0"/>
|
||||
<property name="Stacknumber" value="10"/>
|
||||
<property name="EconomicValue" value="0"/>
|
||||
@@ -892,9 +897,10 @@
|
||||
<property name="DescriptionKey" value="bookSummonZombieBearDesc"/>
|
||||
<property name="DisplayType" value="ammoGrenadeFire"/>
|
||||
<property name="HoldType" value="21"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
|
||||
<!-- Своя модель книги (03.10.2026): кожа тёмно-бурая, в тон шкуре зомбомедведя, светящийся фиолетовый рисунок из референса;
|
||||
та же модель, что у книги крысы (бандл necrobooks). -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookBearPrefab.prefab"/>
|
||||
<property name="Material" value="Mpaper"/>
|
||||
<property name="TintColor" value="160, 220, 40"/>
|
||||
<property name="Weight" value="0"/>
|
||||
<property name="Stacknumber" value="10"/>
|
||||
<property name="EconomicValue" value="0"/>
|
||||
@@ -948,9 +954,10 @@
|
||||
<property name="DescriptionKey" value="bookSummonZombieWolfDesc"/>
|
||||
<property name="DisplayType" value="ammoGrenadeFire"/>
|
||||
<property name="HoldType" value="21"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
|
||||
<!-- Своя модель книги (03.10.2026): кожа серая, в тон шкуре зомбоволка, светящийся фиолетовый рисунок из референса;
|
||||
та же модель, что у книги крысы (бандл necrobooks). -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookWolfPrefab.prefab"/>
|
||||
<property name="Material" value="Mpaper"/>
|
||||
<property name="TintColor" value="160, 220, 40"/>
|
||||
<property name="Weight" value="0"/>
|
||||
<property name="Stacknumber" value="10"/>
|
||||
<property name="EconomicValue" value="0"/>
|
||||
@@ -1067,9 +1074,11 @@
|
||||
<property name="DescriptionKey" value="bookSummonRatSpiritDesc"/>
|
||||
<property name="DisplayType" value="ammoGrenadeFire"/>
|
||||
<property name="HoldType" value="21"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
|
||||
<!-- Своя модель книги (01.10.2026, по иконке RatBook): чёрная кожа, фиолетовый светящийся
|
||||
узор со скелетом крысы и семиконечной звездой, ржавые уголки, кожаный ремешок.
|
||||
Бандл necrobooks, основа - копия ванильного bookPrefab (тот же размер и хват). -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookRatPrefab.prefab"/>
|
||||
<property name="Material" value="Mpaper"/>
|
||||
<property name="TintColor" value="120, 135, 120"/>
|
||||
<property name="Weight" value="0"/>
|
||||
<property name="Stacknumber" value="10"/>
|
||||
<property name="EconomicValue" value="0"/>
|
||||
@@ -1889,4 +1898,69 @@
|
||||
</effect_group>
|
||||
</item>
|
||||
</append>
|
||||
|
||||
<!-- Костяной скакун - предмет-транспорт (1.7.0), ванильная система: в руке призрачный
|
||||
предпросмотр (ItemActionSpawnVehicle грузит его из Meshfile - поэтому Meshfile = модель
|
||||
скакуна), применение ставит, подбор возвращает предмет с прочностью и модами. Решение
|
||||
01.10: «не книга призыва, а та же система, что у ванильного транспорта». -->
|
||||
<append xpath="/items">
|
||||
<item name="necroBoneSteedPlaceable">
|
||||
<property name="Extends" value="vehicleMotorcyclePlaceable"/>
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobonesteed?necroBoneSteedP.prefab"/>
|
||||
<property name="CustomIcon" value="necroBoneSteedPlaceable"/>
|
||||
<property name="DescriptionKey" value="necroBoneSteedPlaceableDesc"/>
|
||||
<property name="EconomicValue" value="0"/>
|
||||
<property name="SellableToTrader" value="false"/>
|
||||
<property name="TraderStageTemplate" value=""/>
|
||||
<property class="Action1">
|
||||
<property name="Vehicle" value="necroBoneSteed"/>
|
||||
</property>
|
||||
<!-- Extends НЕ наследует effect_group (ItemClassesFromXml парсит эффекты только со
|
||||
своего узла) - без этой группы у скакуна не было ни ячеек, ни прочности: здоровье 0,
|
||||
бак не заполнялся (тест 01.10). 4 ячейки - под кровавые сферы (BoneSteedFuelPatch),
|
||||
здоровье 4000 как у мотоцикла; кодом оно закреплено, чтобы DegradationMax сфер
|
||||
в ячейках его не перебивал. -->
|
||||
<effect_group name="necroBoneSteedPlaceable" tiered="false">
|
||||
<passive_effect name="ModSlots" operation="base_set" value="4"/>
|
||||
<passive_effect name="DegradationMax" operation="base_set" value="4000"/>
|
||||
</effect_group>
|
||||
</item>
|
||||
</append>
|
||||
<!-- Пакет крови чинит Кровавую сферу (RepairTools сферы, 01.10): 250 из 500 прочности за пакет,
|
||||
два пакета - полная сфера. У ванили пакет ничего не чинит, свойства новые. Кровь некроманта
|
||||
наследует пакет (Extends) - ей эти свойства ни на что не влияют: её ремонтом не чинят. -->
|
||||
<append xpath="/items/item[@name='medicalBloodBag']">
|
||||
<property name="RepairAmount" value="250"/>
|
||||
<property name="RepairTime" value="2"/>
|
||||
</append>
|
||||
<!-- Чёрная роза (05.10.2026): урожай кровавой розы (blocks.xml, plantedNecroBloodRose*).
|
||||
Пока ни во что не идёт - рецепты под неё будут отдельно. Вид в руке временный (мешочек как у
|
||||
ванильных цветов); иконка NecroBlackRose - рендер BlenderWork/necroBloodRosePrefab (make_icon.py). -->
|
||||
<append xpath="/items">
|
||||
<item name="resourceNecroBlackRose">
|
||||
<property name="DescriptionKey" value="resourceNecroBlackRoseDesc"/>
|
||||
<property name="HoldType" value="45"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Misc/sackPrefab.prefab"/>
|
||||
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
|
||||
<property name="CustomIcon" value="NecroBlackRose"/>
|
||||
<property name="Material" value="MresourceCrop"/>
|
||||
<property name="Stacknumber" value="500"/>
|
||||
<property name="EconomicValue" value="40"/>
|
||||
<property name="Group" value="Resources"/>
|
||||
<property name="SoundPickup" value="plantfibers_grab"/>
|
||||
<property name="SoundPlace" value="plantfibers_place"/>
|
||||
</item>
|
||||
</append>
|
||||
<!-- Сок чёрной розы (05.10.2026): банка, на ступке из 4 роз. Заменяет кровь некроманта в копиях
|
||||
рецептов (recipes.xml). Наследует кровь (стак, звуки, хват банки); модель - necroJuicePrefab в бандле
|
||||
necroblood (копия банки крови, текстуры make_blood_jar_textures.py juice; Unity: NecroJuiceSetup.cs).
|
||||
Кровь в свою очередь наследует medicalBloodBag - сок, как и кровь, ничем не лечит. -->
|
||||
<append xpath="/items">
|
||||
<item name="resourceNecroBlackRoseJuice">
|
||||
<property name="Extends" value="resourceNecromancerBlood"/>
|
||||
<property name="DescriptionKey" value="resourceNecroBlackRoseJuiceDesc"/>
|
||||
<property name="CustomIcon" value="NecroBlackRoseJuice"/>
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroJuicePrefab.prefab"/>
|
||||
</item>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -84,4 +84,19 @@
|
||||
</compass_settings>
|
||||
</nav_object_class>
|
||||
</append>
|
||||
|
||||
<!-- Дух змеи на карте (02.10.2026, просьба пользователя): фиолетовая змея - иконка следа змеи
|
||||
(ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же
|
||||
змеи бессмысленна. Вешается в SnakeControl.Begin, снимается в End. -->
|
||||
<append xpath="/nav_object_classes">
|
||||
<nav_object_class name="necroSnakeSpirit">
|
||||
<map_settings>
|
||||
<property name="sprite_name" value="ui_game_symbol_tracking_snake"/>
|
||||
<property name="min_distance" value="0"/>
|
||||
<property name="max_distance" value="-1"/>
|
||||
<property name="color" value="170,60,255,255"/>
|
||||
<property name="has_pulse" value="false"/>
|
||||
</map_settings>
|
||||
</nav_object_class>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
ОБЯЗАНЫ совпадать. display_entry рисует замок сам по себе, по unlock_tier, и про
|
||||
теги не знает - разойдутся, и на панели будет замок на доступном рецепте. -->
|
||||
<display_entry icon="ScrollOfDeviation" name_key="craftingNecroNecromancyTier2Name" has_quality="false" unlock_level="25,65,70,85">
|
||||
<unlock_entry item="thrownBookGrimoireDeviation,bookSummonZombieGriffin" unlock_tier="1"/>
|
||||
<unlock_entry item="thrownBookGrimoireDeviation,bookSummonZombieGriffin,necroGeoOssuary,necroBoneSteedPlaceable,necroMortar" unlock_tier="1"/>
|
||||
<!-- Зомбогриф, 18.09: "гриф пусть тоже будет на подмастерье, но 1300 (65 уровень
|
||||
некромантии)". 65 вставлен в unlock_level МЕЖДУ 25 и 70, поэтому unlock_tier
|
||||
у двух модов ножа ниже сдвинулись на единицу (были 2 и 3, стали 3 и 4).
|
||||
|
||||
@@ -473,4 +473,121 @@
|
||||
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||
</recipe>
|
||||
</append>
|
||||
<!-- Гео-оссуарий (1.7.0, 2026-09-30). Рецепт придуман по указанию «туда явно нужен флакон
|
||||
крови некроманта и много костей»; дерево и волокна - корни, которыми
|
||||
оплетены кости (добавлено тем же вечером). Порог - «Подмастерье» (уровень 25, как Гримуар
|
||||
девиации): к этому времени игрок строит базу и кузницу, и камень с железом ему нужны
|
||||
именно тогда (правило «порог по нужде, а не по силе»). Тег и unlock_level группы 2 в
|
||||
progression.xml обязаны совпадать. -->
|
||||
<append xpath="/recipes">
|
||||
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
|
||||
<ingredient name="resourceBone" count="400"/>
|
||||
<ingredient name="resourceZombieAsh" count="200"/>
|
||||
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||
<ingredient name="resourceWood" count="100"/>
|
||||
<ingredient name="resourceYuccaFibers" count="50"/>
|
||||
</recipe>
|
||||
</append>
|
||||
|
||||
<!-- Костяной скакун (1.7.0, 01.10.2026). Порог - «Подмастерье» (уровень 25), как у
|
||||
гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по
|
||||
указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. -->
|
||||
<append xpath="/recipes">
|
||||
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
|
||||
<ingredient name="resourceBone" count="300"/>
|
||||
<ingredient name="foodRottingFlesh" count="30"/>
|
||||
<ingredient name="resourceZombieAsh" count="150"/>
|
||||
<ingredient name="resourceVictimSkin" count="3"/>
|
||||
<ingredient name="resourceNecromancerBlood" count="2"/>
|
||||
</recipe>
|
||||
</append>
|
||||
<!-- Кровавые семена (05.10.2026): «50 семян травы + банка крови некроманта». Порог - как у
|
||||
самой крови: рецепт крови без тегов разблокировки, значит и этот тоже. -->
|
||||
<append xpath="/recipes">
|
||||
<recipe name="plantedNecroBloodRose1" count="1" tags="packMuleCrafting">
|
||||
<ingredient name="plantedtreeGrassSeed1" count="50"/>
|
||||
<ingredient name="resourceNecromancerBlood" count="1"/>
|
||||
</recipe>
|
||||
</append>
|
||||
<!-- Ступка и сок чёрной розы (05.10.2026). Ступка - с рук, на Подмастерье (указание: «рецепт
|
||||
ступки пусть открывается на уровне подмастерья»; пара - unlock_entry в progression.xml).
|
||||
Сок: 4 розы + пустая банка на ступке. Прикидка: куст без перка даёт 3 розы, то есть ~3/4 банки
|
||||
сока (~1.5 крови на семя, которое стоило 1 кровь); с перком «Жизнь на природе» - вдвое-вчетверо. -->
|
||||
<append xpath="/recipes">
|
||||
<recipe name="necroMortar" count="1" tags="learnable,packMuleCrafting,necroNecromancyJourneyman">
|
||||
<ingredient name="resourceRockSmall" count="20"/>
|
||||
<ingredient name="resourceWood" count="2"/>
|
||||
</recipe>
|
||||
<recipe name="resourceNecroBlackRoseJuice" count="1" craft_time="10" craft_area="necroMortar">
|
||||
<ingredient name="resourceNecroBlackRose" count="4"/>
|
||||
<ingredient name="drinkJarEmpty" count="1"/>
|
||||
</recipe>
|
||||
</append>
|
||||
<!-- Копии всех рецептов на крови некроманта, где кровь заменена соком чёрной розы (05.10.2026).
|
||||
Указание: где крови больше одной банки, банка сока = две банки крови (нечётное - вверх: 3 -> 2);
|
||||
где одна - одна банка сока. Сферы: из банки сока 3 вместо 2. Имена те же - игра показывает
|
||||
их как второй вариант рецепта, теги и порог у копии те же, что у оригинала.
|
||||
Новый рецепт на крови - сразу копию сюда. -->
|
||||
<append xpath="/recipes">
|
||||
<recipe name="thrownStonePortalBlack" count="1" tags="learnable,packMuleCrafting,necroNecromancyMaster">
|
||||
<ingredient name="resourceRockSmall" count="1"/>
|
||||
<ingredient name="medicalBloodBag" count="5"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="5"/>
|
||||
<ingredient name="casinoCoin" count="20"/>
|
||||
<ingredient name="resourceYuccaFibers" count="15"/>
|
||||
<ingredient name="resourceZombieAsh" count="20"/>
|
||||
</recipe>
|
||||
<recipe name="necroHeresyPyramid" count="1" craft_area="workbench" tags="workbenchCrafting,necroNecromancyAdept">
|
||||
<ingredient name="resourceZombieAsh" count="1200"/>
|
||||
<ingredient name="resourceRockSmall" count="300"/>
|
||||
<ingredient name="resourceYuccaFibers" count="200"/>
|
||||
<ingredient name="resourceScrapIron" count="300"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="2"/>
|
||||
</recipe>
|
||||
<recipe name="necroWpnBladeNecroKnife" count="1" tags="packMuleCrafting">
|
||||
<ingredient name="resourceBone" count="5"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||
</recipe>
|
||||
<recipe name="necroModKnifeDeadMansGrip" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1400">
|
||||
<ingredient name="resourceZombieAsh" count="10"/>
|
||||
<ingredient name="resourceBone" count="15"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||
</recipe>
|
||||
<recipe name="necroModKnifeDeadStorm" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1700">
|
||||
<ingredient name="resourceZombieAsh" count="15"/>
|
||||
<ingredient name="resourceElectricParts" count="5"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
<ingredient name="resourceYuccaFibers" count="10"/>
|
||||
</recipe>
|
||||
<recipe name="resourceBloodSphere" count="3" tags="learnable,packMuleCrafting,necroNecromancyLvl20">
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
<ingredient name="resourceZombieAsh" count="5"/>
|
||||
</recipe>
|
||||
<recipe name="resourceBloodStone" count="1" craft_area="chemistryStation" tags="learnable,chemStationCrafting,necroNecromancyApprentice">
|
||||
<ingredient name="resourceZombieAsh" count="50"/>
|
||||
<ingredient name="resourceBone" count="20"/>
|
||||
<ingredient name="medicalBloodBag" count="4"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
</recipe>
|
||||
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
|
||||
<ingredient name="resourceBone" count="400"/>
|
||||
<ingredient name="resourceZombieAsh" count="200"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
<ingredient name="resourceWood" count="100"/>
|
||||
<ingredient name="resourceYuccaFibers" count="50"/>
|
||||
</recipe>
|
||||
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
|
||||
<ingredient name="resourceBone" count="300"/>
|
||||
<ingredient name="foodRottingFlesh" count="30"/>
|
||||
<ingredient name="resourceZombieAsh" count="150"/>
|
||||
<ingredient name="resourceVictimSkin" count="3"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
</recipe>
|
||||
<recipe name="plantedNecroBloodRose1" count="1" tags="packMuleCrafting">
|
||||
<ingredient name="plantedtreeGrassSeed1" count="50"/>
|
||||
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
|
||||
</recipe>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<config>
|
||||
<!-- Костяной скакун (1.7.0, 01.10.2026). vehicles.xml наследования не знает - это копия
|
||||
ванильного vehicleMotorcycle (игра 3.3) под именем сущности necroBoneSteed. Отличия:
|
||||
recipeName -> предмет necroBoneSteedPlaceable (его и возвращает подбор скакуна), без
|
||||
honkEvent (гудок открывает двери торговца - скакуну незачем). Мотор молчит
|
||||
(BoneSteedEnginePatch) - слышны только колёса, гудок - вопль умирающей
|
||||
зомби-собаки (zombiedogdeath). Топливо - кровавые сферы
|
||||
в ячейках модификаций (BoneSteedFuelPatch), канистрой не заправить. -->
|
||||
<append xpath="/vehicles">
|
||||
<vehicle name="necroBoneSteed">
|
||||
<property name="cameraDistance" value="3.5, 5"/>
|
||||
<property name="cameraTurnRate" value=".2, .35"/>
|
||||
<property name="upAngleMax" value="70"/>
|
||||
<property name="upForce" value="1"/>
|
||||
<property name="steerRate" value="130"/>
|
||||
<property name="steerCenteringRate" value="90"/>
|
||||
<property name="tiltAngleMax" value="20"/>
|
||||
<property name="tiltThreshold" value="3"/>
|
||||
<property name="tiltDampening" value=".22"/>
|
||||
<property name="tiltDampenThreshold" value="8"/>
|
||||
<property name="tiltUpForce" value="5"/>
|
||||
<property name="motorTorque_turbo" value="1400, 500, 2100, 650"/>
|
||||
<property name="velocityMax_turbo" value="9.8, 6, 14, 8"/>
|
||||
<property name="brakeTorque" value="3000"/>
|
||||
<property name="hopForce" value="1, .8"/>
|
||||
<property name="unstickForce" value="1"/>
|
||||
<property name="waterDrag_y_velScale_velMaxScale" value="1.7, .9, .1"/>
|
||||
<property name="wheelPtlScale" value="1"/>
|
||||
<property name="hornSound" value="zombiedogdeath"/><!-- вопль умирающей зомби-собаки (01.10) -->
|
||||
<property name="recipeName" value="necroBoneSteedPlaceable" />
|
||||
|
||||
<property class="chassis">
|
||||
<property name="class" value="Chassis"/>
|
||||
<property name="paint" value="M"/>
|
||||
</property>
|
||||
|
||||
<property class="engine">
|
||||
<property name="class" value="Engine"/>
|
||||
<!-- Частиц нет: ванильные дым/огонь в нашем бандле без материалов - розовые кубы (01.10). -->
|
||||
<property name="fuelKmPerL" value=".2"/>
|
||||
<property name="foodDrain" value=".002,.0101"/><!-- driving/turbo, motorcycle -->
|
||||
|
||||
<property name="gear1" value="
|
||||
500,2500, -1400,800,0, 700,2000,900,
|
||||
Vehicles/Motorbike/motorbike_accel_1,
|
||||
,
|
||||
0, .7, 1, .1, -9, .15, .1, Vehicles/Motorbike/motorbike_idle_lp,
|
||||
-.3, .1, .1, .4, -.05, 9, .2, Vehicles/Motorbike/motorbike_run_lp
|
||||
"/>
|
||||
<property name="gear2" value="
|
||||
500,2500, -2000,1000,2000, 620,2200,800,
|
||||
Vehicles/Motorbike/motorbike_accel_2,
|
||||
Vehicles/Motorbike/motorbike_decel,
|
||||
-.2, -.02, .1, .2, -1, 1, .1, Vehicles/Motorbike/motorbike_run_lp
|
||||
"/>
|
||||
<property name="gear3" value="
|
||||
500,2500, -2000,1000,2000, 570,2200,1000,
|
||||
Vehicles/Motorbike/motorbike_accel_3,
|
||||
Vehicles/Motorbike/motorbike_decel,
|
||||
-.21, -.05, .1, .2, -1, 1, .1, Vehicles/Motorbike/motorbike_run_lp
|
||||
"/>
|
||||
<property name="gear4" value="
|
||||
500,2500, -2000,1000,2000, 500,9999,9999,
|
||||
,
|
||||
Vehicles/Motorbike/motorbike_decel,
|
||||
-.25, -.08, .55, .55, -1, 1, .1, Vehicles/Motorbike/motorbike_run_lp
|
||||
"/>
|
||||
<property name="sound_start" value="Vehicles/Motorbike/minibike_start_"/>
|
||||
<property name="sound_shut_off" value="Vehicles/Motorbike/minibike_shutoff_"/>
|
||||
<property name="sound_no_fuel_shut_off" value="Vehicles/Motorbike/motorbike_empty"/>
|
||||
</property>
|
||||
|
||||
<property class="fuelTank">
|
||||
<property name="class" value="FuelTank"/>
|
||||
<!-- Уровень бака скакуна считается по сферам в ячейках (BoneSteedFuelPatch):
|
||||
4 сферы x 120. Мода бака нет - ячейки заняты кровью. -->
|
||||
<property name="capacity" value="480"/>
|
||||
</property>
|
||||
|
||||
<property class="handlebars">
|
||||
<property name="class" value="Steering"/>
|
||||
<property name="transform" value="M/Forks"/>
|
||||
<property name="steerAngle" value="0, 30, 0"/>
|
||||
<property name="IKHandLPosition" value="-.3362, .4901, -.1789"/>
|
||||
<property name="IKHandLRotation" value="-7.2919, 77.3004, 29.6909"/>
|
||||
<property name="IKHandRPosition" value=".3353, .4747, -.1735"/>
|
||||
<property name="IKHandRRotation" value="4.1479, 105.6533, -158.7339"/>
|
||||
<property name="paint" value="handlebar"/>
|
||||
</property>
|
||||
|
||||
<property class="seat0">
|
||||
<property name="class" value="Seat"/>
|
||||
<property name="pose" value="30"/>
|
||||
<property name="position" value="0, 0, 0"/>
|
||||
<property name="rotation" value="0, 0, 0"/>
|
||||
<property name="exit" value="-.9,0,0 ~ .9,0,0 ~ 0,0,-1.5 ~ 0,0,1.6 ~ 0,1.5,0"/>
|
||||
</property>
|
||||
|
||||
<property class="seat1">
|
||||
<property name="class" value="Seat"/>
|
||||
<property name="pose" value="31"/>
|
||||
<property name="position" value="0, 0, 0"/>
|
||||
<property name="rotation" value="0, 0, 0"/>
|
||||
<property name="exit" value="-.9,0,-.7 ~ .9,0,-.7 ~ -.6,0,-1.5 ~ -.6,0,1.6 ~ 0,1.5,-.6"/>
|
||||
<property name="mod" value="seat"/>
|
||||
<property name="modT" value="M/Seat"/>
|
||||
</property>
|
||||
|
||||
<property class="wheel0">
|
||||
<property name="tireTransform" value="M/Forks/Wheel0"/>
|
||||
<property name="torqueScale_motor_brake" value=".15, .5"/>
|
||||
</property>
|
||||
|
||||
<property class="wheel1">
|
||||
<property name="tireTransform" value="Wheel1"/>
|
||||
<property name="torqueScale_motor_brake" value="1, 1"/>
|
||||
</property>
|
||||
|
||||
<property class="headlight">
|
||||
<property name="class" value="Headlight"/>
|
||||
<property name="bright" value=".9"/>
|
||||
<property name="transform" value="M/Forks/HeadLights/Lights"/>
|
||||
<property name="matT" value="M/Forks/HeadLights"/>
|
||||
<property name="matEmissive" value="808080"/>
|
||||
<property name="mod" value="light"/>
|
||||
<property name="modT" value="M/Forks/LightMod"/>
|
||||
<property name="modOnT" value="M/Forks/LightMod/Lights"/>
|
||||
<property name="modMatEmissive" value="909090"/>
|
||||
<property name="tailEmissive" value="c0c0c0"/>
|
||||
</property>
|
||||
|
||||
<property class="armor">
|
||||
<property name="class" value="VehiclePart"/>
|
||||
<property name="mod" value="armor"/>
|
||||
<property name="modT" value="M/Forks/Armor"/>
|
||||
<property name="modRBT" value="Armor"/>
|
||||
</property>
|
||||
|
||||
<property class="storage">
|
||||
<property name="class" value="Storage"/>
|
||||
<property name="mod" value="storage"/>
|
||||
<property name="modT" value="M/Storage"/>
|
||||
</property>
|
||||
</vehicle>
|
||||
</append>
|
||||
</config>
|
||||
@@ -0,0 +1,200 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// «Книга памяти» в чертогах разума (указание 03.10): единственный предмет чертогов, с которым
|
||||
/// можно взаимодействовать. При наведении - «Нажмите E, чтобы прочитать», по E - окно «Память»
|
||||
/// (потом доделаем, как окна концовки в FinalSlides.cs). Блок подключается в blocks.xml через
|
||||
/// Class="NecromancerTome.NecroMemoryBook, NecromancerHarmony": игра (ReflectionHelpers.
|
||||
/// GetTypeWithPrefix) вставляет приставку Block перед именем после последней точки.
|
||||
/// Вид - одиночная книга игры (bookInvisibleKillerPrefab, как cntBookPile09 на столе префаба
|
||||
/// chertogi), но без хранилища и лута.
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class BlockNecroMemoryBook : Block
|
||||
{
|
||||
public const string ReadCommand = "read";
|
||||
|
||||
static readonly BlockActivationCommand[] Commands =
|
||||
{
|
||||
new BlockActivationCommand(ReadCommand, "book", true)
|
||||
};
|
||||
|
||||
public override bool HasBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public override BlockActivationCommand[] GetBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
|
||||
{
|
||||
return Commands;
|
||||
}
|
||||
|
||||
public override string GetActivationText(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
|
||||
{
|
||||
return Localization.Get("necroMemoryBookPrompt");
|
||||
}
|
||||
|
||||
public override bool OnBlockActivated(string _commandName, WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
|
||||
{
|
||||
return _commandName == ReadCommand && OnBlockActivated(_world, _blockPos, _blockValue, _player);
|
||||
}
|
||||
|
||||
public override bool OnBlockActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
|
||||
{
|
||||
Open(_player);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Окно книги (оглавление, главы). Отдельным методом - его же позовёт дух в чертогах (своё E).</summary>
|
||||
public static void Open(EntityPlayerLocal _player)
|
||||
{
|
||||
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
|
||||
if (ui == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 03.10: вместо окна-сообщения - книга с оглавлением (NecroTome.cs).
|
||||
NecroTomeBook.Open(_player);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- автор на обложке
|
||||
|
||||
/// <summary>Шрифт надписи - тот же готический, что у названия в текстуре (UnifrakturMaguntia, OFL).</summary>
|
||||
const string FontAsset = "#@modfolder(NecromancerTome):Resources/necrobooks?UnifrakturMaguntia-Book.ttf";
|
||||
const string AuthorChild = "NecroAuthor";
|
||||
|
||||
// Место под автора - просвет под рисунком (make_rat_book_textures.py, Mind), в осях корня модели
|
||||
// necroBookMindPrefab: из Blender (x, y, z) -> Unity (-x, z, -y); книга в модели повёрнута на -65°.
|
||||
// Подбирается на ходу командой necroauthor (x y z, размер, ширина).
|
||||
public static Vector3 AuthorPos = new Vector3(0.0944f, 0.0885f, -0.1534f);
|
||||
public static Vector3 AuthorUp = new Vector3(-0.4226f, 0f, 0.9063f);
|
||||
public static float AuthorHeight = 0.030f, AuthorMaxWidth = 0.21f;
|
||||
public static Color AuthorColor = new Color(0.85f, 0.06f, 0.05f, 1f);
|
||||
|
||||
static Font _font;
|
||||
|
||||
public override void OnBlockEntityTransformAfterActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, BlockEntityData _ebcd)
|
||||
{
|
||||
base.OnBlockEntityTransformAfterActivated(_world, _blockPos, _blockValue, _ebcd);
|
||||
try
|
||||
{
|
||||
if (_ebcd?.transform != null)
|
||||
{
|
||||
PlaceAuthor(_ebcd.transform);
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] книга памяти: автор не встал - " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ник игрока на обложке, как подпись на ящике: TextMesh поверх модели (модель общая,
|
||||
/// а ник у каждого свой, в текстуру его не запечь). Чертоги у каждого свои - пишем ник своего игрока.</summary>
|
||||
public static void PlaceAuthor(Transform _root)
|
||||
{
|
||||
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
string name = player != null ? player.PlayerDisplayName : null;
|
||||
Transform old = _root.Find(AuthorChild);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
if (old != null) Object.Destroy(old.gameObject);
|
||||
return;
|
||||
}
|
||||
if (_font == null)
|
||||
{
|
||||
_font = DataLoader.LoadAsset<Font>(FontAsset);
|
||||
}
|
||||
GameObject go = old != null ? old.gameObject : new GameObject(AuthorChild);
|
||||
go.layer = _root.gameObject.layer;
|
||||
Transform t = go.transform;
|
||||
t.SetParent(_root, false);
|
||||
t.localPosition = AuthorPos;
|
||||
// лицом вверх (обложка), низ строки - к нижнему краю обложки
|
||||
t.localRotation = Quaternion.LookRotation(Vector3.down, AuthorUp.normalized);
|
||||
t.localScale = Vector3.one;
|
||||
TextMesh tm = go.GetComponent<TextMesh>() ?? go.AddComponent<TextMesh>();
|
||||
MeshRenderer mr = go.GetComponent<MeshRenderer>();
|
||||
if (_font != null)
|
||||
{
|
||||
tm.font = _font;
|
||||
mr.sharedMaterial = _font.material;
|
||||
if (mr.sharedMaterial != null && (mr.sharedMaterial.shader == null || !mr.sharedMaterial.shader.isSupported))
|
||||
{
|
||||
Shader sh = Shader.Find("GUI/Text Shader");
|
||||
if (sh != null) mr.sharedMaterial.shader = sh;
|
||||
}
|
||||
}
|
||||
tm.text = name;
|
||||
tm.anchor = TextAnchor.MiddleCenter;
|
||||
tm.alignment = TextAlignment.Center;
|
||||
tm.fontSize = 96;
|
||||
tm.color = AuthorColor;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
tm.characterSize = 1f;
|
||||
// Размер подгоняется кадром позже: меш надписи TextMesh строит не сразу.
|
||||
NecroAuthorFit fit = go.GetComponent<NecroAuthorFit>() ?? go.AddComponent<NecroAuthorFit>();
|
||||
fit.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Подгонка надписи автора: высота строки AuthorHeight, длинный ник ужимается до AuthorMaxWidth.</summary>
|
||||
public class NecroAuthorFit : MonoBehaviour
|
||||
{
|
||||
void LateUpdate()
|
||||
{
|
||||
MeshRenderer mr = GetComponent<MeshRenderer>();
|
||||
Bounds b = mr != null ? mr.localBounds : default;
|
||||
if (b.size.y < 0.0001f)
|
||||
{
|
||||
return; // меш ещё не построен
|
||||
}
|
||||
float k = BlockNecroMemoryBook.AuthorHeight / b.size.y;
|
||||
if (b.size.x * k > BlockNecroMemoryBook.AuthorMaxWidth)
|
||||
{
|
||||
k = BlockNecroMemoryBook.AuthorMaxWidth / b.size.x;
|
||||
}
|
||||
transform.localScale = new Vector3(k, k, k);
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>necroauthor [x y z] [h N] [w N] - подобрать место и размер ника на книге памяти на ходу.</summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroAuthor : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necroauthor" };
|
||||
public override string getDescription() => "NecromancerTome: ник на книге памяти - x y z | h N | w N";
|
||||
|
||||
public override void Execute(System.Collections.Generic.List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
System.Globalization.CultureInfo ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
if (_params.Count == 3)
|
||||
{
|
||||
BlockNecroMemoryBook.AuthorPos = new Vector3(float.Parse(_params[0], ci), float.Parse(_params[1], ci), float.Parse(_params[2], ci));
|
||||
}
|
||||
else if (_params.Count == 2 && _params[0] == "h")
|
||||
{
|
||||
BlockNecroMemoryBook.AuthorHeight = float.Parse(_params[1], ci);
|
||||
}
|
||||
else if (_params.Count == 2 && _params[0] == "w")
|
||||
{
|
||||
BlockNecroMemoryBook.AuthorMaxWidth = float.Parse(_params[1], ci);
|
||||
}
|
||||
int n = 0;
|
||||
foreach (NecroAuthorFit f in Object.FindObjectsOfType<NecroAuthorFit>(true))
|
||||
{
|
||||
BlockNecroMemoryBook.PlaceAuthor(f.transform.parent);
|
||||
n++;
|
||||
}
|
||||
Vector3 p = BlockNecroMemoryBook.AuthorPos;
|
||||
SdtdConsole.Instance.Output(string.Format(ci, "автор: {0:0.####} {1:0.####} {2:0.####}, h {3}, w {4}; надписей {5}",
|
||||
p.x, p.y, p.z, BlockNecroMemoryBook.AuthorHeight, BlockNecroMemoryBook.AuthorMaxWidth, n));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Двигатель костяного скакуна (тест 01.10.2026):
|
||||
///
|
||||
/// - «ДАЖЕ БЕЗ СФЕР ЕДЕТ БЫСТРО». Ванильный VPEngine.startEngine ставит isRunning без
|
||||
/// проверки топлива, а глохнет только по событию FuelEmpty - то есть когда бак пустеет на
|
||||
/// ходу. Заведённый с пустым баком мотор так и работал на полной тяге. Здесь: без крови мотор
|
||||
/// не заводится, кончилась на ходу - глохнет; вставили сферу, сидя в седле, - заводится сам.
|
||||
/// Без мотора у ванили остаётся тяга x50 - еле ползёт, как пустой мотоцикл.
|
||||
/// - «ЗВУК МОТОЦИКЛА УБРАТЬ». Все звуки мотора (заводка, глушение, обороты) у скакуна
|
||||
/// молчат - слышно только то, что слышно у мотоцикла с пустым баком (колёса). Стук педалей
|
||||
/// пробовали - «тоже не нужен» (01.10).
|
||||
/// Дым и огонь (розовые кубы - частицы без материалов) убраны в vehicles.xml и префабе.
|
||||
/// </summary>
|
||||
public static class BoneSteedEngine
|
||||
{
|
||||
public static bool Is(VehiclePart part) => part?.vehicle != null && BoneSteed.Is(part.vehicle.entity);
|
||||
|
||||
public static bool HasBlood(Vehicle v) => BoneSteedFuel.FuelOf(v) > 0f;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.startEngine))]
|
||||
public static class Patch_VPEngine_startEngine_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPEngine __instance) =>
|
||||
!BoneSteedEngine.Is(__instance) || BoneSteedEngine.HasBlood(__instance.vehicle);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.Update))]
|
||||
public static class Patch_VPEngine_Update_BoneSteed
|
||||
{
|
||||
public static void Postfix(VPEngine __instance, float _dt)
|
||||
{
|
||||
if (!BoneSteedEngine.Is(__instance))
|
||||
return;
|
||||
Vehicle v = __instance.vehicle;
|
||||
bool blood = BoneSteedEngine.HasBlood(v);
|
||||
if (__instance.isRunning && !blood)
|
||||
__instance.stopEngine(true);
|
||||
else if (!__instance.isRunning && blood && v.entity.HasDriver && !__instance.IsBroken())
|
||||
__instance.startEngine();
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.playSound))]
|
||||
public static class Patch_VPEngine_playSound_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPEngine __instance) => !BoneSteedEngine.Is(__instance);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.updateEngineSounds))]
|
||||
public static class Patch_VPEngine_updateEngineSounds_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPEngine __instance) => !BoneSteedEngine.Is(__instance);
|
||||
}
|
||||
|
||||
/// <summary>Глазница черепа гаснет вместе с фарой (01.10). Свечение глазницы - карта эмиссии
|
||||
/// в текстуре вилки, ваниль её не переключает. На все уровни детализации вилки ставится
|
||||
/// MaterialPropertyBlock (материал не копируется): эмиссия белая при включённой фаре, чёрная -
|
||||
/// при выключенной. Обе ручки - _EmissionColor и _EmissionMultiply: какую из них читает
|
||||
/// шейдер транспорта, по декомпилятору не видно, а лишняя не мешает.</summary>
|
||||
public static class BoneSteedEye
|
||||
{
|
||||
static readonly MaterialPropertyBlock block = new MaterialPropertyBlock();
|
||||
|
||||
public static void Apply(EntityVehicle e, bool on)
|
||||
{
|
||||
if (e == null)
|
||||
return;
|
||||
foreach (Renderer r in e.transform.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
if (!r.name.StartsWith("frontFork_LOD"))
|
||||
continue;
|
||||
r.GetPropertyBlock(block);
|
||||
block.SetColor("_EmissionColor", on ? Color.white : Color.black);
|
||||
block.SetFloat("_EmissionMultiply", on ? 1f : 0f);
|
||||
r.SetPropertyBlock(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPHeadlight), nameof(VPHeadlight.UpdateOn))]
|
||||
public static class Patch_VPHeadlight_UpdateOn_BoneSteedEye
|
||||
{
|
||||
public static void Postfix(VPHeadlight __instance)
|
||||
{
|
||||
if (BoneSteedEngine.Is(__instance))
|
||||
BoneSteedEye.Apply(__instance.vehicle.entity, __instance.IsOn());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>При появлении скакуна фара выключена - глазница тоже тёмная.</summary>
|
||||
[HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.PostInit))]
|
||||
public static class Patch_EntityVehicle_PostInit_BoneSteedEye
|
||||
{
|
||||
public static void Postfix(EntityVehicle __instance)
|
||||
{
|
||||
if (!BoneSteed.Is(__instance))
|
||||
return;
|
||||
VPHeadlight light = null;
|
||||
foreach (VehiclePart part in __instance.GetVehicle().vehicleParts)
|
||||
if (part is VPHeadlight h)
|
||||
light = h;
|
||||
BoneSteedEye.Apply(__instance, light != null && light.IsOn());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Топливо костяного скакуна - кровавые сферы в ячейках модификаций (указание 01.10.2026:
|
||||
/// «4 ячейки под кровавые сферы, которые будут постепенно по очереди тратиться; заправку
|
||||
/// через канистру убрать»). Ванильный бак (VPFuelTank) остаётся частью машины - двигатель,
|
||||
/// HUD и глохнутие читают его, - но для скакуна его уровень не хранится, а СЧИТАЕТСЯ по
|
||||
/// сферам: каждая сфера - FuelPerSphere единиц, пропорционально остатку прочности.
|
||||
///
|
||||
/// Расход: двигатель шлёт событие FuelRemove; его префикс переводит количество в прочность
|
||||
/// первой сферы с остатком (ячейки по порядку); выработанная сфера исчезает из ячейки, как в
|
||||
/// браслете и оссуарии. Прямые записи уровня (SetFuelLevel: загрузка, синхронизация,
|
||||
/// заправка) игнорируются - кровь приходит только сферами.
|
||||
///
|
||||
/// Ячейки: у ванили в слот модификации идёт только модификация с подходящими тегами и не
|
||||
/// больше MaxModsAllowed одного вида. Сфера - модификация браслета, поэтому для скакуна
|
||||
/// CanSwap решает сам: только сфера с остатком прочности, прочие моды не ставятся.
|
||||
///
|
||||
/// Здоровье: Extends не наследует effect_group (ItemClassesFromXml парсит эффекты только
|
||||
/// со своего узла), поэтому у necroBoneSteedPlaceable свой DegradationMax в items.xml. Но
|
||||
/// эффекты установленных модификаций применяются к хозяину, а у сферы DegradationMax
|
||||
/// base_set 500 - со сферами здоровье скакуна упало бы до 500 и прыгало бы при их смене.
|
||||
/// Поэтому MaxUseTimesBase скакуна закреплён кодом.
|
||||
/// </summary>
|
||||
public static class BoneSteedFuel
|
||||
{
|
||||
public const int Slots = 4;
|
||||
public const float FuelPerSphere = 120f;
|
||||
public const float MaxFuel = Slots * FuelPerSphere;
|
||||
public const int MaxHealth = 4000;
|
||||
|
||||
static int steedItemType = -1;
|
||||
|
||||
public static int SteedItemType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (steedItemType < 0)
|
||||
{
|
||||
ItemValue iv = ItemClass.GetItem(BoneSteed.ItemName);
|
||||
steedItemType = iv != null ? iv.type : 0;
|
||||
}
|
||||
return steedItemType;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsSteedItem(ItemValue iv) =>
|
||||
iv != null && iv.type != 0 && iv.type == SteedItemType;
|
||||
|
||||
public static bool IsSphere(ItemValue iv) =>
|
||||
iv != null && !iv.IsEmpty() && iv.ItemClass != null && iv.ItemClass.Name == BoneSteed.FuelItem;
|
||||
|
||||
/// <summary>Кровавый камень - неиссякаемая сфера (01.10: «полная замена сферы»): один камень
|
||||
/// в любой ячейке - вечный полный бак, сферы рядом с ним не тратятся.</summary>
|
||||
public const string StoneItem = "resourceBloodStone";
|
||||
|
||||
public static bool IsStone(ItemValue iv) =>
|
||||
iv != null && !iv.IsEmpty() && iv.ItemClass != null && iv.ItemClass.Name == StoneItem;
|
||||
|
||||
public static bool HasStone(ItemValue item)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
for (int i = 0; i < item.ModificationCount; i++)
|
||||
if (IsStone(item.GetModification(i)))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Остаток сферы 0..1.</summary>
|
||||
static float Left(ItemValue sphere)
|
||||
{
|
||||
int max = sphere.MaxUseTimes;
|
||||
if (max <= 0)
|
||||
return 1f;
|
||||
return Mathf.Clamp01(1f - sphere.UseTimes / max);
|
||||
}
|
||||
|
||||
public static float FuelOf(Vehicle v)
|
||||
{
|
||||
ItemValue item = v?.itemValue;
|
||||
if (item == null)
|
||||
return 0f;
|
||||
if (HasStone(item))
|
||||
return MaxFuel;
|
||||
float fuel = 0f;
|
||||
for (int i = 0; i < item.ModificationCount; i++)
|
||||
{
|
||||
ItemValue m = item.GetModification(i);
|
||||
if (IsSphere(m))
|
||||
fuel += Left(m) * FuelPerSphere;
|
||||
}
|
||||
return fuel;
|
||||
}
|
||||
|
||||
/// <summary>Снимает топливо со сфер по очереди. Возвращает остаток топлива.</summary>
|
||||
public static float Burn(Vehicle v, float amount)
|
||||
{
|
||||
ItemValue item = v.itemValue;
|
||||
if (HasStone(item))
|
||||
return MaxFuel;
|
||||
for (int i = 0; i < item.ModificationCount && amount > 0f; i++)
|
||||
{
|
||||
ItemValue m = item.GetModification(i);
|
||||
if (!IsSphere(m))
|
||||
continue;
|
||||
int max = m.MaxUseTimes;
|
||||
if (max <= 0)
|
||||
{
|
||||
item.SetModification(i, ItemValue.None.Clone());
|
||||
amount -= FuelPerSphere;
|
||||
continue;
|
||||
}
|
||||
float have = Left(m) * FuelPerSphere;
|
||||
float take = Mathf.Min(have, amount);
|
||||
m.UseTimes += take / FuelPerSphere * max;
|
||||
amount -= take;
|
||||
if (m.UseTimes >= max - 0.001f)
|
||||
{
|
||||
item.SetModification(i, ItemValue.None.Clone());
|
||||
Debug.Log("[NecromancerTome] BoneSteed: сфера в ячейке " + i + " выработана и исчезла");
|
||||
}
|
||||
}
|
||||
return FuelOf(v);
|
||||
}
|
||||
|
||||
public static bool IsSteedTank(VPFuelTank tank) =>
|
||||
tank?.vehicle != null && BoneSteed.Is(tank.vehicle.entity);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetFuelLevel))]
|
||||
public static class Patch_VPFuelTank_GetFuelLevel_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPFuelTank __instance, ref float __result)
|
||||
{
|
||||
if (!BoneSteedFuel.IsSteedTank(__instance))
|
||||
return true;
|
||||
__result = BoneSteedFuel.FuelOf(__instance.vehicle);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetMaxFuelLevel))]
|
||||
public static class Patch_VPFuelTank_GetMaxFuelLevel_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPFuelTank __instance, ref float __result)
|
||||
{
|
||||
if (!BoneSteedFuel.IsSteedTank(__instance))
|
||||
return true;
|
||||
__result = BoneSteedFuel.MaxFuel;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetFuelLevelPercent))]
|
||||
public static class Patch_VPFuelTank_GetFuelLevelPercent_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPFuelTank __instance, ref float __result)
|
||||
{
|
||||
if (!BoneSteedFuel.IsSteedTank(__instance))
|
||||
return true;
|
||||
__result = Mathf.Clamp01(BoneSteedFuel.FuelOf(__instance.vehicle) / BoneSteedFuel.MaxFuel);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Любая прямая запись уровня бака (загрузка из Meta, сетевая синхронизация,
|
||||
/// ванильная заправка) для скакуна ничего не меняет: уровень - производная от сфер. Тест 01.10:
|
||||
/// после установки сферы игра записала в бак прежний 0, и сфера сгорала целиком.</summary>
|
||||
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.SetFuelLevel))]
|
||||
public static class Patch_VPFuelTank_SetFuelLevel_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPFuelTank __instance)
|
||||
{
|
||||
if (!BoneSteedFuel.IsSteedTank(__instance))
|
||||
return true;
|
||||
__instance.fuelLevel = BoneSteedFuel.FuelOf(__instance.vehicle);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Расход - только событием двигателя FuelRemove: снимается прочность сфер.</summary>
|
||||
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.HandleEvent))]
|
||||
public static class Patch_VPFuelTank_HandleEvent_BoneSteed
|
||||
{
|
||||
public static bool Prefix(VPFuelTank __instance, VehiclePart.Event _event, float _amount)
|
||||
{
|
||||
if (_event != VehiclePart.Event.FuelRemove || !BoneSteedFuel.IsSteedTank(__instance))
|
||||
return true;
|
||||
Vehicle v = __instance.vehicle;
|
||||
bool had = BoneSteedFuel.FuelOf(v) > 0f;
|
||||
float left = _amount > 0f ? BoneSteedFuel.Burn(v, _amount) : BoneSteedFuel.FuelOf(v);
|
||||
__instance.fuelLevel = left;
|
||||
if (had && left <= 0f && EntityVehicle.VehicleFuelUsageModifier > 0f)
|
||||
v.FireEvent(VehiclePart.Event.FuelEmpty, __instance, 0f);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Vehicle), nameof(Vehicle.SetItemValue))]
|
||||
public static class Patch_Vehicle_SetItemValue_BoneSteed
|
||||
{
|
||||
public static bool Loading;
|
||||
|
||||
/// <summary>Скакун, сделанный до появления ячеек (или выданный без них), получает свои
|
||||
/// 4 ячейки здесь: ItemValue размечает ячейки один раз при создании и сам не дорастает.</summary>
|
||||
public static void Prefix(ItemValue _itemValue)
|
||||
{
|
||||
Loading = true;
|
||||
if (!BoneSteedFuel.IsSteedItem(_itemValue) || _itemValue.ModificationCount >= BoneSteedFuel.Slots)
|
||||
return;
|
||||
var mods = new ItemValue[BoneSteedFuel.Slots];
|
||||
for (int i = 0; i < mods.Length; i++)
|
||||
mods[i] = i < _itemValue.ModificationCount && _itemValue.modifications[i] != null
|
||||
? _itemValue.modifications[i]
|
||||
: ItemValue.None.Clone();
|
||||
_itemValue.modifications = mods;
|
||||
}
|
||||
|
||||
public static void Finalizer(Vehicle __instance)
|
||||
{
|
||||
Loading = false;
|
||||
if (!BoneSteed.Is(__instance.entity))
|
||||
return;
|
||||
// Слота покраски у скакуна нет (01.10: в нём сам собой появлялся коричневый краситель,
|
||||
// вынимался и появлялся снова). Ваниль в SetItemValue заводит его заново, если пусто,
|
||||
// поэтому снимаем каждый раз после неё. Что там лежало - в лог, для поиска источника.
|
||||
ItemValue iv = __instance.itemValue;
|
||||
if (iv.CosmeticModCount > 0)
|
||||
{
|
||||
for (int i = 0; i < iv.CosmeticModCount; i++)
|
||||
{
|
||||
ItemValue c = iv.GetCosmeticMod(i);
|
||||
if (c != null && !c.IsEmpty())
|
||||
Debug.Log("[NecromancerTome] BoneSteed: в слоте покраски было '" + c.ItemClass?.Name + "' - снято");
|
||||
}
|
||||
iv.ResizeCosmeticMods(0);
|
||||
}
|
||||
Debug.Log("[NecromancerTome] BoneSteed: ячеек " + __instance.itemValue.ModificationCount +
|
||||
", топливо " + BoneSteedFuel.FuelOf(__instance).ToString("0.#") + "/" + BoneSteedFuel.MaxFuel +
|
||||
", здоровье " + __instance.entity.Health + "/" + __instance.GetMaxHealth() +
|
||||
", расход x" + __instance.EffectFuelUsePer.ToString("0.##"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>В ячейки скакуна - только кровавые сферы с остатком прочности и кровавые камни.</summary>
|
||||
[HarmonyPatch(typeof(XUiC_ItemPartStack), "CanSwap")]
|
||||
public static class Patch_XUiC_ItemPartStack_CanSwap_BoneSteed
|
||||
{
|
||||
public static bool Prefix(XUiC_ItemPartStack __instance, ItemStack stack, ref bool __result)
|
||||
{
|
||||
ItemStack current = __instance.xui?.AssembleItem?.CurrentItem;
|
||||
if (current == null || !BoneSteedFuel.IsSteedItem(current.itemValue))
|
||||
return true;
|
||||
ItemValue here = __instance.ItemValue;
|
||||
ItemValue add = stack.itemValue;
|
||||
bool blood = BoneSteedFuel.IsStone(add) ||
|
||||
BoneSteedFuel.IsSphere(add) && add.UseTimes < add.MaxUseTimes;
|
||||
__result = blood && (here == null || here.IsEmpty() || BoneSteedFuel.IsSphere(here) || BoneSteedFuel.IsStone(here));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Шифт-клик сферой или камнем в окне скакуна (01.10: «в оссуарий сфера вставляется
|
||||
/// по шифт-клику, а в мотоцикл только перетаскиванием»). Ваниль ставит деталь шифт-кликом
|
||||
/// через AddPartToItem, а он пускает модификацию только по InstallableTags хозяина - у сферы
|
||||
/// и камня там браслет. Для скакуна - первая пустая ячейка, как у ванили.</summary>
|
||||
[HarmonyPatch(typeof(XUiM_AssembleItem), nameof(XUiM_AssembleItem.AddPartToItem))]
|
||||
public static class Patch_XUiM_AssembleItem_AddPartToItem_BoneSteed
|
||||
{
|
||||
public static bool Prefix(XUiM_AssembleItem __instance, ItemStack _partStack, out ItemStack _resultStack, ref bool __result)
|
||||
{
|
||||
_resultStack = _partStack;
|
||||
ItemStack current = __instance.CurrentItem;
|
||||
if (current == null || current.IsEmpty() || !BoneSteedFuel.IsSteedItem(current.itemValue))
|
||||
return true;
|
||||
__result = false;
|
||||
ItemValue add = _partStack.itemValue;
|
||||
bool blood = BoneSteedFuel.IsStone(add) ||
|
||||
BoneSteedFuel.IsSphere(add) && add.UseTimes < add.MaxUseTimes;
|
||||
if (!blood)
|
||||
return false;
|
||||
ItemValue item = current.itemValue;
|
||||
for (int i = 0; i < item.ModificationCount; i++)
|
||||
{
|
||||
ItemValue m = item.GetModification(i);
|
||||
if (m != null && !m.IsEmpty())
|
||||
continue;
|
||||
item.SetModification(i, add.Clone());
|
||||
__instance.RefreshAssembleItem();
|
||||
__instance.UpdateAssembleWindow();
|
||||
_resultStack = ItemStack.Empty;
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Здоровье скакуна не зависит от сфер в ячейках (у сферы DegradationMax 500).</summary>
|
||||
[HarmonyPatch(typeof(ItemValue), nameof(ItemValue.MaxUseTimesBase), MethodType.Getter)]
|
||||
public static class Patch_ItemValue_MaxUseTimesBase_BoneSteed
|
||||
{
|
||||
public static void Postfix(ItemValue __instance, ref int __result)
|
||||
{
|
||||
if (BoneSteedFuel.IsSteedItem(__instance))
|
||||
__result = BoneSteedFuel.MaxHealth;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ремонт скакуна - костями вместо ремкомплекта (указание 01.10): BonesPerRepair
|
||||
/// костей за ход, как ванильный ремкомплект (1000 здоровья + 10% за уровень «Механика»).</summary>
|
||||
[HarmonyPatch(typeof(XUiM_Vehicle), nameof(XUiM_Vehicle.RepairVehicle))]
|
||||
public static class Patch_XUiM_Vehicle_RepairVehicle_BoneSteed
|
||||
{
|
||||
public const string BoneItem = "resourceBone";
|
||||
public const int BonesPerRepair = 50;
|
||||
|
||||
public static bool Prefix(XUi _xui, Vehicle _vehicle, ref bool __result)
|
||||
{
|
||||
Vehicle v = _vehicle ?? _xui.Vehicle.CurrentVehicle?.GetVehicle();
|
||||
if (v == null || !BoneSteed.Is(v.entity))
|
||||
return true;
|
||||
__result = false;
|
||||
EntityPlayerLocal player = _xui.playerUI.entityPlayer;
|
||||
ItemValue bone = ItemClass.GetItem(BoneItem);
|
||||
if (bone == null || bone.IsEmpty() || v.GetRepairAmountNeeded() <= 0)
|
||||
return false;
|
||||
int have = player.bag.GetItemCount(bone) + player.inventory.GetItemCount(bone);
|
||||
if (have < BonesPerRepair)
|
||||
{
|
||||
Audio.Manager.PlayInsidePlayerHead("misc/missingitemtorepair");
|
||||
GameManager.ShowTooltip(player, string.Format(Localization.Get("necroBoneSteedNeedBones"), BonesPerRepair),
|
||||
string.Empty, "ui_denied");
|
||||
return false;
|
||||
}
|
||||
float perk = 0f;
|
||||
ProgressionValue pv = player.Progression.GetProgressionValue("perkGreaseMonkey");
|
||||
if (pv != null)
|
||||
perk += pv.Level * 0.1f;
|
||||
v.RepairParts(1000, perk);
|
||||
int left = BonesPerRepair;
|
||||
left -= player.inventory.DecItem(bone, left);
|
||||
if (left > 0)
|
||||
player.bag.DecItem(bone, left);
|
||||
_xui.CollectedItemList.RemoveItemStack(new ItemStack(bone, BonesPerRepair));
|
||||
Audio.Manager.PlayInsidePlayerHead("craft_complete_item");
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Кнопка «Заправить» в окне обслуживания: у скакуна выключена, текст - «нельзя».</summary>
|
||||
[HarmonyPatch(typeof(XUiC_VehicleFrameWindow), nameof(XUiC_VehicleFrameWindow.Update))]
|
||||
public static class Patch_XUiC_VehicleFrameWindow_Update_BoneSteed
|
||||
{
|
||||
public static void Postfix(XUiC_VehicleFrameWindow __instance)
|
||||
{
|
||||
if (__instance.btnRefuel_Background != null && __instance.Vehicle != null && BoneSteed.Is(__instance.Vehicle))
|
||||
__instance.btnRefuel_Background.Enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_VehicleFrameWindow), "GetBindingValueInternal")]
|
||||
public static class Patch_XUiC_VehicleFrameWindow_Binding_BoneSteed
|
||||
{
|
||||
public static void Postfix(XUiC_VehicleFrameWindow __instance, ref string value, string bindingName)
|
||||
{
|
||||
if (bindingName == "refueltext" && __instance.Vehicle != null && BoneSteed.Is(__instance.Vehicle))
|
||||
value = Localization.Get("xuiRefuelNotAllowed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Страховка: любая ванильная заправка скакуна (кнопка, меню) ничего не делает.</summary>
|
||||
[HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.AddFuelFromInventory))]
|
||||
public static class Patch_EntityVehicle_AddFuelFromInventory_BoneSteed
|
||||
{
|
||||
public static bool Prefix(EntityVehicle __instance, ref bool __result)
|
||||
{
|
||||
if (!BoneSteed.Is(__instance))
|
||||
return true;
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей, призывается книгой, ездит на
|
||||
/// кровавых сферах. Сущность necroBoneSteed в entityclasses.xml - наследник vehicleMotorcycle
|
||||
/// со своей моделью (бандл necrobonesteed, собран из копии ванильного VMotorcycleP.prefab).
|
||||
///
|
||||
/// Топливо - кровавые сферы в четырёх ячейках модификаций, ремонт - костями: всё это в
|
||||
/// BoneSteedFuelPatch. Здесь только запрет заправки канистрой (GetFuelItem).
|
||||
///
|
||||
/// Модель выросла из рипнутого префаба, и у неё те же две беды, что у оссуария: игровые теги
|
||||
/// коллайдеров потеряны (без них на скакуна не сесть и по нему не попасть) и шейдер-заглушка.
|
||||
/// Оба лечатся с ванильного мотоцикла - теги по путям объектов, шейдер у его материала.
|
||||
/// </summary>
|
||||
public static class BoneSteed
|
||||
{
|
||||
public const string EntityClassName = "necroBoneSteed";
|
||||
public const string FuelItem = "resourceBloodSphere";
|
||||
public const string ItemName = "necroBoneSteedPlaceable";
|
||||
const string VanillaPrefab = "@:Entities/Vehicles/VMotorcycle/VMotorcycleP.prefab";
|
||||
|
||||
public static bool Is(Entity e)
|
||||
{
|
||||
return e != null && EntityClass.list.TryGetValue(e.entityClass, out EntityClass cls) &&
|
||||
cls != null && cls.entityClassName == EntityClassName;
|
||||
}
|
||||
|
||||
static Transform vanilla;
|
||||
static Shader vanillaShader;
|
||||
|
||||
public static void FixModel(EntityVehicle ev)
|
||||
{
|
||||
if (vanilla == null)
|
||||
{
|
||||
vanilla = DataLoader.LoadAsset<Transform>(VanillaPrefab);
|
||||
if (vanilla == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] BoneSteed: ванильный мотоцикл не найден - теги и шейдер не поправлены");
|
||||
return;
|
||||
}
|
||||
foreach (Renderer r in vanilla.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
if (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_RMOM"))
|
||||
{
|
||||
vanillaShader = r.sharedMaterial.shader;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var byPath = new Dictionary<string, Transform>();
|
||||
foreach (Transform t in vanilla.GetComponentsInChildren<Transform>(true))
|
||||
byPath[PathOf(t, vanilla)] = t;
|
||||
int tags = 0, mats = 0;
|
||||
foreach (Transform t in ev.transform.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
if (!byPath.TryGetValue(PathOf(t, ev.transform), out Transform src))
|
||||
continue;
|
||||
if (t.tag != src.tag || t.gameObject.layer != src.gameObject.layer)
|
||||
{
|
||||
t.tag = src.tag;
|
||||
t.gameObject.layer = src.gameObject.layer;
|
||||
tags++;
|
||||
}
|
||||
}
|
||||
if (vanillaShader != null)
|
||||
{
|
||||
foreach (Renderer r in ev.transform.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
foreach (Material m in r.sharedMaterials)
|
||||
{
|
||||
if (m != null && m.HasProperty("_RMOM") && m.shader != vanillaShader)
|
||||
{
|
||||
m.shader = vanillaShader;
|
||||
m.enableInstancing = true;
|
||||
mats++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Debug.Log("[NecromancerTome] BoneSteed: теги поправлены у " + tags + " объектов, шейдер '" +
|
||||
(vanillaShader != null ? vanillaShader.name : "-") + "' поставлен " + mats + " материалам");
|
||||
}
|
||||
|
||||
/// <summary>Путь объекта от корня префаба. Корень - пустая строка: у экземпляра корень
|
||||
/// называется иначе (necroBoneSteedP(Clone)), чем у ванильного префаба.</summary>
|
||||
static string PathOf(Transform t, Transform root)
|
||||
{
|
||||
if (t == root)
|
||||
return "";
|
||||
string p = t.name;
|
||||
for (Transform x = t.parent; x != null && x != root; x = x.parent)
|
||||
p = x.name + "/" + p;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.PostInit))]
|
||||
public static class Patch_EntityVehicle_PostInit_BoneSteed
|
||||
{
|
||||
public static void Postfix(EntityVehicle __instance)
|
||||
{
|
||||
if (BoneSteed.Is(__instance))
|
||||
BoneSteed.FixModel(__instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Канистрой и вообще из рюкзака скакуна не заправить (указание 01.10): кровь
|
||||
/// вставляется только в ячейки. Пустой предмет топлива прячет пункт «Заправить» в меню.</summary>
|
||||
[HarmonyPatch(typeof(Vehicle), nameof(Vehicle.GetFuelItem))]
|
||||
public static class Patch_Vehicle_GetFuelItem_BoneSteed
|
||||
{
|
||||
public static void Postfix(Vehicle __instance, ref string __result)
|
||||
{
|
||||
if (BoneSteed.Is(__instance.entity))
|
||||
__result = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ namespace NecromancerTome
|
||||
/// property, quest tracking, anything else keyed off EntityFlags) is ever affected outside
|
||||
/// this one call. A Stack (not a single field) survives re-entrant DamageEntity calls
|
||||
/// correctly (e.g. explosions/knockback triggering further damage inside the same callstack).
|
||||
///
|
||||
/// ОДЕРЖИМОСТЬ (03.10, тест: «здоровье противника не снижалось»): то же самое, если атакующий -
|
||||
/// одержимый зомби или цель - одержимый зомби (зомби бьют его, когда он их танкует).
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
|
||||
public static class Patch_EntityAlive_DamageEntity_CharmedZombieVsZombie
|
||||
@@ -34,8 +37,8 @@ namespace NecromancerTome
|
||||
{
|
||||
EntityAlive attacker = __instance.world.GetEntity(_damageSource.getEntityId()) as EntityAlive;
|
||||
if (attacker is EntityZombie attackerZombie
|
||||
&& attackerZombie.Buffs != null
|
||||
&& attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
|
||||
&& (attackerZombie.Buffs != null && attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
|
||||
|| Possession.IsPossessed(attackerZombie) || Possession.IsPossessed(__instance))
|
||||
&& (attackerZombie.entityFlags & EntityFlags.Zombie) != EntityFlags.None)
|
||||
{
|
||||
attackerZombie.entityFlags &= ~EntityFlags.Zombie;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Хитбокс наших декораций (03.10: «Ты не добавляешь к декору хитбокс? А как удалять, если не туда
|
||||
/// поставил?»). В префабах книги памяти, ножа-декора и плакатов на корне стоит BoxCollider, но игра
|
||||
/// понимает, что луч попал в блок, только по игровому тегу на объекте с коллайдером. В нашем
|
||||
/// Unity-проекте такого тега нет (AssetRipper знает его как unknown_20004) - при сборке он стал
|
||||
/// Untagged. Поэтому при первой загрузке модели корню ставим тег и слой корня ванильной рамки
|
||||
/// (postersFramedGunSchematicPrefab - у неё коллайдер тоже на корне). Префаб из бандла общий,
|
||||
/// правка одна на все экземпляры. Тот же приём, что у оссуария (GeoOssuaryPatch.CopyTags).
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
|
||||
public static class Patch_BlockShapeModelEntity_getPrefab_DecorColliderTag
|
||||
{
|
||||
const string VanillaModel = "@:Entities/Decor/postersFramedGunSchematicPrefab.prefab";
|
||||
static readonly System.Collections.Generic.HashSet<string> done = new System.Collections.Generic.HashSet<string>();
|
||||
|
||||
static bool Ours(string _name)
|
||||
{
|
||||
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster");
|
||||
}
|
||||
|
||||
public static void Postfix(Transform __result)
|
||||
{
|
||||
if (__result == null || !Ours(__result.name) || !done.Add(__result.name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Transform vanilla = DataLoader.LoadAsset<Transform>(VanillaModel);
|
||||
if (vanilla == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] DecorCollider: ванильная рамка не загрузилась - тег не поставлен");
|
||||
return;
|
||||
}
|
||||
__result.tag = vanilla.tag;
|
||||
__result.gameObject.layer = vanilla.gameObject.layer;
|
||||
Debug.Log("[NecromancerTome] DecorCollider: '" + __result.name + "' тег " + vanilla.tag + ", слой " + vanilla.gameObject.layer +
|
||||
", коллайдер " + (__result.GetComponent<Collider>() != null ? "есть" : "НЕТ"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// «Стиратель» (указание 02.10) - античит к режиму змеи: если игрок в кровавую луну стал змеёй,
|
||||
/// в его орде с большой вероятностью приходят Стиратели - маленькие быстрые зомби с
|
||||
/// нитроглицериновыми вздутиями. Взрываются, когда пытаются сломать блок и когда их разрывает
|
||||
/// змея; взрыв ломает блоки, ранит тело игрока и соседних зомби, змею не ранит (она бессмертна),
|
||||
/// а отбрасывает.
|
||||
///
|
||||
/// Сущность - zombieEraser (entityclasses.xml): имя НЕ на necro, иначе IsNecroEntity сочтёт её
|
||||
/// своей и змея перестанет видеть в ней врага. Параметры взрыва - блок Explosion там же,
|
||||
/// подрыв - как у ванильного EntityZombieCop: ExplosionServer + SetDead.
|
||||
/// </summary>
|
||||
public static class EraserPatch
|
||||
{
|
||||
public const string EntityClassName = "zombieEraser";
|
||||
/// <summary>Доля Стирателей в орде игрока-змеи («с большой вероятностью»).</summary>
|
||||
public const float HordeChance = 0.5f;
|
||||
/// <summary>Отброс змеи: дальше этого от взрыва змею не задевает.</summary>
|
||||
public const float KnockRange = 8f;
|
||||
|
||||
static int _classId = -1;
|
||||
|
||||
public static bool IsEraser(Entity _e)
|
||||
{
|
||||
if (_e == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (_classId == -1)
|
||||
{
|
||||
_classId = EntityClass.FromString(EntityClassName);
|
||||
}
|
||||
return _e.entityClass == _classId;
|
||||
}
|
||||
|
||||
static System.Collections.IEnumerator BurstNextFrame(EntityAlive _e)
|
||||
{
|
||||
yield return null;
|
||||
if (_e != null && _e.world != null && _e.world.GetEntity(_e.entityId) != null)
|
||||
{
|
||||
NecroBurst.Burst(_e, _e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Подрыв на сервере. Возвращает false, если подрывать нечего или не здесь.</summary>
|
||||
public static bool Explode(EntityAlive _e)
|
||||
{
|
||||
if (_e == null || _e.IsDead() || _e.world == null || _e.world.IsRemote())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Vector3 pos = _e.GetPosition();
|
||||
_e.NotifySleeperDeath();
|
||||
_e.SetModelLayer(2);
|
||||
GameManager.Instance.ExplosionServer(pos, World.worldToBlockPos(pos), _e.transform.rotation,
|
||||
EntityClass.list[_e.entityClass].explosionData, _e.entityId, 0f, false);
|
||||
_e.timeStayAfterDeath = 0;
|
||||
_e.SetDead();
|
||||
SnakeControl.Knockback(pos, KnockRange);
|
||||
// Тела не остаётся (указание 02.10: «после взрыва остаются трупы туристов»): чёрный
|
||||
// разрыв, как от змеи. Не сразу, а в следующем кадре: подрыв идёт изнутри хода ИИ
|
||||
// (Attack из задачи), и удаление посреди него роняло EntityAlive.updateTasks (NRE, тест 02.10).
|
||||
GameManager.Instance.StartCoroutine(BurstNextFrame(_e));
|
||||
Debug.Log("[NecromancerTome] EraserPatch: Стиратель " + _e.entityId + " взорвался в " + pos);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Стиратель упёрся и начинает ломать блок - вместо удара взрыв.</summary>
|
||||
[HarmonyPatch(typeof(EAIBreakBlock), nameof(EAIBreakBlock.Start))]
|
||||
public static class Patch_EAIBreakBlock_Start_Eraser
|
||||
{
|
||||
public static bool Prefix(EAIBreakBlock __instance)
|
||||
{
|
||||
return !(EraserPatch.IsEraser(__instance.theEntity) && EraserPatch.Explode(__instance.theEntity));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Любая атака Стирателя - по блоку (BreakBlock, DestroyArea) или по цели
|
||||
/// (ApproachAndAttackTarget) - идёт через EntityAlive.Attack: взрыв вместо удара (тест 02.10:
|
||||
/// «взрываются не всегда, хотя атакуют»).</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.Attack))]
|
||||
public static class Patch_EntityAlive_Attack_Eraser
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance, ref bool __result)
|
||||
{
|
||||
if (EraserPatch.IsEraser(__instance) && EraserPatch.Explode(__instance))
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подмешивание в орду. Ванильный образец - AIDirectorBloodMoonParty.SpawnZombie: игрок в
|
||||
/// транспорте получает в орду стервятников с шансом 0.5. Класс зомби там берётся из
|
||||
/// EntityGroups.GetRandomEntityFromGroupMaxTier; на время SpawnZombie запоминаем цель и, если
|
||||
/// это игрок в режиме змеи, с шансом подменяем результат на Стирателя.
|
||||
/// Режим змеи - состояние локального игрока (SnakeControl.Controlling), поэтому работает в
|
||||
/// одиночной игре и у хоста; змея подключённого клиента орду не меняет.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(AIDirectorBloodMoonParty), "SpawnZombie")]
|
||||
public static class Patch_BloodMoonParty_SpawnZombie_Eraser
|
||||
{
|
||||
[System.ThreadStatic] public static EntityPlayer Target;
|
||||
|
||||
public static void Prefix(EntityPlayer _target)
|
||||
{
|
||||
Target = _target;
|
||||
}
|
||||
|
||||
public static void Finalizer()
|
||||
{
|
||||
Target = null;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(EntityGroups), nameof(EntityGroups.GetRandomEntityFromGroupMaxTier))]
|
||||
public static class Patch_EntityGroups_GetRandom_Eraser
|
||||
{
|
||||
public static void Postfix(ref int __result)
|
||||
{
|
||||
EntityPlayer target = Patch_BloodMoonParty_SpawnZombie_Eraser.Target;
|
||||
if (target == null || __result == -1 || !SnakeControl.Controlling ||
|
||||
!(target is EntityPlayerLocal) || Random.value >= EraserPatch.HordeChance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int id = EntityClass.FromString(EraserPatch.EntityClassName);
|
||||
if (id != -1 && EntityClass.list.ContainsKey(id))
|
||||
{
|
||||
__result = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Вид Стирателя (02.10): тело - ванильная модель толстого гавайца (скелет, физика, теги,
|
||||
/// анимации игры), а раскраска своя. Игра в EModelBase.createModel сама копирует материал тела
|
||||
/// (ветка MatColor/AltMats); после неё подставляем в материал LOD-рендереров свои текстуры
|
||||
/// (Resources/Eraser/*.png, нарисованы скриптом по развёртке гавайца): _Albedo, _Normal и _RMOE
|
||||
/// (шероховатость, металл, затенение, в альфе - маска свечения), свечение - _EmissiveColor.
|
||||
/// Глаза - отдельные рендереры *Eye* - прячем: на их месте в текстуре чёрные провалы.
|
||||
/// Ступни босые - пальцы и ногти нарисованы в текстуре (слоновья нога масштабом костей не
|
||||
/// получилась, 02.10).
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))]
|
||||
public static class Patch_EModelBase_createModel_EraserLook
|
||||
{
|
||||
public static readonly Color Emission = new Color(1f, 0.1f, 0.65f) * 2.5f;
|
||||
|
||||
static Texture2D _albedo, _normal, _rmoe;
|
||||
|
||||
/// <summary>Своя раскраска на материал тела (только у материалов с _Albedo - тело и куски).</summary>
|
||||
public static void Paint(Renderer _r)
|
||||
{
|
||||
// Сначала смотрим общий материал: обращение к .material создаёт копию, и делать её
|
||||
// у глаз, огня и прочих рендереров без _Albedo незачем. Если копия уже наша - .material
|
||||
// вернёт её же, повторной копии не будет. Брошенные копии убирает сама игра
|
||||
// (GameManager: Resources.UnloadUnusedAssets по таймеру).
|
||||
Material sm = _r.sharedMaterial;
|
||||
if (sm == null || !sm.HasProperty("_Albedo") || sm.GetTexture("_Albedo") == _albedo && sm.GetColor("_EmissiveColor") == Emission)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Material m = _r.material;
|
||||
if (_albedo != null) m.SetTexture("_Albedo", _albedo);
|
||||
if (_normal != null) m.SetTexture("_Normal", _normal);
|
||||
if (_rmoe != null) m.SetTexture("_RMOE", _rmoe);
|
||||
m.SetColor("_EmissiveColor", Emission);
|
||||
}
|
||||
static bool _loaded;
|
||||
|
||||
static Texture2D Load(string _file, bool _linear)
|
||||
{
|
||||
string path = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(typeof(EraserPatch).Assembly.Location), "Resources", "Eraser", _file);
|
||||
if (!System.IO.File.Exists(path))
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] EraserLook: нет файла " + path);
|
||||
return null;
|
||||
}
|
||||
Texture2D t = new Texture2D(2, 2, TextureFormat.RGBA32, true, _linear);
|
||||
if (!ImageConversion.LoadImage(t, System.IO.File.ReadAllBytes(path)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
t.name = "Eraser_" + _file;
|
||||
t.Apply(true, true);
|
||||
return t;
|
||||
}
|
||||
|
||||
public static void Postfix(EModelBase __instance)
|
||||
{
|
||||
Entity e = __instance.entity;
|
||||
if (!EraserPatch.IsEraser(e))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!_loaded)
|
||||
{
|
||||
_loaded = true;
|
||||
_albedo = Load("eraser_albedo.png", false);
|
||||
_normal = Load("eraser_normal.png", true);
|
||||
_rmoe = Load("eraser_rmoe.png", true);
|
||||
}
|
||||
foreach (SkinnedMeshRenderer r in __instance.GetComponentsInChildren<SkinnedMeshRenderer>(true))
|
||||
{
|
||||
if (r.name.IndexOf("Eye", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
r.enabled = false;
|
||||
continue;
|
||||
}
|
||||
if (!r.CompareTag("LOD"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Paint(r);
|
||||
}
|
||||
Debug.Log("[NecromancerTome] EraserLook: вид Стирателя на " + e.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отрубленная конечность (тест 02.10: «если у трупа отрубить руку, он становится туристом»):
|
||||
/// игра при расчленении подменяет материалы тела и создаёт отлетающий кусок с ванильным
|
||||
/// материалом гавайца. После RemoveLimb заново красим тело и новые куски.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(AvatarZombieController), nameof(AvatarZombieController.RemoveLimb))]
|
||||
public static class Patch_AvatarZombieController_RemoveLimb_EraserLook
|
||||
{
|
||||
public static void Prefix(out int __state)
|
||||
{
|
||||
__state = DismembermentManager.Instance?.parts.Count ?? 0;
|
||||
}
|
||||
|
||||
public static void Postfix(AvatarZombieController __instance, int __state)
|
||||
{
|
||||
EntityAlive e = Traverse.Create(__instance).Field("entity").GetValue<EntityAlive>();
|
||||
if (!EraserPatch.IsEraser(e) || e.emodel == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (Renderer r in e.emodel.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
Patch_EModelBase_createModel_EraserLook.Paint(r);
|
||||
}
|
||||
var parts = DismembermentManager.Instance?.parts;
|
||||
for (int i = __state; parts != null && i < parts.Count; i++)
|
||||
{
|
||||
foreach (Transform t in new[] { parts[i].prefabT, parts[i].detachT })
|
||||
{
|
||||
if (t == null) continue;
|
||||
foreach (Renderer r in t.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
Patch_EModelBase_createModel_EraserLook.Paint(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Стиратель убит чем угодно (игроком, взрывом соседа, под бафом жертвы) - чёрный
|
||||
/// разрыв, трупа не остаётся (указание 02.10). Тот же путь, что у змеи: постфикс ClientKill.</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.ClientKill))]
|
||||
public static class Patch_EntityAlive_ClientKill_EraserBurst
|
||||
{
|
||||
public static void Postfix(EntityAlive __instance)
|
||||
{
|
||||
if (EraserPatch.IsEraser(__instance) && __instance.IsDead() && __instance.world != null &&
|
||||
__instance.world.GetEntity(__instance.entityId) != null)
|
||||
{
|
||||
NecroBurst.Burst(__instance, __instance.entityThatKilledMe != null ? (Entity)__instance.entityThatKilledMe : __instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере. Блок -
|
||||
/// necroGeoOssuary в blocks.xml, наследник ванильного коллектора росы; всё, что коллектор
|
||||
/// умеет сам (окно, топливо, таймеры, работа без игрока рядом), остаётся его. Здесь только то,
|
||||
/// чего ваниль не умеет, - разобрано по декомпилятору 3.3:
|
||||
///
|
||||
/// - ЯЧЕЙКА МОДИФИКАЦИИ ПРИНИМАЕТ ОДИН ПРЕДМЕТ. XUiC_DewCollectorModGrid.SetTileEntity
|
||||
/// зовёт SetAllowedItemClassSingle(RequiredMods[i]). У оссуария в любую из трёх ячеек идёт
|
||||
/// любой из пяти ресурсов, по одной штуке (OverrideStackCount = 1). Окно у всех
|
||||
/// коллекторов одно и то же, поэтому для чужих коллекторов предел штук сбрасывается
|
||||
/// обратно - иначе коллектор росы после оссуария тоже брал бы по одной.
|
||||
/// - ВАНИЛЬ НЕ СМОТРИТ, ЧТО ЛЕЖИТ В ЯЧЕЙКЕ. ModTypes=Type - просто флаг HasModConvert
|
||||
/// (OutputItem -> OutputItemModded), одна замена на весь блок. У оссуария три OutputType
|
||||
/// os1..os3, по одному на ячейку продукции; os(i+1) выдаёт полный стак ресурса из ячейки
|
||||
/// модификации i. Пустая или чужая модификация - этот OutputType выключен (isDisabled),
|
||||
/// значит таймер стоит и сферу он не ест.
|
||||
/// - ПРОВЕРКА НЕБА. HandleSkyCheck (7 блоков вверх) у ванили безусловна для всех
|
||||
/// коллекторов; оссуарий работает где угодно, в том числе под землёй (решение 30.09).
|
||||
/// </summary>
|
||||
public static class GeoOssuary
|
||||
{
|
||||
public const string BlockName = "necroGeoOssuary";
|
||||
|
||||
/// <summary>Камень, уголь, селитра, железо (его дают железные жилы), сланец.</summary>
|
||||
public static readonly string[] ResourceNames =
|
||||
{
|
||||
"resourceRockSmall", "resourceCoal", "resourcePotassiumNitratePowder",
|
||||
"resourceScrapIron", "resourceOilShale",
|
||||
};
|
||||
|
||||
static ItemClass[] resourceClasses;
|
||||
|
||||
public static ItemClass[] ResourceClasses
|
||||
{
|
||||
get
|
||||
{
|
||||
if (resourceClasses == null)
|
||||
{
|
||||
resourceClasses = new ItemClass[ResourceNames.Length];
|
||||
for (int i = 0; i < ResourceNames.Length; i++)
|
||||
resourceClasses[i] = ItemClass.GetItemClass(ResourceNames[i]);
|
||||
}
|
||||
return resourceClasses;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Is(Block block) => block != null && block.GetBlockName() == BlockName;
|
||||
|
||||
public static bool Is(TileEntityCollector te) => te != null && Is(te.blockValue.Block);
|
||||
|
||||
/// <summary>os1 -> 0, os2 -> 1, os3 -> 2; иначе -1.</summary>
|
||||
public static int ModSlotOf(BlockCollector.OutputType outputType)
|
||||
{
|
||||
string name = outputType?.Name;
|
||||
if (name == null || name.Length != 3 || !name.StartsWith("os"))
|
||||
return -1;
|
||||
return name[2] - '1';
|
||||
}
|
||||
|
||||
/// <summary>Ресурс в ячейке модификации, если он из списка; иначе null.</summary>
|
||||
public static ItemClass ResourceIn(TileEntityCollector te, int modSlot)
|
||||
{
|
||||
ItemStack[] mods = te.ModSlots;
|
||||
if (modSlot < 0 || mods == null || modSlot >= mods.Length)
|
||||
return null;
|
||||
ItemStack stack = mods[modSlot];
|
||||
if (stack == null || stack.IsEmpty())
|
||||
return null;
|
||||
ItemClass cls = stack.itemValue.ItemClass;
|
||||
return cls != null && System.Array.IndexOf(ResourceNames, cls.Name) >= 0 ? cls : null;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(XUiC_DewCollectorModGrid), nameof(XUiC_DewCollectorModGrid.SetTileEntity))]
|
||||
public static class Patch_XUiC_DewCollectorModGrid_SetTileEntity_GeoOssuary
|
||||
{
|
||||
public static void Postfix(XUiC_DewCollectorModGrid __instance, TileEntityCollector te)
|
||||
{
|
||||
bool ossuary = GeoOssuary.Is(te);
|
||||
foreach (XUiC_ItemStack slot in __instance.itemControllers)
|
||||
{
|
||||
if (!(slot is XUiC_RequiredItemStack required))
|
||||
continue;
|
||||
required.OverrideStackCount = ossuary ? 1 : -1;
|
||||
if (ossuary)
|
||||
{
|
||||
required.SetAllowedItemClasses(GeoOssuary.ResourceClasses);
|
||||
required.RequiredItemOnly = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.isDisabled))]
|
||||
public static class Patch_TileEntityCollector_isDisabled_GeoOssuary
|
||||
{
|
||||
public static void Postfix(TileEntityCollector __instance, BlockCollector.OutputType _outputType, ref bool __result)
|
||||
{
|
||||
if (__result || !GeoOssuary.Is(__instance))
|
||||
return;
|
||||
if (GeoOssuary.ResourceIn(__instance, GeoOssuary.ModSlotOf(_outputType)) == null)
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.newItem))]
|
||||
public static class Patch_TileEntityCollector_newItem_GeoOssuary
|
||||
{
|
||||
public static void Postfix(TileEntityCollector __instance, BlockCollector.OutputType outputType, ref ItemStack __result)
|
||||
{
|
||||
if (__result == null || !GeoOssuary.Is(__instance))
|
||||
return;
|
||||
ItemClass resource = GeoOssuary.ResourceIn(__instance, GeoOssuary.ModSlotOf(outputType));
|
||||
if (resource == null)
|
||||
{
|
||||
// isDisabled не пускает сюда без ресурса; если всё же дошло - сфера уже списана,
|
||||
// и отдать хотя бы заглушку честнее, чем молча съесть топливо.
|
||||
Debug.LogWarning("[NecromancerTome] GeoOssuary: " + outputType.Name + " сработал без ресурса в ячейке модификации");
|
||||
return;
|
||||
}
|
||||
__result = new ItemStack(new ItemValue(resource.Id), resource.MaxCount);
|
||||
Debug.Log("[NecromancerTome] GeoOssuary: " + outputType.Name + " -> " + resource.Name + " x" + resource.MaxCount + " в " + __instance.ToWorldPos());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Шейдер модели. Своя модель оссуария лежит в нашем бандле с запасным шейдером
|
||||
/// NecromancerTome/OssuaryProxy (Standard-поверхность), а у пирамиды 10.09 именно
|
||||
/// Standard из бандла на расстоянии рисовался маджентой. Поэтому при первой загрузке модели
|
||||
/// материалу ставится тот шейдер, которым игра рисует ванильный коллектор росы: имена текстур
|
||||
/// у них общие (_MainTex, _Normal, _RMOM, _Emissive), модель выросла из его префаба. Префаб из
|
||||
/// бандла - общий ассет, так что правка одна на все экземпляры.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
|
||||
public static class Patch_BlockShapeModelEntity_getPrefab_GeoOssuaryShader
|
||||
{
|
||||
const string VanillaModel = "@:Entities/Furniture/collectorDewPrefab.prefab";
|
||||
// Пирамида отрицания (01.10) собрана из копии префаба оссуария - те же имена объектов,
|
||||
// тот же запасной шейдер, поэтому лечится тем же постфиксом.
|
||||
static readonly System.Collections.Generic.HashSet<string> models =
|
||||
new System.Collections.Generic.HashSet<string> { "necroOssuaryPrefab", "necroWardPrefab" };
|
||||
static readonly System.Collections.Generic.HashSet<string> done = new System.Collections.Generic.HashSet<string>();
|
||||
|
||||
public static void Postfix(Transform __result)
|
||||
{
|
||||
if (__result == null || !models.Contains(__result.name) || !done.Add(__result.name))
|
||||
return;
|
||||
Transform vanilla = DataLoader.LoadAsset<Transform>(VanillaModel);
|
||||
Renderer src = vanilla != null ? vanilla.GetComponentInChildren<Renderer>(true) : null;
|
||||
Shader shader = src != null && src.sharedMaterial != null ? src.sharedMaterial.shader : null;
|
||||
if (shader == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] GeoOssuary: шейдер коллектора росы не найден - остаётся запасной");
|
||||
return;
|
||||
}
|
||||
int n = 0;
|
||||
foreach (Renderer r in __result.GetComponentsInChildren<Renderer>(true))
|
||||
foreach (Material m in r.sharedMaterials)
|
||||
if (m != null && m.shader != shader) { m.shader = shader; m.enableInstancing = true; n++; }
|
||||
Debug.Log("[NecromancerTome] GeoOssuary: материалам модели (" + n + ") поставлен шейдер '" + shader.name + "'");
|
||||
CopyTags(vanilla, __result);
|
||||
}
|
||||
|
||||
/// <summary>Теги коллайдера. У ванильного префаба корень и объект collision помечены
|
||||
/// игровым тегом (AssetRipper записал его как unknown_20004 - имени он не знал), и именно
|
||||
/// по тегу игра понимает, что луч попал в блок, с которым можно взаимодействовать. В нашем
|
||||
/// Unity-проекте такого тега нет, при сборке он стал Untagged - и оссуарий не реагировал
|
||||
/// на наведение (тест 30.09). Берём теги у ванильного коллектора по именам объектов.</summary>
|
||||
static void CopyTags(Transform vanilla, Transform ours)
|
||||
{
|
||||
var byName = new System.Collections.Generic.Dictionary<string, Transform>();
|
||||
foreach (Transform t in vanilla.GetComponentsInChildren<Transform>(true))
|
||||
byName[t == vanilla ? "<root>" : t.name] = t;
|
||||
foreach (Transform t in ours.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
Transform src;
|
||||
if (!byName.TryGetValue(t == ours ? "<root>" : t.name, out src))
|
||||
continue;
|
||||
if (t.tag != src.tag || t.gameObject.layer != src.gameObject.layer)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] GeoOssuary: '" + t.name + "' тег " + t.tag + " -> " + src.tag +
|
||||
", слой " + t.gameObject.layer + " -> " + src.gameObject.layer);
|
||||
t.tag = src.tag;
|
||||
t.gameObject.layer = src.gameObject.layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Расход сферы - по прочности, как в пространственном хранилище (указание 01.10: «одна
|
||||
/// сфера на стак - это жирно; 1/10 прочности на стак»). Ваниль в removeFuel вычитает ШТУКИ
|
||||
/// топлива, то есть съедала бы сферу целиком за каждый стак. Здесь вместо этого у первой
|
||||
/// сферы в отсеке списывается ChargePerStack прочности (DegradationMax сферы 500 -> 10 стаков
|
||||
/// со сферы); выработанная до нуля сфера исчезает из ячейки, как из браслета. Наличие топлива
|
||||
/// ваниль по-прежнему считает штуками (getFuelCount) - любая сфера с остатком годится.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.removeFuel))]
|
||||
public static class Patch_TileEntityCollector_removeFuel_GeoOssuary
|
||||
{
|
||||
public const int ChargePerStack = 50;
|
||||
|
||||
public static bool Prefix(TileEntityCollector __instance)
|
||||
{
|
||||
if (!GeoOssuary.Is(__instance))
|
||||
return true;
|
||||
ItemStack[] fuel = __instance.FuelSlots;
|
||||
// Кровавый камень в отсеке - вечное топливо (01.10: «полная замена сферы»): не тратится
|
||||
// ни он, ни сферы рядом.
|
||||
for (int i = 0; i < fuel.Length; i++)
|
||||
if (fuel[i] != null && !fuel[i].IsEmpty() && fuel[i].itemValue.ItemClass != null &&
|
||||
fuel[i].itemValue.ItemClass.Name == BoneSteedFuel.StoneItem)
|
||||
return false;
|
||||
for (int i = 0; i < fuel.Length; i++)
|
||||
{
|
||||
ItemStack stack = fuel[i];
|
||||
if (stack == null || stack.IsEmpty() || stack.itemValue.ItemClass == null ||
|
||||
stack.itemValue.ItemClass.Name != SpatialVaultPickup.ChargeItemName)
|
||||
continue;
|
||||
int max = stack.itemValue.MaxUseTimes;
|
||||
if (max <= 0)
|
||||
{
|
||||
// Прочность не задалась (DegradationMax) - ведём себя как ваниль: сфера за стак.
|
||||
fuel[i] = ItemStack.Empty;
|
||||
Debug.LogWarning("[NecromancerTome] GeoOssuary: у сферы MaxUseTimes 0 - списана целиком");
|
||||
return false;
|
||||
}
|
||||
stack.itemValue.UseTimes += ChargePerStack;
|
||||
if (stack.itemValue.UseTimes >= max)
|
||||
{
|
||||
fuel[i] = ItemStack.Empty;
|
||||
Debug.Log("[NecromancerTome] GeoOssuary: сфера в ячейке " + i + " выработана и исчезла");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.HandleSkyCheck))]
|
||||
public static class Patch_TileEntityCollector_HandleSkyCheck_GeoOssuary
|
||||
{
|
||||
public static void Postfix(TileEntityCollector __instance, ref bool __result)
|
||||
{
|
||||
if (__result && GeoOssuary.Is(__instance))
|
||||
__result = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
using GUI_2;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Хваты пустой руки (03.10, BACKLOG «Заклинания с руки и жесты»): на R - меню из трёх строк:
|
||||
/// обычная рука, «отрицание», «пиф-паф». Пустая рука - предмет meleeHandPlayer с DynamicMelee,
|
||||
/// меню на R игра открывает ей так же, как ножу (PlayerMoveController: HasRadial).
|
||||
///
|
||||
/// Выбор НЕ сохраняется (указание 03.10: «после перезахода будет снова обычная рука») - просто поле
|
||||
/// в памяти. Ни cvar, ни предмет не трогаем. На выходе из мира (нет мира или сменился игрок) всё
|
||||
/// сбрасывается, и ссылки на кости, снимок заморозки и цель отпускаются (HandPose.ResetAll) - чтобы
|
||||
/// за сессию ничего не копилось (указание: «не допустить утечку памяти на сохранение состояний»).
|
||||
/// В хвате рука стоит позой (HandPose: кости после аниматора + заморозка остальных), а кнопки мыши
|
||||
/// не бьют: ExecuteAction у DynamicMelee пустой руки перехвачен.
|
||||
/// ЛКМ в «отрицании» - зомби под прицелом в леденящем ужасе: бежит от игрока с ускорением и теряет
|
||||
/// его как цель (NecroFleeSnakeTask.ScareFrom). Звук - как у празднующего убийства Twitch.
|
||||
/// ЛКМ в «пиф-пафе» - одержимость зомби-гуманоида под прицелом (Possession.cs).
|
||||
/// ПКМ в хватах - ничего.
|
||||
/// </summary>
|
||||
public static class HandHolds
|
||||
{
|
||||
public const string HandItem = "meleeHandPlayer";
|
||||
public const int None = 0, Deny = 1, PifPaf = 2;
|
||||
|
||||
/// <summary>Дальность и конус выбора зомби для «отрицания».</summary>
|
||||
public const float DenyRange = 25f;
|
||||
public const float DenyConeDeg = 12f;
|
||||
/// <summary>Бежит, пока игрок не дальше этого: зрение зомби до 30 м, у Чака 40 (entityclasses.xml,
|
||||
/// SightRange) - указание 03.10: «убегать он должен дальше 40 м, чтобы терять из прицела».</summary>
|
||||
public const float DenyFarDistance = 40f;
|
||||
/// <summary>Предел бегства на случай, если застрял и не может уйти на 40 м.</summary>
|
||||
public const float DenyMaxSeconds = 25f;
|
||||
/// <summary>Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»).</summary>
|
||||
public const float DenySpeed = 2.2f;
|
||||
public const float Cooldown = 0.6f;
|
||||
/// <summary>Звук «козы» (05.10: «нужен короткий звук» - вместо twitch_celebrate): бросок змеи - тот же,
|
||||
/// что у рывка духа змеи (SnakeControl.AttackSound): «раз зомби боятся духа змеи, некромант может
|
||||
/// пугать им и в телесной форме».</summary>
|
||||
public const string DenySound = SnakeControl.AttackSound;
|
||||
/// <summary>«Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок
|
||||
/// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success).</summary>
|
||||
public const string PifPafFailSound = "glassdestroy";
|
||||
public const string PifPafPossessSound = "lockpick_success";
|
||||
|
||||
/// <summary>Выбранный хват - только на время сессии в мире.</summary>
|
||||
public static int Hold;
|
||||
static int _applied = -1;
|
||||
static int _playerId = -1;
|
||||
static float _nextCast;
|
||||
/// <summary>Вселение - на пике толчка руки (05.10): иначе вид сразу уходит к зомби и толчка не видно.
|
||||
/// Цель берётся в момент щелчка.</summary>
|
||||
static EntityAlive _pifPafVictim;
|
||||
static float _pifPafAt = -1f;
|
||||
|
||||
public static bool IsHand(ItemClass _item) => _item != null && _item.Name == HandItem;
|
||||
|
||||
public static int Current(EntityPlayerLocal _player)
|
||||
{
|
||||
if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) ||
|
||||
SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
return Hold;
|
||||
}
|
||||
|
||||
/// <summary>Из HandPoseDriver.LateUpdate - до HandPose.Apply. Позу трогаем только при смене
|
||||
/// хвата: ручная настройка командой necropose держится до следующей смены.</summary>
|
||||
public static void Update()
|
||||
{
|
||||
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
int id = player != null ? player.entityId : -1;
|
||||
if (id != _playerId)
|
||||
{
|
||||
// Вышли из мира, зашли в другой или перезашли - обычная рука и пустые ссылки.
|
||||
_playerId = id;
|
||||
Hold = None;
|
||||
_applied = None;
|
||||
_target = null;
|
||||
_targetFrame = -1;
|
||||
_pifPafVictim = null;
|
||||
_pifPafAt = -1f;
|
||||
HandPose.ResetAll();
|
||||
return;
|
||||
}
|
||||
if (_pifPafAt >= 0f && Time.time >= _pifPafAt)
|
||||
{
|
||||
PifPafShot(player);
|
||||
}
|
||||
int want = Current(player);
|
||||
if (want == _applied)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_applied = want;
|
||||
if (want == Deny)
|
||||
{
|
||||
HandPose.PresetDeny();
|
||||
}
|
||||
else if (want == PifPaf)
|
||||
{
|
||||
HandPose.PresetPifPaf();
|
||||
}
|
||||
else
|
||||
{
|
||||
HandPose.Enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void PifPafShot(EntityPlayerLocal _player)
|
||||
{
|
||||
EntityAlive victim = _pifPafVictim;
|
||||
_pifPafVictim = null;
|
||||
_pifPafAt = -1f;
|
||||
if (Current(_player) != PifPaf)
|
||||
{
|
||||
return; // за полтика сменил хват или руку - выстрела нет
|
||||
}
|
||||
if (victim != null && !victim.IsDead() && Possession.CanPossess(victim) && Possession.Enter(_player, victim))
|
||||
{
|
||||
_player.PlayOneShot(PifPafPossessSound);
|
||||
}
|
||||
else
|
||||
{
|
||||
_player.PlayOneShot(PifPafFailSound);
|
||||
Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид"));
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
|
||||
{
|
||||
if (_commandIndex >= None && _commandIndex <= PifPaf)
|
||||
{
|
||||
Hold = _commandIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ЛКМ в хвате. True - нажатие наше, удар не выполнять.</summary>
|
||||
public static void Cast(EntityPlayerLocal _player, int _hold)
|
||||
{
|
||||
if (Time.time < _nextCast)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_nextCast = Time.time + Cooldown;
|
||||
if (_hold == PifPaf)
|
||||
{
|
||||
// Одержимость (решение 03.10): «пиф-паф» по зомби-гуманоиду - управление им.
|
||||
HandPose.Jab();
|
||||
_pifPafVictim = Target(_player);
|
||||
_pifPafAt = Time.time + HandPose.JabTime * 0.5f;
|
||||
return;
|
||||
}
|
||||
HandPose.Jab(); // толчок к прицелу, как у «пиф-пафа» (05.10)
|
||||
_player.PlayOneShot(DenySound);
|
||||
World world = _player.world;
|
||||
if (world.IsRemote())
|
||||
{
|
||||
return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно
|
||||
}
|
||||
EntityAlive target = Target(_player);
|
||||
if (target != null)
|
||||
{
|
||||
if (target.HasAnyTags(FlyerTag))
|
||||
{
|
||||
NecroFleeSnakeTask.ForgetNow(target, _player); // летает - наземного бегства нет, только теряет цель
|
||||
Debug.Log("[NecromancerTome] HandHolds: стервятник " + target.entityId + " отрицание - потерял цель");
|
||||
}
|
||||
else
|
||||
{
|
||||
NecroFleeSnakeTask.ScareFrom(target, _player, DenyMaxSeconds, DenySpeed, DenyFarDistance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static readonly FastTags<TagGroup.Global> ZombieTag = FastTags<TagGroup.Global>.Parse("zombie");
|
||||
static readonly FastTags<TagGroup.Global> FlyerTag = FastTags<TagGroup.Global>.Parse("vulture");
|
||||
|
||||
/// <summary>Только зомби (указание 03.10: «кабаны и змеи хоть и противник, но не зомби, кроме
|
||||
/// зомбокабана»; «стервятники тоже зомби, обычных в игре нету») - метка zombie в entityclasses.xml:
|
||||
/// люди-зомби, зомби-собаки, лютые волки, зомби-медведи, зомби-кабаны, стервятники.</summary>
|
||||
public static bool IsZombie(EntityAlive _e)
|
||||
{
|
||||
return _e != null && _e.HasAnyTags(ZombieTag);
|
||||
}
|
||||
|
||||
static int _targetFrame = -1;
|
||||
static EntityAlive _target;
|
||||
|
||||
/// <summary>Зомби под прицелом (раз за кадр): и для заклинания, и для красного прицела.</summary>
|
||||
public static EntityAlive Target(EntityPlayerLocal _player)
|
||||
{
|
||||
if (_targetFrame != Time.frameCount)
|
||||
{
|
||||
_targetFrame = Time.frameCount;
|
||||
_target = FindTarget(_player.world, _player);
|
||||
}
|
||||
return _target;
|
||||
}
|
||||
|
||||
/// <summary>Прямая видимость своим лучом: между глазом и точкой нет блоков и земли (сущности на луче
|
||||
/// не мешают). Не EntityAlive.CanSee: у игрока нет ИИ, и его кэш видимости (seeCache) не обновляется -
|
||||
/// тест 03.10: Арлин захватывалась, зомби-коп нет.</summary>
|
||||
static bool Visible(Vector3 _eye, Vector3 _to)
|
||||
{
|
||||
Vector3 d = _to - _eye;
|
||||
return !Physics.Raycast(_eye - Origin.position, d.normalized, d.magnitude - 0.3f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
|
||||
/// <summary>Зомби ближе всех к линии прицела в конусе DenyConeDeg, не дальше DenyRange,
|
||||
/// с прямой видимостью.</summary>
|
||||
static EntityAlive FindTarget(World _world, EntityPlayerLocal _player)
|
||||
{
|
||||
Transform cam = _player.cameraTransform;
|
||||
if (cam == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Vector3 eye = cam.position + Origin.position;
|
||||
Vector3 fwd = cam.forward;
|
||||
EntityAlive best = null;
|
||||
float bestAngle = DenyConeDeg;
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (e == null || e.IsDead() || !IsZombie(e))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3 to = e.getChestPosition() - eye;
|
||||
float d = to.magnitude;
|
||||
if (d > DenyRange || d < 0.1f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float angle = Vector3.Angle(fwd, to);
|
||||
if (angle < bestAngle && Visible(eye, e.getChestPosition()))
|
||||
{
|
||||
bestAngle = angle;
|
||||
best = e;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Меню на R у пустой руки - как у ножа (Patch_ItemAction_HasRadial_SnakeKnife).</summary>
|
||||
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))]
|
||||
public static class Patch_ItemAction_HasRadial_HandHolds
|
||||
{
|
||||
public static void Postfix(ItemAction __instance, ref bool __result)
|
||||
{
|
||||
if (!__result && !SnakeMode.Active && !MindPalace.Active && HandHolds.IsHand(__instance.item))
|
||||
{
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
|
||||
public static class Patch_ItemAction_SetupRadial_HandHolds
|
||||
{
|
||||
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
|
||||
{
|
||||
if (!HandHolds.IsHand(__instance.item))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
int cur = HandHolds.Hold;
|
||||
_xuiRadialWindow.ResetRadialEntries();
|
||||
_xuiRadialWindow.CreateRadialEntry(HandHolds.None, "ui_game_symbol_hand", "UIAtlas", "",
|
||||
Localization.Get("necroHoldNone"), cur == HandHolds.None);
|
||||
_xuiRadialWindow.CreateRadialEntry(HandHolds.Deny, "ui_game_symbol_run", "UIAtlas", "",
|
||||
Localization.Get("necroHoldDeny"), cur == HandHolds.Deny);
|
||||
_xuiRadialWindow.CreateRadialEntry(HandHolds.PifPaf, "ui_game_symbol_pistol", "UIAtlas", "",
|
||||
Localization.Get("necroHoldPifPaf"), cur == HandHolds.PifPaf);
|
||||
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
|
||||
HandHolds.OnRadialCommand, null, cur);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>В хвате кнопки мыши не бьют: ЛКМ - заклинание хвата, ПКМ - ничего (03.10: «руки
|
||||
/// деформируются, пытаясь совершить старую анимацию»).</summary>
|
||||
[HarmonyPatch(typeof(ItemActionDynamicMelee), nameof(ItemActionDynamicMelee.ExecuteAction))]
|
||||
public static class Patch_ItemActionDynamicMelee_ExecuteAction_HandHolds
|
||||
{
|
||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||
{
|
||||
if (!(_actionData?.invData?.holdingEntity is EntityPlayerLocal player))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
int hold = HandHolds.Current(player);
|
||||
if (hold == HandHolds.None || !HandHolds.IsHand(_actionData.invData.item))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!_bReleased && _actionData.indexInEntityOfAction == 0)
|
||||
{
|
||||
HandHolds.Cast(player, hold);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Прицел краснеет, когда в нём зомби, в обоих хватах (указание 03.10). Цвет прицела игра
|
||||
/// берёт из EntityPlayerLocal.crosshairColors[настройка] - на время рисования подменяем его красным.</summary>
|
||||
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))]
|
||||
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_HandHolds
|
||||
{
|
||||
static readonly Color Red = new Color(1f, 0.15f, 0.1f);
|
||||
|
||||
public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
|
||||
{
|
||||
__state = null;
|
||||
int hold = HandHolds.Current(__instance);
|
||||
EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
|
||||
// «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
|
||||
if (target == null || hold == HandHolds.PifPaf && !Possession.CanPossess(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
|
||||
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
|
||||
{
|
||||
i = 0;
|
||||
}
|
||||
__state = EntityPlayerLocal.crosshairColors[i];
|
||||
EntityPlayerLocal.crosshairColors[i] = Red;
|
||||
}
|
||||
|
||||
public static void Postfix(Color? __state)
|
||||
{
|
||||
if (__state == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
|
||||
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
|
||||
{
|
||||
i = 0;
|
||||
}
|
||||
EntityPlayerLocal.crosshairColors[i] = __state.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.7.0). Свой хват -
|
||||
/// это неподвижная поза рук от первого лица, как у браслета (кулак к противнику, без движения
|
||||
/// при действии): кости рига ставятся в нужную комбинацию после аниматора. Сначала надо знать
|
||||
/// кости: команда пишет в лог дерево рига рук от первого лица (EModelSDCS.baseRigFP, его создаёт
|
||||
/// SDCSUtils.CreateVizFP под Camera/UFPSRoot) с локальными поворотами и позициями, и параметры
|
||||
/// аниматора FPPlayerController (WeaponHoldType и прочие) с текущими значениями.
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroBones : ConsoleCmdAbstract
|
||||
{
|
||||
const string Tag = "[NecromancerTome] necrobones: ";
|
||||
|
||||
public override string[] getCommands() => new[] { "necrobones" };
|
||||
public override string getDescription() => "NecromancerTome: дерево костей рук от первого лица и параметры аниматора - в лог";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
|
||||
if (player == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!(player.emodel is EModelSDCS sdcs) || sdcs.baseRigFP == null)
|
||||
{
|
||||
SdtdConsole.Instance.Output("риг рук от первого лица не найден (EModelSDCS.baseRigFP)");
|
||||
return;
|
||||
}
|
||||
Transform root = sdcs.baseRigFP.transform;
|
||||
ItemClass held = player.inventory?.holdingItem;
|
||||
Debug.Log(Tag + "в руке " + (held != null ? held.GetItemName() + ", HoldType " + held.HoldType.Value : "ничего") + ", вид от первого лица " + sdcs.IsFPV);
|
||||
Debug.Log(Tag + "путь рига: " + PathOf(root));
|
||||
int count = 0;
|
||||
var sb = new StringBuilder();
|
||||
Dump(root, 0, sb, ref count);
|
||||
foreach (string line in sb.ToString().Split('\n'))
|
||||
{
|
||||
if (line.Length > 0)
|
||||
{
|
||||
Debug.Log(Tag + line);
|
||||
}
|
||||
}
|
||||
Animator anim = root.GetComponentInChildren<Animator>(true) ?? root.GetComponentInParent<Animator>();
|
||||
if (anim != null)
|
||||
{
|
||||
Debug.Log(Tag + "аниматор на " + anim.name + ", контроллер " + (anim.runtimeAnimatorController != null ? anim.runtimeAnimatorController.name : "нет"));
|
||||
foreach (AnimatorControllerParameter p in anim.parameters)
|
||||
{
|
||||
string v = p.type == AnimatorControllerParameterType.Float ? anim.GetFloat(p.nameHash).ToString("0.###")
|
||||
: p.type == AnimatorControllerParameterType.Int ? anim.GetInteger(p.nameHash).ToString()
|
||||
: p.type == AnimatorControllerParameterType.Bool ? anim.GetBool(p.nameHash).ToString()
|
||||
: "trigger";
|
||||
Debug.Log(Tag + " параметр " + p.name + " (" + p.type + ") = " + v);
|
||||
}
|
||||
for (int l = 0; l < anim.layerCount; l++)
|
||||
{
|
||||
AnimatorClipInfo[] clips = anim.GetCurrentAnimatorClipInfo(l);
|
||||
string names = "";
|
||||
foreach (AnimatorClipInfo c in clips)
|
||||
{
|
||||
names += (names.Length > 0 ? ", " : "") + (c.clip != null ? c.clip.name : "?") + " x" + c.weight.ToString("0.##");
|
||||
}
|
||||
Debug.Log(Tag + " слой " + l + " " + anim.GetLayerName(l) + " вес " + anim.GetLayerWeight(l).ToString("0.##") + ": " + names);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log(Tag + "аниматор не найден");
|
||||
}
|
||||
SdtdConsole.Instance.Output("костей рук: " + count + " - дерево и параметры аниматора в логе (строки necrobones)");
|
||||
}
|
||||
|
||||
static void Dump(Transform _t, int _depth, StringBuilder _sb, ref int _count)
|
||||
{
|
||||
_count++;
|
||||
Vector3 e = _t.localEulerAngles;
|
||||
Vector3 p = _t.localPosition;
|
||||
_sb.Append(new string(' ', _depth * 2)).Append(_t.name)
|
||||
.Append(_t.gameObject.activeSelf ? "" : " [выкл]")
|
||||
.Append(" пов. ").Append(e.x.ToString("0.#")).Append(' ').Append(e.y.ToString("0.#")).Append(' ').Append(e.z.ToString("0.#"))
|
||||
.Append(" поз. ").Append(p.x.ToString("0.###")).Append(' ').Append(p.y.ToString("0.###")).Append(' ').Append(p.z.ToString("0.###"))
|
||||
.Append('\n');
|
||||
for (int i = 0; i < _t.childCount; i++)
|
||||
{
|
||||
Dump(_t.GetChild(i), _depth + 1, _sb, ref _count);
|
||||
}
|
||||
}
|
||||
|
||||
static string PathOf(Transform _t)
|
||||
{
|
||||
string s = _t.name;
|
||||
for (Transform p = _t.parent; p != null; p = p.parent)
|
||||
{
|
||||
s = p.name + "/" + s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.7.0). Хват - неподвижная
|
||||
/// поза: после аниматора (LateUpdate) кости рига EModelSDCS.baseRigFP ставятся в заданные локальные
|
||||
/// повороты. Аниматор игры не трогаем. Снимки necrobones 03.10: кулак браслета (FP_Block_Hold,
|
||||
/// HoldType 7) - суставы пальцев повёрнуты по локальной Z на 77-92°, у пустой руки 8-38°;
|
||||
/// направление руки - RightArm, RightArmRoll, RightForeArm (сгиб по Y), RightHand.
|
||||
/// Пока - инструмент подбора позы (команда necropose); готовую позу потом зашить как хват.
|
||||
/// </summary>
|
||||
public static class HandPose
|
||||
{
|
||||
public static bool Enabled;
|
||||
public static readonly Dictionary<string, Vector3> Overrides = new Dictionary<string, Vector3>();
|
||||
|
||||
static GameObject _rig;
|
||||
static readonly Dictionary<string, Transform> _bones = new Dictionary<string, Transform>();
|
||||
static GameObject _host;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
if (_host != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_host = new GameObject("NecroHandPose");
|
||||
Object.DontDestroyOnLoad(_host);
|
||||
_host.AddComponent<HandPoseDriver>();
|
||||
}
|
||||
|
||||
public static Transform Bone(string _name)
|
||||
{
|
||||
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
GameObject rig = (player?.emodel as EModelSDCS)?.baseRigFP;
|
||||
if (rig == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (rig != _rig)
|
||||
{
|
||||
_rig = rig;
|
||||
_bones.Clear();
|
||||
foreach (Transform t in rig.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
if (!_bones.ContainsKey(t.name))
|
||||
{
|
||||
_bones[t.name] = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_bones.TryGetValue(_name, out Transform b) && b == null)
|
||||
{
|
||||
_rig = null; // кость уничтожена (риг пересобран) - перечитать в следующий раз
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/// <summary>Кости, повёрнутые относительно КАМЕРЫ, а не родителя: ставятся после Overrides
|
||||
/// (03.10: локальные углы кисти «плывут» при каждом изменении локтя - подбор вслепую).</summary>
|
||||
public static readonly Dictionary<string, Quaternion> CamOverrides = new Dictionary<string, Quaternion>();
|
||||
|
||||
public static Transform Camera()
|
||||
{
|
||||
return GameManager.Instance?.World?.GetPrimaryPlayer()?.cameraTransform;
|
||||
}
|
||||
|
||||
/// <summary>Заморозка (03.10: «при нажатии на кнопки руки деформируются, пытаясь совершить старую
|
||||
/// анимацию»). Поза задаёт 20-30 костей, а удар двигает и остальные - позвоночник, ключицы, вспомогательные
|
||||
/// кости предплечья и кожи (*_sup_*, *Roll*). Поэтому при включении хвата в покое снимается локальное
|
||||
/// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза.
|
||||
/// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.</summary>
|
||||
public static bool Freeze = true;
|
||||
static readonly List<Transform> _frozen = new List<Transform>();
|
||||
static readonly List<Vector3> _frozenPos = new List<Vector3>();
|
||||
static readonly List<Quaternion> _frozenRot = new List<Quaternion>();
|
||||
static GameObject _frozenRig;
|
||||
static readonly int ItemActionIndexHash = Animator.StringToHash("ItemActionIndex");
|
||||
|
||||
/// <summary>Выход из мира: всё в исходное, ссылки на кости рига отпустить (HandHolds.Update).</summary>
|
||||
public static void ResetAll()
|
||||
{
|
||||
Enabled = false;
|
||||
Overrides.Clear();
|
||||
CamOverrides.Clear();
|
||||
_jabStart = -1f;
|
||||
Unfreeze();
|
||||
_bones.Clear();
|
||||
_rig = null;
|
||||
}
|
||||
|
||||
public static void Unfreeze()
|
||||
{
|
||||
_frozen.Clear();
|
||||
_frozenPos.Clear();
|
||||
_frozenRot.Clear();
|
||||
_frozenRig = null;
|
||||
}
|
||||
|
||||
static void FreezeBones()
|
||||
{
|
||||
Transform hips = Bone("Hips");
|
||||
if (hips == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_frozenRig != _rig || _frozen.Count == 0 || _frozen[0] == null)
|
||||
{
|
||||
Unfreeze();
|
||||
Animator anim = _rig.GetComponent<Animator>();
|
||||
if (anim != null && (anim.GetInteger(ItemActionIndexHash) != -1 || anim.IsInTransition(0)))
|
||||
{
|
||||
return; // идёт действие - снимок позже, в покое
|
||||
}
|
||||
foreach (Transform t in hips.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
_frozen.Add(t);
|
||||
_frozenPos.Add(t.localPosition);
|
||||
_frozenRot.Add(t.localRotation);
|
||||
}
|
||||
_frozenRig = _rig;
|
||||
Debug.Log("[NecromancerTome] HandPose: заморожено костей " + _frozen.Count);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < _frozen.Count; i++)
|
||||
{
|
||||
Transform t = _frozen[i];
|
||||
if (t != null)
|
||||
{
|
||||
t.localPosition = _frozenPos[i];
|
||||
t.localRotation = _frozenRot[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Apply()
|
||||
{
|
||||
if (!Enabled || Overrides.Count == 0 && CamOverrides.Count == 0)
|
||||
{
|
||||
if (_frozen.Count > 0)
|
||||
{
|
||||
Unfreeze();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Freeze)
|
||||
{
|
||||
FreezeBones();
|
||||
}
|
||||
foreach (var kv in Overrides)
|
||||
{
|
||||
Transform b = Bone(kv.Key);
|
||||
if (b != null)
|
||||
{
|
||||
b.localRotation = Quaternion.Euler(kv.Value);
|
||||
}
|
||||
}
|
||||
Transform cam = CamOverrides.Count > 0 || _jabStart >= 0f ? Camera() : null;
|
||||
ApplyJab(cam);
|
||||
if (cam != null)
|
||||
{
|
||||
foreach (var kv in CamOverrides)
|
||||
{
|
||||
Transform b = Bone(kv.Key);
|
||||
if (b != null)
|
||||
{
|
||||
b.rotation = cam.rotation * kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Толчок руки к прицелу (указание 05.10, «пиф-паф» на ЛКМ: «чуть сдвигает руку в сторону
|
||||
/// прицела и возвращает»). Плечо доворачивается так, чтобы кисть прошла JabAmount пути до линии
|
||||
/// взгляда и вернулась - по синусу за JabTime. Ставится до CamOverrides: кисть сохраняет поворот
|
||||
/// относительно камеры, сдвигается только её место.</summary>
|
||||
public const float JabTime = 0.25f;
|
||||
public const float JabAmount = 0.35f;
|
||||
static float _jabStart = -1f;
|
||||
|
||||
public static void Jab()
|
||||
{
|
||||
_jabStart = Time.time;
|
||||
}
|
||||
|
||||
static void ApplyJab(Transform _cam)
|
||||
{
|
||||
if (_jabStart < 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
float t = (Time.time - _jabStart) / JabTime;
|
||||
if (t >= 1f)
|
||||
{
|
||||
_jabStart = -1f;
|
||||
return;
|
||||
}
|
||||
Transform arm = Bone("RightArm");
|
||||
Transform hand = Bone("RightHand");
|
||||
if (_cam == null || arm == null || hand == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3 toHand = hand.position - arm.position;
|
||||
Vector3 aim = _cam.position + _cam.forward * Vector3.Dot(hand.position - _cam.position, _cam.forward);
|
||||
Quaternion full = Quaternion.FromToRotation(toHand, aim - arm.position);
|
||||
arm.rotation = Quaternion.Slerp(Quaternion.identity, full, Mathf.Sin(t * Mathf.PI) * JabAmount) * arm.rotation;
|
||||
}
|
||||
|
||||
/// <summary>Кисть в осях камеры. Оси кисти правой руки (лог necrobones 03.10): пальцы - локальная +X
|
||||
/// (суставы пальцев лежат по +X), ладонь - +Y (сгиб пальцев +Z ведёт +X к +Y), +Z - к мизинцу,
|
||||
/// большой палец - к -Z. Нулевая поза: пальцы вверх, ладонь от игрока, тыльная сторона к камере,
|
||||
/// большой палец влево. tilt - наклон пальцев влево (+) вокруг взгляда; back - кисть «от себя»
|
||||
/// (+, верх пальцев уходит вперёд); turn - разворот кисти вокруг вертикали (+ ладонью вправо).</summary>
|
||||
public static Quaternion PalmToCamera(float _tilt, float _back, float _turn)
|
||||
{
|
||||
Quaternion basis = Quaternion.LookRotation(Vector3.right, Vector3.forward);
|
||||
return Quaternion.AngleAxis(_turn, Vector3.up) * Quaternion.AngleAxis(_back, Vector3.right) * Quaternion.AngleAxis(_tilt, Vector3.forward) * basis;
|
||||
}
|
||||
|
||||
static readonly string[] Fingers = { "Index", "Middle", "Ring", "Pinky" };
|
||||
|
||||
/// <summary>Все суставы четырёх пальцев: сгиб по Z (curl), веер по Y у первого сустава
|
||||
/// (указательный +spread, средний +spread/3, безымянный -spread/3, мизинец -spread).</summary>
|
||||
public static void SetFingers(string _side, float _curl, float _spread)
|
||||
{
|
||||
float[] fan = { 1f, 1f / 3f, -1f / 3f, -1f };
|
||||
for (int f = 0; f < Fingers.Length; f++)
|
||||
{
|
||||
for (int j = 1; j <= 3; j++)
|
||||
{
|
||||
Overrides[_side + "Hand" + Fingers[f] + j] = new Vector3(0f, j == 1 ? _spread * fan[f] : 0f, _curl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Заготовка «раскрытая ладонь»: рука - как у браслета (снимок 03.10), пальцы прямые.</summary>
|
||||
public static void PresetOpen()
|
||||
{
|
||||
Overrides.Clear();
|
||||
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
|
||||
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
|
||||
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
|
||||
Overrides["RightHand"] = new Vector3(13.3f, 325.8f, 7.7f);
|
||||
SetFingers("Right", 3f, 0f);
|
||||
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 40.6f);
|
||||
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
|
||||
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
|
||||
Enabled = true;
|
||||
}
|
||||
|
||||
/// <summary>Левая рука вдоль туловища, вне кадра (общая для хватов «отрицание» и «пиф-паф», 03.10).</summary>
|
||||
static void LeftArmDown()
|
||||
{
|
||||
Overrides["LeftArm"] = new Vector3(30f, 13.6f, 57.3f);
|
||||
}
|
||||
|
||||
/// <summary>Хват «коза» (до 05.10 - «отрицание»; утверждён пользователем 03.10): рука вперёд, как у браслета, ниже прицела,
|
||||
/// кисть в линию с предплечьем, пальцы раскрыты. 05.10: средний и безымянный поджаты, как в кулаке
|
||||
/// (сгиб - как у поджатых пальцев «пиф-пафа»), указательный и мизинец прямые.</summary>
|
||||
public static void PresetDeny()
|
||||
{
|
||||
Overrides.Clear();
|
||||
CamOverrides.Clear();
|
||||
Overrides["RightArm"] = new Vector3(345.8f, 34.9f, 24.3f);
|
||||
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
|
||||
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
|
||||
Overrides["RightHand"] = new Vector3(45f, 0f, 0f);
|
||||
SetFingers("Right", 3f, 12f);
|
||||
for (int j = 1; j <= 3; j++)
|
||||
{
|
||||
float curl = j == 3 ? 55f : 80f;
|
||||
Overrides["RightHandMiddle" + j] = new Vector3(0f, 0f, curl);
|
||||
Overrides["RightHandRing" + j] = new Vector3(0f, 0f, curl);
|
||||
}
|
||||
// Большой палец прижимает поджатые (05.10: «полноценная коза»). Подбор на глаз - уточнять necropose.
|
||||
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 75f);
|
||||
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 40f);
|
||||
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 30f);
|
||||
LeftArmDown();
|
||||
Unfreeze();
|
||||
Enabled = true;
|
||||
}
|
||||
|
||||
/// <summary>Хват «пиф-паф» (утверждён 03.10, «Стоп. Замри»): пистолет из пальцев - указательный и средний
|
||||
/// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.</summary>
|
||||
public static void PresetPifPaf()
|
||||
{
|
||||
Overrides.Clear();
|
||||
CamOverrides.Clear();
|
||||
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
|
||||
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
|
||||
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
|
||||
CamOverrides["RightHand"] = PalmToCamera(-90f, 0f, -119f);
|
||||
for (int j = 1; j <= 3; j++)
|
||||
{
|
||||
Overrides["RightHandIndex" + j] = new Vector3(0f, j == 1 ? -3f : 0f, 3f);
|
||||
Overrides["RightHandMiddle" + j] = new Vector3(0f, j == 1 ? 2f : 0f, 3f);
|
||||
float curl = j == 3 ? 55f : 80f;
|
||||
Overrides["RightHandRing" + j] = new Vector3(0f, 0f, curl);
|
||||
Overrides["RightHandPinky" + j] = new Vector3(0f, 0f, curl);
|
||||
}
|
||||
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 20f);
|
||||
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
|
||||
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
|
||||
LeftArmDown();
|
||||
Unfreeze();
|
||||
Enabled = true;
|
||||
}
|
||||
|
||||
public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture);
|
||||
public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
|
||||
}
|
||||
|
||||
public class HandPoseDriver : MonoBehaviour
|
||||
{
|
||||
void LateUpdate()
|
||||
{
|
||||
HandHolds.Update();
|
||||
HandPose.Apply();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// necropose - подбор позы рук от первого лица:
|
||||
/// on | off | clear | open (заготовка: ладонь, рука как у браслета)
|
||||
/// <Кость> x y z - локальный поворот кости (градусы)
|
||||
/// add <Кость> dx dy dz - добавить к текущему (заданному или от аниматора)
|
||||
/// fingers Right|Left сгиб [веер] - все суставы четырёх пальцев
|
||||
/// show <Кость> - текущий поворот кости
|
||||
/// save - вся поза в лог строками кода
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroPose : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necropose" };
|
||||
public override string getDescription() => "NecromancerTome: поза рук от первого лица - on|off|clear|deny|pifpaf|freeze [off]|open|<кость> x y z|aim <кость> наклон от_себя разворот|unaim <кость>|add <кость> dx dy dz|fingers Right|Left сгиб [веер]|show <кость>|save";
|
||||
|
||||
static bool Num(string _s, out float _v) => float.TryParse(_s.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
|
||||
|
||||
void Out(string _s)
|
||||
{
|
||||
SdtdConsole.Instance.Output(_s);
|
||||
Debug.Log("[NecromancerTome] necropose: " + _s);
|
||||
}
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
string cmd = _params.Count > 0 ? _params[0] : "";
|
||||
switch (cmd)
|
||||
{
|
||||
case "on":
|
||||
HandPose.Enabled = true;
|
||||
Out("поза включена, костей " + HandPose.Overrides.Count);
|
||||
return;
|
||||
case "off":
|
||||
HandPose.Enabled = false;
|
||||
Out("поза выключена");
|
||||
return;
|
||||
case "clear":
|
||||
HandPose.Overrides.Clear();
|
||||
HandPose.CamOverrides.Clear();
|
||||
Out("поза очищена");
|
||||
return;
|
||||
case "deny":
|
||||
HandPose.PresetDeny();
|
||||
Out("хват «отрицание»");
|
||||
return;
|
||||
case "pifpaf":
|
||||
HandPose.PresetPifPaf();
|
||||
Out("хват «пиф-паф»");
|
||||
return;
|
||||
case "freeze":
|
||||
HandPose.Freeze = _params.Count < 2 || _params[1] != "off";
|
||||
HandPose.Unfreeze();
|
||||
Out("заморозка остальных костей " + (HandPose.Freeze ? "включена (снимок - в покое)" : "выключена"));
|
||||
return;
|
||||
case "open":
|
||||
HandPose.PresetOpen();
|
||||
Out("заготовка «ладонь» включена, костей " + HandPose.Overrides.Count);
|
||||
return;
|
||||
case "save":
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var kv in HandPose.Overrides)
|
||||
{
|
||||
Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(kv.Value.x) + "f, " + HandPose.F(kv.Value.y) + "f, " + HandPose.F(kv.Value.z) + "f);");
|
||||
}
|
||||
foreach (var kv in HandPose.CamOverrides)
|
||||
{
|
||||
Transform cb = HandPose.Bone(kv.Key);
|
||||
if (cb != null)
|
||||
{
|
||||
Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(cb.localEulerAngles.x) + "f, " + HandPose.F(cb.localEulerAngles.y) + "f, " + HandPose.F(cb.localEulerAngles.z) + "f); // от камеры (aim)");
|
||||
}
|
||||
}
|
||||
Out("всего костей " + HandPose.Overrides.Count + " + от камеры " + HandPose.CamOverrides.Count + ", включена " + HandPose.Enabled);
|
||||
return;
|
||||
}
|
||||
case "show":
|
||||
{
|
||||
if (_params.Count < 2)
|
||||
{
|
||||
break;
|
||||
}
|
||||
Transform b = HandPose.Bone(_params[1]);
|
||||
Out(b == null ? "нет кости " + _params[1] : _params[1] + " = " + HandPose.F(b.localEulerAngles) + (HandPose.Overrides.ContainsKey(_params[1]) ? " (задана)" : " (от аниматора)"));
|
||||
return;
|
||||
}
|
||||
case "fingers":
|
||||
{
|
||||
if (_params.Count >= 3 && (_params[1] == "Right" || _params[1] == "Left") && Num(_params[2], out float curl))
|
||||
{
|
||||
float spread = 0f;
|
||||
if (_params.Count >= 4)
|
||||
{
|
||||
Num(_params[3], out spread);
|
||||
}
|
||||
HandPose.SetFingers(_params[1], curl, spread);
|
||||
HandPose.Enabled = true;
|
||||
Out("пальцы " + _params[1] + ": сгиб " + HandPose.F(curl) + ", веер " + HandPose.F(spread));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "aim":
|
||||
{
|
||||
// aim <Кость> [наклон] [от_себя] [разворот] - кисть в осях камеры (см. HandPose.PalmToCamera)
|
||||
if (_params.Count >= 2)
|
||||
{
|
||||
float tilt = 0f, back = 0f, turn = 0f;
|
||||
if (_params.Count >= 3) Num(_params[2], out tilt);
|
||||
if (_params.Count >= 4) Num(_params[3], out back);
|
||||
if (_params.Count >= 5) Num(_params[4], out turn);
|
||||
if (HandPose.Bone(_params[1]) == null)
|
||||
{
|
||||
Out("нет кости " + _params[1]);
|
||||
return;
|
||||
}
|
||||
HandPose.Overrides.Remove(_params[1]);
|
||||
HandPose.CamOverrides[_params[1]] = HandPose.PalmToCamera(tilt, back, turn);
|
||||
HandPose.Enabled = true;
|
||||
Out(_params[1] + " от камеры: наклон " + HandPose.F(tilt) + ", от себя " + HandPose.F(back) + ", разворот " + HandPose.F(turn));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "unaim":
|
||||
{
|
||||
if (_params.Count >= 2 && HandPose.CamOverrides.Remove(_params[1]))
|
||||
{
|
||||
Out(_params[1] + " - снова от родителя (от аниматора или заданная)");
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "add":
|
||||
{
|
||||
if (_params.Count >= 5 && Num(_params[2], out float dx) && Num(_params[3], out float dy) && Num(_params[4], out float dz))
|
||||
{
|
||||
string name = _params[1];
|
||||
Transform b = HandPose.Bone(name);
|
||||
if (b == null)
|
||||
{
|
||||
Out("нет кости " + name);
|
||||
return;
|
||||
}
|
||||
Vector3 cur = HandPose.Overrides.TryGetValue(name, out Vector3 v) ? v : b.localEulerAngles;
|
||||
HandPose.Overrides[name] = cur + new Vector3(dx, dy, dz);
|
||||
HandPose.Enabled = true;
|
||||
Out(name + " = " + HandPose.F(HandPose.Overrides[name]));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
if (_params.Count >= 4 && Num(_params[1], out float x) && Num(_params[2], out float y) && Num(_params[3], out float z))
|
||||
{
|
||||
if (HandPose.Bone(cmd) == null)
|
||||
{
|
||||
Out("нет кости " + cmd);
|
||||
return;
|
||||
}
|
||||
HandPose.CamOverrides.Remove(cmd);
|
||||
HandPose.Overrides[cmd] = new Vector3(x, y, z);
|
||||
HandPose.Enabled = true;
|
||||
Out(cmd + " = " + HandPose.F(HandPose.Overrides[cmd]));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Out(getDescription());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Дух в чертогах разума - камера без сущности (указание 03.10: «тело можно вообще не делать,
|
||||
/// нужен только прицел»). Ходит по полу как человек: столкновения - по тем же слоям, что у змеи
|
||||
/// (SnakeControl.WallMask / TerrainMask), гравитация и ступенька. Взаимодействие одно - книга
|
||||
/// памяти по E; остальное в чертогах неприкосновенно. Камеру ставят патчи камеры режима змеи
|
||||
/// (SnakeControl.cs), звук и мир вокруг считаются от духа через SnakeAudioSwap.
|
||||
///
|
||||
/// СОН (указание: «тёплый фильтр, это как бы сон», эффекты - на моё усмотрение): готовые эффекты
|
||||
/// экрана игры - Hot (тёплый оттенок), Blur (мягкое размытие), Drunk (медленное покачивание).
|
||||
/// Силы крутятся командой necrodream на ходу.
|
||||
/// </summary>
|
||||
public static class MindControl
|
||||
{
|
||||
public const float EyeHeight = 1.6f;
|
||||
public const float Radius = 0.3f;
|
||||
public const float Height = 1.75f;
|
||||
public const float WalkSpeed = 2.2f;
|
||||
public const float RunSpeed = 3.6f;
|
||||
public const float Gravity = 20f;
|
||||
public const float JumpSpeed = 6f;
|
||||
public const float StepUp = 0.6f;
|
||||
public const float ReachDistance = 3f;
|
||||
|
||||
public static float Hot = 0.45f;
|
||||
public static float Blur = 0.25f;
|
||||
public static float Drunk = 0.15f;
|
||||
|
||||
public static bool Controlling;
|
||||
static Vector3 _feet;
|
||||
static Vector3 _spawn;
|
||||
static float _yaw, _pitch, _vy;
|
||||
static bool _grounded;
|
||||
public static bool HasFocus;
|
||||
static EntityPlayerLocal _player;
|
||||
|
||||
public static Vector3 FeetPos => _feet;
|
||||
|
||||
public static void Begin(EntityPlayerLocal _p, Vector3 _at, float _atYaw)
|
||||
{
|
||||
_player = _p;
|
||||
_feet = _at;
|
||||
_spawn = _at;
|
||||
_yaw = _atYaw;
|
||||
_pitch = 0f;
|
||||
_vy = 0f;
|
||||
_grounded = false;
|
||||
HasFocus = false;
|
||||
Controlling = true;
|
||||
_p.PlayerUI?.windowManager.Close(SnakeControl.ToolbeltWindow);
|
||||
Dream(_p, true);
|
||||
Debug.Log("[NecromancerTome] MindControl: дух в чертогах, " + _at);
|
||||
}
|
||||
|
||||
public static void End(EntityPlayerLocal _p)
|
||||
{
|
||||
if (!Controlling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Controlling = false;
|
||||
HasFocus = false;
|
||||
Dream(_p ?? _player, false);
|
||||
_player = null;
|
||||
}
|
||||
|
||||
public static void Dream(EntityPlayerLocal _p, bool _on)
|
||||
{
|
||||
ScreenEffects fx = _p?.ScreenEffectManager;
|
||||
if (fx == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
fx.SetScreenEffect("Hot", _on ? Hot : 0f, _on ? 2f : 0.5f);
|
||||
fx.SetScreenEffect("Blur", _on ? Blur : 0f, _on ? 2f : 0.5f);
|
||||
fx.SetScreenEffect("Drunk", _on ? Drunk : 0f, _on ? 2f : 0.5f);
|
||||
}
|
||||
|
||||
static bool Blocked(Vector3 _p)
|
||||
{
|
||||
Vector3 a = _p + Vector3.up * (Radius + 0.05f) - Origin.position;
|
||||
Vector3 b = _p + Vector3.up * (Height - Radius) - Origin.position;
|
||||
return Physics.CheckCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
|
||||
static bool TryMove(Vector3 _d)
|
||||
{
|
||||
Vector3 c = _feet + _d;
|
||||
if (!Blocked(c))
|
||||
{
|
||||
_feet = c;
|
||||
return true;
|
||||
}
|
||||
// Ступенька: на полблока выше свободно - шагаем вверх.
|
||||
if (_grounded && !Blocked(c + Vector3.up * StepUp))
|
||||
{
|
||||
_feet = c + Vector3.up * StepUp;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void Update(EntityPlayerLocal _p)
|
||||
{
|
||||
if (!Controlling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
float dt = Time.deltaTime;
|
||||
bool gui = _p.PlayerUI != null && _p.PlayerUI.windowManager.IsModalWindowOpen();
|
||||
PlayerActionsLocal input = _p.playerInput;
|
||||
Vector2 move = Vector2.zero;
|
||||
bool run = false, jump = false;
|
||||
if (!gui && input != null)
|
||||
{
|
||||
PlayerMoveController pmc = PlayerMoveController.Instance;
|
||||
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
|
||||
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
|
||||
_yaw += Input.GetAxis("Mouse X") * sens.x;
|
||||
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -85f, 85f);
|
||||
move = new Vector2(input.Move.X, input.Move.Y);
|
||||
run = input.Run.IsPressed;
|
||||
jump = input.Jump.WasPressed;
|
||||
if (HasFocus && (input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed))
|
||||
{
|
||||
BlockNecroMemoryBook.Open(_p);
|
||||
}
|
||||
}
|
||||
Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
|
||||
Vector3 dir = yawRot * new Vector3(move.x, 0f, move.y);
|
||||
if (dir.sqrMagnitude > 1f)
|
||||
{
|
||||
dir.Normalize();
|
||||
}
|
||||
if (dir.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt;
|
||||
int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / 0.1f));
|
||||
Vector3 d = delta / steps;
|
||||
for (int i = 0; i < steps; i++)
|
||||
{
|
||||
if (!TryMove(d) && !TryMove(new Vector3(d.x, 0f, 0f)) && !TryMove(new Vector3(0f, 0f, d.z)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Падение и прыжок: земля - луч вниз по слою ландшафта и блоков.
|
||||
if (_grounded && jump)
|
||||
{
|
||||
_vy = JumpSpeed;
|
||||
_grounded = false;
|
||||
}
|
||||
_vy -= Gravity * dt;
|
||||
float dy = _vy * dt;
|
||||
if (dy > 0f && Blocked(_feet + Vector3.up * dy))
|
||||
{
|
||||
_vy = 0f;
|
||||
dy = 0f;
|
||||
}
|
||||
if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
|
||||
{
|
||||
_feet.y = hit.point.y + Origin.position.y;
|
||||
_vy = 0f;
|
||||
_grounded = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_feet.y += dy;
|
||||
_grounded = false;
|
||||
}
|
||||
// Провалился (коллизии свежепоставленных блоков ещё не построены) - назад на точку появления.
|
||||
if (_feet.y < _spawn.y - 3f)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindControl: дух провалился - назад на точку появления");
|
||||
_feet = _spawn + Vector3.up * 0.3f;
|
||||
_vy = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Из патчей камеры режима змеи: глаза духа и поиск книги под прицелом.</summary>
|
||||
public static void PlaceCamera(Transform _camera)
|
||||
{
|
||||
Vector3 eye = _feet + Vector3.up * EyeHeight - Origin.position;
|
||||
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
|
||||
_camera.SetPositionAndRotation(eye, rot);
|
||||
HasFocus = FindBook(_camera.position + Origin.position, _camera.forward);
|
||||
}
|
||||
|
||||
/// <summary>Шагаем по лучу на ReachDistance: первая встреченная книга памяти - цель; твёрдый
|
||||
/// блок раньше неё - нет цели (сквозь стены не читаем).</summary>
|
||||
static bool FindBook(Vector3 _from, Vector3 _dir)
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Vector3i last = new Vector3i(int.MinValue, 0, 0);
|
||||
for (float t = 0.1f; t <= ReachDistance; t += 0.05f)
|
||||
{
|
||||
Vector3i b = World.worldToBlockPos(_from + _dir * t);
|
||||
if (b == last)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
last = b;
|
||||
Block block = world.GetBlock(b).Block;
|
||||
if (block is BlockNecroMemoryBook)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (block != null && block.IsCollideMovement && !block.shape.IsTerrain() && block.shape.IsSolidCube)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Прицел духа и подсказка у книги (OnGUI, как прицел змеи).</summary>
|
||||
public class MindHud : MonoBehaviour
|
||||
{
|
||||
static GUIStyle _style;
|
||||
|
||||
public static void Create()
|
||||
{
|
||||
var go = new GameObject("NecroMindHud");
|
||||
DontDestroyOnLoad(go);
|
||||
go.AddComponent<MindHud>();
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (!MindControl.Controlling || Event.current.type != EventType.Repaint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
if (player == null || (player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
float s = 4f * Screen.height / 1080f;
|
||||
Color old = GUI.color;
|
||||
GUI.color = MindControl.HasFocus ? new Color(1f, 0.85f, 0.5f, 1f) : new Color(1f, 0.95f, 0.85f, 0.7f);
|
||||
GUI.DrawTexture(new Rect(Screen.width * 0.5f - s * 0.5f, Screen.height * 0.5f - s * 0.5f, s, s), Texture2D.whiteTexture);
|
||||
if (MindControl.HasFocus)
|
||||
{
|
||||
if (_style == null)
|
||||
{
|
||||
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
|
||||
}
|
||||
_style.fontSize = Mathf.RoundToInt(26f * Screen.height / 1080f);
|
||||
// E в игре привязана к PermanentActions.Activate; у обычного Activate привязок нет - он даёт «Нет».
|
||||
string key = KeyName(player.playerInput?.PermanentActions?.Activate) ?? KeyName(player.playerInput?.Activate) ?? "E";
|
||||
string text = string.Format(Localization.Get("necroMemoryBookPrompt"), key);
|
||||
Rect r = new Rect(0f, Screen.height * 0.5f + 30f * Screen.height / 1080f, Screen.width, 40f * Screen.height / 1080f);
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.8f);
|
||||
GUI.Label(new Rect(r.x + 2f, r.y + 2f, r.width, r.height), text, _style);
|
||||
GUI.color = new Color(1f, 0.9f, 0.7f, 1f);
|
||||
GUI.Label(r, text, _style);
|
||||
}
|
||||
GUI.color = old;
|
||||
}
|
||||
|
||||
private static string KeyName(InControl.PlayerAction action)
|
||||
{
|
||||
if (action == null || action.Bindings.Count == 0) return null;
|
||||
string name = action.GetBindingString(false);
|
||||
return string.IsNullOrEmpty(name) ? null : name;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>necrodream hot|blur|drunk N - подобрать силу сонных эффектов на ходу.</summary>
|
||||
[UnityEngine.Scripting.Preserve]
|
||||
public class ConsoleCmdNecroDream : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necrodream" };
|
||||
public override string getDescription() => "NecromancerTome: сила эффектов сна - hot|blur|drunk N (0-1)";
|
||||
|
||||
public override void Execute(System.Collections.Generic.List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
if (_params.Count == 2 && float.TryParse(_params[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v))
|
||||
{
|
||||
switch (_params[0])
|
||||
{
|
||||
case "hot": MindControl.Hot = v; break;
|
||||
case "blur": MindControl.Blur = v; break;
|
||||
case "drunk": MindControl.Drunk = v; break;
|
||||
}
|
||||
if (MindControl.Controlling)
|
||||
{
|
||||
MindControl.Dream(GameManager.Instance.World.GetPrimaryPlayer(), true);
|
||||
}
|
||||
}
|
||||
SdtdConsole.Instance.Output("сон: hot " + MindControl.Hot + ", blur " + MindControl.Blur + ", drunk " + MindControl.Drunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Ментальная форма - «Чертоги разума» (решения пользователя 02-03.10, BACKLOG «Чертоги разума»).
|
||||
/// Вход - третья строка меню ножа некроманта на R, только ночью, здоровье не тратит. Тело
|
||||
/// падает рэгдоллом и лежит беззащитным, как в режиме змеи; дух - камера без сущности
|
||||
/// (MindControl.cs): ходит по полу, видит только своё, с другими игроками не встречается.
|
||||
/// Выход - R, утро или смерть тела.
|
||||
///
|
||||
/// ГДЕ ЧЕРТОГИ. Под землёй, в радиационном поясе у северо-восточного угла карты: сначала от
|
||||
/// угла по диагонали ищется пояс (biomeProvider.GetRadiationAt > 2.5); пояс шире префаба -
|
||||
/// чертоги в его середине, уже (Navezgane: 16 блоков) - в 45 блоках от края. Своё место у каждого
|
||||
/// игрока - вдоль северного края с шагом SlotStep (дальше дальности показа сущностей). Места
|
||||
/// пишутся в файл в папке сохранения мира, чтобы смена ширины пояса в новой версии игры их не
|
||||
/// сдвинула.
|
||||
///
|
||||
/// КАК СТАВЯТСЯ. Префаб chertogi (PalacePrefab/, нарисован пользователем в редакторе игры)
|
||||
/// грузится Prefab.Load и ставится поблочно через World.SetBlocksRPC - НЕ Prefab.CopyIntoLocal:
|
||||
/// тот перед постановкой вычищает весь столб над префабом до неба от не-земляных блоков,
|
||||
/// и над чертогами вырубило бы деревья (видно сверху). Заодно: внешний слой оболочки
|
||||
/// (woodShapes:cube) -> terrBedrock (в меню творчества его нет, рисовали деревом),
|
||||
/// одиночная книга на столе -> necroMemoryBook, на стол с реактивами - нож-декорация,
|
||||
/// кровать (SleepingBag) -> necroPalaceBed: иначе игра делает её точкой возрождения игрока.
|
||||
/// Построены ли - по книге: нет её - ставим заново (самопочинка).
|
||||
///
|
||||
/// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
|
||||
/// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
|
||||
/// раз в секунду выбрасываются: змея развеивается, игрок - на поверхность, прочие - удаляются.
|
||||
///
|
||||
/// ОГРАНИЧЕНИЕ: одиночная игра и хост. Клиенту в мультиплеере вход пока не даётся - чертоги ставит
|
||||
/// сервер, а камера живёт у клиента (решать отдельно).
|
||||
/// </summary>
|
||||
public static class MindPalace
|
||||
{
|
||||
public const string PrefabName = "chertogi";
|
||||
public const string BookBlock = "necroMemoryBook";
|
||||
public const string KnifeBlock = "necroKnifeDecor";
|
||||
/// <summary>Кровать без класса SleepingBag - ею заменяется кровать префаба (bunkBedMadeWhite).</summary>
|
||||
public const string BedBlock = "necroPalaceBed";
|
||||
public const string ShellFrom = "woodShapes:cube";
|
||||
public const string ShellTo = "terrBedrock";
|
||||
/// <summary>Одиночная книга на обеденном столе (cntBookPile09 в префабе).</summary>
|
||||
public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
|
||||
/// <summary>Место ножа на столе с реактивами (указание пользователя).</summary>
|
||||
public static readonly Vector3i KnifeAt = new Vector3i(4, 2, 11);
|
||||
/// <summary>Входная дверь чертогов - во внутреннюю оболочку, никуда не ведёт: остаётся закрытой
|
||||
/// (указание 03.10). Остальные четыре - между комнатами - открываются.</summary>
|
||||
public static readonly Vector3i EntranceDoorAt = new Vector3i(8, 1, 3);
|
||||
/// <summary>Где появляется дух: комната с книгой, свободная клетка у стола.</summary>
|
||||
public static readonly Vector3 SpawnAt = new Vector3(11.5f, 1.05f, 11.5f);
|
||||
public const float SpawnYaw = 180f;
|
||||
public const int SlotStep = 300;
|
||||
public const int FallbackInset = 45;
|
||||
public const int FloorY = 3;
|
||||
/// <summary>Рамка вокруг чертогов, которая заливается камнем перед постановкой.</summary>
|
||||
public const int LooseMargin = 3;
|
||||
public const float RadiationThreshold = 2.5f;
|
||||
/// <summary>Поворот ножа-декорации (0-23): 9 - плашмя на столешнице (подобрано пользователем
|
||||
/// 03.10 командой necropalace knife N).</summary>
|
||||
public static int KnifeRotation = 0;
|
||||
|
||||
public static bool Active;
|
||||
static Vector3i _origin;
|
||||
static Prefab _prefab;
|
||||
static ChunkManager.ChunkObserver _observer;
|
||||
enum Stage { None, Loading, Settling, Ready }
|
||||
static Stage _stage;
|
||||
static float _deadline;
|
||||
static float _settleUntil;
|
||||
static float _ragdollAt;
|
||||
static bool _waitStandUp;
|
||||
static float _standUpDeadline;
|
||||
static float _guardCheck;
|
||||
|
||||
static readonly Dictionary<string, Vector3i> _slots = new Dictionary<string, Vector3i>();
|
||||
static string _slotsFor;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||
MindHud.Create();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- места
|
||||
|
||||
static string SlotsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaces.txt");
|
||||
|
||||
static void LoadSlots()
|
||||
{
|
||||
string dir = GameIO.GetSaveGameDir();
|
||||
if (_slotsFor == dir)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_slotsFor = dir;
|
||||
_slots.Clear();
|
||||
try
|
||||
{
|
||||
if (File.Exists(SlotsFile))
|
||||
{
|
||||
foreach (string line in File.ReadAllLines(SlotsFile))
|
||||
{
|
||||
string[] p = line.Split(' ');
|
||||
if (p.Length == 4 && int.TryParse(p[1], out int x) && int.TryParse(p[2], out int y) && int.TryParse(p[3], out int z))
|
||||
{
|
||||
_slots[p[0]] = new Vector3i(x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать места - " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
static void SaveSlots()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var kv in _slots)
|
||||
{
|
||||
lines.Add(kv.Key + " " + kv.Value.x + " " + kv.Value.y + " " + kv.Value.z);
|
||||
}
|
||||
File.WriteAllLines(SlotsFile, lines);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: не записать места - " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
static string PlayerKey(EntityPlayer _player)
|
||||
{
|
||||
string id = GameManager.Instance.persistentLocalPlayer?.PrimaryId?.CombinedString;
|
||||
return string.IsNullOrEmpty(id) ? "local" : id.Replace(' ', '_');
|
||||
}
|
||||
|
||||
static Vector3i GetSlot(World _world, EntityPlayer _player)
|
||||
{
|
||||
LoadSlots();
|
||||
string key = PlayerKey(_player);
|
||||
if (_slots.TryGetValue(key, out Vector3i o))
|
||||
{
|
||||
return o;
|
||||
}
|
||||
o = ComputeSlot(_world, _slots.Count);
|
||||
_slots[key] = o;
|
||||
SaveSlots();
|
||||
Debug.Log("[NecromancerTome] MindPalace: место " + _slots.Count + " для " + key + " - " + o);
|
||||
return o;
|
||||
}
|
||||
|
||||
/// <summary>Отступ от края: середина радиационного пояса, если он шире префаба, иначе 45.</summary>
|
||||
static int Inset(World _world, Vector3i _min, Vector3i _max, int _size)
|
||||
{
|
||||
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
|
||||
int start = -1, end = -1;
|
||||
if (biomes != null)
|
||||
{
|
||||
for (int t = 0; t < 600; t += 4)
|
||||
{
|
||||
bool rad = biomes.GetRadiationAt(_max.x - t, _max.z - t) > RadiationThreshold;
|
||||
if (rad && start < 0) start = t;
|
||||
if (!rad && start >= 0) { end = t; break; }
|
||||
}
|
||||
}
|
||||
if (start >= 0 && end > start && end - start >= _size + 20)
|
||||
{
|
||||
return Mathf.Max(FallbackInset, (start + end) / 2 - _size / 2);
|
||||
}
|
||||
return FallbackInset;
|
||||
}
|
||||
|
||||
static Vector3i ComputeSlot(World _world, int _index)
|
||||
{
|
||||
_world.GetWorldExtent(out Vector3i min, out Vector3i max);
|
||||
Prefab p = LoadPrefab();
|
||||
int size = p != null ? Mathf.Max(p.size.x, p.size.z) : 17;
|
||||
int inset = Inset(_world, min, max, size);
|
||||
int perEdge = Mathf.Max(1, (max.x - min.x - 2 * inset - size) / SlotStep);
|
||||
int x, z;
|
||||
if (_index < perEdge)
|
||||
{
|
||||
x = max.x - inset - size - _index * SlotStep;
|
||||
z = max.z - inset - size;
|
||||
}
|
||||
else
|
||||
{
|
||||
x = max.x - inset - size;
|
||||
z = max.z - inset - size - (_index - perEdge + 1) * SlotStep;
|
||||
}
|
||||
x = Mathf.FloorToInt(x / 16f) * 16;
|
||||
z = Mathf.FloorToInt(z / 16f) * 16;
|
||||
return new Vector3i(x, FloorY, z);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- префаб и постройка
|
||||
|
||||
static string ModDir => Path.GetDirectoryName(typeof(MindPalace).Assembly.Location);
|
||||
|
||||
static Prefab LoadPrefab()
|
||||
{
|
||||
if (_prefab != null)
|
||||
{
|
||||
return _prefab;
|
||||
}
|
||||
string path = Path.Combine(ModDir, "PalacePrefab", PrefabName + ".tts");
|
||||
var p = new Prefab();
|
||||
if (!p.Load(new PathAbstractions.AbstractedLocation(PathAbstractions.EAbstractedLocationType.Mods, PrefabName, path, null, false)))
|
||||
{
|
||||
Debug.LogError("[NecromancerTome] MindPalace: префаб не загрузился - " + path);
|
||||
return null;
|
||||
}
|
||||
_prefab = p;
|
||||
Debug.Log("[NecromancerTome] MindPalace: префаб " + PrefabName + " " + p.size);
|
||||
return _prefab;
|
||||
}
|
||||
|
||||
static bool ChunksLoaded(World _world, Vector3i _o, Vector3i _size)
|
||||
{
|
||||
for (int cx = World.toChunkXZ(_o.x); cx <= World.toChunkXZ(_o.x + _size.x - 1); cx++)
|
||||
{
|
||||
for (int cz = World.toChunkXZ(_o.z); cz <= World.toChunkXZ(_o.z + _size.z - 1); cz++)
|
||||
{
|
||||
// Не просто «есть в памяти», а сгенерирован и украшен: иначе генерация досыпала
|
||||
// землю поверх поставленного (тест 03.10: «внутри блоки камня»).
|
||||
Chunk c = _world.GetChunkSync(cx, cz) as Chunk;
|
||||
if (c == null || !c.IsInitialized || c.NeedsDecoration)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Стоит ли блок уже в самом префабе (03.10: пользователь расставляет книгу и нож в редакторе
|
||||
/// сам) - тогда старые принудительные замены (книга в BookAt, нож в KnifeAt) не делаем.</summary>
|
||||
static bool PrefabHas(Prefab _p, string _block)
|
||||
{
|
||||
if (_hasCache == null || _hasCacheFor != _p)
|
||||
{
|
||||
_hasCache = new Dictionary<string, bool>();
|
||||
_hasCacheFor = _p;
|
||||
}
|
||||
if (_hasCache.TryGetValue(_block, out bool has))
|
||||
{
|
||||
return has;
|
||||
}
|
||||
int type = Block.GetBlockValue(_block).type;
|
||||
for (int y = 0; y < _p.size.y && !has; y++)
|
||||
for (int z = 0; z < _p.size.z && !has; z++)
|
||||
for (int x = 0; x < _p.size.x && !has; x++)
|
||||
{
|
||||
has = _p.GetBlock(x, y, z).type == type;
|
||||
}
|
||||
_hasCache[_block] = has;
|
||||
return has;
|
||||
}
|
||||
|
||||
static Dictionary<string, bool> _hasCache;
|
||||
static Prefab _hasCacheFor;
|
||||
|
||||
/// <summary>Что должно стоять в клетке префаба после постановки (с заменами). False - клетку
|
||||
/// не трогаем (дочерняя часть многоблочного предмета).</summary>
|
||||
static bool Expected(Prefab _p, int _x, int _y, int _z, out BlockValue _bv, out sbyte _d)
|
||||
{
|
||||
_bv = _p.GetBlock(_x, _y, _z);
|
||||
_d = _p.GetDensity(_x, _y, _z);
|
||||
if (_bv.ischild)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Vector3i lp = new Vector3i(_x, _y, _z);
|
||||
// Пол (y 0) - НЕ bedrock: в префабе он же пол комнат, а bedrock - блок-земля, её сглаженная
|
||||
// поверхность выпирала из пола в комнаты каменными наплывами (тест 03.10: «в помещении
|
||||
// блоки камня»). Защита снизу - отдельный слой bedrock под префабом (FloorShield).
|
||||
bool outer = _x == 0 || _z == 0 || _x == _p.size.x - 1 || _z == _p.size.z - 1 || _y == _p.size.y - 1;
|
||||
if (outer && !_bv.isair && _bv.Block.GetBlockName() == ShellFrom)
|
||||
{
|
||||
_bv = Block.GetBlockValue(ShellTo);
|
||||
_d = MarchingCubes.DensityTerrain;
|
||||
}
|
||||
else if (lp == BookAt && !PrefabHas(_p, BookBlock))
|
||||
{
|
||||
BlockValue b = Block.GetBlockValue(BookBlock);
|
||||
b.rotation = _bv.rotation;
|
||||
_bv = b;
|
||||
_d = MarchingCubes.DensityAir;
|
||||
}
|
||||
else if (lp == KnifeAt && !PrefabHas(_p, KnifeBlock))
|
||||
{
|
||||
BlockValue k = Block.GetBlockValue(KnifeBlock);
|
||||
k.rotation = (byte)KnifeRotation;
|
||||
_bv = k;
|
||||
_d = MarchingCubes.DensityAir;
|
||||
}
|
||||
else if (!_bv.isair && _bv.Block is BlockSleepingBag)
|
||||
{
|
||||
// Кровать префаба - просто мебель (тест 03.10: синий камень портала отправил в чертоги).
|
||||
// Чертоги ставятся SetBlocksRPC без игрока, а GameManager.ChangeBlocks тогда считает
|
||||
// ставившим локального игрока и любую SleepingBag-кровать делает его точкой возрождения.
|
||||
BlockValue bed = Block.GetBlockValue(BedBlock);
|
||||
bed.rotation = _bv.rotation;
|
||||
_bv = bed;
|
||||
_d = MarchingCubes.DensityAir;
|
||||
}
|
||||
else if (_bv.isair || !_bv.Block.shape.IsTerrain())
|
||||
{
|
||||
_d = MarchingCubes.DensityAir;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Диагностика (03.10, «нож пропал со стола»): что стоит в клетке и есть ли у блока модель.</summary>
|
||||
static void LogCell(World _world, Vector3i _wp, string _what)
|
||||
{
|
||||
BlockValue bv = _world.GetBlock(_wp);
|
||||
string s = "[NecromancerTome] MindPalace: " + _what + " " + _wp + " - " + bv.Block.GetBlockName() + " пов. " + bv.rotation;
|
||||
Chunk c = _world.GetChunkFromWorldPos(_wp) as Chunk;
|
||||
BlockEntityData be = c?.GetBlockEntity(_wp);
|
||||
if (be == null)
|
||||
{
|
||||
s += ", модели нет (нет BlockEntity)";
|
||||
}
|
||||
else if (!be.bHasTransform || be.transform == null)
|
||||
{
|
||||
s += ", модель ещё не создана";
|
||||
}
|
||||
else
|
||||
{
|
||||
Renderer[] rs = be.transform.GetComponentsInChildren<Renderer>(true);
|
||||
int on = 0;
|
||||
foreach (Renderer r in rs) if (r.enabled && r.gameObject.activeInHierarchy) on++;
|
||||
s += ", модель " + be.transform.name + " в " + (be.transform.position + Origin.position) + ", активна " + be.transform.gameObject.activeInHierarchy + ", рендеров " + on + "/" + rs.Length;
|
||||
}
|
||||
Debug.Log(s);
|
||||
}
|
||||
|
||||
/// <summary>Сколько клеток в мире не совпадает с префабом по типу блока (0 - чертоги целы).</summary>
|
||||
public static int Mismatches(World _world, Vector3i _o, List<string> _report = null)
|
||||
{
|
||||
Prefab p = LoadPrefab();
|
||||
if (p == null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
int n = 0;
|
||||
int shieldType = Block.GetBlockValue(ShellTo).type;
|
||||
for (int z = 0; z < p.size.z; z++)
|
||||
for (int x = 0; x < p.size.x; x++)
|
||||
{
|
||||
if (_world.GetBlock(_o + new Vector3i(x, -1, z)).type != shieldType)
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
for (int y = 0; y < p.size.y; y++)
|
||||
for (int z = 0; z < p.size.z; z++)
|
||||
for (int x = 0; x < p.size.x; x++)
|
||||
{
|
||||
Vector3i wp = _o + new Vector3i(x, y, z);
|
||||
if (!Expected(p, x, y, z, out BlockValue want, out sbyte wantD))
|
||||
{
|
||||
// Дочерняя клетка: там не должно быть земли/камня.
|
||||
BlockValue c = _world.GetBlock(wp);
|
||||
if (c.Block.shape.IsTerrain() || _world.GetDensity(wp.x, wp.y, wp.z) < 0)
|
||||
{
|
||||
n++;
|
||||
if (_report != null && _report.Count < 40)
|
||||
{
|
||||
_report.Add("(" + x + "," + y + "," + z + ") дочерняя клетка, стоит " + c.Block.GetBlockName() + " плотн. " + _world.GetDensity(wp.x, wp.y, wp.z));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
BlockValue have = _world.GetBlock(wp);
|
||||
// Плотность тоже: тип блока - воздух, а плотность земли - и клетка рисуется камнем
|
||||
// (тест 03.10: «камень под столом, в дверном проёме, под потолком»).
|
||||
sbyte haveD = _world.GetDensity(wp.x, wp.y, wp.z);
|
||||
bool densityBad = (wantD < 0) != (haveD < 0);
|
||||
// И покраска (указание 03.10: «покраску обязательно перенеси из префаба»).
|
||||
bool paintBad = !want.isair && want.type == p.GetBlock(x, y, z).type &&
|
||||
_world.GetTextureFullArray(wp.x, wp.y, wp.z) != p.GetTexture(x, y, z);
|
||||
if (have.type != want.type || densityBad || paintBad)
|
||||
{
|
||||
n++;
|
||||
if (_report != null && _report.Count < 40)
|
||||
{
|
||||
_report.Add("(" + x + "," + y + "," + z + ") ждали " + want.Block.GetBlockName() + " плотн. " + wantD +
|
||||
", стоит " + have.Block.GetBlockName() + " плотн. " + haveD + (paintBad ? ", не та покраска" : ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
public static bool IsBuilt(World _world, Vector3i _o)
|
||||
{
|
||||
return Mismatches(_world, _o) == 0;
|
||||
}
|
||||
|
||||
/// <summary>Все двери чертогов - открыты (в редакторе сохранились закрытыми, а дух дверей не
|
||||
/// открывает). Состояние двери - в её TEFeatureDoor, не в блоке.</summary>
|
||||
public static void OpenDoors(World _world, Vector3i _o)
|
||||
{
|
||||
Vector3i s = Size;
|
||||
int opened = 0;
|
||||
for (int y = 0; y < s.y; y++)
|
||||
for (int z = 0; z < s.z; z++)
|
||||
for (int x = 0; x < s.x; x++)
|
||||
{
|
||||
if (_world.GetTileEntity(_o + new Vector3i(x, y, z)) is TileEntityComposite tec)
|
||||
{
|
||||
TEFeatureDoor door = tec.GetFeature<TEFeatureDoor>();
|
||||
// Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
|
||||
bool wantOpen = new Vector3i(x, y, z) != EntranceDoorAt;
|
||||
if (door != null && door.IsOpen() != wantOpen)
|
||||
{
|
||||
door.SetOpen(wantOpen);
|
||||
opened++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opened > 0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + opened);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Stamp(World _world, Vector3i _o)
|
||||
{
|
||||
Prefab p = LoadPrefab();
|
||||
if (p == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// ПОРЯДОК ВАЖЕН (тест 03.10: при очистке всей коробки разом на миг открывалась пустота
|
||||
// 17x8x17, земля над ней и края теряли опору - обвал, падающие блоки ушли под мир):
|
||||
// 1) слой bedrock под чертогами;
|
||||
// 2) оболочка - пол (y 0), стены (края по x/z) и крыша - сразу на место земли, без очистки;
|
||||
// 3) очистка внутренности (её уже держит оболочка) - в том числе дочерних клеток стола,
|
||||
// дверей, шкафов, где иначе оставался камень;
|
||||
// 4) всё внутреннее по префабу.
|
||||
var list = new List<BlockChangeInfo>();
|
||||
// 0) Рамка LooseMargin блоков вокруг чертогов, со всех сторон и сверху, - сплошь камнем
|
||||
// (указание 03.10: «заполни сначала камнем, на 3 блока во все стороны»): земля, песок,
|
||||
// гравий (осыпаются без опоры) и пустоты пещер -> terrStone. Не земля и не воздух
|
||||
// (чужие постройки) - не трогаем.
|
||||
BlockValue stone = Block.GetBlockValue("terrStone");
|
||||
int loose = 0;
|
||||
for (int y = -1 - LooseMargin; y < p.size.y + LooseMargin; y++)
|
||||
for (int z = -LooseMargin; z < p.size.z + LooseMargin; z++)
|
||||
for (int x = -LooseMargin; x < p.size.x + LooseMargin; x++)
|
||||
{
|
||||
bool inside = y >= -1 && y < p.size.y && x >= 0 && x < p.size.x && z >= 0 && z < p.size.z;
|
||||
if (inside)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3i wp = _o + new Vector3i(x, y, z);
|
||||
if (wp.y < 1)
|
||||
{
|
||||
continue; // ниже 1 игра блоки не ставит (дно мира)
|
||||
}
|
||||
BlockValue have = _world.GetBlock(wp);
|
||||
Block b = have.Block;
|
||||
if (have.type != stone.type && (have.isair || b != null && b.shape.IsTerrain()))
|
||||
{
|
||||
list.Add(new BlockChangeInfo(wp, stone, MarchingCubes.DensityTerrain));
|
||||
loose++;
|
||||
}
|
||||
}
|
||||
if (loose > 0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: рамка вокруг залита камнем - " + loose + " клеток");
|
||||
}
|
||||
BlockValue shield = Block.GetBlockValue(ShellTo);
|
||||
for (int z = 0; z < p.size.z; z++)
|
||||
for (int x = 0; x < p.size.x; x++)
|
||||
{
|
||||
list.Add(new BlockChangeInfo(_o + new Vector3i(x, -1, z), shield, MarchingCubes.DensityTerrain));
|
||||
}
|
||||
for (int pass = 0; pass < 3; pass++)
|
||||
for (int yi = 0; yi < p.size.y; yi++)
|
||||
for (int z = 0; z < p.size.z; z++)
|
||||
for (int x = 0; x < p.size.x; x++)
|
||||
{
|
||||
// Очистка (проход 1) - СВЕРХУ ВНИЗ: снизу вверх убранный слой оставлял над собой
|
||||
// песок/гравий без опоры, тот падал и рушил уже поставленные стены (тест 03.10,
|
||||
// necropalace test: 30 расхождений сразу, 309 - при следующем входе). Постановка -
|
||||
// снизу вверх, на опору.
|
||||
int y = pass == 1 ? p.size.y - 1 - yi : yi;
|
||||
bool shellCell = y == 0 || y == p.size.y - 1 || x == 0 || z == 0 || x == p.size.x - 1 || z == p.size.z - 1;
|
||||
Vector3i wp = _o + new Vector3i(x, y, z);
|
||||
if (pass == 0 && shellCell || pass == 2 && !shellCell)
|
||||
{
|
||||
if (Expected(p, x, y, z, out BlockValue bv, out sbyte d))
|
||||
{
|
||||
// С покраской из префаба (указание 03.10).
|
||||
list.Add(new BlockChangeInfo(wp, bv, d, p.GetTexture(x, y, z)));
|
||||
}
|
||||
}
|
||||
else if (pass == 1 && !shellCell)
|
||||
{
|
||||
list.Add(new BlockChangeInfo(wp, BlockValue.Air, MarchingCubes.DensityAir));
|
||||
}
|
||||
}
|
||||
_world.SetBlocksRPC(list);
|
||||
Debug.Log("[NecromancerTome] MindPalace: чертоги поставлены в " + _o + ", блоков " + list.Count);
|
||||
}
|
||||
|
||||
static Vector3i Size => _prefab != null ? _prefab.size : new Vector3i(17, 8, 17);
|
||||
|
||||
/// <summary>Проверка постановки в нетронутую землю (указание 03.10): залить камнем коробку на
|
||||
/// 2 блока шире чертогов во все стороны (включая слой bedrock снизу), подождать, поставить
|
||||
/// чертоги заново, выставить двери. Сразу заливкой, без предварительной очистки - очистка и
|
||||
/// есть та пустота без опоры, из-за которой был обвал.</summary>
|
||||
public static System.Collections.IEnumerator StressTest(World _world, Vector3i _o)
|
||||
{
|
||||
Vector3i s = Size;
|
||||
// Гравием - худший случай: сыпучее, падает без опоры (указание 03.10).
|
||||
BlockValue stone = Block.GetBlockValue("terrGravel");
|
||||
var list = new List<BlockChangeInfo>();
|
||||
for (int y = -3; y < s.y + 2; y++)
|
||||
for (int z = -2; z < s.z + 2; z++)
|
||||
for (int x = -2; x < s.x + 2; x++)
|
||||
{
|
||||
list.Add(new BlockChangeInfo(_o + new Vector3i(x, y, z), stone, MarchingCubes.DensityTerrain));
|
||||
}
|
||||
_world.SetBlocksRPC(list);
|
||||
Debug.Log("[NecromancerTome] necropalace test: залито гравием " + list.Count + " клеток, ждём 2 с");
|
||||
yield return new WaitForSeconds(2f);
|
||||
Stamp(_world, _o);
|
||||
yield return new WaitForSeconds(1f);
|
||||
OpenDoors(_world, _o);
|
||||
Debug.Log("[NecromancerTome] necropalace test: готово, расхождений с префабом " + Mismatches(_world, _o));
|
||||
}
|
||||
|
||||
public static bool InAnyPalace(Vector3i _pos, int _margin = 0)
|
||||
{
|
||||
if (_slots.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Vector3i s = Size;
|
||||
foreach (Vector3i o in _slots.Values)
|
||||
{
|
||||
if (_pos.x >= o.x - _margin && _pos.x < o.x + s.x + _margin &&
|
||||
_pos.y >= o.y - 1 - _margin && _pos.y < o.y + s.y + _margin &&
|
||||
_pos.z >= o.z - _margin && _pos.z < o.z + s.z + _margin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- вход и выход
|
||||
|
||||
public static void Enter(EntityPlayerLocal _player)
|
||||
{
|
||||
if (Active || SnakeMode.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!SnakeMode.IsNight())
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: днём чертоги недоступны");
|
||||
return;
|
||||
}
|
||||
World world = GameManager.Instance.World;
|
||||
if (world.IsRemote())
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: в мультиплеере у клиента пока недоступно");
|
||||
return;
|
||||
}
|
||||
if (LoadPrefab() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_origin = GetSlot(world, _player);
|
||||
Vector3 center = _origin.ToVector3() + new Vector3(Size.x * 0.5f, 0f, Size.z * 0.5f);
|
||||
// Дальность как у змеи: при радиусе 2 игра не строила коллизии участка (тест 03.10:
|
||||
// «чертоги не построились вовремя» - ждали коллизий 30 с).
|
||||
_observer = GameManager.Instance.AddChunkObserver(center, true, GameUtils.GetViewDistance(), -1);
|
||||
_stage = Stage.Loading;
|
||||
_deadline = Time.time + 30f;
|
||||
Active = true;
|
||||
_player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 1f, 0.6f);
|
||||
_player.SetFirstPersonView(false, true);
|
||||
_ragdollAt = Time.time + SnakeMode.RagdollDelay;
|
||||
_waitStandUp = false;
|
||||
Debug.Log("[NecromancerTome] MindPalace: вход, чертоги в " + _origin);
|
||||
}
|
||||
|
||||
public static void Exit(EntityPlayerLocal _player)
|
||||
{
|
||||
if (!Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Active = false;
|
||||
_stage = Stage.None;
|
||||
MindControl.End(_player);
|
||||
RemoveObserver();
|
||||
_player?.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1f);
|
||||
if (_player == null || _player.IsDead())
|
||||
{
|
||||
return;
|
||||
}
|
||||
_ragdollAt = 0f;
|
||||
_player.emodel.ragdollTime = _player.emodel.ragdollDuration;
|
||||
_waitStandUp = true;
|
||||
_standUpDeadline = Time.time + 5f;
|
||||
Debug.Log("[NecromancerTome] MindPalace: выход");
|
||||
}
|
||||
|
||||
static void RemoveObserver()
|
||||
{
|
||||
if (_observer != null && GameManager.Instance?.World != null)
|
||||
{
|
||||
GameManager.Instance.RemoveChunkObserver(_observer);
|
||||
}
|
||||
_observer = null;
|
||||
}
|
||||
|
||||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null)
|
||||
{
|
||||
Active = false;
|
||||
_stage = Stage.None;
|
||||
_observer = null;
|
||||
MindControl.End(null);
|
||||
_slotsFor = null;
|
||||
return;
|
||||
}
|
||||
EntityPlayerLocal player = world.GetPrimaryPlayer();
|
||||
Guard(world);
|
||||
if (!Active)
|
||||
{
|
||||
if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline))
|
||||
{
|
||||
_waitStandUp = false;
|
||||
player.SetFirstPersonView(true, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (player == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (player.IsDead())
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: тело убито");
|
||||
Exit(player);
|
||||
return;
|
||||
}
|
||||
if (!SnakeMode.IsNight())
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: наступило утро - обратно в тело");
|
||||
Exit(player);
|
||||
return;
|
||||
}
|
||||
Body(player);
|
||||
switch (_stage)
|
||||
{
|
||||
case Stage.Loading:
|
||||
if (ChunksLoaded(world, _origin, Size))
|
||||
{
|
||||
var report = new List<string>();
|
||||
int bad = Mismatches(world, _origin, report);
|
||||
if (bad != 0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: расхождений с префабом " + bad + " - ставим заново");
|
||||
for (int i = 0; i < report.Count && i < 10; i++)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: " + report[i]);
|
||||
}
|
||||
Stamp(world, _origin);
|
||||
}
|
||||
_stage = Stage.Settling;
|
||||
_settleUntil = Time.time + 1.5f;
|
||||
}
|
||||
else if (Time.time > _deadline)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: участки чертогов не загрузились");
|
||||
Exit(player);
|
||||
}
|
||||
break;
|
||||
case Stage.Settling:
|
||||
Vector3 spawn = _origin.ToVector3() + SpawnAt;
|
||||
// Коллизии готовы - или прошло 5 с: тогда всё равно пускаем, от провала страхует
|
||||
// MindControl (возврат на точку появления).
|
||||
if (Time.time > _settleUntil && (world.IsChunkAreaCollidersLoaded(spawn) || Time.time > _settleUntil + 5f))
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: коллизии " + (world.IsChunkAreaCollidersLoaded(spawn) ? "готовы" : "НЕ готовы, пускаем по таймеру"));
|
||||
_stage = Stage.Ready;
|
||||
OpenDoors(world, _origin);
|
||||
LogCell(world, _origin + KnifeAt, "нож");
|
||||
LogCell(world, _origin + BookAt, "книга");
|
||||
MindControl.Begin(player, spawn, SpawnYaw);
|
||||
player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1.5f);
|
||||
}
|
||||
else if (Time.time > _deadline)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] MindPalace: чертоги не построились вовремя");
|
||||
Exit(player);
|
||||
}
|
||||
break;
|
||||
case Stage.Ready:
|
||||
MindControl.Update(player);
|
||||
break;
|
||||
}
|
||||
// Выход по R - когда тело уже лежит и не открыто окно (как у змеи).
|
||||
PlayerActionsLocal input = player.playerInput;
|
||||
bool gui = player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen();
|
||||
if (_ragdollAt <= 0f && player.emodel.IsRagdollActive && input != null && !gui &&
|
||||
(input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
|
||||
{
|
||||
Exit(player);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Тело падает и лежит - как в режиме змеи (SnakeMode.OnUnityUpdate).</summary>
|
||||
static void Body(EntityPlayerLocal _player)
|
||||
{
|
||||
if (_ragdollAt > 0f)
|
||||
{
|
||||
if (Time.time >= _ragdollAt)
|
||||
{
|
||||
_ragdollAt = 0f;
|
||||
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
|
||||
}
|
||||
}
|
||||
else if (!_player.emodel.IsRagdollActive)
|
||||
{
|
||||
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Раз в секунду: всё живое в коробке чертогов - вон. Дух сущности не имеет.</summary>
|
||||
static void Guard(World _world)
|
||||
{
|
||||
_guardCheck -= Time.deltaTime;
|
||||
if (_guardCheck > 0f || _world.IsRemote())
|
||||
{
|
||||
return;
|
||||
}
|
||||
_guardCheck = 1f;
|
||||
LoadSlots();
|
||||
if (_slots.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Игроков в EntityAlives нет (World.SpawnEntityInWorld кладёт туда только не-игроков) - их
|
||||
// отдельно, по Players.list (тест 03.10: камень портала привёл в чертоги, охрана молчала).
|
||||
for (int i = _world.Players.list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
EntityPlayer pl = _world.Players.list[i];
|
||||
if (pl == null || pl.IsDead() || !InAnyPalace(World.worldToBlockPos(pl.position)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3i b = World.worldToBlockPos(pl.position);
|
||||
Vector3 up = new Vector3(pl.position.x, _world.GetTerrainHeight(b.x, b.z) + 2f, pl.position.z);
|
||||
Debug.Log("[NecromancerTome] MindPalace: игрок " + pl.entityId + " в чертогах - выброшен на " + up);
|
||||
if (pl is EntityPlayerLocal)
|
||||
{
|
||||
// Как камень портала (PortalStonePatch.TeleportToBedroll).
|
||||
NetPackageManager.GetPackage<NetPackageTeleportPlayer>().Setup(up, null).ProcessPackage(_world, GameManager.Instance);
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Teleport(up);
|
||||
}
|
||||
}
|
||||
for (int i = _world.EntityAlives.Count - 1; i >= 0; i--)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (e == null || e.IsDead() || !InAnyPalace(World.worldToBlockPos(e.position)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
EntityAlive snake = SnakeMode.Snake(_world);
|
||||
if (snake != null && e == snake)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: змея в чертогах - развеяна");
|
||||
SnakeMode.Exit(_world.GetPrimaryPlayer());
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("[NecromancerTome] MindPalace: " + e.EntityName + " в чертогах - удалён");
|
||||
_world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>В чертогах и в рамке камня вокруг ничего не падает (указание 03.10: «почему не
|
||||
/// отключить просчёт физики в той области»): World.AddFallingBlock - единственная очередь
|
||||
/// падающих блоков (туда же игра сама не пускает блоки со StabilityIgnore). Тест гравием
|
||||
/// 03.10: после постановки нижний/верхний ряд внутренней стены всё равно обрушивался.</summary>
|
||||
[HarmonyPatch(typeof(World), nameof(World.AddFallingBlock))]
|
||||
public static class Patch_World_AddFallingBlock_MindPalace
|
||||
{
|
||||
public static bool Prefix(Vector3i _blockPos)
|
||||
{
|
||||
return !MindPalace.InAnyPalace(_blockPos, MindPalace.LooseMargin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Чертоги - «зона торговца»: блоки не ломаются ударом и взрывом, строить нельзя
|
||||
/// (ItemActionAttack, Explosion, Block.DamageBlock проверяют IsWithinTraderArea).</summary>
|
||||
[HarmonyPatch(typeof(World), nameof(World.IsWithinTraderArea), new[] { typeof(Vector3i) })]
|
||||
public static class Patch_World_IsWithinTraderArea_MindPalace
|
||||
{
|
||||
public static void Postfix(Vector3i _worldBlockPos, ref bool __result)
|
||||
{
|
||||
if (!__result && MindPalace.InAnyPalace(_worldBlockPos))
|
||||
{
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// necropalace - отладка чертогов: where (где мои), rebuild (поставить заново, если участки
|
||||
/// загружены), knife N (поворот ножа 0-23 и переставить его).
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroPalace : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necropalace" };
|
||||
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
World world = GameManager.Instance.World;
|
||||
EntityPlayerLocal player = world?.GetPrimaryPlayer();
|
||||
if (player == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i o = Traverse.Create(typeof(MindPalace)).Method("GetSlot", world, (EntityPlayer)player).GetValue<Vector3i>();
|
||||
string cmd = _params.Count > 0 ? _params[0] : "where";
|
||||
if (cmd == "knife" && _params.Count > 1 && int.TryParse(_params[1], out int r))
|
||||
{
|
||||
MindPalace.KnifeRotation = Mathf.Clamp(r, 0, 3);
|
||||
BlockValue k = Block.GetBlockValue(MindPalace.KnifeBlock);
|
||||
k.rotation = (byte)MindPalace.KnifeRotation;
|
||||
world.SetBlockRPC(o + MindPalace.KnifeAt, k, MarchingCubes.DensityAir);
|
||||
SdtdConsole.Instance.Output("нож: поворот " + MindPalace.KnifeRotation);
|
||||
}
|
||||
else if (cmd == "scan")
|
||||
{
|
||||
var report = new List<string>();
|
||||
int n = MindPalace.Mismatches(world, o, report);
|
||||
SdtdConsole.Instance.Output("расхождений с префабом: " + n);
|
||||
foreach (string line in report)
|
||||
{
|
||||
SdtdConsole.Instance.Output(line);
|
||||
Debug.Log("[NecromancerTome] necropalace scan: " + line);
|
||||
}
|
||||
}
|
||||
else if (cmd == "test")
|
||||
{
|
||||
GameManager.Instance.StartCoroutine(MindPalace.StressTest(world, o));
|
||||
SdtdConsole.Instance.Output("проверка: заливка гравием, через 2 с - постановка (итог - в логе)");
|
||||
}
|
||||
else if (cmd == "rebuild")
|
||||
{
|
||||
MindPalace.Stamp(world, o);
|
||||
SdtdConsole.Instance.Output("чертоги поставлены заново в " + o);
|
||||
}
|
||||
else
|
||||
{
|
||||
SdtdConsole.Instance.Output("чертоги: " + o + ", построены: " + MindPalace.IsBuilt(world, o));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@ namespace NecromancerTome
|
||||
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
|
||||
GhostTraderPatch.Init();
|
||||
SnakeMode.Init();
|
||||
MindPalace.Init();
|
||||
HandPose.Init();
|
||||
NecroMailbox.Init();
|
||||
Possession.Init();
|
||||
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using GamePath;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// «Леденящий ужас» (указание 02.10): зомби, которого учуяла змея (тёмное чутьё, 30 м, сквозь
|
||||
/// стены), сразу пугается и бежит от неё как можно дальше, ломая препятствия. Спящих будим -
|
||||
/// «пусть будет хаос». Заменяет «шум» (зомби шли к змее, 28.09).
|
||||
///
|
||||
/// ОБРАЗЕЦ - ванильный EAIRunAway (основа RunawayFromEntity у зверей): точка в FleeDistance от
|
||||
/// угрозы через RandomPositionGenerator.CalcPositionInDirection, пересчёт по таймеру. Отличия:
|
||||
/// - путь с canBreak: true. У ванили false, и зверь не ломает ничего. Ломает не эта задача, а
|
||||
/// ванильный BreakBlock (AITask-1 у зомби): он включается, когда moveHelper упёрся
|
||||
/// (BlockedTime ≥ 0.35) на пути с CanBreakBlocks - а этот флаг приходит из нашего пути.
|
||||
/// - угроза - позиция змеи, её даёт SnakeControl.Sense; задача сама ничего не ищет.
|
||||
/// - работает, пока Until не истёк: Sense продлевает его, пока змея чует зомби.
|
||||
///
|
||||
/// ПРИОРИТЕТ 1, MutexBits 3. Задачи у зомби: 1 BreakBlock (биты 8), 2 ApproachAndAttackTarget
|
||||
/// (3), 3 ApproachSpot (3) ... EAITaskList.isBestTask: задача с меньшим номером и общими битами
|
||||
/// вытесняет остальные, с BreakBlock биты не пересекаются - он работает вместе с бегством.
|
||||
/// Добавляется в рантайме (AddTask), как NecroFollowOwnerTask; из списка не убирается -
|
||||
/// с истёкшим Until CanExecute сразу false, а AI-задачи в сейв не пишутся.
|
||||
/// </summary>
|
||||
public class NecroFleeSnakeTask : EAIBase
|
||||
{
|
||||
public const int Priority = 1;
|
||||
/// <summary>На сколько блоков вперёд выбирается точка бегства; как у ванили.</summary>
|
||||
public const float FleeDistance = 20f;
|
||||
/// <summary>Сколько секунд зомби ещё бежит после последнего «учуяла» (Sense - раз в 1 с).</summary>
|
||||
public const float FearSeconds = 4f;
|
||||
/// <summary>Тиков ИИ (20 в секунду) между выборами новой точки и между запросами пути.</summary>
|
||||
public const int RetargetTicks = 40;
|
||||
/// <summary>Скорость бегства - максимум бега зомби-людей в entityclasses.xml (MoveSpeedAggro до
|
||||
/// 1.5; быстрее только звери: медведь 2.0, пума 2.3). Указание 02.10: «пусть убегают быстро».</summary>
|
||||
public const float FleeSpeed = 1.5f;
|
||||
|
||||
public static Vector3 SnakePos;
|
||||
public float Until;
|
||||
/// <summary>Своя угроза вместо змеи (хват «отрицание», 03.10): бежать от этой точки.</summary>
|
||||
public bool HasThreat;
|
||||
public Vector3 Threat;
|
||||
public float Speed = FleeSpeed;
|
||||
/// <summary>Кого забыть, когда бегство кончится (хват «отрицание»: «должен терять меня как цель»).
|
||||
/// Он же угроза: бежать от его ТЕКУЩЕГО места.</summary>
|
||||
public EntityAlive Forget;
|
||||
/// <summary>Больше нуля - бежать, пока Forget ближе этого (и не дольше Until).</summary>
|
||||
public float FarDistance;
|
||||
Vector3 targetPos;
|
||||
int retargetTicks;
|
||||
|
||||
public override void Init(EntityAlive _theEntity)
|
||||
{
|
||||
base.Init(_theEntity);
|
||||
MutexBits = 3;
|
||||
executeDelay = 0.1f;
|
||||
}
|
||||
|
||||
public override bool CanExecute()
|
||||
{
|
||||
return Time.time < Until && !FarEnough() && FindFleePos();
|
||||
}
|
||||
|
||||
/// <summary>Сколько криков тревоги уже было в этом кадре: у толпы кричат не все.</summary>
|
||||
static int _alertFrame = -1, _alertCount;
|
||||
public const int MaxAlertsPerFrame = 2;
|
||||
|
||||
public override void Start()
|
||||
{
|
||||
// Тест 02.10: «если пугать зомби одновременно - фризы». Все испуганные за один проход
|
||||
// чутья стартовали в одном кадре: каждый кричал и сразу просил путь с ломанием блоков
|
||||
// (дорогой для пафайндера). Теперь первый путь - через случайные 0-1.5 с, крик - не
|
||||
// больше двух за кадр.
|
||||
retargetTicks = theEntity.rand.RandomRange(0, 30);
|
||||
PathFinderThread.Instance.RemovePathsFor(theEntity.entityId);
|
||||
if (_alertFrame != Time.frameCount)
|
||||
{
|
||||
_alertFrame = Time.frameCount;
|
||||
_alertCount = 0;
|
||||
}
|
||||
string alert = theEntity.GetSoundAlert();
|
||||
if (alert != null && ++_alertCount <= MaxAlertsPerFrame)
|
||||
{
|
||||
theEntity.PlayOneShot(alert);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
if (Forget != null)
|
||||
{
|
||||
ForgetNow(theEntity, Forget);
|
||||
Forget = null;
|
||||
}
|
||||
theEntity.moveHelper.Stop();
|
||||
theEntity.SetLookPosition(Vector3.zero);
|
||||
}
|
||||
|
||||
public override bool Continue()
|
||||
{
|
||||
return Time.time < Until && !theEntity.IsDead() && !FarEnough();
|
||||
}
|
||||
|
||||
float _diagTime;
|
||||
|
||||
public override void Update()
|
||||
{
|
||||
PathEntity path = theEntity.navigator.getPath();
|
||||
if (path != null)
|
||||
{
|
||||
theEntity.moveHelper.CalcIfUnreachablePos();
|
||||
}
|
||||
theEntity.SetLookPosition(targetPos + Vector3.up * 0.8f);
|
||||
if (Time.time > _diagTime)
|
||||
{
|
||||
// Диагностика (тест 02.10: «зомби не убегает, стоит на месте») - убрать после проверки.
|
||||
_diagTime = Time.time + 3f;
|
||||
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: " + theEntity.entityId + " поз " + theEntity.position.ToString("F0") +
|
||||
" цель " + targetPos.ToString("F0") + " путь " + (path == null ? "нет" : path.NodeCountRemaining() + " узл.") +
|
||||
" считается " + PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId) +
|
||||
" скор " + Speed.ToString("F2"));
|
||||
}
|
||||
if (--retargetTicks > 0 || PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
retargetTicks = RetargetTicks;
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
FindFleePos();
|
||||
theEntity.FindPath(targetPos, Speed, true, this);
|
||||
if (sw.Elapsed.TotalMilliseconds > 3.0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: долгий перерасчёт у " + theEntity.entityId + " - " + sw.Elapsed.TotalMilliseconds.ToString("F1") + " мс");
|
||||
}
|
||||
}
|
||||
|
||||
bool FindFleePos()
|
||||
{
|
||||
if (Forget != null && FarDistance > 0f)
|
||||
{
|
||||
Threat = Forget.position;
|
||||
}
|
||||
Vector3 away = theEntity.position - (HasThreat ? Threat : SnakePos);
|
||||
away.y = 0f;
|
||||
if (away.sqrMagnitude < 0.01f)
|
||||
{
|
||||
away = UnityEngine.Random.insideUnitSphere;
|
||||
away.y = 0f;
|
||||
}
|
||||
Vector3 p = RandomPositionGenerator.CalcPositionInDirection(theEntity, theEntity.position, away, FleeDistance, 60f);
|
||||
// Ванильный подбор вернул ноль (нет чанка, не нашлось опоры) - всё равно прочь по прямой:
|
||||
// путь найдёт пафайндер, а препятствия на нём сломает BreakBlock.
|
||||
targetPos = p.y != 0f ? p : theEntity.position + away.normalized * FleeDistance;
|
||||
// Как ванильный ApproachSpot: цель - на опорном блоке, а не в воздухе/стене.
|
||||
targetPos = theEntity.world.FindSupportingBlockPos(targetPos);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Уже напуган - продлить испуг (дёшево). False - ещё не напуган, нужна очередь.</summary>
|
||||
public static bool Refresh(EntityAlive _e)
|
||||
{
|
||||
NecroFleeSnakeTask task = _e.aiManager?.tasks.GetTask<NecroFleeSnakeTask>();
|
||||
if (task == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
task.Until = Time.time + FearSeconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Из очереди SnakeControl, только на сервере: напугать (или продлить испуг).</summary>
|
||||
public static void Scare(EntityAlive _e)
|
||||
{
|
||||
if (_e.aiManager == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_e.ConditionalTriggerSleeperWakeUp();
|
||||
NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask<NecroFleeSnakeTask>();
|
||||
if (task == null)
|
||||
{
|
||||
task = new NecroFleeSnakeTask();
|
||||
task.Init(_e);
|
||||
_e.aiManager.tasks.AddTask(Priority, task);
|
||||
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " в ужасе");
|
||||
}
|
||||
task.HasThreat = false;
|
||||
task.Speed = FleeSpeed;
|
||||
task.Forget = null;
|
||||
task.FarDistance = 0f;
|
||||
task.Until = Time.time + FearSeconds;
|
||||
}
|
||||
|
||||
/// <summary>Хват «отрицание» (03.10): напугать зомби жестом игрока - бежит от игрока быстрее обычного
|
||||
/// _seconds секунд, сразу и в конце бегства теряет игрока как цель.</summary>
|
||||
bool FarEnough()
|
||||
{
|
||||
return FarDistance > 0f && Forget != null && (theEntity.position - Forget.position).sqrMagnitude > FarDistance * FarDistance;
|
||||
}
|
||||
|
||||
public static void ScareFrom(EntityAlive _e, EntityAlive _threat, float _seconds, float _speed, float _farDistance = 0f)
|
||||
{
|
||||
if (_e.aiManager == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_e.ConditionalTriggerSleeperWakeUp();
|
||||
NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask<NecroFleeSnakeTask>();
|
||||
if (task == null)
|
||||
{
|
||||
task = new NecroFleeSnakeTask();
|
||||
task.Init(_e);
|
||||
_e.aiManager.tasks.AddTask(Priority, task);
|
||||
}
|
||||
task.HasThreat = true;
|
||||
task.Threat = _threat.position;
|
||||
task.Speed = _speed;
|
||||
task.Forget = _threat;
|
||||
task.FarDistance = _farDistance;
|
||||
task.Until = Time.time + _seconds;
|
||||
ForgetNow(_e, _threat);
|
||||
// Сетка поиска пути строится вокруг игроков - здесь она есть; но зомби может быть на краю.
|
||||
AstarManager.Instance?.AddLocation(_e.position, 40);
|
||||
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " отрицание - бежит до " + _farDistance + " м (не дольше " + _seconds + " с), скор " + _speed);
|
||||
}
|
||||
|
||||
public static void ForgetNow(EntityAlive _e, EntityAlive _who)
|
||||
{
|
||||
if (_e.GetAttackTarget() == _who)
|
||||
{
|
||||
_e.SetAttackTarget(null, 0);
|
||||
}
|
||||
if (_e.GetRevengeTarget() == _who)
|
||||
{
|
||||
_e.SetRevengeTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return base.ToString() + ", flee to " + targetPos + ", " + (Until - Time.time).ToString("F1") + " s";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Почтовый ящик консоли - только для разработки (03.10: ассистент подбирает позу рук сам, без
|
||||
/// клавиатуры). Работает, лишь если есть папка %APPDATA%\7DaysToDie\NecroMailbox - у игроков её
|
||||
/// нет. Раз в полсекунды: команды из cmd.txt (по строке) выполняются в консоли игры, как набранные
|
||||
/// вручную, файл удаляется, ответ - в лог строками «[NecromancerTome] mailbox:».
|
||||
/// </summary>
|
||||
public static class NecroMailbox
|
||||
{
|
||||
public static string Dir => Path.Combine(GameIO.GetUserGameDataDir(), "NecroMailbox");
|
||||
static string CmdFile => Path.Combine(Dir, "cmd.txt");
|
||||
static float _next;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||
}
|
||||
|
||||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
if (Time.unscaledTime < _next)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_next = Time.unscaledTime + 0.5f;
|
||||
if (!File.Exists(CmdFile) || SdtdConsole.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string[] lines;
|
||||
try
|
||||
{
|
||||
lines = File.ReadAllLines(CmdFile);
|
||||
File.Delete(CmdFile);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return; // файл ещё пишется - в следующий раз
|
||||
}
|
||||
foreach (string raw in lines)
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] mailbox: > " + line);
|
||||
try
|
||||
{
|
||||
List<string> res = SdtdConsole.Instance.ExecuteSync(line, null);
|
||||
if (res != null)
|
||||
{
|
||||
foreach (string r in res)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] mailbox: " + r);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] mailbox: ошибка - " + ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>necroshot [имя] - снимок экрана игры в %APPDATA%\7DaysToDie\NecroMailbox\shots\имя.png
|
||||
/// (как F9, только в известное место). Файл появляется в конце кадра.</summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroShot : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necroshot" };
|
||||
public override string getDescription() => "NecromancerTome: снимок экрана в NecroMailbox/shots/<имя>.png";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
string dir = Path.Combine(NecroMailbox.Dir, "shots");
|
||||
Directory.CreateDirectory(dir);
|
||||
string name = _params.Count > 0 ? _params[0] : System.DateTime.Now.ToString("HHmmss");
|
||||
string path = Path.Combine(dir, name + ".png");
|
||||
ScreenCapture.CaptureScreenshot(path);
|
||||
SdtdConsole.Instance.Output("снимок: " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»). Окно necroTome
|
||||
/// (Config/XUi_InGame/windows.xml): оглавление - кнопки глав (до восьми), глава - страницы
|
||||
/// с «Назад/Далее», «Оглавление» возвращает к списку, «Закрыть» и Esc закрывают.
|
||||
///
|
||||
/// СОДЕРЖАНИЕ - список Chapters ниже: ключ названия, ключи страниц (Localization.csv,
|
||||
/// necroTome*), условие открытия. Закрытая глава в оглавлении не показывается. Условия брать
|
||||
/// из того, что игра уже хранит (уровень некромантии, рецепты, испытания), своё хранение - только
|
||||
/// для чистых событий, по cvar на событие.
|
||||
/// </summary>
|
||||
public static class NecroTomeBook
|
||||
{
|
||||
public const string WindowGroup = "necroTome";
|
||||
|
||||
public class Chapter
|
||||
{
|
||||
public string TitleKey;
|
||||
public string[] PageKeys;
|
||||
public Func<EntityPlayerLocal, bool> Unlocked;
|
||||
}
|
||||
|
||||
public static readonly List<Chapter> Chapters = new List<Chapter>
|
||||
{
|
||||
// Глава 1 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
|
||||
new Chapter
|
||||
{
|
||||
TitleKey = "necroTomeCh1Title",
|
||||
PageKeys = new[] { "necroTomeCh1P1", "necroTomeCh1P2", "necroTomeCh1P3", "necroTomeCh1P4" },
|
||||
Unlocked = _p => true
|
||||
},
|
||||
};
|
||||
|
||||
public static void Open(EntityPlayerLocal _player)
|
||||
{
|
||||
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
|
||||
ui?.windowManager.Open(WindowGroup, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Preserve]
|
||||
public class XUiC_NecroTome : XUiController
|
||||
{
|
||||
const int MaxButtons = 8;
|
||||
|
||||
XUiV_Label _title, _text, _page;
|
||||
XUiC_SimpleButton _toc, _prev, _next, _close;
|
||||
readonly XUiC_SimpleButton[] _chapterButtons = new XUiC_SimpleButton[MaxButtons];
|
||||
/// <summary>Глава по кнопке оглавления (только открытые главы, по порядку).</summary>
|
||||
readonly int[] _buttonChapter = new int[MaxButtons];
|
||||
int _chapter = -1, _pageIndex;
|
||||
|
||||
public override void Init()
|
||||
{
|
||||
base.Init();
|
||||
_title = GetChildById("lblTitle")?.ViewComponent as XUiV_Label;
|
||||
_text = GetChildById("lblText")?.ViewComponent as XUiV_Label;
|
||||
_page = GetChildById("lblPage")?.ViewComponent as XUiV_Label;
|
||||
_toc = GetChildById("btnToc") as XUiC_SimpleButton;
|
||||
_prev = GetChildById("btnPrev") as XUiC_SimpleButton;
|
||||
_next = GetChildById("btnNext") as XUiC_SimpleButton;
|
||||
_close = GetChildById("btnClose") as XUiC_SimpleButton;
|
||||
if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc();
|
||||
if (_prev != null) _prev.OnPressed += (_s, _m) => Turn(-1);
|
||||
if (_next != null) _next.OnPressed += (_s, _m) => Turn(1);
|
||||
if (_close != null) _close.OnPressed += (_s, _m) => xui.playerUI.windowManager.Close(NecroTomeBook.WindowGroup);
|
||||
for (int i = 0; i < MaxButtons; i++)
|
||||
{
|
||||
int idx = i;
|
||||
_chapterButtons[i] = GetChildById("btnCh" + (i + 1)) as XUiC_SimpleButton;
|
||||
if (_chapterButtons[i] != null)
|
||||
{
|
||||
_chapterButtons[i].OnPressed += (_s, _m) => ShowChapter(_buttonChapter[idx], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnOpen()
|
||||
{
|
||||
base.OnOpen();
|
||||
ShowToc();
|
||||
}
|
||||
|
||||
void ShowToc()
|
||||
{
|
||||
_chapter = -1;
|
||||
SetLabel(_title, Localization.Get("necroTomeToc"));
|
||||
SetLabel(_text, "");
|
||||
SetLabel(_page, "");
|
||||
EntityPlayerLocal player = xui.playerUI.entityPlayer;
|
||||
int b = 0;
|
||||
for (int c = 0; c < NecroTomeBook.Chapters.Count && b < MaxButtons; c++)
|
||||
{
|
||||
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
|
||||
if (ch.Unlocked != null && !ch.Unlocked(player))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
_buttonChapter[b] = c;
|
||||
if (_chapterButtons[b] != null)
|
||||
{
|
||||
_chapterButtons[b].Text = Localization.Get(ch.TitleKey);
|
||||
_chapterButtons[b].ViewComponent.IsVisible = true;
|
||||
}
|
||||
b++;
|
||||
}
|
||||
for (; b < MaxButtons; b++)
|
||||
{
|
||||
if (_chapterButtons[b] != null)
|
||||
{
|
||||
_chapterButtons[b].ViewComponent.IsVisible = false;
|
||||
}
|
||||
}
|
||||
SetVisible(_toc, false);
|
||||
SetVisible(_prev, false);
|
||||
SetVisible(_next, false);
|
||||
}
|
||||
|
||||
void ShowChapter(int _c, int _p)
|
||||
{
|
||||
if (_c < 0 || _c >= NecroTomeBook.Chapters.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[_c];
|
||||
_chapter = _c;
|
||||
_pageIndex = Mathf.Clamp(_p, 0, ch.PageKeys.Length - 1);
|
||||
foreach (XUiC_SimpleButton btn in _chapterButtons)
|
||||
{
|
||||
SetVisible(btn, false);
|
||||
}
|
||||
SetLabel(_title, Localization.Get(ch.TitleKey));
|
||||
SetLabel(_text, Localization.Get(ch.PageKeys[_pageIndex]));
|
||||
SetLabel(_page, (_pageIndex + 1) + " / " + ch.PageKeys.Length);
|
||||
SetVisible(_toc, true);
|
||||
SetVisible(_prev, true);
|
||||
SetVisible(_next, true);
|
||||
if (_prev != null) _prev.Enabled = _pageIndex > 0;
|
||||
if (_next != null) _next.Enabled = _pageIndex < ch.PageKeys.Length - 1;
|
||||
}
|
||||
|
||||
void Turn(int _d)
|
||||
{
|
||||
if (_chapter >= 0)
|
||||
{
|
||||
ShowChapter(_chapter, _pageIndex + _d);
|
||||
}
|
||||
}
|
||||
|
||||
static void SetLabel(XUiV_Label _l, string _s)
|
||||
{
|
||||
if (_l != null)
|
||||
{
|
||||
_l.Text = _s;
|
||||
}
|
||||
}
|
||||
|
||||
static void SetVisible(XUiController _c, bool _v)
|
||||
{
|
||||
if (_c?.ViewComponent != null)
|
||||
{
|
||||
_c.ViewComponent.IsVisible = _v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>necrotome - открыть книгу (проверка окна без чертогов, в том числе через почтовый ящик).</summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroTome : ConsoleCmdAbstract
|
||||
{
|
||||
public override string[] getCommands() => new[] { "necrotome" };
|
||||
public override string getDescription() => "NecromancerTome: открыть книгу с оглавлением";
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
|
||||
if (player != null)
|
||||
{
|
||||
NecroTomeBook.Open(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,15 @@
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\Assembly-CSharp.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- DataLoader.LoadAsset: у метода есть перегрузка с AssetReference (гео-оссуарий, 30.09) -->
|
||||
<Reference Include="Unity.Addressables">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\Unity.Addressables.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ScreenCaptureModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.ScreenCaptureModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
|
||||
@@ -153,6 +153,11 @@ namespace NecromancerTome
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Одержимый зомби, пока в нём игрок, в некромантию не идёт (решение 03.10).
|
||||
if (Possession.IsPossessed(killedEntity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float kills = AddKillsCVar(__instance);
|
||||
SyncNecromancyLevel(__instance, kills);
|
||||
|
||||
@@ -77,6 +77,9 @@ namespace NecromancerTome
|
||||
/// светятся сильнее обычного; плотнее и крупнее, чем у девиантов.</summary>
|
||||
public static readonly GlowConfig SnakeSightGlow = new GlowConfig { Tint = new Color(1.1f, 1.5f, 0.15f), SizeFactor = 0.4f, AlphaFactor = 1f, DensityFactor = 3f };
|
||||
public const string SnakeSightBuffName = "buffNecroSnakeSight";
|
||||
/// <summary>«Стиратель» (02.10): маджента - этим цветом зомби больше никто не светится.</summary>
|
||||
public static readonly GlowConfig EraserGlow = new GlowConfig { Tint = new Color(1f, 0.1f, 0.65f), SizeFactor = 0.4f, AlphaFactor = 1f, DensityFactor = 3f };
|
||||
public const string EraserBuffName = "buffNecroEraser";
|
||||
public static readonly GlowConfig PortalChannelGlow = new GlowConfig { Tint = new Color(0.1f, 0.85f, 0.8f), SizeFactor = 0.9f, AlphaFactor = 0.9f, DensityFactor = 5f };
|
||||
|
||||
public static void Postfix(MinEventActionAttachParticleEffectToEntity __instance, MinEventParams _params)
|
||||
@@ -97,7 +100,8 @@ namespace NecromancerTome
|
||||
bool isVictim = entity.Buffs.HasBuff(Patch_EntityAlive_dropItemOnDeath_VictimBag.VictimBuffName);
|
||||
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
|
||||
bool isSnakeSight = entity.Buffs.HasBuff(SnakeSightBuffName);
|
||||
if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight)
|
||||
bool isEraser = entity.Buffs.HasBuff(EraserBuffName);
|
||||
if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight && !isEraser)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -109,6 +113,10 @@ namespace NecromancerTome
|
||||
{
|
||||
config = PortalChannelGlow;
|
||||
}
|
||||
else if (isEraser)
|
||||
{
|
||||
config = EraserGlow;
|
||||
}
|
||||
else if (isSnakeSight)
|
||||
{
|
||||
config = SnakeSightGlow;
|
||||
|
||||
@@ -42,6 +42,32 @@ namespace NecromancerTome
|
||||
/// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира.</summary>
|
||||
public const int OrderTicks = 6000;
|
||||
|
||||
/// <summary>Кого игрок сейчас держит в прицеле и кого можно назначить целью. null - некого.
|
||||
/// Общее для приказа и для покраснения прицела (PetCrosshair): прицел краснеет ровно тогда,
|
||||
/// когда приказ будет принят. Свои питомцы целью не бывают.</summary>
|
||||
public static EntityAlive TargetUnderCrosshair(EntityPlayerLocal _local)
|
||||
{
|
||||
WorldRayHitInfo hit = _local.HitInfo;
|
||||
if (hit == null || !hit.bHitValid || hit.transform == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
EntityAlive target = ItemActionAttack.GetEntityFromHit(hit) as EntityAlive;
|
||||
if (target == null || target.IsDead() || target == _local)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (target is EntityPlayer || target is EntityTrader)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (PetFollowPatch.FindOwnerOfPet(target.entityId) != null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/// <summary>Отдаёт питомцу приказ атаковать то, на что смотрит игрок. false - приказа не
|
||||
/// вышло, и вызывающая сторона показывает "Нет цели для атаки".</summary>
|
||||
public static bool TryOrderAttack(EntityAlive _owner, EntityAlive _pet)
|
||||
@@ -59,23 +85,8 @@ namespace NecromancerTome
|
||||
return false;
|
||||
}
|
||||
|
||||
WorldRayHitInfo hit = local.HitInfo;
|
||||
if (hit == null || !hit.bHitValid || hit.transform == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Entity hitEntity = ItemActionAttack.GetEntityFromHit(hit);
|
||||
EntityAlive target = hitEntity as EntityAlive;
|
||||
if (target == null || target.IsDead())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (target == _pet || target == _owner)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (target is EntityPlayer || target is EntityTrader)
|
||||
EntityAlive target = TargetUnderCrosshair(local);
|
||||
if (target == null || target == _pet)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -84,9 +95,110 @@ namespace NecromancerTome
|
||||
// следующем тике перебить наш приказ тем, кто ударил крысу последним.
|
||||
_pet.SetRevengeTarget(null);
|
||||
_pet.SetAttackTarget(target, OrderTicks);
|
||||
// Охрана (PetFollowPatch.Guard) сама раздаёт цели; приказ игрока она перебивать не должна.
|
||||
PetFollowPatch.NoteOrder(_pet.entityId, target.entityId);
|
||||
Debug.Log("[NecromancerTome] PetAttackCommand: pet " + _pet.entityId + " ordered to attack " +
|
||||
target.entityId + " (" + target.EntityClass.entityClassName + ")");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ПРИЦЕЛ КНИГ И СВИТКОВ ПРИЗЫВА - указание 30.09.2026: «сделать другой прицел (более явный, из
|
||||
/// уже имеющихся в игре) и чтобы он краснел при попадании в него врага. Как у змеи краснеет».
|
||||
///
|
||||
/// У ItemActionSpawnEntity своего прицела нет, он получал общий маленький «плюс». Игра рисует
|
||||
/// прицел одним методом, EntityPlayerLocal.guiDrawCrosshair, и цвет там берётся из настроек,
|
||||
/// так что подменить один лишь ВИД прицела мало - покраснеть он не сможет. Поэтому для наших
|
||||
/// книг метод заменяется целиком: те же условия показа, тот же размер и та же готовая
|
||||
/// прицел арбалета (DrawDynamicCrosshair, 4 штриха), а цвет - наш.
|
||||
///
|
||||
/// Красный = приказ будет принят: питомец этой книги призван и под прицелом годная цель
|
||||
/// (PetAttackCommand.TargetUnderCrosshair - тот же разбор, что у самого приказа). Пока питомец
|
||||
/// не призван, основное действие - это призыв, и красить прицел незачем.
|
||||
///
|
||||
/// Только у книг с приказом (PetInfo.CommandsAttack). Свиток Роя оставлен как был.
|
||||
/// </summary>
|
||||
[HarmonyLib.HarmonyPatch(typeof(EntityPlayerLocal), "guiDrawCrosshair")]
|
||||
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_PetCrosshair
|
||||
{
|
||||
/// <summary>Тот же красный, что у прицела змеи (SnakeCrosshair.OnTarget).</summary>
|
||||
static readonly Color OnTarget = new Color(1f, 0.15f, 0.1f, 1f);
|
||||
|
||||
public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen)
|
||||
{
|
||||
if (__instance.inventory == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
ItemClass holding = __instance.inventory.holdingItem;
|
||||
ItemActionSpawnEntity action = holding != null && holding.Actions != null && holding.Actions.Length > 0
|
||||
? holding.Actions[0] as ItemActionSpawnEntity
|
||||
: null;
|
||||
if (action == null ||
|
||||
!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(action.entityToSpawn,
|
||||
out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo) ||
|
||||
!petInfo.CommandsAttack)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Дальше - условия показа из самого guiDrawCrosshair, в том же порядке.
|
||||
if (!_guiInGame.showCrosshair || Event.current.type != EventType.Repaint || __instance.IsDead() ||
|
||||
__instance.emodel.IsRagdollActive || __instance.AttachedToEntity != null ||
|
||||
!GamePrefs.GetBool(EnumGamePrefs.OptionsCrosshairEnabled) || bModalWindowOpen ||
|
||||
_guiInGame.CrosshairSquare == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float opacity = GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairOpacity);
|
||||
int colorIndex = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
|
||||
if (colorIndex < 0 || colorIndex >= EntityPlayerLocal.crosshairColors.Count)
|
||||
{
|
||||
colorIndex = 0;
|
||||
}
|
||||
Color color = EntityPlayerLocal.crosshairColors[colorIndex];
|
||||
color.a = opacity;
|
||||
if (PetIsOut(__instance, action.entityToSpawn) && PetAttackCommand.TargetUnderCrosshair(__instance) != null)
|
||||
{
|
||||
color = OnTarget;
|
||||
}
|
||||
|
||||
Vector2 center = __instance.GetCrosshairPosition2D();
|
||||
center.y = Screen.height - center.y;
|
||||
float size = Screen.height * 0.059f * GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairScale);
|
||||
// Прицел арбалета (тест 30.09: CrosshairDamage выглядел «кружком со стрелкой вниз»):
|
||||
// четыре штриха игрового DrawDynamicCrosshair, длина та же, что у стрелкового оружия
|
||||
// (size / 3); разброс у книги не меняется, поэтому зазор постоянный.
|
||||
EntityPlayerLocal.DrawDynamicCrosshair(center.x, center.y, size * 0.22f, size * 0.22f, size / 3f,
|
||||
GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairThickness), color.a, color);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Призван ли сейчас питомец этой книги у этого игрока. По списку PetFollowPatch, а не
|
||||
/// GetOwnedEntities: тот на каждый вызов заводит новый список, а мы здесь каждый кадр.</summary>
|
||||
static bool PetIsOut(EntityPlayerLocal _player, string _entityClassName)
|
||||
{
|
||||
int classId = EntityClass.GetId(_entityClassName);
|
||||
World world = _player.world;
|
||||
if (classId == -1 || world == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < PetFollowPatch.TrackedPets.Count; i++)
|
||||
{
|
||||
PetFollowPatch.TrackedPet tracked = PetFollowPatch.TrackedPets[i];
|
||||
if (tracked.OwnerEntityId != _player.entityId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Entity pet = world.GetEntity(tracked.PetEntityId);
|
||||
if (pet != null && pet.entityClass == classId && !pet.IsDead())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,17 @@ namespace NecromancerTome
|
||||
/// <summary>Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль -
|
||||
/// питомец не летающий, дом ему не переставляем.</summary>
|
||||
public int FlyingHomeRadius;
|
||||
|
||||
/// <summary>Охраняет ли хозяина сам - копия PetInfo.Guards.</summary>
|
||||
public bool Guards;
|
||||
|
||||
/// <summary>На кого игрок указал прицелом (PetAttackCommand). Пока эта цель жива и питомец
|
||||
/// занят именно ею, охрана его не перенацеливает. -1 - приказа нет.</summary>
|
||||
public int OrderedTargetId = -1;
|
||||
|
||||
/// <summary>Питомец ушёл дальше GuardLeash и возвращается: пока он не подойдёт к хозяину
|
||||
/// на GuardRange, любые цели с него снимаются - иначе укус в спину разворачивал бы его.</summary>
|
||||
public bool Returning;
|
||||
}
|
||||
|
||||
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
|
||||
@@ -106,7 +117,7 @@ namespace NecromancerTome
|
||||
|
||||
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
|
||||
public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored,
|
||||
bool wantsLitEyes, int flyingHomeRadius)
|
||||
bool wantsLitEyes, int flyingHomeRadius, bool guards)
|
||||
{
|
||||
TrackedPets.Add(new TrackedPet
|
||||
{
|
||||
@@ -116,6 +127,7 @@ namespace NecromancerTome
|
||||
CollisionIgnored = collisionIgnored,
|
||||
WantsLitEyes = wantsLitEyes,
|
||||
FlyingHomeRadius = flyingHomeRadius,
|
||||
Guards = guards,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,6 +202,121 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ОХРАНА ХОЗЯИНА - указание 30.09.2026. Образ: «ночь, мы в виде змеи ползаем на
|
||||
/// другом конце карты, а тело охраняет стервятник». До этого питомец дрался только по приказу
|
||||
/// (PetAttackCommand) - отзыв sanovskiy 23.09: «приказ нужен на каждую цель».
|
||||
///
|
||||
/// Решения пользователя по пунктам:
|
||||
/// - радиус охраны 20 м от хозяина (GuardRange); в режиме змеи хозяин - это лежащее тело,
|
||||
/// позиция игрока там и остаётся;
|
||||
/// - охраняют все, кроме крысы (PetInfo.Guards);
|
||||
/// - отходить можно до 40 м (GuardLeash), дальше - возвращается;
|
||||
/// - бить ближайшего к телу, если не было приказа атаковать конкретного.
|
||||
///
|
||||
/// Враг - тот же, что для тревоги у тела в режиме змеи (SnakeControl.IsFoe): живой EntityEnemy,
|
||||
/// не питомец и не девиант. СПЯЩИЕ НЕ В СЧЁТ: иначе питомец, проходящий сквозь стены, будил бы
|
||||
/// всё здание, пока игрок крадётся, - к спящему слово «приближается» не относится.
|
||||
///
|
||||
/// Уже начатую цель охрана не бросает, пока та в радиусе: выбор «ближайшего» заново каждую
|
||||
/// секунду заставил бы питомца метаться между двумя зомби и не добить ни одного.</summary>
|
||||
public const float GuardRange = 20f;
|
||||
public const float GuardLeash = 40f;
|
||||
|
||||
public static void NoteOrder(int petEntityId, int targetEntityId)
|
||||
{
|
||||
for (int i = 0; i < TrackedPets.Count; i++)
|
||||
{
|
||||
if (TrackedPets[i].PetEntityId == petEntityId)
|
||||
{
|
||||
TrackedPets[i].OrderedTargetId = targetEntityId;
|
||||
TrackedPets[i].Returning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static EntityAlive NearestFoe(World world, Vector3 center, float range)
|
||||
{
|
||||
EntityAlive nearest = null;
|
||||
float best = range * range;
|
||||
for (int i = 0; i < world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = world.EntityAlives[i];
|
||||
if (!SnakeControl.IsFoe(e, null) || e.IsSleeping)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float distSq = (e.position - center).sqrMagnitude;
|
||||
if (distSq < best)
|
||||
{
|
||||
best = distSq;
|
||||
nearest = e;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
public static void Guard(TrackedPet tracked, EntityAlive pet, EntityAlive owner, World world)
|
||||
{
|
||||
if (owner.IsDead())
|
||||
{
|
||||
return;
|
||||
}
|
||||
float fromOwnerSq = (pet.position - owner.position).sqrMagnitude;
|
||||
EntityAlive target = pet.GetAttackTarget();
|
||||
|
||||
// Поводок 40 м - для всех спутников, и для приказа игрока тоже.
|
||||
if (tracked.Returning)
|
||||
{
|
||||
if (fromOwnerSq > GuardRange * GuardRange)
|
||||
{
|
||||
if (target != null)
|
||||
{
|
||||
pet.SetRevengeTarget(null);
|
||||
pet.SetAttackTarget(null, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tracked.Returning = false;
|
||||
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " вернулся к хозяину");
|
||||
}
|
||||
else if (fromOwnerSq > GuardLeash * GuardLeash)
|
||||
{
|
||||
tracked.Returning = true;
|
||||
tracked.OrderedTargetId = -1;
|
||||
pet.SetRevengeTarget(null);
|
||||
pet.SetAttackTarget(null, 0);
|
||||
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " ушёл на " +
|
||||
Mathf.Sqrt(fromOwnerSq).ToCultureInvariantString("0") + " м от хозяина - цель снята, возвращается");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tracked.Guards)
|
||||
{
|
||||
return;
|
||||
}
|
||||
bool live = target != null && !target.IsDead();
|
||||
if (live && target.entityId == tracked.OrderedTargetId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
tracked.OrderedTargetId = -1;
|
||||
if (live && SnakeControl.IsFoe(target, null) &&
|
||||
(target.position - owner.position).sqrMagnitude <= GuardRange * GuardRange)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityAlive foe = NearestFoe(world, owner.position, GuardRange);
|
||||
if (foe == null || foe == target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pet.SetRevengeTarget(null);
|
||||
pet.SetAttackTarget(foe, PetAttackCommand.OrderTicks);
|
||||
Debug.Log("[NecromancerTome] PetFollowPatch: охрана - pet " + pet.entityId + " идёт на " + foe.entityId +
|
||||
" (" + foe.EntityClass.entityClassName + "), до хозяина " +
|
||||
(foe.position - owner.position).magnitude.ToCultureInvariantString("0.0") + " м");
|
||||
}
|
||||
|
||||
/// <summary>ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как
|
||||
/// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье".
|
||||
///
|
||||
@@ -392,6 +519,13 @@ namespace NecromancerTome
|
||||
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
|
||||
}
|
||||
|
||||
// Охрана и поводок 40 м - до блока про «дом» летающих: тот в этом же тике должен
|
||||
// увидеть уже назначенную или снятую цель. Рой (не спутник) сюда не попадает.
|
||||
if (tracked.Guards || tracked.OwnFollowTask || tracked.FlyingHomeRadius > 0)
|
||||
{
|
||||
Guard(tracked, pet, owner, world);
|
||||
}
|
||||
|
||||
// СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую
|
||||
// секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка
|
||||
// "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни
|
||||
|
||||
@@ -0,0 +1,818 @@
|
||||
using GamePath;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Одержимость - заклинание хвата «пиф-паф» (решения пользователя 03.10, BACKLOG «Одержимость»).
|
||||
/// ЛКМ по зомби-гуманоиду (EntityZombie и наследники; не звери и не стервятники) - игрок перехватывает
|
||||
/// управление им: бегает, прыгает, бьёт. Тело игрока не падает, а цепенеет и стоит (аниматор на паузе,
|
||||
/// ввод снят) - его могут атаковать, это и есть цена. Любое время суток, и в кровавую луну.
|
||||
/// Остальные зомби, увидев одержимого, бросаются на него, как на игрока, - можно танковать.
|
||||
/// Выход - R, гибель одержимого или смерть тела. После выхода зомби пугается и убегает, как при
|
||||
/// «отрицании». Убийства одержимым игроку не засчитываются (урон от его имени), его собственная
|
||||
/// смерть - тоже (NecromancyKillCreditPatch).
|
||||
///
|
||||
/// КАК ВЕДЁМ ЗОМБИ. Его ИИ выключен (EntityAlive.updateTasks пропущен), а движение - родное:
|
||||
/// SetMoveForwardWithModifiers, как делает сама игра при DebugStopEnemiesMoving и EntityMoveHelper;
|
||||
/// прыжок - Jumping + SetJumpDistance, как EntityMoveHelper.StartJump; удар - Attack(false/true),
|
||||
/// как EAIApproachAndAttackTarget. Походка, анимация и урон - самого зомби.
|
||||
///
|
||||
/// КАК СТОИТ ТЕЛО. PlayerMoveController.Update пропущен (и сам вызывает stopMoving, как у тела в
|
||||
/// рэгдолле), вид - от третьего лица, аниматор модели на паузе. Камера - за головой зомби (постфикс
|
||||
/// к vp_FPCamera.LateUpdate, как у змеи).
|
||||
///
|
||||
/// ПРИЁМЫ ЗОМБИ (указание 03.10: «у копа есть плевок и подрыв - чтобы такие спеллы становились доступны»):
|
||||
/// ПКМ - дальняя атака, если у зомби есть задача ИИ EAIRangedAttackTarget (Ранчер, Чак, коп, мутант):
|
||||
/// её параметры берутся из задачи захваченного зомби, а шаги повторяют её Start/Update/Reset -
|
||||
/// замах (StartAnimAction startAnimType+3000), отпуск (ContinueAnimAction +1), выстрел родным
|
||||
/// оружием (UseHoldingItem itemType), пока держишь кнопку и не вышло attackDuration. Направление -
|
||||
/// точка под прицелом камеры (SetLookPosition), как у ИИ - голова цели.
|
||||
/// Пробел - прыжок на собственную дальность зомби (jumpMaxDistance из JumpMaxDistance): у паука 7-9 м,
|
||||
/// как его EAILeap, у остальных их 1-4 м.
|
||||
/// ПКМ у крикуньи - её крик (SoundAlert): все зомби в ScreamRange бросаются на неё, даже не видя (её
|
||||
/// ванильный зов орды - часть разведки AIScoutHordeSpawner, напрямую не вызываем).
|
||||
/// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько
|
||||
/// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело.
|
||||
///
|
||||
/// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время
|
||||
/// одержимости; на выходе и при выходе из мира всё обнуляется.
|
||||
/// </summary>
|
||||
public static class Possession
|
||||
{
|
||||
public static bool Active;
|
||||
public static EntityAlive Zombie;
|
||||
|
||||
/// <summary>Камера - позади и выше головы зомби.</summary>
|
||||
public const float CamBack = 2.6f;
|
||||
public const float CamUp = 0.6f;
|
||||
/// <summary>Как далеко зомби замечают одержимого: зрение зомби - 30 м (entityclasses.xml SightRange).</summary>
|
||||
public const float TauntRange = 30f;
|
||||
public const float TauntPeriod = 1f;
|
||||
public const int TauntTicks = 1200;
|
||||
/// <summary>Спящие просыпаются от одержимого (тест 05.10: «не разбудил спящего»). Игра будит их
|
||||
/// только игроком - увидел или услышал (EAISetNearestEntityAsTarget.FindTargetPlayer), одержимый для
|
||||
/// неё просто зомби. Будим сами: видит одержимого ближе SleeperWakeRange (пригнувшегося - ближе
|
||||
/// SleeperWakeCrouched) или тот вплотную, ближе SleeperHearRange, хоть и за спиной.</summary>
|
||||
public const float SleeperWakeRange = 8f;
|
||||
public const float SleeperWakeCrouched = 3f;
|
||||
public const float SleeperHearRange = 2f;
|
||||
/// <summary>Приседание (указание 05.10): пока зажата кнопка приседания. Высота - доля роста зомби;
|
||||
/// ниже 0.7 игра включает ходьбу пригнувшись (walkType 8), как у её зомби под низким потолком.</summary>
|
||||
public static bool Crouching;
|
||||
/// <summary>Удар по прицелу (тест 05.10: «не попадаю по другим зомби»). Удар зомби - луч ~1.65 м из
|
||||
/// головы по взгляду (ItemActionMelee, EntityHuman.GetLookVector), а взгляд брался из наклона камеры,
|
||||
/// которая сзади и выше: под прицелом враг, а луч из головы уходит ниже и мимо. На время замаха
|
||||
/// взгляд зомби - на точку под прицелом, а если рядом враг почти по прицелу - на его грудь.</summary>
|
||||
public const float MeleeAssistRange = 2.5f;
|
||||
public const float MeleeAssistDeg = 25f;
|
||||
static float _meleeLookUntil;
|
||||
/// <summary>Ctrl в игре - приседание переключателем (ToggleCrouch), C - пока держишь (Crouch).</summary>
|
||||
static bool _crouchToggled;
|
||||
public const float CrouchHeight = 0.6f;
|
||||
/// <summary>Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить
|
||||
/// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора
|
||||
/// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков
|
||||
/// хватает, чтобы тело стояло на земле и зомби могли к нему подойти.</summary>
|
||||
public const int BodyViewDim = 3;
|
||||
static int _bodyViewDim = -1;
|
||||
/// <summary>Полный радиус области игрока (до сжатия) - для систем, рисующих мир у одержимого
|
||||
/// (SnakeAudioSwap: дальний ландшафт, Dynamic Mesh, детализация зданий). -1 - не сжата.</summary>
|
||||
public static int FullViewDim => _bodyViewDim;
|
||||
public const float ScreamRange = 50f;
|
||||
public const float ScreamCooldown = 10f;
|
||||
static float _nextScream;
|
||||
|
||||
static EntityPlayerLocal _player;
|
||||
static float _yaw, _pitch;
|
||||
/// <summary>Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») -
|
||||
/// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера.</summary>
|
||||
static float _bodyYaw;
|
||||
public const float TurnSpeed = 540f;
|
||||
static float _nextAttack;
|
||||
static float _nextTaunt;
|
||||
static float _sleeperCheck;
|
||||
static ChunkManager.ChunkObserver _observer;
|
||||
static readonly List<Animator> _frozen = new List<Animator>();
|
||||
static readonly List<float> _frozenSpeed = new List<float>();
|
||||
static bool _loggedCamera;
|
||||
// Дальняя атака захваченного зомби (копия параметров его EAIRangedAttackTarget).
|
||||
static bool _hasRanged;
|
||||
static int _rItem, _rStartAnim;
|
||||
static float _rRelease, _rDuration, _rCooldown;
|
||||
static string _rSoundStart, _rSoundRelease;
|
||||
enum RState { Idle, StartAnim, Release, Fire }
|
||||
static RState _rState;
|
||||
static float _rTime, _rNext;
|
||||
|
||||
static GameObject _hud;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||
if (_hud == null)
|
||||
{
|
||||
_hud = new GameObject("NecroPossessionHud");
|
||||
Object.DontDestroyOnLoad(_hud);
|
||||
_hud.AddComponent<PossessionHud>();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
|
||||
|
||||
/// <summary>Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).</summary>
|
||||
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
|
||||
|
||||
/// <summary>True - вселились (для звука «пиф-пафа»).</summary>
|
||||
public static bool Enter(EntityPlayerLocal _player0, EntityAlive _zombie)
|
||||
{
|
||||
if (Active || SnakeMode.Active || MindPalace.Active || _player0 == null || _player0.IsDead() ||
|
||||
_player0.AttachedToEntity != null || !CanPossess(_zombie) || _player0.world.IsRemote())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Active = true;
|
||||
_player = _player0;
|
||||
Zombie = _zombie;
|
||||
_yaw = _player0.rotation.y;
|
||||
_bodyYaw = _zombie.rotation.y;
|
||||
_pitch = 0f;
|
||||
_nextAttack = 0f;
|
||||
_nextTaunt = 0f;
|
||||
_nextScream = 0f;
|
||||
_loggedCamera = false;
|
||||
|
||||
// Зомби - в руки игрока: цели, путь и шаги ИИ сброшены.
|
||||
_zombie.SetAttackTarget(null, 0);
|
||||
_zombie.SetRevengeTarget(null);
|
||||
PathFinderThread.Instance.RemovePathsFor(_zombie.entityId);
|
||||
_zombie.navigator?.clearPath();
|
||||
_zombie.moveHelper?.Stop();
|
||||
_zombie.ConditionalTriggerSleeperWakeUp();
|
||||
_zombie.SetRotation(new Vector3(0f, _yaw, 0f));
|
||||
|
||||
// Тело цепенеет: вид от третьего лица, аниматоры модели на паузе.
|
||||
_player0.SetFirstPersonView(false, true);
|
||||
FreezeBody(_player0, true);
|
||||
_observer = GameManager.Instance.AddChunkObserver(_zombie.position, true, GameUtils.GetViewDistance(), -1);
|
||||
ChunkManager.ChunkObserver body = _player0.ChunkObserver;
|
||||
if (body != null && body.viewDim > BodyViewDim)
|
||||
{
|
||||
_bodyViewDim = body.viewDim;
|
||||
body.viewDim = BodyViewDim;
|
||||
}
|
||||
ReadRanged(_zombie);
|
||||
Hint(_player0, _zombie);
|
||||
Debug.Log("[NecromancerTome] Possession: вселение в " + _zombie.EntityName + " (" + _zombie.entityId + ")");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Exit(string _why)
|
||||
{
|
||||
if (!Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Active = false;
|
||||
Crouching = false;
|
||||
_meleeLookUntil = 0f;
|
||||
_crouchToggled = false;
|
||||
EntityAlive z = Zombie;
|
||||
z?.SetLookPosition(Vector3.zero);
|
||||
EntityPlayerLocal player = _player;
|
||||
if (_rState != RState.Idle && z != null && !z.IsDead())
|
||||
{
|
||||
StopRanged(z);
|
||||
}
|
||||
_rState = RState.Idle;
|
||||
_hasRanged = false;
|
||||
Zombie = null;
|
||||
_player = null;
|
||||
Debug.Log("[NecromancerTome] Possession: выход - " + _why);
|
||||
FreezeBody(player, false);
|
||||
if (_bodyViewDim > 0 && player?.ChunkObserver != null)
|
||||
{
|
||||
player.ChunkObserver.viewDim = _bodyViewDim;
|
||||
}
|
||||
_bodyViewDim = -1;
|
||||
if (player != null && !player.IsDead())
|
||||
{
|
||||
player.SetFirstPersonView(true, true);
|
||||
}
|
||||
if (_observer != null && GameManager.Instance?.World != null)
|
||||
{
|
||||
GameManager.Instance.RemoveChunkObserver(_observer);
|
||||
}
|
||||
_observer = null;
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null || z == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Кто гнался за одержимым - отпускает его.
|
||||
for (int i = 0; i < world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = world.EntityAlives[i];
|
||||
if (e != null && e != z && e.GetAttackTarget() == z)
|
||||
{
|
||||
e.SetAttackTarget(null, 0);
|
||||
}
|
||||
}
|
||||
// Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»).
|
||||
if (!z.IsDead() && player != null)
|
||||
{
|
||||
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
|
||||
NecroFleeSnakeTask.ScareFrom(z, player, HandHolds.DenyMaxSeconds, HandHolds.DenySpeed, HandHolds.DenyFarDistance);
|
||||
}
|
||||
}
|
||||
|
||||
static void FreezeBody(EntityPlayerLocal _p, bool _freeze)
|
||||
{
|
||||
if (_freeze)
|
||||
{
|
||||
_frozen.Clear();
|
||||
_frozenSpeed.Clear();
|
||||
Transform model = _p?.emodel?.GetModelTransform();
|
||||
if (model == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (Animator a in model.GetComponentsInChildren<Animator>(true))
|
||||
{
|
||||
_frozen.Add(a);
|
||||
_frozenSpeed.Add(a.speed);
|
||||
a.speed = 0f;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < _frozen.Count; i++)
|
||||
{
|
||||
if (_frozen[i] != null)
|
||||
{
|
||||
_frozen[i].speed = _frozenSpeed[i];
|
||||
}
|
||||
}
|
||||
_frozen.Clear();
|
||||
_frozenSpeed.Clear();
|
||||
}
|
||||
|
||||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null)
|
||||
{
|
||||
if (Active || Zombie != null || _frozen.Count > 0)
|
||||
{
|
||||
Active = false;
|
||||
Zombie = null;
|
||||
_player = null;
|
||||
_observer = null;
|
||||
_bodyViewDim = -1;
|
||||
_frozen.Clear();
|
||||
_frozenSpeed.Clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityAlive z = Zombie;
|
||||
if (_player == null || _player.IsDead())
|
||||
{
|
||||
Exit("тело погибло");
|
||||
return;
|
||||
}
|
||||
if (z == null || z.IsDead() || z.IsMarkedForUnload())
|
||||
{
|
||||
Exit("одержимый погиб");
|
||||
return;
|
||||
}
|
||||
PlayerActionsLocal input = _player.playerInput;
|
||||
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
|
||||
if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
|
||||
{
|
||||
Exit("R");
|
||||
return;
|
||||
}
|
||||
Vector2 move = Vector2.zero;
|
||||
bool run = false, jump = false, attack = false, ranged = false, rangedHeld = false, detonate = false;
|
||||
if (input != null && !gui)
|
||||
{
|
||||
Look();
|
||||
move = new Vector2(input.Move.X, input.Move.Y);
|
||||
run = input.Run.IsPressed;
|
||||
jump = input.Jump.WasPressed;
|
||||
attack = input.Primary.WasPressed;
|
||||
ranged = input.Secondary.WasPressed;
|
||||
rangedHeld = input.Secondary.IsPressed;
|
||||
// E на клавиатуре - в PlayerActionsPermanent.Activate; у PlayerActionsLocal.Activate только
|
||||
// кнопка геймпада (тест 03.10: «подрыва нету»).
|
||||
detonate = input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed;
|
||||
}
|
||||
if (input != null && !gui && input.ToggleCrouch.WasPressed)
|
||||
{
|
||||
_crouchToggled = !_crouchToggled;
|
||||
}
|
||||
Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
|
||||
if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
|
||||
cop.HandlePrimingDetonator();
|
||||
}
|
||||
Ranged(z, ranged, rangedHeld);
|
||||
|
||||
bool moving = move.sqrMagnitude > 0.01f;
|
||||
float wantYaw = moving ? _yaw + Mathf.Atan2(move.x, move.y) * Mathf.Rad2Deg : _yaw;
|
||||
_bodyYaw = Mathf.MoveTowardsAngle(_bodyYaw, wantYaw, TurnSpeed * Time.deltaTime);
|
||||
z.SetRotation(new Vector3(0f, _bodyYaw, 0f));
|
||||
z.rotation.x = -_pitch; // наклон головы - для луча удара
|
||||
if (moving)
|
||||
{
|
||||
z.SetMoveForwardWithModifiers(run ? z.GetMoveSpeedAggro() : z.GetMoveSpeed(), 1f, 0f, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
|
||||
}
|
||||
if (jump && z.onGround && !z.Jumping)
|
||||
{
|
||||
z.Jumping = true;
|
||||
z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f);
|
||||
}
|
||||
if (ranged && IsScreamer(z))
|
||||
{
|
||||
Scream(world, z);
|
||||
}
|
||||
if (attack && Time.time >= _nextAttack)
|
||||
{
|
||||
z.SetLookPosition(MeleeAim(world, z));
|
||||
if (z.Attack(false))
|
||||
{
|
||||
z.Attack(true);
|
||||
float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
|
||||
_nextAttack = Time.time + timeout;
|
||||
_meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout);
|
||||
}
|
||||
}
|
||||
if (_meleeLookUntil > 0f && _rState == RState.Idle)
|
||||
{
|
||||
if (Time.time < _meleeLookUntil)
|
||||
{
|
||||
z.SetLookPosition(MeleeAim(world, z));
|
||||
}
|
||||
else
|
||||
{
|
||||
_meleeLookUntil = 0f;
|
||||
z.SetLookPosition(Vector3.zero);
|
||||
}
|
||||
}
|
||||
_observer?.SetPosition(z.position);
|
||||
// Спящие в зданиях просыпаются от одержимого, как от змеи (указание 05.10).
|
||||
SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime);
|
||||
Taunt(world, z);
|
||||
}
|
||||
|
||||
static void ReadRanged(EntityAlive _z)
|
||||
{
|
||||
_hasRanged = false;
|
||||
_rState = RState.Idle;
|
||||
_rNext = 0f;
|
||||
List<EAIRangedAttackTarget> tasks = _z.aiManager?.GetTasks<EAIRangedAttackTarget>();
|
||||
if (tasks == null || tasks.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EAIRangedAttackTarget t = tasks[0];
|
||||
_hasRanged = true;
|
||||
_rItem = t.itemActionType;
|
||||
_rStartAnim = t.startAnimType;
|
||||
_rRelease = t.releaseDelay;
|
||||
_rDuration = t.attackDuration > 0f ? t.attackDuration : 3f;
|
||||
// Вдвое быстрее ИИ (тест 03.10: «перезаряд медленный»).
|
||||
_rCooldown = Mathf.Max(1f, t.baseCooldown * 0.5f);
|
||||
_rSoundStart = t.soundStartName;
|
||||
_rSoundRelease = t.soundReleaseName;
|
||||
Debug.Log("[NecromancerTome] Possession: дальняя атака - оружие " + _rItem + ", замах " + _rStartAnim + ", длит. " + _rDuration + " с, откат " + _rCooldown + " с");
|
||||
}
|
||||
|
||||
/// <summary>Подсказка при вселении: что умеет этот зомби.</summary>
|
||||
static void Hint(EntityPlayerLocal _p, EntityAlive _z)
|
||||
{
|
||||
string text = Localization.Get("necroPossessHintBase");
|
||||
if (_hasRanged)
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintRanged");
|
||||
}
|
||||
if (IsScreamer(_z))
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintScream");
|
||||
}
|
||||
if (_z.jumpMaxDistance >= 5f)
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintLeap");
|
||||
}
|
||||
if (_z is EntityZombieCop)
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintDetonate");
|
||||
}
|
||||
GameManager.ShowTooltip(_p, text);
|
||||
}
|
||||
|
||||
/// <summary>Куда бить: грудь врага ближе MeleeAssistRange к голове и не дальше MeleeAssistDeg от
|
||||
/// направления на прицел (ближайший к прицелу), иначе - точка под прицелом.</summary>
|
||||
static Vector3 MeleeAim(World _world, EntityAlive _z)
|
||||
{
|
||||
Vector3 aim = AimPoint();
|
||||
Vector3 head = _z.getHeadPosition();
|
||||
Vector3 dir = (aim - head).normalized;
|
||||
EntityAlive best = null;
|
||||
float bestAngle = MeleeAssistDeg;
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (e == null || e == _z || e.IsDead() || e is EntityPlayer || SnakeControl.IsNecroEntity(e))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3 to = e.getChestPosition() - head;
|
||||
if (to.sqrMagnitude > MeleeAssistRange * MeleeAssistRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float angle = Vector3.Angle(dir, to);
|
||||
if (angle < bestAngle)
|
||||
{
|
||||
bestAngle = angle;
|
||||
best = e;
|
||||
}
|
||||
}
|
||||
return best != null ? best.getChestPosition() : aim;
|
||||
}
|
||||
|
||||
/// <summary>Точка под прицелом камеры (в мировых координатах), до 50 м.</summary>
|
||||
static Vector3 AimPoint()
|
||||
{
|
||||
Transform cam = _player?.cameraTransform;
|
||||
if (cam == null)
|
||||
{
|
||||
return Zombie.position + Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * 30f;
|
||||
}
|
||||
if (Physics.Raycast(cam.position, cam.forward, out RaycastHit hit, 50f, -5, QueryTriggerInteraction.Ignore) &&
|
||||
hit.distance > CamBack + 0.5f)
|
||||
{
|
||||
return hit.point + Origin.position;
|
||||
}
|
||||
return cam.position + Origin.position + cam.forward * 50f;
|
||||
}
|
||||
|
||||
/// <summary>ПКМ - дальняя атака зомби по шагам ванильной EAIRangedAttackTarget.</summary>
|
||||
static void Ranged(EntityAlive _z, bool _pressed, bool _held)
|
||||
{
|
||||
if (!_hasRanged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
float dt = Time.deltaTime;
|
||||
switch (_rState)
|
||||
{
|
||||
case RState.Idle:
|
||||
if (!_pressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Time.time < _rNext || !_z.IsAttackValid())
|
||||
{
|
||||
Debug.Log("[NecromancerTome] Possession: дальняя атака не готова - откат " + Mathf.Max(0f, _rNext - Time.time).ToString("F1") + " с, атака " + (_z.IsAttackValid() ? "можно" : "нельзя"));
|
||||
return;
|
||||
}
|
||||
RearmRanged(_z);
|
||||
_z.emodel.avatarController.hitWeightMax = 0.5f;
|
||||
_rTime = 0f;
|
||||
if (!string.IsNullOrEmpty(_rSoundStart))
|
||||
{
|
||||
Audio.Manager.BroadcastPlay(_z, _rSoundStart);
|
||||
}
|
||||
if (_rStartAnim >= 0)
|
||||
{
|
||||
_z.StartAnimAction(_rStartAnim + 3000);
|
||||
_rState = RState.StartAnim;
|
||||
}
|
||||
else
|
||||
{
|
||||
_rState = RState.Fire;
|
||||
}
|
||||
return;
|
||||
case RState.StartAnim:
|
||||
_z.SetLookPosition(AimPoint());
|
||||
if (_z.GetAnimActionState() != AvatarController.ActionState.Ready)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_z.ContinueAnimAction(_rStartAnim + 1 + 3000);
|
||||
if (!string.IsNullOrEmpty(_rSoundRelease))
|
||||
{
|
||||
Audio.Manager.BroadcastPlay(_z, _rSoundRelease);
|
||||
}
|
||||
_rState = RState.Release;
|
||||
_rTime = 0f;
|
||||
return;
|
||||
case RState.Release:
|
||||
_z.SetLookPosition(AimPoint());
|
||||
_rTime += dt;
|
||||
if (_rTime >= _rRelease)
|
||||
{
|
||||
_rState = RState.Fire;
|
||||
_rTime = 0f;
|
||||
}
|
||||
return;
|
||||
case RState.Fire:
|
||||
_z.SetLookPosition(AimPoint());
|
||||
_rTime += dt;
|
||||
_z.UseHoldingItem(_rItem, false);
|
||||
// Доводим до конца по одному нажатию: у плевка сначала 1-3 предупреждения по 1.2 с
|
||||
// (ItemActionVomit), и с отпущенной кнопкой он сбрасывался (тест 03.10: «если зажать
|
||||
// ПКМ - плюёт»). Конец - оружие закончило или вышла длительность задачи.
|
||||
if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration)
|
||||
{
|
||||
StopRanged(_z);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
|
||||
/// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
|
||||
/// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).</summary>
|
||||
static void RearmRanged(EntityAlive _z)
|
||||
{
|
||||
ItemInventoryData inv = _z.inventory?.holdingItemData;
|
||||
if (inv == null || _rItem >= inv.actionData.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ItemValue iv = _z.inventory.holdingItemItemValue;
|
||||
string before = "патронов " + (iv != null ? iv.Meta.ToString() : "?");
|
||||
if (inv.actionData[_rItem] is ItemActionRanged.ItemActionDataRanged d && _z.inventory.holdingItem.Actions[_rItem] is ItemActionRanged ranged)
|
||||
{
|
||||
before += ", очередь " + d.curBurstCount + ", перезарядка " + d.isReloading;
|
||||
ranged.ResetBurstShot(d);
|
||||
d.bReleased = true;
|
||||
d.bPressed = false;
|
||||
d.isReloading = false;
|
||||
d.isReloadRequested = false;
|
||||
}
|
||||
// Плевок: без лишних «позывов» - сразу последнее предупреждение (ItemActionVomit: 1-3 по 1.2 с).
|
||||
if (inv.actionData[_rItem] is ItemActionVomit.ItemActionDataVomit dv && _z.inventory.holdingItem.Actions[_rItem] is ItemActionVomit vomit)
|
||||
{
|
||||
dv.numWarningsPlayed = Mathf.Max(0, Traverse.Create(vomit).Field("warningMax").GetValue<int>() - 1);
|
||||
dv.warningTime = 0f;
|
||||
}
|
||||
if (iv != null && iv.Meta < 1)
|
||||
{
|
||||
iv.Meta = 1;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] Possession: дальняя атака - взвод (" + before + ")");
|
||||
}
|
||||
|
||||
static void StopRanged(EntityAlive _z)
|
||||
{
|
||||
_z.ShowHoldingItem(false);
|
||||
_z.UseHoldingItem(_rItem, true);
|
||||
_z.StartAnimAction(9999);
|
||||
_z.SetLookPosition(Vector3.zero);
|
||||
_z.emodel.avatarController.hitWeightMax = 1f;
|
||||
_rState = RState.Idle;
|
||||
_rNext = Time.time + _rCooldown;
|
||||
}
|
||||
|
||||
static bool IsScreamer(EntityAlive _z)
|
||||
{
|
||||
return EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c) && c.entityClassName.StartsWith("zombieScreamer");
|
||||
}
|
||||
|
||||
/// <summary>Крик одержимой крикуньи: её звук тревоги и все зомби в ScreamRange - на неё (танкование).</summary>
|
||||
static void Scream(World _world, EntityAlive _z)
|
||||
{
|
||||
if (Time.time < _nextScream)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_nextScream = Time.time + ScreamCooldown;
|
||||
string snd = _z.GetSoundAlert();
|
||||
if (!string.IsNullOrEmpty(snd))
|
||||
{
|
||||
Audio.Manager.BroadcastPlay(_z, snd);
|
||||
}
|
||||
int n = 0;
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
|
||||
PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _z.position).sqrMagnitude > ScreamRange * ScreamRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
e.ConditionalTriggerSleeperWakeUp();
|
||||
AllowZombieTarget(e);
|
||||
e.SetAttackTarget(_z, TauntTicks);
|
||||
n++;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] Possession: крик крикуньи - на зов идут " + n);
|
||||
}
|
||||
|
||||
static void Look()
|
||||
{
|
||||
PlayerMoveController pmc = PlayerMoveController.Instance;
|
||||
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
|
||||
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
|
||||
_yaw += Input.GetAxis("Mouse X") * sens.x;
|
||||
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f);
|
||||
}
|
||||
|
||||
/// <summary>Раз в секунду: зомби, которые видят одержимого, бросаются на него (решение 03.10:
|
||||
/// «зомби начинают считать этого зомби ещё одним игроком»). Ванильный подход к цели принимает
|
||||
/// только свои классы (EntityPlayer...) - добавляем EntityZombie, как CharmPatch; это безопасно:
|
||||
/// цель-зомби им ставим только мы (поиск цели EAISetNearestEntityAsTarget не трогаем).</summary>
|
||||
static void Taunt(World _world, EntityAlive _z)
|
||||
{
|
||||
if (Time.time < _nextTaunt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_nextTaunt = Time.time + TauntPeriod;
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.GetAttackTarget() == _z ||
|
||||
e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float d2 = (e.position - _z.position).sqrMagnitude;
|
||||
if (d2 > TauntRange * TauntRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (e.IsSleeping)
|
||||
{
|
||||
float wake = Crouching ? SleeperWakeCrouched : SleeperWakeRange;
|
||||
if (d2 > SleeperHearRange * SleeperHearRange && (d2 > wake * wake || !e.CanSee(_z)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
e.ConditionalTriggerSleeperWakeUp();
|
||||
Debug.Log("[NecromancerTome] Possession: проснулся " + e.EntityName + " (" + e.entityId + "), " + Mathf.Sqrt(d2).ToString("F1") + " м");
|
||||
}
|
||||
else if (!e.CanSee(_z))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
AllowZombieTarget(e);
|
||||
e.SetAttackTarget(_z, TauntTicks);
|
||||
}
|
||||
}
|
||||
|
||||
static void AllowZombieTarget(EntityAlive _e)
|
||||
{
|
||||
List<EAIApproachAndAttackTarget> tasks = _e.aiManager.GetTasks<EAIApproachAndAttackTarget>();
|
||||
if (tasks == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (EAIApproachAndAttackTarget t in tasks)
|
||||
{
|
||||
bool has = false;
|
||||
foreach (EAIApproachAndAttackTarget.TargetClass c in t.targetClasses)
|
||||
{
|
||||
has |= c.type == typeof(EntityZombie);
|
||||
}
|
||||
if (!has)
|
||||
{
|
||||
t.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = typeof(EntityZombie), chaseTimeMax = 0f });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.</summary>
|
||||
public static void PlaceCamera(Transform _camera)
|
||||
{
|
||||
EntityAlive z = Zombie;
|
||||
if (z == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null;
|
||||
Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f;
|
||||
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
|
||||
Vector3 eye = anchor + Vector3.up * CamUp - rot * Vector3.forward * CamBack;
|
||||
Vector3 to = eye - anchor;
|
||||
if (Physics.Raycast(anchor, to.normalized, out RaycastHit hit, to.magnitude + 0.2f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f);
|
||||
}
|
||||
_camera.SetPositionAndRotation(eye, rot);
|
||||
if (!_loggedCamera)
|
||||
{
|
||||
_loggedCamera = true;
|
||||
Debug.Log("[NecromancerTome] Possession: камера за " + (head != null ? "головой " + head.name : "корнем (головы нет)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы.</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))]
|
||||
public static class Patch_EntityAlive_updateTasks_Possession
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance)
|
||||
{
|
||||
return !Possession.IsPossessed(__instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary>
|
||||
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
|
||||
public static class Patch_PlayerMoveController_Update_Possession
|
||||
{
|
||||
public static bool Prefix(PlayerMoveController __instance)
|
||||
{
|
||||
if (!Possession.Active)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
__instance.stopMoving();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
|
||||
/// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
|
||||
/// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.</summary>
|
||||
public class PossessionHud : MonoBehaviour
|
||||
{
|
||||
const float Width = 420f, Height = 16f, Bottom = 150f;
|
||||
static readonly Color Back = new Color(0f, 0f, 0f, 0.6f);
|
||||
static readonly Color Fill = new Color(0.55f, 0.08f, 0.06f, 0.95f);
|
||||
static GUIStyle _style;
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
EntityAlive z = Possession.Active ? Possession.Zombie : null;
|
||||
if (z == null || Event.current.type != EventType.Repaint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
float scale = Screen.height / 1080f;
|
||||
float w = Width * scale, h = Height * scale;
|
||||
Rect r = new Rect((Screen.width - w) / 2f, Screen.height - Bottom * scale - h, w, h);
|
||||
float frac = Mathf.Clamp01(z.Health / (float)Mathf.Max(1, z.GetMaxHealth()));
|
||||
Color prev = GUI.color;
|
||||
GUI.color = Back;
|
||||
GUI.DrawTexture(r, Texture2D.whiteTexture);
|
||||
GUI.color = Fill;
|
||||
GUI.DrawTexture(new Rect(r.x + 2f, r.y + 2f, (r.width - 4f) * frac, r.height - 4f), Texture2D.whiteTexture);
|
||||
GUI.color = prev;
|
||||
if (_style == null)
|
||||
{
|
||||
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.LowerCenter };
|
||||
_style.normal.textColor = new Color(0.9f, 0.85f, 0.8f);
|
||||
}
|
||||
_style.fontSize = Mathf.RoundToInt(18f * scale);
|
||||
string name = Localization.Get(z.EntityName);
|
||||
GUI.Label(new Rect(r.x, r.y - 26f * scale, r.width, 24f * scale), name + " " + z.Health + " / " + z.GetMaxHealth(), _style);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Приседание одержимого: пока зажата кнопка, рост зомби опускается до CrouchHeight, как игра
|
||||
/// опускает его под низким потолком (EntityAlive.CrouchHeightFixedUpdate, её же шаги). Отпустил - дальше
|
||||
/// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой,
|
||||
/// а проём впереди чует через moveHelper, который у одержимого остановлен.</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.CrouchHeightFixedUpdate))]
|
||||
public static class Patch_EntityAlive_CrouchHeightFixedUpdate_Possession
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance)
|
||||
{
|
||||
if (!Possession.Crouching || !Possession.IsPossessed(__instance) || __instance.crouchType == 0 ||
|
||||
__instance.physicsBaseHeight <= 1.3f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
float h = Utils.FastMoveTowards(__instance.physicsHeight, __instance.physicsBaseHeight * Possession.CrouchHeight, 0.099999994f);
|
||||
__instance.SetHeight(h);
|
||||
if (h <= __instance.physicsBaseHeight * 0.7f)
|
||||
{
|
||||
__instance.crouchBendPerTarget = 0f;
|
||||
if (__instance.walkType != 8 && __instance.walkType != 21)
|
||||
{
|
||||
int was = __instance.walkType;
|
||||
__instance.SetWalkType(8);
|
||||
__instance.walkTypeBeforeCrouch = was;
|
||||
}
|
||||
}
|
||||
__instance.crouchBendPer = Mathf.MoveTowards(__instance.crouchBendPer, __instance.crouchBendPerTarget, 0.099999994f);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,97 +269,108 @@ namespace NecromancerTome
|
||||
// `ToWorldPos()` (this feature's own position) instead of a dictionary-passed key.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// 01.10.2026: огня больше нет. Своя модель (бандл necroward) светится сама - кристалл и
|
||||
// глазницы в карте эмиссии; включённая пирамида светится ярко, выключенная тускло
|
||||
// (MaterialPropertyBlock на рендерерах модели блока, материал общий не трогается).
|
||||
static readonly MaterialPropertyBlock glowBlock = new MaterialPropertyBlock();
|
||||
|
||||
Transform ModelTransform()
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
if (world == null) return null;
|
||||
Vector3i pos = ToWorldPos();
|
||||
Chunk chunk = world.GetChunkFromWorldPos(pos) as Chunk;
|
||||
BlockEntityData be = chunk?.GetBlockEntity(pos);
|
||||
return be != null && be.bHasTransform ? be.transform : null;
|
||||
}
|
||||
|
||||
bool ApplyModelGlow(bool on)
|
||||
{
|
||||
Transform t = ModelTransform();
|
||||
if (t == null) return false;
|
||||
Color c = on ? new Color(2.2f, 2.2f, 2.2f, 1f) : new Color(0.25f, 0.25f, 0.25f, 1f);
|
||||
foreach (Renderer r in t.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
r.GetPropertyBlock(glowBlock);
|
||||
glowBlock.SetColor("_EmissionColor", c);
|
||||
r.SetPropertyBlock(glowBlock);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SpawnGlow()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i pos = ToWorldPos();
|
||||
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// FIXED 2026-09-02 (user report: "горение пирамидки почему-то смещено на куб вверх и
|
||||
// вбок"): World.blockToTransformPos(Vector3i) ALREADY returns (x+0.5, y, z+0.5)
|
||||
// (decompiled to confirm - horizontally centered, y left raw/un-centered) - adding
|
||||
// another +0.5 on x/z on top of that (the original bug) double-centered it, landing a
|
||||
// full extra block over on both horizontal axes. Only the vertical lift (how far above
|
||||
// the block the flame sits) is actually ours to add. Height LOWERED again same day
|
||||
// ("сделай ниже не 2/3 куба" - after the sideways offset was gone, still sat too high):
|
||||
// 1.2 (0.2 above the full block top) -> 0.6, under 2/3 (0.667) of a block as asked.
|
||||
// Height lowered twice same day: 1.2 -> 0.6 ("сделай ниже не 2/3 куба"), then -> 0.4
|
||||
// (direct follow-up: "Снизь высоту пламени до +0.4").
|
||||
Vector3 worldPos = World.blockToTransformPos(pos) + new Vector3(0f, 0.4f, 0f);
|
||||
// WardTint (not Color.white) as the ParticleEffect's own _color: ParticleEffect.
|
||||
// SpawnParticleEffect applies this directly to any non-ParticleSystem Renderer on the
|
||||
// prefab (decompiled to confirm) - covers a sub-emitter/glow sprite ApplyGlowTint's own
|
||||
// ParticleSystem-only loop wouldn't reach, belt-and-suspenders alongside it.
|
||||
GameManager.Instance.SpawnBlockParticleEffect(pos, new ParticleEffect(GlowParticleName, worldPos, Quaternion.identity, 0f, WardTint));
|
||||
pendingTint.Add(pos);
|
||||
if (GameManager.Instance != null && !ApplyModelGlow(true)) glowPending = true;
|
||||
}
|
||||
|
||||
public void RemoveGlow()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (GameManager.Instance == null) return;
|
||||
// пирамиды, поставленные до 01.10, могли оставить огонь - снимаем, если есть
|
||||
Vector3i pos = ToWorldPos();
|
||||
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||
{
|
||||
GameManager.Instance.RemoveBlockParticleEffect(pos);
|
||||
}
|
||||
if (GameManager.Instance.HasBlockParticleEffect(pos)) GameManager.Instance.RemoveBlockParticleEffect(pos);
|
||||
pendingTint.Remove(pos);
|
||||
if (!ApplyModelGlow(false)) glowPending = true;
|
||||
}
|
||||
|
||||
/// <summary>Ring of glow points marking EffectRadius, keyed by y = -1000-i (real block
|
||||
/// y-coordinates never go negative that far, so these keys can never collide with an
|
||||
/// actual placed block's own glow key).</summary>
|
||||
bool glowPending = true;
|
||||
|
||||
// Граница зоны (01.10: «вместо огня другие частицы, как круг у квеста на копание»):
|
||||
// светящееся фиолетовое кольцо по земле - LineRenderer, точки опущены на рельеф лучом.
|
||||
// Дочерний объект модели блока - поэтому сдвиги Origin и выгрузка чанка его не теряют.
|
||||
GameObject ring;
|
||||
|
||||
public void SpawnZoneRing()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
if (ring != null) return;
|
||||
Transform t = ModelTransform();
|
||||
if (t == null) return;
|
||||
ring = new GameObject("NecroWardRing");
|
||||
ring.transform.SetParent(t, false);
|
||||
var lr = ring.AddComponent<LineRenderer>();
|
||||
lr.useWorldSpace = false; lr.loop = true;
|
||||
const int n = 96;
|
||||
lr.positionCount = n;
|
||||
lr.widthMultiplier = 0.14f;
|
||||
var mat = new Material(Shader.Find("Sprites/Default"));
|
||||
lr.material = mat;
|
||||
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;
|
||||
Vector3 c = t.position;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i basePos = ToWorldPos();
|
||||
// Same double-centering bug as SpawnGlow's own fix above - blockToTransformPos already
|
||||
// centers x/z, only the vertical offset (0.5, mid-block height) is ours to add.
|
||||
Vector3 center = World.blockToTransformPos(basePos) + new Vector3(0f, 0.5f, 0f);
|
||||
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||
{
|
||||
float angle = i * (360f / ZoneRingPointCount) * Mathf.Deg2Rad;
|
||||
Vector3 point = center + new Vector3(Mathf.Cos(angle) * EffectRadius, 0f, Mathf.Sin(angle) * EffectRadius);
|
||||
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ZoneRingParticleName (not GlowParticleName) - see that constant's own comment:
|
||||
// user asked for the boundary to read as "some particles", not literal fire.
|
||||
GameManager.Instance.SpawnBlockParticleEffect(key, new ParticleEffect(ZoneRingParticleName, point, Quaternion.identity, 0f, WardTint));
|
||||
pendingTint.Add(key);
|
||||
float ang = i * Mathf.PI * 2f / n;
|
||||
Vector3 p = c + new Vector3(Mathf.Cos(ang) * EffectRadius, 0f, Mathf.Sin(ang) * EffectRadius);
|
||||
if (Physics.Raycast(p + Vector3.up * 12f, Vector3.down, out RaycastHit hit, 30f, 65536))
|
||||
p.y = hit.point.y + 0.08f;
|
||||
lr.SetPosition(i, t.InverseTransformPoint(p));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveZoneRing()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (ring != null) { UnityEngine.Object.Destroy(ring); ring = null; }
|
||||
if (GameManager.Instance == null) return;
|
||||
// кольца из огоньков от версий до 01.10
|
||||
Vector3i basePos = ToWorldPos();
|
||||
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||
{
|
||||
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
GameManager.Instance.RemoveBlockParticleEffect(key);
|
||||
}
|
||||
if (GameManager.Instance.HasBlockParticleEffect(key)) GameManager.Instance.RemoveBlockParticleEffect(key);
|
||||
pendingTint.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Модель блока появляется позже, чем срабатывает OnAdded/загрузка, - поэтому
|
||||
/// свечение и кольцо досоздаются из UpdateTick, когда модель уже есть.</summary>
|
||||
void EnsureVisuals()
|
||||
{
|
||||
if (glowPending && ApplyModelGlow(EffectOn)) glowPending = false;
|
||||
if (ZoneShown && ring == null) SpawnZoneRing();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Mirrors ParticlePatch.cs's own tint technique (same ParticleSystem.MainModule
|
||||
/// fields, same reasoning: the particle prefab always instantiates at its authored color,
|
||||
/// nothing in XML/the block-particle API can override that) - kept as its own copy here
|
||||
@@ -463,6 +474,7 @@ namespace NecromancerTome
|
||||
{
|
||||
return;
|
||||
}
|
||||
EnsureVisuals();
|
||||
|
||||
for (int i = pendingTint.Count - 1; i >= 0; i--)
|
||||
{
|
||||
|
||||
@@ -40,8 +40,9 @@ namespace NecromancerTome
|
||||
/// позади головы: голова впереди и чуть ниже, в поле зрения.
|
||||
public const float EyeUp = 0.5f;
|
||||
public const float EyeForward = -1.2f;
|
||||
/// <summary>Сколько змея живёт в радиации края карты (указание 28.09: «таймер 30 сек»).</summary>
|
||||
public const float RadiationTime = 30f;
|
||||
/// <summary>Как часто змея проверяет радиацию края карты. Указание 02.10: без таймера, сразу
|
||||
/// в тело; проверка раз в 2 с ради нагрузки (было 30 с отсчёта и проверка раз в 0.5 с).</summary>
|
||||
public const float RadiationCheckPeriod = 2f;
|
||||
/// <summary>Порог игры: buffRadiation03 вешается при _biomeradiation > 2.5
|
||||
/// (buffs.xml, buffStatusCheck01), значение - biomeProvider.GetRadiationAt.</summary>
|
||||
public const float RadiationThreshold = 2.5f;
|
||||
@@ -55,6 +56,18 @@ namespace NecromancerTome
|
||||
/// <summary>Радиус проверочных сфер при шаге. Меньше полублока - в дверной проём и щель в
|
||||
/// один блок змея пролезает.</summary>
|
||||
public const float BodyRadius = 0.25f;
|
||||
/// <summary>Точка тела для проверки «внутри текстуры»: над корнем змеи, ниже головы.</summary>
|
||||
public const float BodyUp = 0.35f;
|
||||
/// <summary>Как далеко вверх искать поверхность, под которой змея может быть зарыта.</summary>
|
||||
public const float InsideProbe = 32f;
|
||||
/// <summary>Полёт (указание 05.10): пока зажата правая кнопка. Скорость - вдвое ниже шага
|
||||
/// зомби (MindControl.WalkSpeed), и в воздухе, и сквозь постройки; Shift не ускоряет.
|
||||
/// Сквозь грунт (земля, песок, камень) - никак.</summary>
|
||||
static bool _flying;
|
||||
public const float FlySpeed = MindControl.WalkSpeed * 0.5f;
|
||||
/// <summary>Не нашлось, куда выбросить змею из текстур, - пауза, а не поиск каждый кадр.</summary>
|
||||
static float _ejectWait;
|
||||
static Vector3[] _ejectOffsets;
|
||||
|
||||
/// <summary>Параметр Forward аниматора змеи при ползании и на Shift. Подобраны на глаз:
|
||||
/// у животного с RootMotion полный ход даёт landMovementFactor * 2.5 = 0.25.</summary>
|
||||
@@ -78,6 +91,11 @@ namespace NecromancerTome
|
||||
public const float KillFreeze = 0.35f;
|
||||
public const float BurstFollow = 1f;
|
||||
static float _freeze;
|
||||
/// <summary>Отброс взрывом Стирателя (02.10): горизонтальная скорость, гаснет сама.</summary>
|
||||
static Vector3 _kick;
|
||||
public const float KickSpeed = 14f;
|
||||
public const float KickUp = 8f;
|
||||
public const float KickDamping = 3f;
|
||||
static int _killId = -1;
|
||||
static Transform _burst;
|
||||
static Vector3 _burstOffset;
|
||||
@@ -92,6 +110,15 @@ namespace NecromancerTome
|
||||
public const string SenseNavObject = "zombie";
|
||||
static readonly System.Collections.Generic.HashSet<int> _sensed = new System.Collections.Generic.HashSet<int>();
|
||||
static float _senseCheck;
|
||||
/// <summary>Очередь испуга (указание 02.10: «пусть пугаются по очереди» - толпа, напуганная
|
||||
/// разом, давала фризы): новые учуянные встают в очередь, пугается по одному раз в
|
||||
/// FearInterval. Уже напуганным страх продлевается сразу, это дёшево.</summary>
|
||||
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;
|
||||
public const float FearInterval = 0.25f;
|
||||
/// <summary>Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
|
||||
/// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.</summary>
|
||||
public static readonly System.Collections.Generic.HashSet<int> NoLoot = new System.Collections.Generic.HashSet<int>();
|
||||
@@ -99,24 +126,22 @@ namespace NecromancerTome
|
||||
/// или лута, она для повышения уровня Некромантии»). Снимается в патче AddKillXP.</summary>
|
||||
public static readonly System.Collections.Generic.HashSet<int> NoXp = new System.Collections.Generic.HashSet<int>();
|
||||
|
||||
/// <summary>Зомби, которые заметили змею (указание 28.09: «встречные зомби просто стоят,
|
||||
/// скучно - пусть начинают ходить, если змея рядом»): не дальше StirRange идут к ней, как на
|
||||
/// шум. Нападать всё равно не станут - змей зомби не трогают.</summary>
|
||||
public const float StirRange = 20f;
|
||||
static float _stirCheck;
|
||||
|
||||
public const string ToolbeltWindow = "toolbelt";
|
||||
|
||||
public static bool Controlling;
|
||||
public static float AnimForward;
|
||||
/// <summary>Секунды до развеивания; меньше нуля - змея не в радиации.</summary>
|
||||
public static float Countdown = -1f;
|
||||
static float _radiationCheck;
|
||||
/// <summary>Свой наблюдатель чанков у змеи: мир подгружается вокруг наблюдателей, а у игрока
|
||||
/// он стоит у тела (тест 28.09: «змея не может уйти далеко от тела»). Так же игра грузит
|
||||
/// место под сброс припасов (AIAirDrop) - только здесь с видимой сеткой и дальностью
|
||||
/// обзора из настроек.</summary>
|
||||
static ChunkManager.ChunkObserver _observer;
|
||||
/// <summary>Область тела сжата до Possession.BodyViewDim, как у одержимого (указание 05.10:
|
||||
/// «делай как у одержимого»): две полные области - лишняя память. Полный радиус - для систем,
|
||||
/// рисующих мир у змеи (SnakeAudioSwap). -1 - не сжата.</summary>
|
||||
static int _bodyViewDim = -1;
|
||||
public static int FullViewDim => _bodyViewDim;
|
||||
public static EntityAlive Target;
|
||||
static Vector3 _pos;
|
||||
static float _vy;
|
||||
@@ -144,18 +169,31 @@ namespace NecromancerTome
|
||||
_loggedCamera = false;
|
||||
_headForward = -1f;
|
||||
_dashTarget = null;
|
||||
_stirCheck = 0f;
|
||||
_freeze = 0f;
|
||||
_kick = Vector3.zero;
|
||||
_flying = false;
|
||||
_ejectWait = 0f;
|
||||
_killId = -1;
|
||||
_burst = null;
|
||||
_sleeperCheck = 0f;
|
||||
_senseCheck = 0f;
|
||||
_fearQueue.Clear();
|
||||
_fearQueued.Clear();
|
||||
_fearCheck = 0f;
|
||||
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10.
|
||||
_snake.AddNavObject(SnakeMapNavObject, null, null);
|
||||
_mapSnake = _snake;
|
||||
Target = null;
|
||||
Countdown = -1f;
|
||||
_radiationCheck = 0f;
|
||||
BodyDanger = false;
|
||||
_bodyCheck = 0f;
|
||||
_observer = GameManager.Instance.AddChunkObserver(_pos, true, GameUtils.GetViewDistance(), -1);
|
||||
ChunkManager.ChunkObserver bodyObserver = _player.ChunkObserver;
|
||||
if (bodyObserver != null && bodyObserver.viewDim > Possession.BodyViewDim)
|
||||
{
|
||||
_bodyViewDim = bodyObserver.viewDim;
|
||||
bodyObserver.viewDim = Possession.BodyViewDim;
|
||||
}
|
||||
Controlling = true;
|
||||
// Пояс прячется на весь режим (указание 28.09); обратно его откроет сама игра в OnHUD.
|
||||
_player.PlayerUI?.windowManager.Close(ToolbeltWindow);
|
||||
@@ -210,11 +248,23 @@ namespace NecromancerTome
|
||||
public static void End()
|
||||
{
|
||||
ClearSense();
|
||||
if (_mapSnake != null)
|
||||
{
|
||||
_mapSnake.RemoveNavObject(SnakeMapNavObject);
|
||||
_mapSnake = null;
|
||||
}
|
||||
_fearQueue.Clear();
|
||||
_fearQueued.Clear();
|
||||
Controlling = false;
|
||||
SnakeVision.Off(GameManager.Instance?.World?.GetPrimaryPlayer());
|
||||
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
SnakeVision.Off(player);
|
||||
if (_bodyViewDim > 0 && player?.ChunkObserver != null)
|
||||
{
|
||||
player.ChunkObserver.viewDim = _bodyViewDim;
|
||||
}
|
||||
_bodyViewDim = -1;
|
||||
_dashTarget = null;
|
||||
Target = null;
|
||||
Countdown = -1f;
|
||||
BodyDanger = false;
|
||||
if (_observer != null)
|
||||
{
|
||||
@@ -239,6 +289,8 @@ namespace NecromancerTome
|
||||
Vector2 move = Vector2.zero;
|
||||
bool run = false;
|
||||
bool jump = false;
|
||||
bool jumpHeld = false;
|
||||
bool downHeld = false;
|
||||
bool attack = false;
|
||||
World world = GameManager.Instance.World;
|
||||
if (_freeze > 0f)
|
||||
@@ -255,13 +307,25 @@ namespace NecromancerTome
|
||||
move = new Vector2(input.Move.X, input.Move.Y);
|
||||
run = input.Run.IsPressed;
|
||||
jump = input.Jump.WasPressed;
|
||||
// Указание 28.09: атака на левую кнопку, правая - выбраться из застревания.
|
||||
jumpHeld = input.Jump.IsPressed;
|
||||
downHeld = input.Crouch.IsPressed || input.ToggleCrouch.IsPressed;
|
||||
// Указание 28.09: атака на левую кнопку. 05.10: E - аварийный выход из текстур
|
||||
// (E в игре на PermanentActions.Activate, см. MindControl).
|
||||
attack = input.Primary.WasPressed;
|
||||
if (input.Secondary.WasPressed)
|
||||
if ((input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed) && _dashTarget == null)
|
||||
{
|
||||
Unstick(world);
|
||||
Eject(world, false, 0.5f);
|
||||
}
|
||||
}
|
||||
// Полёт - пока зажата правая кнопка (указание 05.10); отпустил - змея падает.
|
||||
bool fly = !gui && input != null && input.Secondary.IsPressed && _dashTarget == null;
|
||||
if (fly != _flying)
|
||||
{
|
||||
_flying = fly;
|
||||
_vy = 0f;
|
||||
_airborne = !fly;
|
||||
Debug.Log("[NecromancerTome] SnakeControl: полёт " + (fly ? "вкл" : "выкл"));
|
||||
}
|
||||
|
||||
if (_dashTarget == null && attack && Target != null)
|
||||
{
|
||||
@@ -275,6 +339,10 @@ namespace NecromancerTome
|
||||
if (_dashTarget != null)
|
||||
{
|
||||
Dash(_player);
|
||||
if (_dashTarget == null)
|
||||
{
|
||||
KeepOut(world, dt);
|
||||
}
|
||||
_observer?.SetPosition(_pos);
|
||||
_snake.SetPosition(_pos);
|
||||
_snake.SetRotation(new Vector3(0f, _snakeYaw, 0f));
|
||||
@@ -286,6 +354,12 @@ namespace NecromancerTome
|
||||
}
|
||||
Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
|
||||
Vector3 dir = yawRot * Vector3.forward * move.y + yawRot * Vector3.right * move.x;
|
||||
if (_flying)
|
||||
{
|
||||
// В полёте - куда смотрит камера, Пробел вверх, приседание вниз.
|
||||
dir = Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * move.y + yawRot * Vector3.right * move.x +
|
||||
Vector3.up * ((jumpHeld ? 1f : 0f) - (downHeld ? 1f : 0f));
|
||||
}
|
||||
if (dir.sqrMagnitude > 1f)
|
||||
{
|
||||
dir.Normalize();
|
||||
@@ -293,21 +367,41 @@ namespace NecromancerTome
|
||||
bool moving = dir.sqrMagnitude > 0.01f;
|
||||
if (moving)
|
||||
{
|
||||
Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt;
|
||||
float speed = _flying ? FlySpeed : run ? RunSpeed : WalkSpeed;
|
||||
Vector3 delta = dir * speed * dt;
|
||||
int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / MaxStep));
|
||||
Vector3 d = delta / steps;
|
||||
for (int i = 0; i < steps; i++)
|
||||
{
|
||||
// Упёрлись - пробуем скользить вдоль стены по одной из осей.
|
||||
if (!TryStep(world, d) && !TryStep(world, new Vector3(d.x, 0f, 0f)) && !TryStep(world, new Vector3(0f, 0f, d.z)))
|
||||
if (!TryStep(world, d) && !TryStep(world, new Vector3(d.x, 0f, 0f)) && !TryStep(world, new Vector3(0f, 0f, d.z)) &&
|
||||
!(_flying && TryStep(world, new Vector3(0f, d.y, 0f))))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_kick.sqrMagnitude > 0.01f)
|
||||
{
|
||||
Vector3 kd = _kick * dt;
|
||||
int ks = Mathf.Max(1, Mathf.CeilToInt(kd.magnitude / MaxStep));
|
||||
for (int i = 0; i < ks; i++)
|
||||
{
|
||||
if (!TryStep(world, kd / ks))
|
||||
{
|
||||
_kick = Vector3.zero;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_kick *= Mathf.Exp(-KickDamping * dt);
|
||||
}
|
||||
// Вид от первого лица: змея смотрит туда же, куда игрок.
|
||||
_snakeYaw = Mathf.LerpAngle(_snakeYaw, _yaw, dt * 12f);
|
||||
Fall(dt, jump);
|
||||
if (!_flying)
|
||||
{
|
||||
Fall(world, dt, jump);
|
||||
}
|
||||
KeepOut(world, dt);
|
||||
_observer?.SetPosition(_pos);
|
||||
|
||||
_snake.SetPosition(_pos);
|
||||
@@ -317,12 +411,37 @@ namespace NecromancerTome
|
||||
_snake.moveDirection = Vector3.zero;
|
||||
AnimForward = moving ? (run ? AnimRun : AnimWalk) : 0f;
|
||||
CheckBody(world, _player, _snake, dt);
|
||||
Stir(world, _snake, dt);
|
||||
Sleepers(world, _player, dt);
|
||||
Sleepers(world, _player, _pos, ref _sleeperCheck, dt);
|
||||
Sense(world, _snake, dt);
|
||||
FearQueue(world, _snake, dt);
|
||||
return Radiation(world, dt);
|
||||
}
|
||||
|
||||
static void FearQueue(World _world, EntityAlive _snake, float _dt)
|
||||
{
|
||||
_fearCheck -= _dt;
|
||||
if (_fearCheck > 0f || _fearQueue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_fearCheck = FearInterval;
|
||||
bool bloodMoon = _world.aiDirector?.BloodMoonComponent?.BloodMoonActive ?? false;
|
||||
while (_fearQueue.Count > 0)
|
||||
{
|
||||
int id = _fearQueue.Dequeue();
|
||||
_fearQueued.Remove(id);
|
||||
EntityAlive e = _world.GetEntity(id) as EntityAlive;
|
||||
// Ушёл из чутья, умер, стал своим, началась луна - пропускаем и берём следующего.
|
||||
if (e == null || bloodMoon || _world.IsRemote() || !IsFoe(e, _snake) ||
|
||||
(e.position - _pos).sqrMagnitude > SenseRange * SenseRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
NecroFleeSnakeTask.Scare(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Шаг рывка: по прямой к ногам цели, без земли и стен. Долетела - убийство: цель
|
||||
/// помечается для чёрного разрыва (NecroBurst.Pending) и получает смертельный урон ОТ ИГРОКА -
|
||||
/// тогда игра сама выдаёт опыт, а NecromancyKillCreditPatch засчитывает убийство в Некромантию.</summary>
|
||||
@@ -349,6 +468,11 @@ namespace NecromancerTome
|
||||
}
|
||||
_pos = target.position - (flat.sqrMagnitude > 0.0001f ? flat.normalized * DashStop : Vector3.zero);
|
||||
_dashTarget = null;
|
||||
// Стиратель от разрыва взрывается; отброс змеи - из EraserPatch.Explode.
|
||||
if (EraserPatch.IsEraser(target) && EraserPatch.Explode(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_freeze = KillFreeze;
|
||||
_killId = target.entityId;
|
||||
NoLoot.Add(target.entityId);
|
||||
@@ -365,6 +489,32 @@ namespace NecromancerTome
|
||||
Debug.Log("[NecromancerTome] SnakeControl: удар по " + target.EntityName + ", " + (target.IsDead() ? "убит" : "ВЫЖИЛ"));
|
||||
}
|
||||
|
||||
/// <summary>Из EraserPatch.Explode: змею ближе _range от взрыва отбрасывает прочь и вверх.
|
||||
/// Не ранит - змея бессмертна.</summary>
|
||||
public static void Knockback(Vector3 _from, float _range)
|
||||
{
|
||||
if (!Controlling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3 away = _pos - _from;
|
||||
if (away.sqrMagnitude > _range * _range)
|
||||
{
|
||||
return;
|
||||
}
|
||||
away.y = 0f;
|
||||
if (away.sqrMagnitude < 0.01f)
|
||||
{
|
||||
away = Quaternion.Euler(0f, _yaw, 0f) * Vector3.back;
|
||||
}
|
||||
_dashTarget = null;
|
||||
_kick = away.normalized * KickSpeed;
|
||||
_vy = KickUp;
|
||||
_airborne = true;
|
||||
_freeze = 0.6f;
|
||||
Debug.Log("[NecromancerTome] SnakeControl: отброшена взрывом Стирателя");
|
||||
}
|
||||
|
||||
/// <summary>Из NecroBurst.OnBurst: наш разрыв - запоминаем частицы, чтобы вести их за змеёй.</summary>
|
||||
public static void OnBurst(EntityAlive _victim, Transform _effect)
|
||||
{
|
||||
@@ -396,17 +546,18 @@ namespace NecromancerTome
|
||||
/// <summary>Комнаты со спящими в зданиях просыпаются, когда в них входит ИГРОК:
|
||||
/// EntityPlayer.CheckSleeperTriggers каждый тик зовёт World.CheckSleeperVolumeTouching и
|
||||
/// CheckTriggerVolumeTrigger, а те берут player.position (тест 28.09: «зомби в локациях не
|
||||
/// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи.</summary>
|
||||
static void Sleepers(World _world, EntityPlayerLocal _player, float _dt)
|
||||
/// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи
|
||||
/// (и одержимого - Possession, 05.10).</summary>
|
||||
public static void Sleepers(World _world, EntityPlayerLocal _player, Vector3 _at, ref float _timer, float _dt)
|
||||
{
|
||||
_sleeperCheck -= _dt;
|
||||
if (_sleeperCheck > 0f || _world.IsRemote())
|
||||
_timer -= _dt;
|
||||
if (_timer > 0f || _world.IsRemote())
|
||||
{
|
||||
return;
|
||||
}
|
||||
_sleeperCheck = 0.25f;
|
||||
_timer = 0.25f;
|
||||
Vector3 body = _player.position;
|
||||
_player.position = _pos;
|
||||
_player.position = _at;
|
||||
try
|
||||
{
|
||||
_world.CheckSleeperVolumeTouching(_player);
|
||||
@@ -426,6 +577,19 @@ namespace NecromancerTome
|
||||
return;
|
||||
}
|
||||
_senseCheck = 1f;
|
||||
var senseWatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
// Леденящий ужас (02.10): учуянный зомби сразу бежит. ИИ живёт на сервере. В кровавую
|
||||
// луну ужаса нет (указание 02.10: иначе змеёй распугивают орду).
|
||||
bool scare = !_world.IsRemote() && !(_world.aiDirector?.BloodMoonComponent?.BloodMoonActive ?? false);
|
||||
NecroFleeSnakeTask.SnakePos = _pos;
|
||||
// Сетка поиска пути (AstarManager) строится только вокруг игроков (76 блоков) и отмеченных
|
||||
// мест; тело далеко - у змеи сетки нет, и путь бегства не находился (тест 02.10, лог
|
||||
// «путь нет»). Отмечаем место змеи, как игра отмечает точки орды (AddLocation(pos, 40) в
|
||||
// AIDirectorBloodMoonParty). Отметка живёт 4 с и сливается с соседней - не копится.
|
||||
if (!_world.IsRemote())
|
||||
{
|
||||
AstarManager.Instance?.AddLocation(_pos, 76);
|
||||
}
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
@@ -437,6 +601,12 @@ namespace NecromancerTome
|
||||
bool foe = IsFoe(e, _snake);
|
||||
if (foe && d2 < SenseRange * SenseRange)
|
||||
{
|
||||
// Только наземные: зомби и звери-зомби. Стервятникам пафайндер не нужен.
|
||||
if (scare && (e is EntityZombie || e is EntityEnemyAnimal) && !NecroFleeSnakeTask.Refresh(e) &&
|
||||
_fearQueued.Add(e.entityId))
|
||||
{
|
||||
_fearQueue.Enqueue(e.entityId);
|
||||
}
|
||||
if (_sensed.Add(e.entityId))
|
||||
{
|
||||
e.AddNavObject(SenseNavObject, null, null);
|
||||
@@ -453,6 +623,10 @@ namespace NecromancerTome
|
||||
}
|
||||
// Исчезнувшие (разорванные, выгруженные) - просто забываем: метку игра снимет сама.
|
||||
_sensed.RemoveWhere(id => _world.GetEntity(id) == null);
|
||||
if (senseWatch.Elapsed.TotalMilliseconds > 3.0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] SnakeControl: долгое чутьё/ужас - " + senseWatch.Elapsed.TotalMilliseconds.ToString("F1") + " мс");
|
||||
}
|
||||
}
|
||||
|
||||
static void ClearSense()
|
||||
@@ -470,37 +644,26 @@ namespace NecromancerTome
|
||||
_sensed.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Раз в 2 с: спокойные зомби рядом со змеёй идут к ней, как на шум.</summary>
|
||||
static void Stir(World _world, EntityAlive _snake, float _dt)
|
||||
{
|
||||
_stirCheck -= _dt;
|
||||
if (_stirCheck > 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_stirCheck = 2f;
|
||||
for (int i = 0; i < _world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive e = _world.EntityAlives[i];
|
||||
if (!(e is EntityZombie) || !IsFoe(e, _snake) || e.IsSleeping || e.GetAttackTarget() != null ||
|
||||
e.HasInvestigatePosition || (e.position - _pos).sqrMagnitude > StirRange * StirRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Vector3 spot = _pos + new Vector3(Random.Range(-3f, 3f), 0f, Random.Range(-3f, 3f));
|
||||
e.SetInvestigatePosition(spot, 300, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под
|
||||
/// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это
|
||||
/// не враг»). Общее для тревоги у тела и для прицела.</summary>
|
||||
static bool IsFoe(EntityAlive _e, EntityAlive _snake)
|
||||
public static bool IsFoe(EntityAlive _e, EntityAlive _snake)
|
||||
{
|
||||
return _e != null && _e != _snake && _e is EntityEnemy && !_e.IsDead() &&
|
||||
!IsNecroEntity(_e) &&
|
||||
!GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
|
||||
}
|
||||
|
||||
/// <summary>Питомцы и змея мода - тоже EntityEnemy (зомби-основа), и без этой проверки
|
||||
/// охрана выбирала целью самого питомца: он ближе всех к хозяину (тест 30.09, лог
|
||||
/// «pet 3838 идёт на 3838»). Все наши классы сущностей начинаются с necro.</summary>
|
||||
public static bool IsNecroEntity(EntityAlive _e)
|
||||
{
|
||||
EntityClass cls;
|
||||
return EntityClass.list.TryGetValue(_e.entityClass, out cls) && cls != null &&
|
||||
cls.entityClassName != null && cls.entityClassName.StartsWith("necro");
|
||||
}
|
||||
|
||||
/// <summary>Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу игрока.</summary>
|
||||
static void CheckBody(World _world, EntityPlayerLocal _player, EntityAlive _snake, float _dt)
|
||||
{
|
||||
@@ -528,32 +691,17 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Отсчёт в радиации края карты. Вышла из зоны - отсчёт сбрасывается.</summary>
|
||||
/// <summary>Радиация края карты: змея в ней сразу развеивается, игрок в теле.</summary>
|
||||
static bool Radiation(World _world, float _dt)
|
||||
{
|
||||
_radiationCheck -= _dt;
|
||||
if (_radiationCheck <= 0f)
|
||||
{
|
||||
_radiationCheck = 0.5f;
|
||||
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
|
||||
bool inside = biomes != null && biomes.GetRadiationAt((int)_pos.x, (int)_pos.z) > RadiationThreshold;
|
||||
if (inside && Countdown < 0f)
|
||||
{
|
||||
Countdown = RadiationTime;
|
||||
Debug.Log("[NecromancerTome] SnakeControl: радиация, отсчёт " + RadiationTime + " с");
|
||||
}
|
||||
else if (!inside && Countdown >= 0f)
|
||||
{
|
||||
Countdown = -1f;
|
||||
Debug.Log("[NecromancerTome] SnakeControl: вышла из радиации");
|
||||
}
|
||||
}
|
||||
if (Countdown < 0f)
|
||||
if (_radiationCheck > 0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Countdown -= _dt;
|
||||
if (Countdown > 0f)
|
||||
_radiationCheck = RadiationCheckPeriod;
|
||||
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
|
||||
if (biomes == null || biomes.GetRadiationAt((int)_pos.x, (int)_pos.z) <= RadiationThreshold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -581,6 +729,16 @@ namespace NecromancerTome
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (_flying)
|
||||
{
|
||||
// Сквозь постройки можно, в грунт - нет.
|
||||
if (Inside(_world, c + Vector3.up * BodyUp, TerrainMask, out bool ground) && ground)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_pos = c;
|
||||
return true;
|
||||
}
|
||||
// Один блок - ступенька, два - стена. Проверяем настоящую геометрию, а не тип блока:
|
||||
// у блока есть только флаг «твёрдый», и открытая дверь для него такая же стена, как
|
||||
// закрытая (тест 28.09: «змея не может пройти в открытые двери»).
|
||||
@@ -599,11 +757,16 @@ namespace NecromancerTome
|
||||
_vy = 0f;
|
||||
}
|
||||
}
|
||||
// Ни в грунт, ни в блоки: обрыв выше подъёма или стена, которую шар изнутри не чует.
|
||||
if (Inside(_world, c + Vector3.up * BodyUp, WallMask, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_pos = c;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void Fall(float _dt, bool _jump)
|
||||
static void Fall(World _world, float _dt, bool _jump)
|
||||
{
|
||||
if (!Ground(_pos + Vector3.up * 0.3f, 300f, out float ground))
|
||||
{
|
||||
@@ -631,6 +794,12 @@ namespace NecromancerTome
|
||||
_vy = 0f;
|
||||
dy = 0f;
|
||||
}
|
||||
// Прыжок не должен заносить тело в текстуры (указание 05.10).
|
||||
if (dy > 0f && Inside(_world, _pos + Vector3.up * (dy + BodyUp), WallMask, out _))
|
||||
{
|
||||
_vy = 0f;
|
||||
dy = 0f;
|
||||
}
|
||||
_pos.y += dy;
|
||||
if (_pos.y <= ground)
|
||||
{
|
||||
@@ -645,38 +814,115 @@ namespace NecromancerTome
|
||||
return Physics.CheckSphere(_p - Origin.position, BodyRadius, WallMask, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
|
||||
/// <summary>Правая кнопка: выбраться из текстур. Ближайшее свободное место - сначала выше
|
||||
/// на месте, потом кольцами до 3 м и до 4 м вверх; там, где тело змеи ни во что не упирается.
|
||||
/// Дальше змея сама падает на землю.</summary>
|
||||
static void Unstick(World _world)
|
||||
/// <summary>Каждый кадр: змея в грунте (в полёте) или в любой текстуре (на земле) -
|
||||
/// выбрасываем на ближайшее свободное место (указание 05.10).</summary>
|
||||
static void KeepOut(World _world, float _dt)
|
||||
{
|
||||
if (_dashTarget != null)
|
||||
if (_ejectWait > 0f)
|
||||
{
|
||||
_ejectWait -= _dt;
|
||||
return;
|
||||
}
|
||||
Vector3 body = _pos + Vector3.up * BodyUp;
|
||||
if (!_world.IsChunkAreaCollidersLoaded(body))
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (float r = 0f; r <= 3f; r += 1f)
|
||||
bool ground;
|
||||
bool stuck = _flying ? Inside(_world, body, TerrainMask, out ground) && ground : Inside(_world, body, WallMask, out ground);
|
||||
if (stuck && !Eject(_world, _flying, 0f))
|
||||
{
|
||||
int dirs = r == 0f ? 1 : 8;
|
||||
// На месте - минимум на полметра вверх: проверка может счесть место свободным, а
|
||||
// змея всё равно не идёт (тест 28.09: дважды «выбралась, сдвиг 0.0 м»).
|
||||
for (float h = r == 0f ? 0.5f : 0f; h <= 4f; h += 0.5f)
|
||||
_ejectWait = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ближайшее свободное место: до 4 м вбок, до 1 м вниз и до 6 м вверх; вниз
|
||||
/// дороже, чем вверх. _groundOnly - мешает только грунт (полёт). E зовёт с _minShift 0.5:
|
||||
/// проверка может счесть место свободным, а змея не идёт (тест 28.09: «сдвиг 0.0 м»).</summary>
|
||||
static bool Eject(World _world, bool _groundOnly, float _minShift)
|
||||
{
|
||||
if (_ejectOffsets == null)
|
||||
{
|
||||
System.Collections.Generic.List<Vector3> list = new System.Collections.Generic.List<Vector3>();
|
||||
for (float r = 0f; r <= 4f; r += 0.5f)
|
||||
{
|
||||
for (int i = 0; i < dirs; i++)
|
||||
int dirs = r == 0f ? 1 : 8;
|
||||
for (float h = -1f; h <= 6f; h += 0.5f)
|
||||
{
|
||||
Vector3 p = _pos + Quaternion.Euler(0f, _yaw + i * 45f, 0f) * Vector3.forward * r + Vector3.up * h;
|
||||
if (!_world.IsChunkAreaCollidersLoaded(p) || Blocked(p + Vector3.up * 0.4f) || Blocked(p + Vector3.up * 1f))
|
||||
for (int i = 0; i < dirs; i++)
|
||||
{
|
||||
continue;
|
||||
list.Add(Quaternion.Euler(0f, i * 45f, 0f) * Vector3.forward * r + Vector3.up * h);
|
||||
}
|
||||
Debug.Log("[NecromancerTome] SnakeControl: выбралась, сдвиг " + (p - _pos).magnitude.ToString("F1") + " м");
|
||||
_pos = p;
|
||||
_vy = 0f;
|
||||
_airborne = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
list.Sort((x, y) => EjectCost(x).CompareTo(EjectCost(y)));
|
||||
_ejectOffsets = list.ToArray();
|
||||
}
|
||||
for (int i = 0; i < _ejectOffsets.Length; i++)
|
||||
{
|
||||
Vector3 o = _ejectOffsets[i];
|
||||
if (o.magnitude < _minShift || !Free(_world, _pos + o, _groundOnly))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] SnakeControl: выброшена из текстур" + (_groundOnly ? " (грунт)" : "") + ", сдвиг " + o.magnitude.ToString("F1") + " м");
|
||||
_pos += o;
|
||||
_vy = 0f;
|
||||
_airborne = !_flying;
|
||||
return true;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] SnakeControl: выбраться некуда");
|
||||
return false;
|
||||
}
|
||||
|
||||
static float EjectCost(Vector3 _o)
|
||||
{
|
||||
return _o.magnitude + (_o.y < 0f ? -_o.y : 0f);
|
||||
}
|
||||
|
||||
static bool Free(World _world, Vector3 _p, bool _groundOnly)
|
||||
{
|
||||
if (!_world.IsChunkAreaCollidersLoaded(_p))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bool ground;
|
||||
if (_groundOnly)
|
||||
{
|
||||
return !(Inside(_world, _p + Vector3.up * BodyUp, TerrainMask, out ground) && ground);
|
||||
}
|
||||
return !Inside(_world, _p + Vector3.up * BodyUp, WallMask, out ground) &&
|
||||
!Blocked(_p + Vector3.up * 0.4f) && !Blocked(_p + Vector3.up * 1f);
|
||||
}
|
||||
|
||||
/// <summary>Точка внутри текстуры? Земля игры - поверхность, а не сплошная масса, и шар
|
||||
/// (Blocked) изнутри её не чует. Поэтому так: луч вверх до первого потолка (поверхность
|
||||
/// лицом вниз), потом луч от потолка вниз до точки. Попалась поверхность лицом вверх -
|
||||
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
|
||||
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
|
||||
/// точке), а не постройка.</summary>
|
||||
static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
|
||||
{
|
||||
_ground = false;
|
||||
Vector3 o = _p - Origin.position;
|
||||
float top = InsideProbe;
|
||||
if (Physics.Raycast(o, Vector3.up, out RaycastHit ceil, InsideProbe, _mask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
top = ceil.distance;
|
||||
}
|
||||
if (top < 0.05f ||
|
||||
!Physics.Raycast(o + Vector3.up * (top - 0.02f), Vector3.down, out RaycastHit floor, top - 0.02f, _mask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_ground = IsGround(_world, floor.point + Origin.position + Vector3.down * 0.05f) || IsGround(_world, _p);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool IsGround(World _world, Vector3 _p)
|
||||
{
|
||||
BlockValue b = _world.GetBlock(World.worldToBlockPos(_p));
|
||||
return !b.isair && b.Block.shape.IsTerrain();
|
||||
}
|
||||
|
||||
static bool Solid(World _world, Vector3 _p)
|
||||
@@ -790,6 +1036,11 @@ namespace NecromancerTome
|
||||
{
|
||||
public static bool Prefix(EntityPlayerLocal __instance)
|
||||
{
|
||||
if (MindControl.Controlling)
|
||||
{
|
||||
MindControl.PlaceCamera(__instance.cameraTransform);
|
||||
return false;
|
||||
}
|
||||
if (!SnakeControl.Controlling)
|
||||
{
|
||||
return true;
|
||||
@@ -837,6 +1088,14 @@ namespace NecromancerTome
|
||||
{
|
||||
SnakeControl.PlaceCamera(__instance.transform);
|
||||
}
|
||||
else if (Possession.Active)
|
||||
{
|
||||
Possession.PlaceCamera(__instance.transform);
|
||||
}
|
||||
else if (MindControl.Controlling)
|
||||
{
|
||||
MindControl.PlaceCamera(__instance.transform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,11 +1140,6 @@ namespace NecromancerTome
|
||||
{
|
||||
DrawBodyDanger();
|
||||
}
|
||||
if (SnakeControl.Countdown >= 0f)
|
||||
{
|
||||
DrawCountdown();
|
||||
return;
|
||||
}
|
||||
Texture tex = _custom != null ? (Texture)_custom : _atlas.texture;
|
||||
Rect uv = _custom != null
|
||||
? new Rect(0f, 0f, 1f, 1f)
|
||||
@@ -907,30 +1161,6 @@ namespace NecromancerTome
|
||||
GUI.color = old;
|
||||
}
|
||||
|
||||
GUIStyle _countdownStyle;
|
||||
|
||||
/// <summary>Вместо глаза - секунды до развеивания, красным, с тенью.</summary>
|
||||
void DrawCountdown()
|
||||
{
|
||||
if (_countdownStyle == null)
|
||||
{
|
||||
_countdownStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontStyle = FontStyle.Bold
|
||||
};
|
||||
}
|
||||
_countdownStyle.fontSize = Mathf.RoundToInt(44f * Screen.height / 1080f);
|
||||
string text = Mathf.CeilToInt(SnakeControl.Countdown).ToString();
|
||||
Rect rect = new Rect(Screen.width * 0.5f - 100f, Screen.height * 0.5f - 50f, 200f, 100f);
|
||||
Color old = GUI.color;
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.8f);
|
||||
GUI.Label(new Rect(rect.x + 2f, rect.y + 2f, rect.width, rect.height), text, _countdownStyle);
|
||||
GUI.color = OnTarget;
|
||||
GUI.Label(rect, text, _countdownStyle);
|
||||
GUI.color = old;
|
||||
}
|
||||
|
||||
GUIStyle _dangerStyle;
|
||||
|
||||
/// <summary>Надпись под прицелом, пока враг у тела.</summary>
|
||||
@@ -1054,13 +1284,10 @@ namespace NecromancerTome
|
||||
public static void Prefix(out Vector3 __state)
|
||||
{
|
||||
__state = Vector3.zero;
|
||||
if (!SnakeControl.Controlling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
World world = GameManager.Instance.World;
|
||||
// И у одержимого зомби (03.10: «если одержимый отошёл подальше, новые не спавнятся возле него»).
|
||||
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(world) : Possession.Active ? Possession.Zombie : null;
|
||||
EntityPlayerLocal player = world?.GetPrimaryPlayer();
|
||||
EntityAlive snake = SnakeMode.Snake(world);
|
||||
if (player == null || snake == null)
|
||||
{
|
||||
return;
|
||||
@@ -1093,12 +1320,18 @@ namespace NecromancerTome
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance, ref int ___ticksNoPlayerAdjacent)
|
||||
{
|
||||
if (!SnakeControl.Controlling)
|
||||
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(__instance.world as World) : Possession.Active ? Possession.Zombie : null;
|
||||
if (snake == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
EntityAlive snake = SnakeMode.Snake(__instance.world as World);
|
||||
if (snake == null || snake == __instance || (__instance.position - snake.position).sqrMagnitude > 6400f)
|
||||
// Одержимый сам не исчезает, как бы далеко от тела ни ушёл.
|
||||
if (snake == __instance)
|
||||
{
|
||||
___ticksNoPlayerAdjacent = 0;
|
||||
return !Possession.IsPossessed(__instance);
|
||||
}
|
||||
if ((__instance.position - snake.position).sqrMagnitude > 6400f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -1115,7 +1348,7 @@ namespace NecromancerTome
|
||||
{
|
||||
public static bool Prefix(GUIWindow _w)
|
||||
{
|
||||
return !(SnakeControl.Controlling && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
|
||||
return !((SnakeControl.Controlling || MindControl.Controlling) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,8 +1358,61 @@ namespace NecromancerTome
|
||||
/// змеи. На время вызова переносим позицию игрока к змее.</summary>
|
||||
public static class SnakeAudioSwap
|
||||
{
|
||||
/// <summary>Вложенность подмены: CheckLODs зовётся и изнутри WorldEnvironment.Update, где позиция уже
|
||||
/// подменена. Подменяет и возвращает только внешний вызов, вложенные ничего не трогают.</summary>
|
||||
static int _swapped, _swapFrame;
|
||||
|
||||
public static Vector3 Prefix()
|
||||
{
|
||||
if (_swapped > 0 && _swapFrame != Time.frameCount)
|
||||
{
|
||||
_swapped = 0; // подмена не вернулась (исключение посреди вызова) - не держим её дольше кадра
|
||||
}
|
||||
if (_swapped > 0)
|
||||
{
|
||||
_swapped++;
|
||||
return Vector3.zero;
|
||||
}
|
||||
Vector3 body = PrefixOuter();
|
||||
if (body != Vector3.zero)
|
||||
{
|
||||
_swapped = 1;
|
||||
_swapFrame = Time.frameCount;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
static Vector3 PrefixOuter()
|
||||
{
|
||||
if (MindControl.Controlling)
|
||||
{
|
||||
// Дух чертогов: звук, мир и компас - от него (MindControl.cs).
|
||||
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
if (p == null)
|
||||
{
|
||||
return Vector3.zero;
|
||||
}
|
||||
Vector3 bodyPos = p.position;
|
||||
p.position = MindControl.FeetPos;
|
||||
return bodyPos;
|
||||
}
|
||||
if (Possession.Active && Possession.Zombie != null)
|
||||
{
|
||||
// Одержимость (тест 03.10: «без звуков») - звуки мерятся от одержимого зомби, а не от тела.
|
||||
EntityPlayerLocal pp = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
if (pp == null)
|
||||
{
|
||||
return Vector3.zero;
|
||||
}
|
||||
Vector3 bodyAt = pp.position;
|
||||
pp.position = Possession.Zombie.position;
|
||||
// Область тела сжата (Possession.BodyViewDim) - этим системам радиус полный, они рисуют у зомби.
|
||||
if (Possession.FullViewDim > 0 && pp.ChunkObserver != null)
|
||||
{
|
||||
pp.ChunkObserver.viewDim = Possession.FullViewDim;
|
||||
}
|
||||
return bodyAt;
|
||||
}
|
||||
if (!SnakeControl.Controlling)
|
||||
{
|
||||
return Vector3.zero;
|
||||
@@ -1140,6 +1426,10 @@ namespace NecromancerTome
|
||||
}
|
||||
Vector3 body = player.position;
|
||||
player.position = snake.position;
|
||||
if (SnakeControl.FullViewDim > 0 && player.ChunkObserver != null)
|
||||
{
|
||||
player.ChunkObserver.viewDim = SnakeControl.FullViewDim;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -1147,16 +1437,45 @@ namespace NecromancerTome
|
||||
{
|
||||
if (_body == Vector3.zero)
|
||||
{
|
||||
if (_swapped > 1)
|
||||
{
|
||||
_swapped--;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_swapped = 0;
|
||||
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
|
||||
if (player != null)
|
||||
{
|
||||
player.position = _body;
|
||||
if (((Possession.Active && Possession.FullViewDim > 0) || (SnakeControl.Controlling && SnakeControl.FullViewDim > 0)) &&
|
||||
player.ChunkObserver != null)
|
||||
{
|
||||
player.ChunkObserver.viewDim = Possession.BodyViewDim;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Полная модель блоков (двери, люки, мебель) ближе 41 м, дальше - упрощённая замена:
|
||||
/// ChunkGameObject.CheckLODs меряет расстояние до ИГРОКА - тела. Его зовёт не только WorldEnvironment.Update
|
||||
/// (там позиция уже подменена), но и сам чанк при появлении - тест 03.10: «змея и одержимый видят вместо
|
||||
/// дверей деревянные блоки». Подменяем позицию и здесь: змея, одержимый, дух чертогов.</summary>
|
||||
[HarmonyPatch(typeof(ChunkGameObject), nameof(ChunkGameObject.CheckLODs))]
|
||||
public static class Patch_ChunkGameObject_CheckLODs_Snake
|
||||
{
|
||||
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
|
||||
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
|
||||
}
|
||||
|
||||
/// <summary>Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела), и вокруг змеи (05.10: «текстуры вокруг неё отображаются коряво»).</summary>
|
||||
[HarmonyPatch(typeof(PrefabLODManager), nameof(PrefabLODManager.FrameUpdate))]
|
||||
public static class Patch_PrefabLODManager_FrameUpdate_Possession
|
||||
{
|
||||
public static void Prefix(out Vector3 __state) { __state = Possession.Active || SnakeControl.Controlling ? SnakeAudioSwap.Prefix() : Vector3.zero; }
|
||||
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Vector3), typeof(string), typeof(int), typeof(bool), typeof(float) })]
|
||||
public static class Patch_AudioManager_PlayAt_Snake
|
||||
{
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace NecromancerTome
|
||||
|
||||
public static void Enter(EntityPlayerLocal _player)
|
||||
{
|
||||
if (Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
|
||||
if (Active || MindPalace.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -318,6 +318,10 @@ namespace NecromancerTome
|
||||
{
|
||||
Enter(_sender.xui.playerUI.entityPlayer);
|
||||
}
|
||||
else if (_commandIndex == 2)
|
||||
{
|
||||
MindPalace.Enter(_sender.xui.playerUI.entityPlayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +330,7 @@ namespace NecromancerTome
|
||||
{
|
||||
public static void Postfix(ItemAction __instance, ref bool __result)
|
||||
{
|
||||
if (!__result && !SnakeMode.Active && SnakeMode.IsKnife(__instance.item))
|
||||
if (!__result && !SnakeMode.Active && !MindPalace.Active && SnakeMode.IsKnife(__instance.item))
|
||||
{
|
||||
__result = true;
|
||||
}
|
||||
@@ -336,6 +340,9 @@ namespace NecromancerTome
|
||||
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
|
||||
public static class Patch_ItemAction_SetupRadial_SnakeKnife
|
||||
{
|
||||
/// <summary>Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается).</summary>
|
||||
public const string MindPalaceIcon = "ui_game_symbol_book";
|
||||
|
||||
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
|
||||
{
|
||||
if (!SnakeMode.IsKnife(__instance.item))
|
||||
@@ -355,6 +362,17 @@ namespace NecromancerTome
|
||||
_xuiRadialWindow.CreateRadialEntry(1, SnakeMode.DayIcon, "UIAtlas", "",
|
||||
Localization.Get("necroSnakeModeDay"), _highlighted: false);
|
||||
}
|
||||
// Чертоги разума (указание 03.10: «вход тоже через нож») - третья строка, тоже только ночью.
|
||||
if (SnakeMode.IsNight())
|
||||
{
|
||||
_xuiRadialWindow.CreateRadialEntry(2, MindPalaceIcon, "UIAtlas", "",
|
||||
Localization.Get("necroMindModePalace"), _highlighted: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "",
|
||||
Localization.Get("necroSnakeModeDay"), _highlighted: false);
|
||||
}
|
||||
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
|
||||
SnakeMode.OnRadialCommand, null, 0);
|
||||
return false;
|
||||
|
||||
@@ -133,6 +133,11 @@ namespace NecromancerTome
|
||||
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
|
||||
/// разные стороны.</summary>
|
||||
public bool UsesFollowTask;
|
||||
|
||||
/// <summary>Охраняет ли питомец хозяина сам (PetFollowPatch.Guard) - указание 30.09.2026:
|
||||
/// «охраняют все, кроме крысы». Рой сюда не входит: он не спутник, а разовое заклинание
|
||||
/// со своим поиском целей.</summary>
|
||||
public bool Guards;
|
||||
}
|
||||
|
||||
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
|
||||
@@ -153,6 +158,7 @@ namespace NecromancerTome
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
Guards = true,
|
||||
UsesFollowTask = true,
|
||||
}
|
||||
},
|
||||
@@ -199,6 +205,7 @@ namespace NecromancerTome
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
Guards = true,
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -217,6 +224,7 @@ namespace NecromancerTome
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
Guards = true,
|
||||
UsesFollowTask = true,
|
||||
}
|
||||
},
|
||||
@@ -236,6 +244,7 @@ namespace NecromancerTome
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
Guards = true,
|
||||
UsesFollowTask = true,
|
||||
}
|
||||
},
|
||||
@@ -340,10 +349,48 @@ namespace NecromancerTome
|
||||
}
|
||||
return false;
|
||||
}
|
||||
RecallOtherCompanions(holdingEntity, __instance.entityToSpawn, tooltips);
|
||||
summonAllowedThisCall = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>ОДИН ПИТОМЕЦ ЗА РАЗ - указание 30.09.2026: «за один раз можно было призвать только
|
||||
/// одного питомца. А то слишком жирно сразу и собаку, и крысу, и стервятника, и медведя».
|
||||
/// Раньше предел был по одному НА ВИД, и все пятеро могли ходить вместе.
|
||||
///
|
||||
/// Сделано заменой, а не отказом: призыв нового сам отзывает прежнего. Игроку не надо
|
||||
/// искать в поясе другую книгу, чтобы отозвать, и новых строк перевода не нужно - о прежнем
|
||||
/// сообщает его же готовая надпись «… отозван».
|
||||
///
|
||||
/// Спутник - это вид с отзывом (RecalledKey). Рой Жуков не спутник: он разовое заклинание
|
||||
/// без отзыва, сам никого не вытесняет, и его не вытесняют.</summary>
|
||||
public static void RecallOtherCompanions(EntityAlive _owner, string _summoned, PetInfo _summonedInfo)
|
||||
{
|
||||
if (_summonedInfo.RecalledKey == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (KeyValuePair<string, PetInfo> entry in LimitedPets)
|
||||
{
|
||||
if (entry.Key == _summoned || entry.Value.RecalledKey == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int classId = EntityClass.GetId(entry.Key);
|
||||
if (classId == -1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
List<OwnedEntityData> others = _owner.GetOwnedEntities(classId);
|
||||
for (int i = others.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] SummonPatch: один питомец за раз - " + entry.Key + " " + others[i].Id +
|
||||
" отозван ради " + _summoned);
|
||||
RecallPet(_owner, others[i].Id, entry.Value.RecalledKey, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
|
||||
/// Последней строкой ваниль делает вот что:
|
||||
///
|
||||
@@ -634,7 +681,7 @@ namespace NecromancerTome
|
||||
// Поэтому результат передаётся в Register, и тик повторяет попытку до успеха.
|
||||
bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result);
|
||||
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
|
||||
petInfo.FlyingHomeRadius);
|
||||
petInfo.FlyingHomeRadius, petInfo.Guards);
|
||||
AttachFollowTask(owner, __result, petInfo);
|
||||
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
|
||||
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using HarmonyLib;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Турели не стреляют по существам мода. Жалоба 01.10: «мои питомцы враги, их атакуют
|
||||
/// турели». Питомцы, рой и змея сделаны на зомби-основе и для игры остаются EntityEnemy, а
|
||||
/// обе турели (стационарная AutoTurretFireController и переносная MiniTurretFireController)
|
||||
/// решают в shouldIgnoreTarget: EntityEnemy - стрелять, если включено «по зомби». Здесь
|
||||
/// ответ «пропустить» для всех necro*-сущностей (SnakeControl.IsNecroEntity) - тот же признак,
|
||||
/// по которому их не выбирает целью охрана.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(AutoTurretFireController), nameof(AutoTurretFireController.shouldIgnoreTarget))]
|
||||
public static class Patch_AutoTurretFireController_shouldIgnoreTarget_Pets
|
||||
{
|
||||
public static void Postfix(Entity _target, ref bool __result)
|
||||
{
|
||||
if (!__result && _target is EntityAlive alive && SnakeControl.IsNecroEntity(alive))
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(MiniTurretFireController), nameof(MiniTurretFireController.shouldIgnoreTarget))]
|
||||
public static class Patch_MiniTurretFireController_shouldIgnoreTarget_Pets
|
||||
{
|
||||
public static void Postfix(Entity _target, ref bool __result)
|
||||
{
|
||||
if (!__result && _target is EntityAlive alive && SnakeControl.IsNecroEntity(alive))
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.5.0" />
|
||||
<Version value="1.6.1" />
|
||||
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
||||
</xml>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<prefab>
|
||||
<property name="CopyAirBlocks" value="True" />
|
||||
<property name="ExcludeDistantPOIMesh" value="False" />
|
||||
<property name="ExcludePOICulling" value="False" />
|
||||
<property name="DistantPOIYOffset" value="0" />
|
||||
<property name="AllowDecorations" value="True" />
|
||||
<property name="DifficultyTier" value="0" />
|
||||
<property name="ShowQuestClearCount" value="1" />
|
||||
<property name="TraderArea" value="False" />
|
||||
<property name="PrefabSize" value="17, 8, 17" />
|
||||
<property name="RotationToFaceNorth" value="2" />
|
||||
<property name="Zoning" value="none" />
|
||||
<property class="IndexedBlockOffsets">
|
||||
<property class="POILight">
|
||||
<property name="0" value="11, 3, 9" />
|
||||
<property name="1" value="12, 3, 9" />
|
||||
<property name="2" value="8, 4, 10" />
|
||||
<property name="3" value="12, 4, 10" />
|
||||
<property name="4" value="12, 4, 12" />
|
||||
<property name="5" value="9, 3, 13" />
|
||||
</property>
|
||||
<property class="challenge_sinks">
|
||||
<property name="0" value="11, 1, 3" />
|
||||
<property name="1" value="13, 1, 4" />
|
||||
</property>
|
||||
<property class="TraderOnOff">
|
||||
<property name="0" value="8, 1, 8" />
|
||||
<property name="1" value="10, 1, 11" />
|
||||
</property>
|
||||
<property class="challenge_gore">
|
||||
<property name="0" value="3, 2, 9" />
|
||||
</property>
|
||||
<property class="challenge_mech_parts">
|
||||
<property name="0" value="6, 1, 13" />
|
||||
</property>
|
||||
</property>
|
||||
<property class="Stats">
|
||||
<property name="LightsVolume" value="5516.637" />
|
||||
<property name="TotalVertices" value="91813" />
|
||||
<property name="TotalTriangles" value="61958" />
|
||||
<property class="BlockEntities">
|
||||
<property name="Vertices" value="73400" />
|
||||
<property name="Triangles" value="52089" />
|
||||
</property>
|
||||
<property class="ChunkMeshes0">
|
||||
<property name="Vertices" value="18121" />
|
||||
<property name="Triangles" value="9645" />
|
||||
</property>
|
||||
<property class="ChunkMeshes1">
|
||||
<property name="Vertices" value="0" />
|
||||
<property name="Triangles" value="0" />
|
||||
</property>
|
||||
<property class="ChunkMeshes2">
|
||||
<property name="Vertices" value="292" />
|
||||
<property name="Triangles" value="224" />
|
||||
</property>
|
||||
<property class="ChunkMeshes3">
|
||||
<property name="Vertices" value="0" />
|
||||
<property name="Triangles" value="0" />
|
||||
</property>
|
||||
<property class="ChunkMeshes4">
|
||||
<property name="Vertices" value="0" />
|
||||
<property name="Triangles" value="0" />
|
||||
</property>
|
||||
<property class="ChunkMeshes5">
|
||||
<property name="Vertices" value="0" />
|
||||
<property name="Triangles" value="0" />
|
||||
</property>
|
||||
</property>
|
||||
</prefab>
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 896 KiB |
|
After Width: | Height: | Size: 539 KiB |
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2010, j. 'mach' wust, with Reserved Font Name UnifrakturMaguntia.
|
||||
Copyright (c) 2009, Peter Wiegel.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |