46 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5.5 a3f825303e Слияние релиза 1.6.2 в линию 3.3 (ModInfo 1.7.0 и DLL линии 1.7 остаются)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:26:44 +03:00
AlexCubeandClaude Opus 5.5 e7a9d93cba DLL: корм кур отдельной группой, NPCCore не призраки, necroview free без рук
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:22:27 +03:00
AlexCubeandClaude Opus 5.5 1b60cfd627 Фильтр логиста: отдельная группа «Корм для кур»; призраками - только торговцы игры (NPCCore)
Корм кур - группа 22 в конце (биты старых не сдвинуты); у урн из прошлых сейвов она берёт выбор
«Сырого», где корм был раньше. Число групп теперь пишется вторым числом.
Призрачный вид - только у точного класса EntityTrader: NPC NPCCore (наследники EntityTrader)
становились почти невидимыми (Nexus, Crozekanov, 16.10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:21:44 +03:00
AlexCubeandClaude Opus 5.5 d0f66c784e Слияние релиза 1.6.1 в линию 3.3 (ModInfo остаётся 1.7.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:09:21 +03:00
AlexCubeandClaude Opus 5.5 5691cd62d1 DLL: тело с ножом-для-нитей, necrobody zdance/mimic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:55:31 +03:00
AlexCubeandClaude Opus 5.5 96da967c61 necroview free: без рук первого лица
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:46:34 +03:00
AlexCubeandClaude Opus 5.5 f7f793b325 Кровавая роза: бутоны и цветы не висят в воздухе (ModelOffset 0,0,0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:46:16 +03:00
AlexCubeandClaude Opus 5.5 a17f2ff375 necrobody zdance/mimic: зомби танцует, тело повторяет его кости
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:42:26 +03:00
AlexCubeandClaude Opus 5.5 e0411ce3e5 Нож-для-нитей у тела: хват лука, кисть +20 к ладони, нож в кулаке (подбор necrobody)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:38:59 +03:00
AlexCubeandClaude Opus 5.5 a447ba1bbc DLL: necrobody play
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:23:01 +03:00
AlexCubeandClaude Opus 5.5 5fc04fd668 necrobody anims/play: любая загруженная анимация на теле (танцы зомби и т.п.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:22:55 +03:00
AlexCubeandClaude Opus 5.5 31ebbb9bc8 DLL: necrobody
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:21:07 +03:00
AlexCubeandClaude Opus 5.5 6267de5656 necrobody: аниматор тела в игре - параметры, клипы, кости, заморозка, скорость, место предмета
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:21:00 +03:00
AlexCubeandClaude Opus 5.5 ba2fe6c202 DLL: necroview мышью
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:19:04 +03:00
AlexCubeandClaude Opus 5.5 180be18dae Нож-для-нитей: тело держит его хватом ножа (necroheld tphold N)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:18:44 +03:00
AlexCubeandClaude Opus 5.5 87fad62c62 necroview: камера под интерфейсом; режим free - выйти из тела
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:15:56 +03:00
AlexCubeandClaude Opus 5.5 606f9c3816 necroview: мышь облетает руки, персонаж стоит (necroview mouse off - как было)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:12:45 +03:00
AlexCubeandClaude Opus 5.5 1b5c68d6f2 DLL: хват для нитей только от первого лица
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:07:02 +03:00
AlexCubeandClaude Opus 5.5 5a05c86657 Хват для нитей - только от первого лица
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:05:22 +03:00
AlexCubeandClaude Opus 5.5 fc1fa53736 necroview: осмотр рук первого лица со стороны, поза замирает; DLL с хватом для нитей
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:00:51 +03:00
AlexCubeandClaude Opus 5.5 a0e93744ed DLL: логисты вне лимита урн
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:59:58 +03:00
AlexCubeandClaude Opus 5.5 ed4015ecee Хват для нитей: основа 68, поза рук мода, нож в левой руке
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:58:28 +03:00
AlexCubeandClaude Opus 5.5 bf334c5c44 Урны-логисты не входят в лимит урн
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:31:11 +03:00
AlexCubeandClaude Opus 5.5 38a8b3b417 Группы логиста: предметы и блоки мода - в «Некромантию» по его items/blocks.xml; факел - инструменты, гвозди - детали, жестянки с водой - напитки, жир - материалы, корм кур - сырое
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:05:24 +03:00
AlexCubeandClaude Opus 5.5 17a4ab2e96 necroheld hold N - смена хвата предмета в руке на лету; дух не собирает список рендереров каждые 0.5 с
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:03:51 +03:00
AlexCubeandClaude Opus 5.5 f4ae9cbcb8 Урна-логист - отдельный блок: без зоны (ставится вплотную), нити только к ней, зелёные руны и глазницы, своя иконка; рецепт с набором для шитья, лимит общий
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:00:19 +03:00
AlexCubeandClaude Opus 5.5 adc9371a09 DLL: меню ножа и хват для нитей
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:56:52 +03:00
AlexCubeandClaude Opus 5.5 06155ff014 Нити видны только с ножом хватом для нитей в руках
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:55:25 +03:00
AlexCubeandClaude Opus 5.5 f5be0e7f43 Нож: хват для нитей - пункт в прежнем меню ножа (человек/змея/чертоги вернулись); хват стриппера убран - держится как обычный нож
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:53:15 +03:00
AlexCubeandClaude Opus 5.5 0290a73477 Урна-логист: фильтр по 22 группам предметов в «Содержимом» (вместо образца); дух засыпает, когда игрок дальше 40 м
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:40:04 +03:00
AlexCubeandClaude Opus 5.5 d8e7c8c66a Нити цинерария: нож «хватом для нитей» (R), нити урна-ящик как у стриппера, урна-логист перевозит по образцу, зелёные глазницы
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:29:50 +03:00
AlexCubeandClaude Opus 5.5 fad57a3756 Урна: дух встаёт у края станции в свободной клетке со стороны урны и смотрит на неё, а не влетает в центр
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:11:52 +03:00
AlexCubeandClaude Opus 5.5 d7c80341d2 Команда necrogrow: подрастить растения вокруг игрока (для проверки грядок духа урны)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:10:55 +03:00
AlexCubeandClaude Opus 5.5 7e93e19c5f Урна: дух летит к делу лицом, над местом жестикулирует руками, дело - в середине жеста, назад лицом к урне
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:03:53 +03:00
AlexCubeandClaude Opus 5.5 23587d78e0 Урна: дух опускает руки из Т-позы (кости плеч и предплечий при появлении); necrogenie arms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:53:43 +03:00
AlexCubeandClaude Opus 5.5 49c7afe273 Урна: дух - светящаяся дымка (сложение One/One) вместо Fade с пятнами; necrogenie glow/tint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:49:20 +03:00
AlexCubeandClaude Opus 5.5 3ba6572bef Урна: «Содержимое» - родное окно хранилища игры с ячейкой на один череп
Своё окно с ячейкой коллектора не принимало череп ни перетаскиванием,
ни Shift+кликом. Теперь - XUiC_BagStorageWindowGroup (как сумка
транспорта и волка) с сумкой 1x1: перенос - кодом игры; после каждого
изменения в ячейке остаётся один череп, лишнее и чужое - в рюкзак.
Своё окно и группа из XUi удалены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:34:31 +03:00
AlexCubeandClaude Opus 5.5 e590fab082 Урна: «Содержимое» с иконкой черепа вместо «Багажника»; «Взять» везде, как у транспорта
Команда багажника переименована в «Содержимое» (меню, заголовок окна,
описание - 13 языков), иконка - ui_game_symbol_skull. «Взять» игра даёт
станциям только внутри своего земельного камня; урне - как транспорту:
везде, кроме зоны торговца, хозяину, с пустой сумкой.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:21:11 +03:00
AlexCubeandClaude Opus 5.5 845455c6a0 Урна: окно «Багажник» не добавлялось - xpath /xui вместо /xui/ruleset (в 3.3 узла ruleset нет)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:18:20 +03:00
AlexCubeandClaude Opus 5.5 9f813bb699 Урна-цинерарий: своя модель, свечение по черепу, кровь в рецепте
Модель (бандл necrocinerarium, BlenderWork/necroCinerarium/make_urn.py):
тело вращения в чёрной глазури, по четырём сторонам - вдавленные лицевые
части черепа, наклонённые вниз; руны старшего футарка кровью «SERVU ĜIS
LA LASTA CINDRO» («Служи до последнего пепла»), меандр на ножке, бусины на
шейке, пояс черепов в двух линиях. Руны тлеют красным, глазницы светятся
фиолетовым, только когда в урне череп (_EmissionColor через
MaterialPropertyBlock). Своя иконка. Коллайдер-тег - DecorColliderTagPatch.
Дух: выше на 10 см и на 7 см вперёд в своей системе (хвост не вылезает
сзади), necrogenie fwd. Рецепт: + кровь некроманта, копия на соке розы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:37:13 +03:00
AlexCubeandClaude Opus 5.5 94bf0a5f5c Некротехнолог: грядки; скакун крафтится 120 с
Дух последним делом собирает созревшее (метка crops, добыча Harvest) с
идеальными бонусами и сразу сажает семя на то же место; засаживает пустые
грядки случайными семенами из ящиков и сумки (можно ли сажать - решает
сам блок семени). Описание урны - про универсального работника, 13 языков.
Костяному скакуну задано craft_time 120 (было - сумма ингредиентов).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:52:47 +03:00
AlexCubeandClaude Opus 5.5 0890ffe1e9 Некротехнолог: работа духа - расходники коллекторов и готовое из станций
Раз в 3 с одно дело: (1) пополнить из ящиков зоны и сумки урны ячейки
расходников любого коллектора (банки, цветы, корм, сферы) и катализатора
(куры, по одной в пустую ячейку); (2) забрать готовое из коллекторов и
станций (верстак, кузня, химстанция, костёр...) в ящики зоны - сначала
к таким же, потом в пустое, ящики полны - в сумку урны; некуда - дух
чернеет и замирает, оживает сам. Модификации, инструменты, топливо и
сырьё станций не трогаются; открытое у игрока и закреплённые ячейки -
тоже. Модель духа плывёт к месту работы и обратно. Грядки - позже.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:43:36 +03:00
AlexCubeandClaude Opus 5.5 0558f46069 Некротехнолог: джинн из урны - появляется по черепу в багажнике
Дух - не сущность, а модель из бандла necrotechnologist, дочерний объект
модели урны (как каркас зоны): без хитбокса, ИИ и сохранения. Кончик
хвоста в горлышке, дух покачивается и медленно поворачивается; тело
полупрозрачное (0.3, как у торговцев), глаза светятся. Появляется и
исчезает с порывом (necroTechGust). Команда necrogenie - подгонка
высоты, поворота, покачивания и прозрачности в игре.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:34 +03:00
AlexCubeandClaude Opus 5.5 35313f2e90 Урна: пункт «Сумка» с иконкой мешка вместо «Обыскать» игры, первым в меню
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:13:07 +03:00
AlexCubeandClaude Opus 5.5 bd76360eeb Урна-цинерарий: блок, багажник с черепом, сумка духа, зона, лимит
Урна устроена как транспорт: на E круговое меню - «Багажник» (своё окно
с одной ячейкой под череп), сумка духа 6x2 (хранилище игры), «Починить»
(5 костей из рюкзака), показать/скрыть зону 5x5 от -1 до +4, «Поднять»
(деталь игры TEFeaturePickup; только с пустой сумкой, череп остаётся
внутри - поднятая урна с черепом становится necroCinerariumSkull).
Постановка: зоны урн не пересекаются, урн не больше ceil(убийств / 500);
реестр урн - файл в папке сейва. Разбитая урна роняет череп.
Сохранение - CinerariumWriteHelper, версия первым байтом.
Рецепт - конец Адепта (уровень 15). Модель и иконка временные (кастрюля).
Тексты - 13 языков. Дух-работник - следующий этап.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:10:02 +03:00
AlexCubeandClaude Opus 5.5 87f3aaf295 Крыса: E на зомби рядом переносит дух в него, крыса развеивается
Одержимой крысой E на зомби в прицеле (не дальше 3 м, в прямой видимости,
не во время прохода сквозь постройки) - выход из крысы и вселение в зомби;
крыса отзывается с чёрным облаком. Цепочки нет: после зомби дух в теле.
Подсказка вселения и описание гримуара крысы - 13 языков. ModInfo 1.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:51:44 +03:00
35 changed files with 4911 additions and 14 deletions
File diff suppressed because one or more lines are too long
+58
View File
@@ -208,4 +208,62 @@
<sprite depth="9" pos="0,20" height="8" color="[black]" sprite="menu_empty2px" globalopacitymod="0" type="sliced" fillcenter="false" /> <sprite depth="9" pos="0,20" height="8" color="[black]" sprite="menu_empty2px" globalopacitymod="0" type="sliced" fillcenter="false" />
<sprite depth="4" pos="0,20" height="8" width="750" color="150,60,220,200" globalopacitymod="0" type="filled" fill="{% cvar('necroNecromancyProgressCVar') / 20 }" /> <sprite depth="4" pos="0,20" height="8" width="750" color="150,60,220,200" globalopacitymod="0" type="filled" fill="{% cvar('necroNecromancyProgressCVar') / 20 }" />
</append> </append>
<!-- Фильтр урны-логиста (16.10.2026, HarmonySrc/CinerariumFilterWindow.cs, CinerariumGroups.cs): значки групп предметов
в окне «Содержимое» рядом с ячейкой черепа. Окно в группе bagStorage (xui.xml) - контроллер прячет его, если окно
открыто не урной-логистом (сумки транспорта и волка). f0..f21 - рамки, g0..g21 - значки (иконки ставит контроллер). -->
<append xpath="/windows">
<window name="necroUrnFilter" width="348" height="279" depth="5" panel="Left" backgroundcolor="[black]" cursor_area="true" controller="NecromancerTome.NecroUrnFilter, NecromancerHarmony">
<rect name="header" height="43" depth="0">
<sprite depth="1" name="headerbackground" sprite="ui_game_panel_header" width="348" height="43" />
<sprite depth="3" name="windowIcon" style="icon32px" pivot="center" pos="21,-21" sprite="ui_game_symbol_sort" />
<label depth="2" name="title" pos="39,-6" width="303" height="32" text_key="necroUrnFilterTitle" font_size="28" />
</rect>
<sprite depth="2" name="f0" pos="6,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g0" pos="8,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f1" pos="62,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g1" pos="64,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f2" pos="118,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g2" pos="120,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f3" pos="174,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g3" pos="176,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f4" pos="230,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g4" pos="232,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f5" pos="286,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g5" pos="288,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f6" pos="6,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g6" pos="8,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f7" pos="62,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g7" pos="64,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f8" pos="118,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g8" pos="120,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f9" pos="174,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g9" pos="176,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f10" pos="230,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g10" pos="232,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f11" pos="286,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g11" pos="288,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f12" pos="6,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g12" pos="8,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f13" pos="62,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g13" pos="64,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f14" pos="118,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g14" pos="120,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f15" pos="174,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g15" pos="176,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f16" pos="230,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g16" pos="232,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f17" pos="286,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g17" pos="288,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f18" pos="6,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g18" pos="8,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f19" pos="62,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g19" pos="64,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f20" pos="118,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g20" pos="120,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f21" pos="174,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g21" pos="176,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f22" pos="230,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g22" pos="232,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
</window>
</append>
</windows> </windows>
+4
View File
@@ -59,4 +59,8 @@
<window name="windowNonPagingHeader" /> <window name="windowNonPagingHeader" />
</window_group> </window_group>
</append> </append>
<!-- Фильтр урны-логиста - в окне хранилища игры (bagStorage), рядом с ячейкой «Содержимого» (windows.xml). -->
<append xpath="/xui/window_group[@name='bagStorage']">
<window name="necroUrnFilter" />
</append>
</xui> </xui>
+64
View File
@@ -519,4 +519,68 @@
<property name="ItemTypeIcon" value="all_blocks"/> <property name="ItemTypeIcon" value="all_blocks"/>
</block> </block>
</append> </append>
<!-- Урна-цинерарий (Harvest Cinerarium), 1.7.0, 16.10.2026: этап 2 некротехнолога (BACKLOG «Некротехнолог v2»).
Составной блок, как земельный камень: TEFeatureStorage - сумка духа (окно хранилища 6x2, «Открыть»),
TEFeatureCinerarium (HarmonySrc/Cinerarium.cs) - череп (положить/забрать), показ зоны 5x5, -1..+4,
лимит ceil(убийств / 500) и запрет пересечения зон при постановке. Всё - в радиальном меню на E (решение 16.10).
Модель (16.10) - своя: бандл necrocinerarium (BlenderWork/necroCinerarium, make_urn.py): чёрная глазурь, черепа по
четырём сторонам, руны футарка кровью «SERVU ĜIS LA LASTA CINDRO», меандр, бусины; свечение рун и глазниц
включает TEFeatureCinerarium по черепу. Иконка - рендер той же модели (make_icon.py). -->
<append xpath="/blocks">
<block name="necroCinerarium">
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumDesc"/>
<property name="UnlockedBy" value="craftingNecroNecromancy"/>
<property name="Class" value="CompositeTileEntity"/>
<property class="CompositeFeatures">
<property class="TEFeatureStorage">
<property name="LootList" value="necroCinerariumBag"/>
</property>
<property class="TEFeatureCinerarium"/>
<property class="TEFeaturePickup">
<property name="TakeDelay" value="2"/>
</property>
</property>
<property name="Material" value="Mstone"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumPrefab.prefab"/>
<property name="ModelOffset" value="0,0,0"/> <!-- меш стоит основанием на y=0; без этого ModelEntity поднимает на полклетки -->
<property name="CustomIcon" value="necroCinerarium"/>
<property name="WaterFlow" value="permitted"/>
<property name="OnlySimpleRotations" value="true"/>
<property name="Collide" value="movement,melee,bullet,arrow,rocket"/>
<property name="StabilitySupport" value="false"/>
<property name="MaxDamage" value="500"/>
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
<property name="FilterTags" value="MC_playerBlocks,SC_decor"/>
<property class="RepairItems">
<property name="resourceBone" value="5"/>
</property>
</block>
<!-- Урна, поднятая с черепом внутри (как транспорт держит модификации): тот же блок, при постановке череп
уже в багажнике. Получается только подъёмом (Patch_Block_OnBlockPickedUp_Cinerarium). -->
<block name="necroCinerariumSkull">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumDesc"/>
</block>
<!-- Урна-логист (16.10.2026): «Я не могу ставить урны рядом. Может нам для логиста вообще сделать отдельные урны?»
Без зоны (ставится вплотную к любым урнам), только нити и перевозка (CinerariumThreads.cs), лимит урн общий.
Модель - та же; отличие - код (TEFeatureCinerarium.SyncGlow): зелёные руны и глазницы
(Resources/necroCinerarium_e_logist.png), зеленоватая глазурь. Иконка - своя (зелёные руны, нити из горлышка).
Своя модель логиста - позже, в Blender. -->
<block name="necroCinerariumLogist">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumLogistDesc"/>
<property name="CustomIcon" value="necroCinerariumLogist"/>
</block>
<block name="necroCinerariumLogistSkull">
<property name="Extends" value="necroCinerariumLogist"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumLogistDesc"/>
</block>
</append>
</config> </config>
+17
View File
@@ -2018,4 +2018,21 @@
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroJuicePrefab.prefab"/> <property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroJuicePrefab.prefab"/>
</item> </item>
</append> </append>
<!-- Нож некроманта «хватом для нитей» (16.10.2026, HarmonySrc/CinerariumThreads.cs): «нож некроманта, но взятый особым
хватом... как кисточка или как тот же стриппер». Отдельный предмет - класс ножа в сейве менять нельзя; переключение -
меню по R (качество, износ и моды переносятся). Не бьёт: ПКМ - тянуть нить урна-ящик, ЛКМ - снять нити. Удары
перехвачены в ItemClass.ExecuteAction, поэтому действия ножа (Extends) остаются, но не срабатывают. Держится пока как
обычный нож (хват стриппера HoldType 52 не подошёл - 16.10). effect_group через Extends не наследуется -
у этого предмета нет урона, лечения и слотов модов (моды в ItemValue сохраняются и вернутся с обычным хватом). -->
<append xpath="/items">
<item name="necroWpnBladeNecroKnifeThread">
<property name="Extends" value="necroWpnBladeNecroKnife"/>
<property name="CreativeMode" value="None"/>
<!-- Хват для нитей (16.10, подбор в игре): основа - 68 (деревянный лук), поверх - поза рук мода
(HandPose.PresetThread): нож в левой руке, правая раскрытой ладонью. 52 (стриппер) не подошёл. -->
<property name="HoldType" value="68"/>
<property name="DescriptionKey" value="necroWpnBladeNecroKnifeThreadDesc"/>
<property name="CustomIconTint" value="150, 205, 255"/>
</item>
</append>
</config> </config>
+7
View File
@@ -45,4 +45,11 @@
<item group="groupZpackVictim"/> <item group="groupZpackVictim"/>
</lootcontainer> </lootcontainer>
</append> </append>
<!-- Сумка духа урны-цинерария (1.7.0): пустое хранилище игрока, 6x2. -->
<append xpath="/lootcontainers">
<lootcontainer name="necroCinerariumBag" count="0" size="6,2" sound_open="UseActions/open_backpack" sound_close="UseActions/close_backpack" open_time="0" loot_quality_template="qualBaseTemplate">
<item name="cobweb" count="0"/>
</lootcontainer>
</append>
</config> </config>
+1 -1
View File
@@ -181,7 +181,7 @@
пользуется, вода сидит на necroNecromancyLvl20. --> пользуется, вода сидит на necroNecromancyLvl20. -->
<unlock_entry item="necroModKnifeScavengersFeast,bookSummonRatSpirit" unlock_tier="3"/> <unlock_entry item="necroModKnifeScavengersFeast,bookSummonRatSpirit" unlock_tier="3"/>
<unlock_entry item="necroModKnifeGravesRepose" unlock_tier="4"/> <unlock_entry item="necroModKnifeGravesRepose" unlock_tier="4"/>
<unlock_entry item="necroModKnifeDarkSense" unlock_tier="5"/> <unlock_entry item="necroModKnifeDarkSense,necroCinerarium,necroCinerariumLogist" unlock_tier="5"/>
</display_entry> </display_entry>
<!-- Ступенчатая разблокировка внутри группы. ПЕРЕРАСПРЕДЕЛЕНО 2026-09-09: четыре из <!-- Ступенчатая разблокировка внутри группы. ПЕРЕРАСПРЕДЕЛЕНО 2026-09-09: четыре из
шести модов ножа (вода/еда/покой/чутьё) уехали отсюда в группу 1 - см. большой шести модов ножа (вода/еда/покой/чутьё) уехали отсюда в группу 1 - см. большой
+34 -2
View File
@@ -493,7 +493,7 @@
гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по
указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. --> указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. -->
<append xpath="/recipes"> <append xpath="/recipes">
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman"> <recipe name="necroBoneSteedPlaceable" count="1" craft_time="120" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/> <ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/> <ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/> <ingredient name="resourceZombieAsh" count="150"/>
@@ -587,7 +587,7 @@
<ingredient name="resourceWood" count="100"/> <ingredient name="resourceWood" count="100"/>
<ingredient name="resourceYuccaFibers" count="50"/> <ingredient name="resourceYuccaFibers" count="50"/>
</recipe> </recipe>
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman"> <recipe name="necroBoneSteedPlaceable" count="1" craft_time="120" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/> <ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/> <ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/> <ingredient name="resourceZombieAsh" count="150"/>
@@ -605,4 +605,36 @@
<ingredient name="necroSkullDecor" count="1"/> <ingredient name="necroSkullDecor" count="1"/>
</recipe> </recipe>
</append> </append>
<!-- Урна-цинерарий (1.7.0, 16.10): конец Адепта - уровень 15, 300 убийств (решение 06.10). Кровь - 16.10: «Да, кровью» (руны на урне расписаны кровью некроманта). -->
<append xpath="/recipes">
<recipe name="necroCinerarium" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
<!-- Копия на соке чёрной розы (правило 05.10: у каждого рецепта на крови - копия; одна банка крови = одна банка сока). -->
<recipe name="necroCinerarium" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
<!-- Урна-логист (16.10: «Рецепт с набором для шитья») - рецепт урны и набор для шитья; копия на соке чёрной розы. -->
<recipe name="necroCinerariumLogist" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceSewingKit" count="1"/>
</recipe>
<recipe name="necroCinerariumLogist" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceSewingKit" count="1"/>
</recipe>
</append>
</config> </config>
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Фильтр урны-логиста (16.10.2026): панель значков групп (CinerariumGroups) в окне «Содержимое» - окно necroUrnFilter
/// добавлено в группу окон игры bagStorage рядом с ячейкой черепа (Config/XUi_InGame). Видна только у урны с нитями;
/// у сумок транспорта и волка (та же группа окон) скрыта. Щелчок по значку включает/выключает группу; по умолчанию все
/// включены. Выключенный значок - тёмный.
/// </summary>
[Preserve]
public class XUiC_NecroUrnFilter : XUiController
{
readonly XUiController[] _icons = new XUiController[CinerariumGroups.Count];
readonly XUiController[] _frames = new XUiController[CinerariumGroups.Count];
static readonly Color On = Color.white;
static readonly Color Off = new Color(0.25f, 0.25f, 0.25f, 0.6f);
static readonly Color FrameOn = new Color(0.45f, 0.85f, 0.55f, 1f);
static readonly Color FrameOff = new Color(0.2f, 0.2f, 0.2f, 1f);
public override void Init()
{
base.Init();
for (int i = 0; i < CinerariumGroups.Count; i++)
{
int g = i;
_icons[i] = GetChildById("g" + i);
_frames[i] = GetChildById("f" + i);
if (_icons[i] == null)
{
continue;
}
_icons[i].OnPress += (_s, _m) => Toggle(g);
if (_icons[i].ViewComponent is XUiV_Sprite sp)
{
sp.UIAtlas = "ItemIconAtlas";
sp.SpriteName = CinerariumGroups.Icons[g];
}
}
}
public override void OnOpen()
{
base.OnOpen();
Refresh();
}
void Toggle(int _g)
{
TEFeatureCinerarium urn = CinerariumContents.Urn;
if (urn == null)
{
return;
}
urn.ToggleGroup(_g);
Refresh();
}
void Refresh()
{
TEFeatureCinerarium urn = CinerariumContents.Urn;
bool show = urn != null && urn.IsLogist;
ViewComponent.IsVisible = show;
if (!show)
{
return;
}
for (int i = 0; i < CinerariumGroups.Count; i++)
{
bool on = urn.GroupOn(i);
if (_icons[i]?.ViewComponent is XUiV_Sprite sp)
{
sp.Color = on ? On : Off;
sp.ToolTip = Localization.Get(CinerariumGroups.NameKey(i)) + " - " + Localization.Get(on ? "necroGroupOn" : "necroGroupOff");
}
if (_frames[i]?.ViewComponent is XUiV_Sprite fr)
{
fr.Color = on ? FrameOn : FrameOff;
}
}
}
}
}
+626
View File
@@ -0,0 +1,626 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Дух-работник урны - «джинн из урны» (решение 16.10): кончик хвоста уходит в горлышко, дух вырастает над урной,
/// без работы висит, чуть покачиваясь и медленно поворачиваясь. Этап 3 некротехнолога.
///
/// Не сущность игры, а видимая модель - дочерний объект модели блока урны, как каркас зоны (решение 16.10 вместо
/// плана 15.10 «сущность на префабе медсестры»): хитбокса, ИИ и сохранения у неё нет, поэтому «убить нельзя»,
/// «зомби не замечают», «сквозь стены» выходят сами, а сейвы дух не трогает - он появляется по черепу в урне
/// (TEFeatureCinerarium.HasSkull) и пропадает без него. Сдвиги Origin и выгрузку чанка переживает с моделью урны.
///
/// Модель - бандл necrotechnologist (NecroTechnologistSetup.cs): своя сетка на своём скелете NT_rig в позе покоя,
/// глаза - отдельные рендереры (в имени «eye»). Тело - светящаяся дымка (16.10: Fade давал пятна; сложение One/One,
/// холодный оттенок Tint, своё слабое свечение Glow); текстура уже серая (сделана под обесцвечивание). Глаза - свой
/// непрозрачный светящийся материал из бандла, очередь отрисовки 3100 - после тела.
/// </summary>
public static class CinerariumGenie
{
public const string ModelPath = "#@modfolder(NecromancerTome):Resources/necrotechnologist?necroTechnologistPrefab.prefab";
/// <summary>Высота корня модели над низом блока урны, м. 16.10 в Blender по модели урны: -0.15 («духа чуть приподними»).
/// Подгоняется командой necrogenie.</summary>
public static float OffsetY = -0.15f;
/// <summary>Сдвиг вперёд в системе самого духа, м (16.10: «чуть вперёд, чтобы хвост не просвечивал сквозь урну сзади»
/// - загнутый назад кончик хвоста; в Blender 0.06 - ни одной вершины за стенкой, взято 0.07). Поворачивается вместе
/// с духом, поэтому хвост всегда в горлышке. Знак проверить в игре: necrogenie fwd.</summary>
public static float Forward = 0.07f;
/// <summary>Поворот без работы, градусов в секунду.</summary>
public static float TurnSpeed = 6f;
/// <summary>Покачивание: размах (м) и период (с).</summary>
public static float BobHeight = 0.04f;
public static float BobPeriod = 4f;
public static float Alpha = 0.3f;
/// <summary>Засыпание (16.10): игрок дальше SleepDistance - дух замирает дома, огоньки нитей стоят. Работа идёт.</summary>
public static float SleepDistance = 40f;
public static bool Hidden;
/// <summary>Далеко ли игрок от точки сцены (без Origin). Для засыпания духа и нитей.</summary>
public static bool PlayerFar(Vector3 _scenePos)
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pl == null)
{
return true;
}
return (pl.GetPosition() - Origin.position - _scenePos).sqrMagnitude > SleepDistance * SleepDistance;
}
/// <summary>Руки (16.10: «Т-поза бесит»): плечо от вертикали вбок и вперёд, локоть согнут вперёд, градусы.
/// Модель в позе покоя, аниматора нет - кости ставятся один раз при появлении.</summary>
public static float ArmOut = 12f;
public static float ArmFwd = 15f;
public static float Elbow = 35f;
static GameObject _prefab;
static bool _tried;
static readonly Dictionary<Material, Material> _ghostMats = new Dictionary<Material, Material>();
/// <summary>Все джинны на сцене - команда necrogenie правит их сразу.</summary>
public static readonly List<CinerariumGenieMotion> Live = new List<CinerariumGenieMotion>();
static bool Load()
{
if (!_tried)
{
_tried = true;
_prefab = DataLoader.LoadAsset<GameObject>(ModelPath);
Debug.Log("[NecromancerTome] CinerariumGenie: модель " + (_prefab != null ? "загружена" : "НЕ загружена: " + ModelPath));
}
return _prefab != null;
}
public static GameObject Spawn(Transform _urnModel)
{
if (_urnModel == null || !Load())
{
return null;
}
GameObject g = Object.Instantiate(_prefab, _urnModel, false);
g.name = "NecroCinerariumGenie";
foreach (Renderer r in g.GetComponentsInChildren<Renderer>(true))
{
r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
r.receiveShadows = false;
if (r is SkinnedMeshRenderer smr)
{
smr.updateWhenOffscreen = false; // 16.10: вне экрана скелет не считать (экономия); границы - от сетки
}
if (r.name.Contains("eye"))
{
continue;
}
Material[] mats = r.sharedMaterials;
for (int i = 0; i < mats.Length; i++)
{
mats[i] = Ghost(mats[i]);
}
r.sharedMaterials = mats;
}
foreach (Collider c in g.GetComponentsInChildren<Collider>(true))
{
Object.Destroy(c);
}
g.AddComponent<CinerariumGenieMotion>().Pose();
return g;
}
/// <summary>Полупрозрачная копия материала тела - одна на исходный материал на всю игру.</summary>
static Material Ghost(Material _src)
{
if (_src == null)
{
return null;
}
if (_ghostMats.TryGetValue(_src, out Material m) && m != null)
{
return m;
}
m = new Material(_src) { name = _src.name + "_ghost" };
Shader std = Shader.Find("Standard");
if (std != null && m.shader != std)
{
m.shader = std;
}
// 16.10: Fade (SrcAlpha/OneMinusSrcAlpha) давал «прозрачный, но какими-то пятнами» и «не выглядит бесплотным
// духом»: без записи глубины треугольники одной сетки ложатся в случайном порядке. Свечение (One/One) от порядка
// не зависит: дух только добавляет свет к тому, что за ним, - дымка. Альфа здесь не нужна, яркость - цветом.
m.SetFloat("_Mode", 2f);
m.SetOverrideTag("RenderType", "Transparent");
m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.One);
m.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.One);
m.SetInt("_ZWrite", 0);
m.DisableKeyword("_ALPHATEST_ON");
m.DisableKeyword("_ALPHABLEND_ON");
m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
if (m.HasProperty("_Glossiness"))
{
m.SetFloat("_Glossiness", 0f); // без бликов: блик при сложении - белое пятно
}
if (m.HasProperty("_Metallic"))
{
m.SetFloat("_Metallic", 0f);
}
if (m.HasProperty("_EmissionMap") && m.HasProperty("_MainTex"))
{
m.SetTexture("_EmissionMap", m.GetTexture("_MainTex")); // своё свечение - чтобы ночью не пропадал
m.EnableKeyword("_EMISSION");
m.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None;
}
m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
SetLook(m);
_ghostMats[_src] = m;
return m;
}
/// <summary>Холодный бледный оттенок духа (умножается на серую текстуру).</summary>
public static Color Tint = new Color(0.55f, 0.75f, 1f, 1f);
/// <summary>Своё свечение тела, доля от Tint.</summary>
public static float Glow = 0.35f;
static void SetLook(Material _m)
{
if (_m == null)
{
return;
}
if (_m.HasProperty("_Color"))
{
_m.color = new Color(Tint.r * Alpha, Tint.g * Alpha, Tint.b * Alpha, 1f);
}
if (_m.HasProperty("_EmissionColor"))
{
_m.SetColor("_EmissionColor", new Color(Tint.r * Glow, Tint.g * Glow, Tint.b * Glow, 1f));
}
}
public static void ApplyAlpha()
{
foreach (Material m in _ghostMats.Values)
{
SetLook(m);
}
}
static readonly Dictionary<Material, Material> _stuckMats = new Dictionary<Material, Material>();
/// <summary>Почерневший дух (класть некуда): свечением чёрное не нарисовать, поэтому обычная Fade-копия, тёмная.</summary>
public static Material Stuck(Material _src)
{
if (_src == null)
{
return null;
}
if (_stuckMats.TryGetValue(_src, out Material m) && m != null)
{
return m;
}
m = new Material(_src) { name = _src.name + "_stuck" };
Shader std = Shader.Find("Standard");
if (std != null && m.shader != std)
{
m.shader = std;
}
m.SetFloat("_Mode", 2f);
m.SetOverrideTag("RenderType", "Transparent");
m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
m.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
m.SetInt("_ZWrite", 1); // с глубиной: пятен нет, тёмный и почти плотный
m.DisableKeyword("_ALPHATEST_ON");
m.EnableKeyword("_ALPHABLEND_ON");
m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
if (m.HasProperty("_Color"))
{
m.color = new Color(0.04f, 0.035f, 0.045f, 0.8f);
}
_stuckMats[_src] = m;
return m;
}
/// <summary>Исходный материал тела по его светящейся копии.</summary>
public static Material Source(Material _ghost)
{
foreach (KeyValuePair<Material, Material> p in _ghostMats)
{
if (p.Value == _ghost)
{
return p.Key;
}
}
return _ghost;
}
}
/// <summary>Покачивание и поворот джинна над урной. Только вид - никакой логики.</summary>
[Preserve]
public class CinerariumGenieMotion : MonoBehaviour
{
float _yaw;
float _phase;
/// <summary>Полёт к месту работы и обратно (16.10: «возвращается задом», «делал действие не сразу, а хоть руками
/// пошевелив»): туда - лицом к месту, над местом - жест руками (дело делается в его середине, см. Visit),
/// обратно - развернувшись лицом к урне. Точка - в координатах модели урны; _t - время с начала полёта, -1 - дома.</summary>
struct Stop
{
public Vector3 Hover, Look; // в осях модели урны
public float Fly;
}
readonly List<Stop> _stops = new List<Stop>();
float _backFly;
float _t = -1f;
/// <summary>Скорость полёта, м/с: время перелёта - не меньше OutSeconds/BackSeconds, дальше - по расстоянию (нити до 15 м).</summary>
public static float FlySpeed = 4f;
public static float OutSeconds = 1.3f;
public static float WorkSeconds = 1.6f;
public static float BackSeconds = 1.3f;
/// <summary>Жест: плечи вперёд до GestureFwd градусов, локти ходят на ±GestureWave вразнобой.</summary>
public static float GestureFwd = 55f;
public static float GestureWave = 30f;
bool _gesturing;
/// <summary>Почернел и замер (готовое класть некуда, решение 06.10).</summary>
bool _stuck;
/// <summary>Кости рук (плечо, предплечье, кисть по сторонам) и их поворот в позе покоя.</summary>
Transform[] _arm;
Quaternion[] _armRest;
static bool _armLogged;
/// <summary>Опустить руки из позы покоя: направление плеча и предплечья задаётся в осях модели (вперёд - +Z),
/// поворот - от текущего направления кости к нужному, так что оси самих костей значения не имеют.</summary>
public void Pose()
{
Pose(CinerariumGenie.ArmFwd, CinerariumGenie.Elbow, CinerariumGenie.Elbow);
}
void Pose(float _fwd, float _elbowL, float _elbowR)
{
if (_arm == null)
{
string[] names = { "LeftArm", "LeftForeArm", "LeftHand", "RightArm", "RightForeArm", "RightHand" };
_arm = new Transform[names.Length];
_armRest = new Quaternion[names.Length];
foreach (Transform t in GetComponentsInChildren<Transform>(true))
{
for (int i = 0; i < names.Length; i++)
{
if (_arm[i] == null && t.name.EndsWith(names[i]))
{
_arm[i] = t;
_armRest[i] = t.localRotation;
}
}
}
if (!_armLogged)
{
_armLogged = true;
System.Text.StringBuilder sb = new System.Text.StringBuilder();
for (int i = 0; i < names.Length; i++)
{
sb.Append(names[i]).Append('=').Append(_arm[i] != null ? "ok" : "НЕТ").Append(' ');
}
Debug.Log("[NecromancerTome] CinerariumGenie: кости рук " + sb);
}
}
for (int i = 0; i < _arm.Length; i++)
{
if (_arm[i] != null)
{
_arm[i].localRotation = _armRest[i];
}
}
for (int side = 0; side < 2; side++)
{
Transform up = _arm[side * 3], fore = _arm[side * 3 + 1], hand = _arm[side * 3 + 2];
if (up == null || fore == null || hand == null)
{
continue;
}
float sign = Mathf.Sign(transform.InverseTransformPoint(up.position).x);
float o = CinerariumGenie.ArmOut * Mathf.Deg2Rad, f = _fwd * Mathf.Deg2Rad;
Vector3 upDir = new Vector3(sign * Mathf.Sin(o), -Mathf.Cos(o) * Mathf.Cos(f), Mathf.Sin(f)).normalized;
Vector3 foreDir = Vector3.RotateTowards(upDir, Vector3.forward, (side == 0 ? _elbowL : _elbowR) * Mathf.Deg2Rad, 0f);
up.rotation = Quaternion.FromToRotation(fore.position - up.position, transform.TransformDirection(upDir)) * up.rotation;
fore.rotation = Quaternion.FromToRotation(hand.position - fore.position, transform.TransformDirection(foreDir)) * fore.rotation;
}
}
/// <summary>Светящиеся материалы рендереров - вернуть после почернения.</summary>
readonly Dictionary<Renderer, Material[]> _glowing = new Dictionary<Renderer, Material[]>();
/// <summary>Облёт мест работы по порядку (у перевозки по нити - ящик «откуда», потом «куда»), у каждого - жест, затем
/// назад к урне. Точки - в координатах сцены (без Origin): stand - где встать (низ клетки), look - низ клетки места,
/// beside - встал сбоку, а не над местом. Возвращает, через сколько секунд дело (середина последнего жеста).</summary>
public float Visit(List<(Vector3 stand, Vector3 look, bool beside)> _list)
{
if (transform.parent == null || _list == null || _list.Count == 0)
{
return 0f;
}
_stops.Clear();
Vector3 home = Home();
Vector3 prev = home;
float delay = 0f;
foreach ((Vector3 stand, Vector3 look, bool beside) in _list)
{
Stop st = new Stop
{
Hover = transform.parent.InverseTransformPoint(stand) + new Vector3(0f, CinerariumGenie.OffsetY + (beside ? 0.15f : 0.8f), 0f),
Look = transform.parent.InverseTransformPoint(look)
};
st.Fly = Mathf.Max(OutSeconds, Vector3.Distance(prev, st.Hover) / FlySpeed);
delay += st.Fly + WorkSeconds;
prev = st.Hover;
_stops.Add(st);
}
_backFly = Mathf.Max(BackSeconds, Vector3.Distance(prev, home) / FlySpeed);
_t = 0f;
return delay - WorkSeconds * 0.5f;
}
Vector3 Home()
{
return new Vector3(0f, CinerariumGenie.OffsetY, 0f) + Quaternion.Euler(0f, _yaw, 0f) * new Vector3(0f, 0f, CinerariumGenie.Forward);
}
/// <summary>Повернуться лицом по направлению _dir (горизонтально), не быстрее 300°/с.</summary>
void Face(Vector3 _dir)
{
if (_dir.x * _dir.x + _dir.z * _dir.z > 0.01f)
{
_yaw = Mathf.MoveTowardsAngle(_yaw, Mathf.Atan2(_dir.x, _dir.z) * Mathf.Rad2Deg, 300f * Time.deltaTime) % 360f;
}
}
void Gesture(float _w)
{
if (_w > 0f)
{
float wave = Mathf.Sin(_t * Mathf.PI * 2f * 1.5f) * GestureWave * _w;
Pose(Mathf.Lerp(CinerariumGenie.ArmFwd, GestureFwd, _w), CinerariumGenie.Elbow + 15f * _w + wave, CinerariumGenie.Elbow + 15f * _w - wave);
_gesturing = true;
}
else if (_gesturing)
{
Pose();
_gesturing = false;
}
}
public void SetStuck(bool _on)
{
if (_stuck == _on)
{
return;
}
_stuck = _on;
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
if (r.name.Contains("eye"))
{
continue;
}
if (!_glowing.ContainsKey(r))
{
_glowing[r] = r.sharedMaterials;
}
Material[] glow = _glowing[r];
if (_on)
{
Material[] dark = new Material[glow.Length];
for (int i = 0; i < glow.Length; i++)
{
dark[i] = CinerariumGenie.Stuck(CinerariumGenie.Source(glow[i]));
}
r.sharedMaterials = dark;
}
else
{
r.sharedMaterials = glow;
}
}
}
void OnEnable()
{
CinerariumGenie.Live.Add(this);
_phase = Random.value * 10f;
_yaw = Random.value * 360f;
}
void OnDisable()
{
CinerariumGenie.Live.Remove(this);
}
float _nextSleepCheck;
bool _asleep;
bool _hiddenApplied;
void Update()
{
if (Time.time >= _nextSleepCheck)
{
_nextSleepCheck = Time.time + 0.5f;
bool asleep = CinerariumGenie.PlayerFar(transform.position);
if (asleep && !_asleep)
{
_t = -1f; // полёт прерван - дело всё равно выполнится
Gesture(0f);
transform.localPosition = Home();
}
_asleep = asleep;
if (_hiddenApplied != CinerariumGenie.Hidden)
{
// Только при смене (16.10, проверка на утечки: раньше список рендереров собирался каждые 0.5 с - мусор для GC).
_hiddenApplied = CinerariumGenie.Hidden;
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
r.enabled = !_hiddenApplied;
}
}
}
if (_stuck || _asleep)
{
return; // замер как был
}
float yawTarget = _yaw + CinerariumGenie.TurnSpeed * Time.deltaTime;
Vector3 home = Home();
if (_t >= 0f && _stops.Count > 0)
{
_t += Time.deltaTime;
float acc = 0f;
Vector3 from = home;
foreach (Stop st in _stops)
{
if (_t < acc + st.Fly)
{
transform.localPosition = Vector3.Lerp(from, st.Hover, Mathf.SmoothStep(0f, 1f, (_t - acc) / st.Fly));
Face(st.Hover - from);
Gesture(0f);
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return;
}
acc += st.Fly;
if (_t < acc + WorkSeconds)
{
transform.localPosition = st.Hover;
Face(st.Look - st.Hover); // за работой - лицом к месту
Gesture(Mathf.Sin((_t - acc) / WorkSeconds * Mathf.PI));
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return;
}
acc += WorkSeconds;
from = st.Hover;
}
if (_t < acc + _backFly)
{
transform.localPosition = Vector3.Lerp(from, home, Mathf.SmoothStep(0f, 1f, (_t - acc) / _backFly));
Face(home - from); // назад - лицом к урне
Gesture(0f);
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return;
}
_t = -1f;
Gesture(0f);
}
_yaw = yawTarget % 360f;
float bob = CinerariumGenie.BobPeriod > 0.01f
? Mathf.Sin((Time.time + _phase) * Mathf.PI * 2f / CinerariumGenie.BobPeriod) * CinerariumGenie.BobHeight
: 0f;
transform.localPosition = home + new Vector3(0f, bob, 0f);
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
}
}
/// <summary>Подгонка джинна в игре: высота над урной, скорость поворота, покачивание, прозрачность. Не сохраняется -
/// найденные значения переносятся в константы CinerariumGenie.</summary>
[Preserve]
public class ConsoleCmdNecroGenie : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necrogenie" };
}
public override string getDescription()
{
return "Necromancer's Tome: tune the cinerarium spirit (height over the urn, turning, bobbing, transparency).";
}
public override string getHelp()
{
return "necrogenie - show the current values and how many spirits are on screen\n" +
"necrogenie y <m> - model root height above the urn block's bottom (tail tip is 0.47 m above the root)\n" +
"necrogenie fwd <m> - shift along the spirit's own forward (keeps the curled tail inside the neck)\n" +
"necrogenie turn <deg/s> - idle turning speed\n" +
"necrogenie bob <m> <s> - bobbing height and period\n" +
"necrogenie alpha <0-1> - body brightness (the body adds light, it is not alpha-blended)\n" +
"necrogenie glow <0-1> - body's own glow (visible at night)\n" +
"necrogenie tint <r> <g> <b> - body colour\n" +
"necrogenie arms <out> <fwd> <elbow> - arm pose, degrees: upper arm from vertical sideways and forward, elbow bend\n" +
"necrogenie sleep <m> - spirits and threads freeze when the player is farther (work goes on)\n" +
"necrogenie hide|show - hide all spirits (weak PCs, video), work goes on";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
if (_params.Count == 1 && (_params[0] == "hide" || _params[0] == "show"))
{
CinerariumGenie.Hidden = _params[0] == "hide";
}
if (_params.Count >= 2 && float.TryParse(_params[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v))
{
switch (_params[0].ToLowerInvariant())
{
case "y":
CinerariumGenie.OffsetY = v;
break;
case "fwd":
CinerariumGenie.Forward = v;
break;
case "turn":
CinerariumGenie.TurnSpeed = v;
break;
case "bob":
CinerariumGenie.BobHeight = v;
if (_params.Count >= 3 && float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float per))
{
CinerariumGenie.BobPeriod = per;
}
break;
case "alpha":
CinerariumGenie.Alpha = Mathf.Clamp01(v);
CinerariumGenie.ApplyAlpha();
break;
case "glow":
CinerariumGenie.Glow = Mathf.Clamp01(v);
CinerariumGenie.ApplyAlpha();
break;
case "sleep":
CinerariumGenie.SleepDistance = Mathf.Max(5f, v);
break;
case "arms":
CinerariumGenie.ArmOut = v;
if (_params.Count >= 3 && float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float af))
{
CinerariumGenie.ArmFwd = af;
}
if (_params.Count >= 4 && float.TryParse(_params[3], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float ae))
{
CinerariumGenie.Elbow = ae;
}
foreach (CinerariumGenieMotion m in CinerariumGenie.Live)
{
m.Pose();
}
break;
case "tint":
if (_params.Count >= 4 &&
float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float g) &&
float.TryParse(_params[3], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float b))
{
CinerariumGenie.Tint = new Color(v, g, b, 1f);
CinerariumGenie.ApplyAlpha();
}
break;
default:
con.Output("necrogenie: unknown '" + _params[0] + "' - see 'help necrogenie'");
return;
}
}
con.Output("necrogenie: y " + CinerariumGenie.OffsetY.ToString("F2") + ", fwd " + CinerariumGenie.Forward.ToString("F2") + ", turn " + CinerariumGenie.TurnSpeed.ToString("F1") +
", bob " + CinerariumGenie.BobHeight.ToString("F2") + " / " + CinerariumGenie.BobPeriod.ToString("F1") + " s, alpha " +
CinerariumGenie.Alpha.ToString("F2") + ", glow " + CinerariumGenie.Glow.ToString("F2") + ", tint " +
CinerariumGenie.Tint.r.ToString("F2") + " " + CinerariumGenie.Tint.g.ToString("F2") + " " + CinerariumGenie.Tint.b.ToString("F2") + ", arms " + CinerariumGenie.ArmOut.ToString("F0") + " " +
CinerariumGenie.ArmFwd.ToString("F0") + " " + CinerariumGenie.Elbow.ToString("F0") + ", sleep " + CinerariumGenie.SleepDistance.ToString("F0") + " m" + (CinerariumGenie.Hidden ? ", HIDDEN" : "") + "; spirits on screen: " + CinerariumGenie.Live.Count);
}
}
}
+225
View File
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Xml;
namespace NecromancerTome
{
/// <summary>
/// Группы предметов для фильтра урны-логиста (16.10.2026): «пиктограммы, которые по умолчанию выбраны и активны...
/// изображения предмета, символизирующего группу предметов... Дух-логист будет переносить только те предметы, которые
/// активированы в его урне. Это же прямой путь к автоматизации сортировки». Деление игры (свойство Group) грубое -
/// «Ресурсы» смешивают руду, слитки, детали и кур, - поэтому группы свои, а предмет относится к группе по классу, тегам и
/// имени. Предметы других модов разойдутся по тем же правилам; что не подошло никуда - «Прочее».
/// Номер группы = номер бита в TEFeatureCinerarium.GroupMask - порядок НЕ менять (сейвы), новые - только в конец.
/// </summary>
public static class CinerariumGroups
{
public const int Cooked = 0, Canned = 1, Raw = 2, Harvest = 3, Flowers = 4, Seeds = 5, Drinks = 6, Medicine = 7, Ore = 8,
Ingots = 9, Parts = 10, Materials = 11, Chemicals = 12, Tools = 13, Weapons = 14, Ammo = 15, Armor = 16, Mods = 17,
Books = 18, Blocks = 19, Necro = 20, Other = 21,
// 17.10: «Корм для кур вынеси в отдельную группу» (был в «Сыром»).
ChickenFeed = 22;
public const int Count = 23;
public const uint All = (1u << Count) - 1;
/// <summary>Значок группы - иконка типичного предмета (ItemIconAtlas).</summary>
public static readonly string[] Icons =
{
"foodMeatStew", "foodCanChili", "foodRawMeat", "foodCropCorn", "plantedChrysanthemum3Harvest", "plantedCorn1",
"drinkJarBoiledWater", "medicalFirstAidKit", "resourceRockSmall", "resourceForgedSteel", "resourceMechanicalParts",
"resourceWood", "resourceGunPowder", "meleeToolPickT1IronPickaxe", "gunHandgunT1Pistol", "ammo9mmBulletBall",
"armorMinerHelmet", "modGunLaserSight", "bookArtOfMiningLuckyStrike", "cobblestoneMaster", "necroSkull", "oldCash",
"resourceChickenFeed"
};
/// <summary>Ключ названия группы в Localization.csv: necroGroup0..necroGroup22.</summary>
public static string NameKey(int _g) => "necroGroup" + _g;
static readonly HashSet<string> OreNames = new HashSet<string>
{
"resourceRockSmall", "resourceRockSmallBundle", "resourceScrapIron", "resourceScrapIronBundle", "resourceScrapLead",
"resourceLeadBundle", "resourceCoal", "resourceCoalBundle", "resourcePotassiumNitratePowder",
"resourcePotassiumNitratePowderBundle", "resourceOilShale", "resourceOilShaleBundle", "resourceClayLump",
"resourceCrushedSand", "resourceCobblestones", "resourceGoldNugget", "resourceSilverNugget", "resourceRawDiamond",
"resourceSnowBall"
};
static readonly HashSet<string> IngotNames = new HashSet<string> { "resourceForgedIron", "resourceForgedSteel" };
static readonly HashSet<string> ChemNames = new HashSet<string>
{
"resourceGunPowder", "resourceGunPowderBundle", "resourceOil", "ammoGasCan", "ammoGasCanBundle", "resourceAcid",
"resourceGlue", "resourcePaint", "resourceTestosteroneExtract"
};
static readonly HashSet<string> MaterialNames = new HashSet<string>
{
"resourceWood", "resourceWoodBundle", "resourceCloth", "resourceLeather", "resourceBone", "resourceFeather",
"resourceYuccaFibers", "resourcePaper", "resourceCement", "resourceConcreteMix", "resourceBeeswax", "resourceCandleStick",
"resourceAnimalFat"
};
/// <summary>Детали, которые теги отнесли бы не туда (гвозди - патроны гвоздомёта).</summary>
static readonly HashSet<string> PartNames = new HashSet<string> { "resourceNail" };
static readonly HashSet<string> AmmoParts = new HashSet<string>
{
"resourceArrowHeadIron", "resourceArrowHeadSteelAP", "resourceBuckshot", "resourceBulletCasing", "resourceBulletTip",
"resourceRocketCasing", "resourceRocketTip"
};
static readonly HashSet<string> RawNames = new HashSet<string>
{
"foodRawMeat", "foodRawMeatBundle", "foodEgg", "foodHoney", "foodRottingFlesh"
};
/// <summary>Предметы и блоки самого мода - по его Config/items.xml и blocks.xml (у многих нет «necro» в имени: книги
/// призыва, браслет, камни портала, прах зомби, кожа жертвы). Читается один раз.</summary>
static HashSet<string> _modNames;
static HashSet<string> ModNames()
{
if (_modNames != null)
{
return _modNames;
}
_modNames = new HashSet<string>();
foreach (string f in new[] { "items.xml", "blocks.xml" })
{
try
{
XmlDocument doc = new XmlDocument();
doc.Load(Path.Combine(ModEntry.Instance.Path, "Config", f));
foreach (XmlNode n in doc.SelectNodes("//item[@name] | //block[@name]"))
{
_modNames.Add(n.Attributes["name"].Value);
}
}
catch (Exception e)
{
UnityEngine.Debug.LogWarning("[NecromancerTome] CinerariumGroups: " + f + " мода не прочитан (" + e.Message + ")");
}
}
return _modNames;
}
static readonly Dictionary<int, int> _cache = new Dictionary<int, int>();
public static int Of(ItemValue _iv)
{
ItemClass ic = _iv?.ItemClass;
if (ic == null)
{
return Other;
}
if (!_cache.TryGetValue(ic.Id, out int g))
{
g = Classify(ic, _iv);
_cache[ic.Id] = g;
}
return g;
}
static bool Tag(ItemClass _ic, string _t)
{
return _ic.ItemTags.Test_AnySet(FastTags<TagGroup.Global>.Parse(_t));
}
static int Classify(ItemClass _ic, ItemValue _iv)
{
string n = _ic.GetItemName() ?? "";
if (n.IndexOf("necro", StringComparison.OrdinalIgnoreCase) >= 0 || ModNames().Contains(n))
{
return Necro;
}
if (n == "resourceChickenFeed")
{
return ChickenFeed;
}
if (_ic is ItemClassModifier)
{
return Mods;
}
if (_ic.IsBlock())
{
return new ItemValue(_ic.Id).ToBlockValue().Block is BlockPlantGrowing ? Seeds : Blocks;
}
if (_ic is ItemClassArmor || n.StartsWith("armor") || n.StartsWith("clothing"))
{
return Armor;
}
if (n.StartsWith("book") || n.Contains("Schematic") || n.Contains("Magazine") || Tag(_ic, "books"))
{
return Books;
}
if (OreNames.Contains(n))
{
return Ore;
}
if (IngotNames.Contains(n) || n.StartsWith("unit_"))
{
return Ingots;
}
if (ChemNames.Contains(n))
{
return Chemicals;
}
if (PartNames.Contains(n))
{
return Parts;
}
if (MaterialNames.Contains(n))
{
return Materials;
}
if (AmmoParts.Contains(n) || n.StartsWith("ammo") || n.StartsWith("thrown") || Tag(_ic, "ammo"))
{
return Ammo;
}
if (n.EndsWith("Parts") || n == "partsMaster" || n.StartsWith("vehicle") && !n.EndsWith("Placeable"))
{
return Parts;
}
if (n.StartsWith("meleeTool"))
{
return Tools; // раньше оружия: у факела тег weapon
}
if (n.StartsWith("gun") || n.StartsWith("meleeWpn") || Tag(_ic, "weapon"))
{
return Weapons;
}
if (n.StartsWith("meleeTool") || n.StartsWith("tool") || n.StartsWith("bucket") || Tag(_ic, "tool"))
{
return Tools;
}
if (n.StartsWith("foodCan"))
{
return Canned;
}
if (n.StartsWith("foodCrop"))
{
return Harvest;
}
if (n.StartsWith("resourceCrop"))
{
return Flowers;
}
if (RawNames.Contains(n) || n.StartsWith("foodRaw"))
{
return Raw;
}
if (n.StartsWith("drink") || n.StartsWith("tinCan"))
{
return Drinks;
}
if (n.StartsWith("medical") || n.StartsWith("drug") || Tag(_ic, "medical"))
{
return Medicine;
}
if (n.StartsWith("food") || Tag(_ic, "food"))
{
return Cooked;
}
if (n.StartsWith("resource") || n == "carBattery" || n == "smallEngine" || n == "solarCell")
{
return Parts;
}
return Other;
}
}
}
+418
View File
@@ -0,0 +1,418 @@
using System.Collections.Generic;
using GUI_2;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Нити цинерария (16.10.2026, BACKLOG «Нити цинерария»): перевозка между ящиками, как провода электрики. «Ящик на
/// территории одного цинерария и присоединяю к нему другой цинерарий проводом. А затем от цинерария тяну провод до другого
/// ящика. Дух в цинерарии будет забирать всё что лежит в ящике из которого в него тянется провод, и тащить в ящик, в
/// который тянется провод.» Решения: у урны несколько нитей «откуда» и одна «куда»; везётся по образцу (только то, что уже
/// лежит в ящике «куда»; пустой - всё подряд); «куда» полон - дух ждёт; нить призрачная; длина - как у провода (15 м);
/// ящики - любые. Нити - только у отдельного блока «урна-логист» (necroCinerariumLogist, 16.10): без зоны, дух только перевозит,
/// глазницы всегда зелёные.
///
/// Инструмент - нож некроманта «хватом для нитей»: отдельный предмет necroWpnBladeNecroKnifeThread (класс ножа в сейве
/// менять нельзя), переключение - пункт в меню ножа по R (то же меню, что «человек/змея/чертоги», SnakeMode.cs; 16.10:
/// своё меню по R перекрывало его - «ты убрал хваты чертогов памяти и духа змеи»); качество и моды переносятся
/// (ItemValue.Clone со сменой типа). Как у стриппера: ПКМ - начать нить и закончить её (порядок = направление: ящик, потом
/// урна - «откуда»; урна, потом ящик - «куда»), ЛКМ по урне или ящику - снять его нити.
/// Первая версия - одиночная игра и хост (связи меняются на месте, клиентам сети - позже).
/// </summary>
public static class CinerariumThreads
{
public const string KnifeName = Patch_ItemClass_ExecuteAction_KnifePowerCost.KnifeName;
public const string ThreadKnifeName = "necroWpnBladeNecroKnifeThread";
/// <summary>Как у провода электрики: ItemActionConnectPower.maxWireLength = 15.</summary>
public const float MaxLength = 15f;
public const int MaxFrom = 8;
public const int GripNormal = 0, GripThread = 1;
/// <summary>Цвет нити - в цвет духа (CinerariumGenie.Tint).</summary>
public static readonly Color ThreadColor = new Color(0.55f, 0.8f, 1f, 0.45f);
public static readonly Color SparkColor = new Color(0.8f, 0.95f, 1f, 1f);
public static bool IsKnife(ItemClass _i) => _i != null && (_i.Name == KnifeName || _i.Name == ThreadKnifeName);
public static bool IsThreadKnife(ItemClass _i) => _i != null && _i.Name == ThreadKnifeName;
// ------------------------------------------------------------------
// Хват.
// ------------------------------------------------------------------
/// <summary>Нож в руке меняется на _name: тот же ItemValue (качество, износ, моды), другой тип.</summary>
public static void Convert(EntityPlayerLocal _pl, string _name)
{
ItemStack cur = _pl?.inventory?.holdingItemStack;
if (cur == null || cur.IsEmpty() || !IsKnife(cur.itemValue.ItemClass) || cur.itemValue.ItemClass.Name == _name)
{
return;
}
ItemValue target = ItemClass.GetItem(_name);
if (target == null || target.IsEmpty())
{
return;
}
ItemValue iv = cur.itemValue.Clone();
iv.type = target.type;
_pl.inventory.SetItem(_pl.inventory.holdingItemIdx, new ItemStack(iv, cur.count));
Cancel();
}
// ------------------------------------------------------------------
// Соединение.
// ------------------------------------------------------------------
static Vector3i? _start;
static bool _startUrn;
static GameObject _preview;
public static Vector3i? Start => _start;
public static bool StartIsUrn => _startUrn;
public static void Cancel()
{
_start = null;
if (_preview != null)
{
Object.Destroy(_preview);
_preview = null;
}
}
/// <summary>Урна или ящик (составной блок игрока с хранилищем) в клетке _p; _owner - главная клетка блока.</summary>
public static bool Resolve(World _w, Vector3i _p, out Vector3i _owner, out TEFeatureCinerarium _urn)
{
BlockValue bv = _w.GetBlock(_p);
_owner = bv.ischild ? _p + bv.parent : _p;
_urn = null;
if (!(_w.GetTileEntity(_owner) is TileEntityComposite comp))
{
return false;
}
if (comp.TryGetSelfOrFeature<TEFeatureCinerarium>(out _urn))
{
return true;
}
return comp.PlayerPlaced && comp.TryGetSelfOrFeature<TEFeatureStorage>(out TEFeatureStorage _);
}
public static TEFeatureCinerarium UrnAt(World _w, Vector3i _p)
{
return _w.GetTileEntity(_p) is TileEntityComposite c && c.TryGetSelfOrFeature<TEFeatureCinerarium>(out TEFeatureCinerarium u) ? u : null;
}
/// <summary>Отпущена кнопка с ножом-для-нитей: _connect - ПКМ (тянуть нить), иначе ЛКМ (снять нити).</summary>
public static void Click(EntityPlayerLocal _pl, bool _connect)
{
World w = GameManager.Instance?.World;
WorldRayHitInfo hi = _pl?.HitInfo;
if (w == null || hi == null || !hi.bHitValid || !GameUtils.IsBlockOrTerrain(hi.tag) || hi.hit.distanceSq > 36f)
{
return;
}
bool ok = Resolve(w, hi.hit.blockPos, out Vector3i owner, out TEFeatureCinerarium urn);
if (!_connect)
{
if (_start.HasValue)
{
Cancel();
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadCancel"));
return;
}
if (ok && RemoveAll(w, owner, urn) > 0)
{
_pl.PlayOneShot("wiretool_grab");
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadRemoved"));
}
return;
}
if (!ok)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedPair"));
return;
}
if (!_start.HasValue)
{
if (urn != null && !urn.IsLogist)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedLogist"));
return;
}
_start = owner;
_startUrn = urn != null;
_preview = new GameObject("NecroThreadPreview");
_preview.AddComponent<CinerariumThreadPreview>();
_pl.PlayOneShot("wiretool_grab");
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadStart"));
return;
}
Vector3i s = _start.Value;
bool sUrn = _startUrn;
Cancel();
if (s == owner)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadCancel"));
return;
}
if (sUrn == (urn != null))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedPair"));
return;
}
TEFeatureCinerarium end = sUrn ? UrnAt(w, s) : urn;
if (end == null || !end.IsLogist)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedLogist"));
return;
}
if (Vector3.Distance(s.ToVector3(), owner.ToVector3()) > MaxLength)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTooFar"));
return;
}
if (sUrn)
{
TEFeatureCinerarium u = UrnAt(w, s);
if (u == null)
{
return;
}
u.SetThreadTo(owner);
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTo"));
}
else
{
if (!urn.AddThreadFrom(s))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadLimit"));
return;
}
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadFrom"));
}
_pl.PlayOneShot("wiretool_place");
}
/// <summary>Снять все нити урны или все нити, идущие к ящику (у всех урн сейва). Сколько снято.</summary>
static int RemoveAll(World _w, Vector3i _owner, TEFeatureCinerarium _urn)
{
if (_urn != null)
{
return _urn.ClearThreads();
}
int n = 0;
foreach (Vector3i p in CinerariumRegistry.All())
{
TEFeatureCinerarium u = UrnAt(_w, p);
if (u != null)
{
n += u.RemoveThreadsTo(_owner);
}
}
return n;
}
// ------------------------------------------------------------------
// Вид нитей.
// ------------------------------------------------------------------
static Material _mat;
public static Material Mat()
{
if (_mat == null)
{
_mat = new Material(Shader.Find("Sprites/Default")) { name = "NecroThread" };
}
return _mat;
}
/// <summary>Где нить крепится: у урны - горлышко, у ящика - край со стороны урны, чуть выше середины. Сцена (без Origin).</summary>
public static Vector3 UrnPoint(Vector3i _urn) => World.blockToTransformPos(_urn) - Origin.position + Vector3.up * 0.5f;
public static Vector3 BoxPoint(Vector3i _box, Vector3i _urn)
{
Vector3 c = World.blockToTransformPos(_box) - Origin.position + Vector3.up * 0.6f;
Vector3 d = (World.blockToTransformPos(_urn) - World.blockToTransformPos(_box));
d.y = 0f;
if (d.sqrMagnitude > 0.01f)
{
d.Normalize();
c += d * 0.5f;
}
return c;
}
/// <summary>Провисающая нить: точка по доле пути _u.</summary>
public static Vector3 Curve(Vector3 _a, Vector3 _b, float _u)
{
float sag = Mathf.Min(0.6f, Vector3.Distance(_a, _b) * 0.05f);
return Vector3.Lerp(_a, _b, _u) + Vector3.down * (sag * 4f * _u * (1f - _u));
}
public static LineRenderer NewLine(Transform _parent, string _name, float _width, Color _c)
{
GameObject g = new GameObject(_name);
g.transform.SetParent(_parent, false);
LineRenderer lr = g.AddComponent<LineRenderer>();
lr.useWorldSpace = false;
lr.widthMultiplier = _width;
lr.sharedMaterial = Mat();
lr.startColor = _c;
lr.endColor = _c;
lr.numCapVertices = 2;
lr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
lr.receiveShadows = false;
return lr;
}
}
/// <summary>Нити одной урны: провисающие линии и бегущие по ним огоньки (направление). Видны, только пока в руках нож
/// хватом для нитей. Дочерний объект модели урны -
/// сдвиги Origin и выгрузка чанка его не теряют; точки - в осях модели.</summary>
[Preserve]
public class CinerariumThreadView : MonoBehaviour
{
struct Thread
{
public Vector3 A, B; // в осях модели урны, огоньки бегут от A к B
public LineRenderer[] Sparks;
}
readonly List<Thread> _threads = new List<Thread>();
const int Segments = 16;
const int SparksPerThread = 3;
public static float SparkSpeed = 1.2f; // м/с
/// <summary>_pairs - концы в координатах сцены (без Origin). Видимость - заново по ножу в руке (Update).</summary>
public void Build(List<(Vector3 a, Vector3 b)> _pairs)
{
foreach (Transform c in transform)
{
Destroy(c.gameObject);
}
_threads.Clear();
Transform t = transform;
foreach ((Vector3 a, Vector3 b) in _pairs)
{
Thread th = new Thread { A = t.InverseTransformPoint(a), B = t.InverseTransformPoint(b), Sparks = new LineRenderer[SparksPerThread] };
LineRenderer lr = CinerariumThreads.NewLine(t, "thread", 0.025f, CinerariumThreads.ThreadColor);
lr.positionCount = Segments + 1;
for (int i = 0; i <= Segments; i++)
{
lr.SetPosition(i, CinerariumThreads.Curve(th.A, th.B, i / (float)Segments));
}
for (int k = 0; k < SparksPerThread; k++)
{
th.Sparks[k] = CinerariumThreads.NewLine(t, "spark", 0.06f, CinerariumThreads.SparkColor);
th.Sparks[k].positionCount = 2;
}
_threads.Add(th);
}
_shown = -1;
}
float _nextSleepCheck;
bool _asleep;
int _shown = -1;
/// <summary>16.10: «Нити должны быть видны, только когда нож в режиме нитей и в руках».</summary>
public static bool ToolInHand()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
return pl != null && CinerariumThreads.IsThreadKnife(pl.inventory?.holdingItem);
}
void Update()
{
if (Time.time >= _nextSleepCheck)
{
_nextSleepCheck = Time.time + 0.25f;
_asleep = CinerariumGenie.PlayerFar(transform.position); // засыпание (16.10): огоньки стоят
int show = ToolInHand() ? 1 : 0;
if (show != _shown)
{
_shown = show;
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
r.enabled = show == 1;
}
}
}
if (_asleep || _shown != 1)
{
return;
}
foreach (Thread th in _threads)
{
float len = Mathf.Max(0.5f, Vector3.Distance(th.A, th.B));
float step = 0.2f / len;
for (int k = 0; k < th.Sparks.Length; k++)
{
float u = Mathf.Repeat(Time.time * SparkSpeed / len + k / (float)th.Sparks.Length, 1f);
th.Sparks[k].SetPosition(0, CinerariumThreads.Curve(th.A, th.B, Mathf.Max(0f, u - step)));
th.Sparks[k].SetPosition(1, CinerariumThreads.Curve(th.A, th.B, u));
}
}
}
}
/// <summary>Нить, которую тянут: от начала до руки игрока; дальше 15 м - красная. Нож убрали - нить брошена.</summary>
[Preserve]
public class CinerariumThreadPreview : MonoBehaviour
{
LineRenderer _lr;
void Start()
{
_lr = CinerariumThreads.NewLine(transform, "line", 0.025f, CinerariumThreads.ThreadColor);
_lr.useWorldSpace = true;
_lr.positionCount = 17;
}
void Update()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pl == null || !CinerariumThreads.Start.HasValue || !CinerariumThreads.IsThreadKnife(pl.inventory?.holdingItem))
{
CinerariumThreads.Cancel();
return;
}
if (_lr == null)
{
return;
}
Vector3i s = CinerariumThreads.Start.Value;
Vector3 a = World.blockToTransformPos(s) - Origin.position + Vector3.up * (CinerariumThreads.StartIsUrn ? 0.5f : 0.6f);
Transform cam = pl.playerCamera != null ? pl.playerCamera.transform : null;
Vector3 b = cam != null ? cam.position + cam.forward * 0.6f + cam.right * 0.2f - cam.up * 0.25f : pl.GetPosition() - Origin.position + Vector3.up * 1.2f;
bool far = Vector3.Distance(s.ToVector3(), pl.GetPosition()) > CinerariumThreads.MaxLength;
Color c = far ? new Color(1f, 0.3f, 0.3f, 0.7f) : CinerariumThreads.ThreadColor;
_lr.startColor = c;
_lr.endColor = c;
for (int i = 0; i <= 16; i++)
{
_lr.SetPosition(i, CinerariumThreads.Curve(a, b, i / 16f));
}
}
}
/// <summary>Нож для нитей не бьёт: ЛКМ и ПКМ - нити (на отпускание кнопки).</summary>
[HarmonyPatch(typeof(ItemClass), nameof(ItemClass.ExecuteAction))]
public static class Patch_ItemClass_ExecuteAction_ThreadKnife
{
public static bool Prefix(ItemClass __instance, int _actionIdx, ItemInventoryData _data, bool _bReleased)
{
if (!CinerariumThreads.IsThreadKnife(__instance))
{
return true;
}
if (_bReleased && _data?.holdingEntity is EntityPlayerLocal pl)
{
CinerariumThreads.Click(pl, _actionIdx == 1);
}
return false;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// «Содержимое» урны-цинерария - ячейка черепа (решение 16.10: «урна как транспорт»; переименовано из «Багажника»).
/// Окно - родное окно хранилища игры (XUiC_BagStorageWindowGroup, как сумка транспорта и сумка волка) с сумкой на одну
/// ячейку: перетаскивание, Shift+клик и правый клик - кодом игры (16.10: «Сумки, ящики в игре есть» - своё окно с
/// ячейкой коллектора не принимало череп ни перетаскиванием, ни Shift+кликом). Своё здесь одно: после каждого изменения
/// в ячейке остаётся один череп, всё остальное (лишние черепа, чужие предметы) - назад в рюкзак, и урна узнаёт, есть ли
/// в ней череп. Череп - обычный предмет (ItemClass): он уже в сейвах, менять ему класс на модификацию нельзя.
/// </summary>
public static class CinerariumContents
{
public const string LootTemplate = "roboticDrone"; // звуки и время открытия, как у сумки волка
static TEFeatureCinerarium _urn;
/// <summary>Урна, чьё «Содержимое» сейчас открыто (окно фильтра логиста), или null.</summary>
public static TEFeatureCinerarium Urn => _urn;
static Bag _bag;
static EntityPlayerLocal _player;
public static void Open(EntityPlayerLocal _p, TEFeatureCinerarium _u)
{
LocalPlayerUI ui = _p != null ? LocalPlayerUI.GetUIForPlayer(_p) : null;
ItemValue skull = ItemClass.GetItem(TEFeatureCinerarium.SkullItemName);
if (ui == null || _u == null)
{
return;
}
_urn = _u;
_player = _p;
_bag = new Bag(new Vector2i(1, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
if (_u.HasSkull && skull != null && skull.type > 0)
{
_bag.SetSlot(0, new ItemStack(skull, 1));
}
XUiC_BagStorageWindowGroup.Open(ui.xui, _p, _bag, LootContainer.GetLootContainer(LootTemplate),
Localization.Get("necroCinerariumTrunk"), Check, () =>
{
Check();
_urn = null;
_bag = null;
_player = null;
});
Debug.Log("[NecromancerTome] Cinerarium: «Содержимое» урны " + _u.ToWorldPos() + " открыто, череп " + (_u.HasSkull ? "есть" : "нет"));
}
/// <summary>Оставить в ячейке один череп, остальное - игроку; отметить в урне.</summary>
static void Check()
{
if (_bag == null || _urn == null)
{
return;
}
ItemStack s = _bag.ItemGrid.items[0];
bool has = false;
if (s != null && !s.IsEmpty())
{
ItemStack back = null;
if (s.itemValue.ItemClass?.GetItemName() != TEFeatureCinerarium.SkullItemName)
{
back = s.Clone();
_bag.SetSlot(0, ItemStack.Empty.Clone());
}
else
{
has = true;
if (s.count > 1)
{
back = s.Clone();
back.count = s.count - 1;
ItemStack one = s.Clone();
one.count = 1;
_bag.SetSlot(0, one);
}
}
if (back != null)
{
GiveBack(back);
}
}
_urn.SetSkull(has);
}
static void GiveBack(ItemStack _s)
{
if (_player == null)
{
return;
}
if (_player.bag != null && _player.bag.AddItem(_s))
{
return;
}
if (_player.inventory != null && _player.inventory.AddItem(_s))
{
return;
}
_player.world.gameManager.ItemDropServer(_s, _player.GetPosition(), Vector3.zero);
}
/// <summary>Урна убрана или выгружена, пока открыто её «Содержимое», - закрыть окно (череп из ячейки не потеряется:
/// Check успеет отметить его в урне, а урна при разрушении роняет череп).</summary>
public static void CloseIfShowing(TEFeatureCinerarium _u)
{
if (_urn == null || _urn != _u)
{
return;
}
Check();
LocalPlayerUI ui = _player != null ? LocalPlayerUI.GetUIForPlayer(_player) : null;
_urn = null;
if (ui != null && ui.windowManager.IsWindowOpen(XUiC_BagStorageWindowGroup.ID))
{
ui.windowManager.Close(XUiC_BagStorageWindowGroup.ID);
}
}
}
}
+565
View File
@@ -0,0 +1,565 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Работа духа урны - этап 4 некротехнолога (указание 16.10: дух - УНИВЕРСАЛЬНЫЙ работник зоны; BACKLOG «Некротехнолог v2»).
/// Раз в StepSeconds - одно дело, по порядку:
/// 1. пополнить расходники коллекторов из ящиков зоны: ячейки топлива (банки, цветы, корм, сферы - что коллектор
/// принимает, GetFuelTypes) и катализатора (куры - GetCatalystTypes; по одной в пустую ячейку). Модификации
/// коллекторов не трогаются;
/// 2. забрать готовое из коллекторов (Items) и станций (TileEntityWorkstation.Output - верстак, кузня, химстанция,
/// костёр, бетономешалка) и сложить в ящики зоны: сначала к таким же предметам, потом в свободное место; ящики полны -
/// в сумку урны; некуда - дух чернеет и замирает над урной, оживает сам, когда место появится;
/// 3. грядки - последними: собрать созревшее (блок с меткой crops и добычей Harvest) с «идеальными бонусами»
/// (06.10: все бонусные доли урожая и семя - всегда), семя сразу посадить на то же место; затем засадить пустые
/// грядки случайными семенами из ящиков и сумки (семя - предмет-блок стадии 1, BlockPlantGrowing; можно ли сажать,
/// решает сам блок семени - CanPlaceBlockAt проверяет плодородие под ним, то есть грядку).
/// Коллектор или станция подходят любые - будущий крольчатник обслуживается тем же кодом. Ящик - составной блок игрока
/// с хранилищем (TEFeatureStorage, PlayerPlaced), кроме урн. Открытое у игрока (IsUserAccessing) не трогается.
/// Работает на сервере: зовётся из TEFeatureCinerarium.UpdateTick.
/// </summary>
public static class CinerariumWork
{
public static float StepSeconds = 3f;
/// <summary>Что в зоне: собирается заново на каждом шаге (150 клеток - дёшево, а ящики могут переставить).</summary>
sealed class Zone
{
public readonly List<TEFeatureStorage> Boxes = new List<TEFeatureStorage>();
public readonly List<TileEntityCollector> Collectors = new List<TileEntityCollector>();
public readonly List<TileEntityWorkstation> Stations = new List<TileEntityWorkstation>();
public readonly Dictionary<TileEntity, Vector3i> Where = new Dictionary<TileEntity, Vector3i>();
public TEFeatureStorage Bag;
public readonly List<Vector3i> Ripe = new List<Vector3i>();
public readonly List<Vector3i> Empty = new List<Vector3i>();
}
static readonly FastTags<TagGroup.Global> CropTag = FastTags<TagGroup.Global>.Parse("crops");
static readonly GameRandom _rnd = GameRandomManager.Instance.CreateGameRandom();
static readonly HashSet<TileEntity> _seen = new HashSet<TileEntity>();
/// <summary>Пробный шаг: найти дело и место, ничего не меняя.</summary>
static bool _dry;
/// <summary>Одно дело. Возвращает, где дух работал (для полёта модели), или null; _stuck - есть что забрать, но некуда.
/// _dryRun - только узнать, где будет дело (дух летит туда заранее), ничего не трогая.</summary>
public static Vector3i? Step(World _world, TEFeatureCinerarium _urn, out bool _stuck, bool _dryRun)
{
_dry = _dryRun;
try
{
return DoStep(_world, _urn, out _stuck);
}
finally
{
_dry = false;
}
}
/// <summary>У перевозки по нити - ящик «откуда» последнего шага (дух летит сначала к нему), иначе null.</summary>
public static Vector3i? LastFrom;
static Vector3i? DoStep(World _world, TEFeatureCinerarium _urn, out bool _stuck)
{
_stuck = false;
LastFrom = null;
// Урна с нитями - логист (16.10: «занимается только перетаскиванием из ящиков в ящики»).
if (_urn.IsLogist)
{
return Ferry(_world, _urn);
}
Zone z = Scan(_world, _urn);
// 1. Расходники.
foreach (TileEntityCollector c in z.Collectors)
{
if (Refill(c, c.FuelSlots, c.GetFuelTypes(), false, z) || Refill(c, c.CatalystSlots, c.GetCatalystTypes(), true, z))
{
if (!_dry)
{
c.SetModified();
}
return z.Where[c];
}
}
// 2. Готовое.
bool blocked = false;
foreach (TileEntityCollector c in z.Collectors)
{
ItemStack[] items = c.Items;
if (Unload(items, z, ref blocked))
{
if (!_dry)
{
c.Items = items;
c.SetModified();
}
return z.Where[c];
}
}
foreach (TileEntityWorkstation w in z.Stations)
{
ItemStack[] output = w.Output;
if (Unload(output, z, ref blocked))
{
if (!_dry)
{
w.Output = output;
}
return z.Where[w];
}
}
// 3. Грядки.
foreach (Vector3i p in z.Ripe)
{
if (Harvest(_world, p, z, ref blocked))
{
return p;
}
}
if (z.Empty.Count > 0)
{
Vector3i? planted = PlantRandom(_world, z);
if (planted.HasValue)
{
return planted;
}
}
_stuck = blocked;
return null;
}
/// <summary>Перевозка по нитям: одна стопка из ящика «откуда» в ящик «куда». Только предметы включённых в урне групп
/// (фильтр логиста, CinerariumGroups; 16.10 заменил прежнее правило «по образцу»); «куда» полон - ждём (дух не чернеет). Открытые у игрока ящики и закреплённые
/// ячейки не трогаются. Возвращает ящик «куда», LastFrom - откуда взято.</summary>
static Vector3i? Ferry(World _world, TEFeatureCinerarium _urn)
{
if (!_urn.HasThreadTo)
{
return null;
}
TEFeatureStorage dst = BoxAt(_world, _urn.ThreadTo);
if (dst == null)
{
return null;
}
foreach (Vector3i f in _urn.ThreadFrom)
{
if (f == _urn.ThreadTo)
{
continue;
}
TEFeatureStorage src = BoxAt(_world, f);
if (src == null)
{
continue;
}
for (int k = 0; k < src.ItemGrid.Length; k++)
{
ItemStack s = src.ItemGrid[k];
if (s == null || s.IsEmpty() || Locked(src, k) || !_urn.GroupOn(CinerariumGroups.Of(s.itemValue)) || !Fits(dst, s))
{
continue;
}
LastFrom = f;
if (_dry)
{
return _urn.ThreadTo;
}
ItemStack moving = s.Clone();
Place(moving, new[] { dst });
int moved = s.count - moving.count;
if (moved <= 0)
{
continue;
}
Take(src, k, moved);
dst.SetModified();
Debug.Log("[NecromancerTome] Cinerarium: дух перенёс " + moved + " " + s.itemValue.ItemClass?.GetItemName() + " из " + f + " в " + _urn.ThreadTo);
return _urn.ThreadTo;
}
}
return null;
}
static TEFeatureStorage BoxAt(World _world, Vector3i _p)
{
return _world.GetTileEntity(_p) is TileEntityComposite c && !c.IsUserAccessing() &&
!c.TryGetSelfOrFeature<TEFeatureCinerarium>(out TEFeatureCinerarium _) &&
c.TryGetSelfOrFeature<TEFeatureStorage>(out TEFeatureStorage s) ? s : null;
}
static bool IsRipe(Block _b)
{
return _b != null && !(_b is BlockPlantGrowing) && _b.HasAnyFastTags(CropTag) &&
_b.itemsToDrop.TryGetValue(EnumDropEvent.Harvest, out List<Block.SItemDropProb> l) && l != null && l.Count > 0;
}
static bool IsSeed(ItemValue _iv, out BlockValue _bv)
{
_bv = BlockValue.Air;
if (_iv == null || _iv.type == 0 || !_iv.ItemClass.IsBlock())
{
return false;
}
_bv = _iv.ToBlockValue();
return _bv.Block is BlockPlantGrowing;
}
/// <summary>Урожай: все доли добычи Harvest (бонусы - тоже, вероятность только у обычных долей), множители сервера на
/// урожай и семена. Одно семя - сразу в землю на то же место, остальное - в ящики/сумку. Нет места ни для чего -
/// грядку не трогаем, дух чернеет.</summary>
static bool Harvest(World _world, Vector3i _p, Zone _z, ref bool _blocked)
{
Block crop = _world.GetBlock(_p).Block;
List<ItemStack> got = new List<ItemStack>();
BlockValue replant = BlockValue.Air;
foreach (Block.SItemDropProb d in crop.itemsToDrop[EnumDropEvent.Harvest])
{
ItemValue iv = ItemClass.GetItem(d.name);
if (iv == null || iv.type == 0)
{
continue;
}
bool seed = IsSeed(iv, out BlockValue seedBv);
bool bonus = d.tag != null && (d.tag.Contains("Bonus") || d.tag.Contains("bonus"));
if (!bonus && d.prob < 1f && _rnd.RandomFloat > d.prob)
{
continue;
}
int n = _rnd.RandomRange(d.minCount, d.maxCount + 1);
n = Mathf.RoundToInt(n * (seed ? XUiM_Recipes.SeedDropOutputModifier : XUiM_Recipes.CropOutputModifier));
if (seed && replant.isair && n > 0)
{
replant = seedBv;
n--;
}
if (n > 0)
{
got.Add(new ItemStack(iv, n));
}
}
if (got.Count > 0 && !HasRoom(got[0], _z))
{
_blocked = true;
return false;
}
if (_dry)
{
return true;
}
_world.SetBlockRPC(_p, BlockValue.Air);
if (!replant.isair)
{
replant.rotation = (byte)_rnd.RandomRange(0, 4);
_world.SetBlockRPC(_p, replant);
}
foreach (ItemStack s in got)
{
Place(s, _z.Boxes);
if (s.count > 0 && _z.Bag != null)
{
Place(s, new[] { _z.Bag });
}
if (s.count > 0)
{
// Влезло не всё (проверяли только первую долю) - остаток падает у урны, не пропадает.
GameManager.Instance.ItemDropServer(s.Clone(), World.blockToTransformPos(_p) + Vector3.up * 0.5f, Vector3.zero);
}
}
Debug.Log("[NecromancerTome] Cinerarium: дух собрал " + crop.GetBlockName() + " в " + _p + (replant.isair ? "" : ", посадил снова"));
return true;
}
static bool HasRoom(ItemStack _s, Zone _z)
{
foreach (TEFeatureStorage t in _z.Boxes)
{
if (Fits(t, _s))
{
return true;
}
}
return _z.Bag != null && Fits(_z.Bag, _s);
}
static bool Fits(TEFeatureStorage _t, ItemStack _s)
{
for (int i = 0; i < _t.ItemGrid.Length; i++)
{
ItemStack x = _t.ItemGrid[i];
if (x == null || x.IsEmpty())
{
return true;
}
int n = _s.count;
if (x.itemValue.type == _s.itemValue.type && x.CanStackPartly(ref n))
{
return true;
}
}
return false;
}
/// <summary>Засадить одну пустую грядку случайным семенем из сумки и ящиков (06.10: «случайно сажает имеющиеся семена»).</summary>
static Vector3i? PlantRandom(World _world, Zone _z)
{
List<(TEFeatureStorage src, int slot, BlockValue bv)> seeds = new List<(TEFeatureStorage, int, BlockValue)>();
foreach (TEFeatureStorage src in Sources(_z))
{
for (int k = 0; k < src.ItemGrid.Length; k++)
{
ItemStack st = src.ItemGrid[k];
if (st != null && !st.IsEmpty() && !Locked(src, k) && IsSeed(st.itemValue, out BlockValue bv))
{
seeds.Add((src, k, bv));
}
}
}
if (seeds.Count == 0)
{
return null;
}
foreach (Vector3i p in _z.Empty)
{
var pick = seeds[_rnd.RandomRange(0, seeds.Count)];
BlockValue bv = pick.bv;
bv.rotation = (byte)_rnd.RandomRange(0, 4);
if (!bv.Block.CanPlaceBlockAt(_world, p, bv))
{
continue;
}
if (_dry)
{
return p;
}
Take(pick.src, pick.slot, 1);
_world.SetBlockRPC(p, bv);
Debug.Log("[NecromancerTome] Cinerarium: дух посадил " + bv.Block.GetBlockName() + " в " + p);
return p;
}
return null;
}
static Zone Scan(World _world, TEFeatureCinerarium _urn)
{
Zone z = new Zone();
_seen.Clear();
Vector3i c = _urn.ToWorldPos();
if (_urn.Parent != null)
{
_urn.Parent.TryGetSelfOrFeature<TEFeatureStorage>(out z.Bag);
_seen.Add(_urn.Parent);
}
int w = TEFeatureCinerarium.HalfWidth;
for (int dy = -TEFeatureCinerarium.Below; dy <= TEFeatureCinerarium.Above; dy++)
{
for (int dx = -w; dx <= w; dx++)
{
for (int dz = -w; dz <= w; dz++)
{
Vector3i p = new Vector3i(c.x + dx, c.y + dy, c.z + dz);
BlockValue bv = _world.GetBlock(p);
if (bv.isair)
{
// Пустая клетка над плодородным блоком - кандидат в грядку (окончательно решит блок семени).
if (_world.GetBlock(p - Vector3i.up).Block?.blockMaterial?.FertileLevel > 0)
{
z.Empty.Add(p);
}
continue;
}
if (!bv.ischild && IsRipe(bv.Block))
{
z.Ripe.Add(p);
continue;
}
Vector3i owner = bv.ischild ? p + bv.parent : p; // часть составного блока (верстак 2x1) - к главной клетке
TileEntity te = _world.GetTileEntity(owner);
if (te == null || !_seen.Add(te) || te.IsUserAccessing())
{
continue;
}
if (te is TileEntityCollector col)
{
z.Collectors.Add(col);
z.Where[col] = owner;
}
else if (te is TileEntityWorkstation ws)
{
z.Stations.Add(ws);
z.Where[ws] = owner;
}
else if (te is TileEntityComposite comp && comp.PlayerPlaced &&
!comp.TryGetSelfOrFeature<TEFeatureCinerarium>(out TEFeatureCinerarium _) &&
comp.TryGetSelfOrFeature<TEFeatureStorage>(out TEFeatureStorage box))
{
z.Boxes.Add(box);
z.Where[comp] = owner;
}
}
}
}
return z;
}
/// <summary>Дополнить ячейки коллектора тем, что он принимает, из ящиков и сумки. _single - по одному предмету в
/// пустую ячейку (куры), иначе до полного стака. true - что-то положено.</summary>
static bool Refill(TileEntityCollector _c, ItemStack[] _slots, string[] _allowed, bool _single, Zone _z)
{
if (_slots == null || _allowed == null || _allowed.Length == 0)
{
return false;
}
for (int i = 0; i < _slots.Length; i++)
{
ItemStack slot = _slots[i];
ItemClass want = null;
int room;
if (slot == null || slot.IsEmpty())
{
room = -1; // любой допустимый
}
else
{
if (_single)
{
continue;
}
want = slot.itemValue.ItemClass;
room = want != null ? want.MaxCount - slot.count : 0;
if (room <= 0)
{
continue;
}
}
foreach (TEFeatureStorage src in Sources(_z))
{
for (int k = 0; k < src.ItemGrid.Length; k++)
{
ItemStack s = src.ItemGrid[k];
if (s == null || s.IsEmpty() || Locked(src, k))
{
continue;
}
ItemClass ic = s.itemValue.ItemClass;
if (ic == null || (want != null ? ic != want : System.Array.IndexOf(_allowed, ic.GetItemName()) < 0))
{
continue;
}
int take = _single ? 1 : (room < 0 ? Mathf.Min(s.count, ic.MaxCount) : Mathf.Min(s.count, room));
if (take <= 0)
{
continue;
}
if (_dry)
{
return true;
}
if (want == null)
{
_slots[i] = new ItemStack(s.itemValue.Clone(), take);
}
else
{
_slots[i].count += take;
}
Take(src, k, take);
return true;
}
}
}
return false;
}
/// <summary>Одна стопка готового - в ящики (к таким же, потом в пустое), затем в сумку урны. true - что-то перенесено;
/// _blocked - была стопка, которой не нашлось места.</summary>
static bool Unload(ItemStack[] _items, Zone _z, ref bool _blocked)
{
if (_items == null)
{
return false;
}
for (int i = 0; i < _items.Length; i++)
{
ItemStack s = _items[i];
if (s == null || s.IsEmpty())
{
continue;
}
if (_dry)
{
if (HasRoom(s, _z))
{
return true;
}
_blocked = true;
continue;
}
int before = s.count;
// Сначала ящики зоны (к таким же, потом в пустое), они полны - тогда сумка урны (решение 06.10).
Place(s, _z.Boxes);
if (s.count > 0 && _z.Bag != null)
{
Place(s, new[] { _z.Bag });
}
if (s.count == 0)
{
_items[i] = ItemStack.Empty.Clone();
return true;
}
if (s.count != before)
{
return true;
}
_blocked = true;
}
return false;
}
static void Place(ItemStack _s, IList<TEFeatureStorage> _dst)
{
for (int i = 0; i < _dst.Count && _s.count > 0; i++)
{
_dst[i].TryStackItem(0, _s);
}
for (int i = 0; i < _dst.Count && _s.count > 0; i++)
{
if (_dst[i].AddItem(_s.Clone()))
{
_s.count = 0;
}
}
}
/// <summary>Откуда брать расходники: сумка урны (там то, что дух накопил), потом ящики зоны.</summary>
static IEnumerable<TEFeatureStorage> Sources(Zone _z)
{
if (_z.Bag != null)
{
yield return _z.Bag;
}
foreach (TEFeatureStorage b in _z.Boxes)
{
yield return b;
}
}
/// <summary>Ячейка, закреплённая игроком (замок в окне ящика), - не трогать.</summary>
static bool Locked(TEFeatureStorage _src, int _slot)
{
PackedBoolArray locks = _src.ItemGrid.SlotLocks;
return locks != null && _slot < locks.Length && locks[_slot];
}
static void Take(TEFeatureStorage _src, int _slot, int _count)
{
ItemStack s = _src.ItemGrid[_slot].Clone();
s.count -= _count;
_src.UpdateSlot(_slot, s.count > 0 ? s : ItemStack.Empty.Clone());
_src.SetModified();
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ namespace NecromancerTome
static bool Ours(string _name) static bool Ours(string _name)
{ {
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster") || _name == "necroBookDoorPrefab" || _name.StartsWith("necroSkullDecor"); return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster") || _name == "necroBookDoorPrefab" || _name.StartsWith("necroSkullDecor") || _name == "necroCinerariumPrefab";
} }
public static void Postfix(Transform __result) public static void Postfix(Transform __result)
+22 -1
View File
@@ -23,7 +23,10 @@ namespace NecromancerTome
public static class HandHolds public static class HandHolds
{ {
public const string HandItem = "meleeHandPlayer"; public const string HandItem = "meleeHandPlayer";
public const int None = 0, Deny = 1, PifPaf = 2; public const int None = 0, Deny = 1, PifPaf = 2, Thread = 3;
/// <summary>Хват для нитей - не сразу: снимок замороженных костей должен быть в покое хвата 68, после доставания.</summary>
const float ThreadDelay = 0.6f;
static float _threadSince = -1f;
/// <summary>Дальность и конус выбора зомби для «отрицания».</summary> /// <summary>Дальность и конус выбора зомби для «отрицания».</summary>
public const float DenyRange = 25f; public const float DenyRange = 25f;
@@ -59,6 +62,19 @@ namespace NecromancerTome
public static int Current(EntityPlayerLocal _player) public static int Current(EntityPlayerLocal _player)
{ {
// Только от первого лица (16.10: «В виде от третьего лица что-то страшное»): поза и перенос ножа - для рук первого
// лица; от третьего лица модель в руке - у тела, и нож улетал к невидимой руке первого лица.
if (_player != null && _player.inventory?.holdingItem?.Name == CinerariumThreads.ThreadKnifeName &&
(_player.emodel as EModelSDCS)?.IsFPV == true && !SnakeMode.Active && !MindPalace.Active && !Possession.Active && !_player.IsDead())
{
if (_threadSince < 0f)
{
_threadSince = Time.time;
}
bool ready = Time.time - _threadSince >= ThreadDelay && !(_player.Hand != null && _player.Hand.isSwitching);
return ready ? Thread : None;
}
_threadSince = -1f;
if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) || if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) ||
SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead()) SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead())
{ {
@@ -104,9 +120,14 @@ namespace NecromancerTome
{ {
HandPose.PresetPifPaf(); HandPose.PresetPifPaf();
} }
else if (want == Thread)
{
HandPose.PresetThread();
}
else else
{ {
HandPose.Enabled = false; HandPose.Enabled = false;
HandPose.KnifeToLeft = false;
} }
} }
+127 -1
View File
@@ -76,6 +76,8 @@ namespace NecromancerTome
/// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза. /// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза.
/// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.</summary> /// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.</summary>
public static bool Freeze = true; public static bool Freeze = true;
/// <summary>Замереть без своего хвата - на время осмотра со стороны (NecroView).</summary>
public static bool HoldStill;
static readonly List<Transform> _frozen = new List<Transform>(); static readonly List<Transform> _frozen = new List<Transform>();
static readonly List<Vector3> _frozenPos = new List<Vector3>(); static readonly List<Vector3> _frozenPos = new List<Vector3>();
static readonly List<Quaternion> _frozenRot = new List<Quaternion>(); static readonly List<Quaternion> _frozenRot = new List<Quaternion>();
@@ -86,6 +88,8 @@ namespace NecromancerTome
public static void ResetAll() public static void ResetAll()
{ {
Enabled = false; Enabled = false;
KnifeToLeft = false;
NecroView.Stop();
Overrides.Clear(); Overrides.Clear();
CamOverrides.Clear(); CamOverrides.Clear();
_jabStart = -1f; _jabStart = -1f;
@@ -142,10 +146,18 @@ namespace NecromancerTome
{ {
if (!Enabled || Overrides.Count == 0 && CamOverrides.Count == 0) if (!Enabled || Overrides.Count == 0 && CamOverrides.Count == 0)
{ {
if (_frozen.Count > 0) if (HoldStill)
{
FreezeBones(); // осмотр со стороны (necroview): поза замирает и без своего хвата
}
else if (_frozen.Count > 0)
{ {
Unfreeze(); Unfreeze();
} }
if (KnifeToLeft && Enabled)
{
ApplyKnifeToLeft();
}
return; return;
} }
if (Freeze) if (Freeze)
@@ -160,6 +172,10 @@ namespace NecromancerTome
b.localRotation = Quaternion.Euler(kv.Value); b.localRotation = Quaternion.Euler(kv.Value);
} }
} }
if (KnifeToLeft)
{
ApplyKnifeToLeft();
}
Transform cam = CamOverrides.Count > 0 || _jabStart >= 0f ? Camera() : null; Transform cam = CamOverrides.Count > 0 || _jabStart >= 0f ? Camera() : null;
ApplyJab(cam); ApplyJab(cam);
if (cam != null) if (cam != null)
@@ -265,6 +281,7 @@ namespace NecromancerTome
/// (сгиб - как у поджатых пальцев «пиф-пафа»), указательный и мизинец прямые.</summary> /// (сгиб - как у поджатых пальцев «пиф-пафа»), указательный и мизинец прямые.</summary>
public static void PresetDeny() public static void PresetDeny()
{ {
KnifeToLeft = false;
Overrides.Clear(); Overrides.Clear();
CamOverrides.Clear(); CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(345.8f, 34.9f, 24.3f); Overrides["RightArm"] = new Vector3(345.8f, 34.9f, 24.3f);
@@ -291,6 +308,7 @@ namespace NecromancerTome
/// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.</summary> /// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.</summary>
public static void PresetPifPaf() public static void PresetPifPaf()
{ {
KnifeToLeft = false;
Overrides.Clear(); Overrides.Clear();
CamOverrides.Clear(); CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f); Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
@@ -313,6 +331,111 @@ namespace NecromancerTome
Enabled = true; Enabled = true;
} }
/// <summary>Нож в левой кисти (хват для нитей): корень модели в руке ставится каждый кадр от LeftHand. Смещение -
/// снимок хвата 68 (деревянный лук) 16.10, где нож лежал в левой руке («Нож в левой руке»).</summary>
public static bool KnifeToLeft;
static readonly Vector3 KnifeLeftPos = new Vector3(-0.0832f, -0.0354f, 0.0186f);
static readonly Quaternion KnifeLeftRot = new Quaternion(0.56471f, -0.62192f, -0.41297f, 0.35182f);
static void ApplyKnifeToLeft()
{
Transform lh = Bone("LeftHand");
Transform m = HeldTweak.Model();
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (lh != null && m != null && (pl?.emodel as EModelSDCS)?.IsFPV == true)
{
m.SetPositionAndRotation(lh.TransformPoint(KnifeLeftPos), lh.rotation * KnifeLeftRot);
}
}
/// <summary>Хват для нитей (16.10, подбор с пользователем: «Нож в левую руку, а правую сделай как в том хвате, где
/// ты говорил что она перебирает нити»): основа - HoldType 68 (деревянный лук, items.xml); левая рука поднята и
/// отведена влево, нож в ней; правая - раскрытая ладонь хвата 64 (бинт), пересчитанная под плечо хвата 68 так,
/// чтобы кисть стояла там же относительно камеры (scratchpad ik.py).</summary>
public static void PresetThread()
{
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightShoulder"] = new Vector3(0.0f, 180.0f, 180.0f);
Overrides["Right_sup_shoulder_01"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_shoulder_02"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_trap_01"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightArm"] = new Vector3(311.4f, 24.9f, 39.2f);
Overrides["Right_sup_arm_01"] = new Vector3(0.0f, 0.0f, 358.2f);
Overrides["RightArmRoll"] = new Vector3(357.9f, 0.0f, 0.0f);
Overrides["RightForeArm"] = new Vector3(0.0f, 79.9f, 0.0f);
Overrides["Right_sup_elbow_00"] = new Vector3(0.0f, 332.8f, 0.0f);
Overrides["RightForeArmRoll"] = new Vector3(8.7f, 0.0f, 0.0f);
Overrides["RightHand"] = new Vector3(7.2f, 346.5f, 5.4f);
Overrides["RightHandIndex0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_index_00"] = new Vector3(0.0f, 0.0f, 17.3f);
Overrides["Right_sup_index_00_palm"] = new Vector3(0.0f, 0.0f, 15.6f);
Overrides["RightHandIndex1"] = new Vector3(2.6f, 2.9f, 31.3f);
Overrides["Right_sup_index_01_under"] = new Vector3(0.0f, 0.0f, 3.5f);
Overrides["RightHandIndex2"] = new Vector3(0.0f, 0.0f, 52.8f);
Overrides["Right_sup_index_02"] = new Vector3(0.0f, 0.0f, 333.6f);
Overrides["Right_sup_index_02_under"] = new Vector3(0.0f, 0.0f, 5.1f);
Overrides["RightHandIndex3"] = new Vector3(0.0f, 0.0f, 77.0f);
Overrides["Right_sup_index_03"] = new Vector3(0.0f, 0.0f, 321.5f);
Overrides["RightHandIndex4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandMiddle0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_middle_00"] = new Vector3(0.0f, 0.0f, 32.1f);
Overrides["Right_sup_middle_00_palm"] = new Vector3(0.0f, 0.0f, 28.8f);
Overrides["RightHandMiddle1"] = new Vector3(0.0f, 0.0f, 57.7f);
Overrides["Right_sup_middle_01_under"] = new Vector3(0.0f, 0.0f, 5.7f);
Overrides["RightHandMiddle2"] = new Vector3(0.0f, 0.0f, 85.0f);
Overrides["Right_sup_middle_02"] = new Vector3(0.0f, 0.0f, 317.5f);
Overrides["Right_sup_middle_02_under"] = new Vector3(0.0f, 0.0f, 5.1f);
Overrides["RightHandMiddle3"] = new Vector3(0.0f, 0.0f, 77.0f);
Overrides["Right_sup_middle_03"] = new Vector3(0.0f, 0.0f, 321.5f);
Overrides["RightHandMiddle4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandPinky0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_pinky_00"] = new Vector3(0.0f, 0.0f, 45.0f);
Overrides["Right_sup_pinky_00_palm"] = new Vector3(0.0f, 0.0f, 45.0f);
Overrides["RightHandPinky1"] = new Vector3(0.2f, 353.1f, 92.0f);
Overrides["Right_sup_pinky_01_under"] = new Vector3(0.0f, 0.0f, 5.6f);
Overrides["RightHandPinky2"] = new Vector3(0.0f, 0.0f, 84.0f);
Overrides["Right_sup_pinky_02"] = new Vector3(0.0f, 0.0f, 318.0f);
Overrides["Right_sup_pinky_02_under"] = new Vector3(0.0f, 0.0f, 5.2f);
Overrides["RightHandPinky3"] = new Vector3(0.0f, 0.0f, 78.0f);
Overrides["Right_sup_pinky_03"] = new Vector3(0.0f, 0.0f, 321.0f);
Overrides["RightHandPinky4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandRing0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_ring_00"] = new Vector3(0.0f, 0.0f, 33.0f);
Overrides["Right_sup_ring_00_palm"] = new Vector3(0.0f, 0.0f, 33.0f);
Overrides["RightHandRing1"] = new Vector3(358.6f, 355.2f, 66.1f);
Overrides["Right_sup_ring_01_under"] = new Vector3(0.0f, 0.0f, 5.5f);
Overrides["RightHandRing2"] = new Vector3(0.0f, 0.0f, 83.0f);
Overrides["Right_sup_ring_02"] = new Vector3(0.0f, 0.0f, 318.5f);
Overrides["Right_sup_ring_02_under"] = new Vector3(0.0f, 0.0f, 5.1f);
Overrides["RightHandRing3"] = new Vector3(0.0f, 0.0f, 77.0f);
Overrides["Right_sup_ring_03"] = new Vector3(0.0f, 0.0f, 321.5f);
Overrides["RightHandRing4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandThumb1"] = new Vector3(27.9f, 75.4f, 33.2f);
Overrides["RightHandThumb2"] = new Vector3(0.0f, 0.0f, 15.8f);
Overrides["Right_sup_thumb_01"] = new Vector3(0.0f, 0.0f, 352.1f);
Overrides["Right_sup_thumb_01_under"] = new Vector3(0.0f, 0.0f, 1.2f);
Overrides["RightHandThumb3"] = new Vector3(0.0f, 0.0f, 17.9f);
Overrides["Right_sup_thumb_02"] = new Vector3(0.0f, 0.0f, 351.0f);
Overrides["RightHandThumb4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["necroKnifePrefab(Clone)"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["boneShiv_LOD0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["boneShiv_LOD1"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["boneShiv_LOD2"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightForeArmRoll1"] = new Vector3(3.5f, 0.0f, 0.0f);
Overrides["RightForeArmRoll2"] = new Vector3(7.0f, 0.0f, 0.0f);
Overrides["RightForeArmRoll3"] = new Vector3(10.5f, 0.0f, 0.0f);
Overrides["RightForeArmRoll4"] = new Vector3(14.0f, 0.0f, 0.0f);
Overrides["Right_sup_wrist_00"] = new Vector3(0.0f, 0.0f, 357.5f);
Overrides["Right_sup_wrist_01"] = new Vector3(0.0f, 347.6f, 0.0f);
Overrides["Right_sup_wrist_02"] = new Vector3(0.0f, 0.0f, 356.8f);
Overrides["Right_sup_wrist_03"] = new Vector3(0.0f, 347.6f, 0.0f);
Overrides["LeftArm"] = new Vector3(329.6f, 42.5f, 8.5f);
KnifeToLeft = true;
Unfreeze();
Enabled = true;
}
public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture); 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 static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
} }
@@ -324,6 +447,9 @@ namespace NecromancerTome
HandHolds.Update(); HandHolds.Update();
HandPose.Apply(); HandPose.Apply();
HeldTweak.Apply(); HeldTweak.Apply();
NecroView.Update();
NecroBody.LateUpdate();
ThreadKnifeBody.LateUpdate();
} }
} }
+37 -2
View File
@@ -72,19 +72,44 @@ namespace NecromancerTome
} }
} }
/// <summary>Сменить хват (HoldType) предмета в руке и пересобрать его в руке, как BloodTide (16.10: «давай попробуем в
/// режиме нитей разные хваты. Ты будешь перебирать их по моей просьбе»). Меняется класс предмета - до перезапуска
/// игры или следующей команды; итог переносится в items.xml.</summary>
public static string SetHold(int _hold)
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
Inventory inv = pl?.inventory;
ItemInventoryData held = inv?.holdingItemData;
if (held?.item == null)
{
return "в руке ничего";
}
if (pl.Hand != null && pl.Hand.isSwitching || inv.IsHoldingItemActionRunning())
{
return "рука занята - повторите";
}
int was = held.item.HoldType.Value;
held.item.HoldType.Value = _hold;
ItemValue pv = (held.physicalItemValue ?? held.itemValue).Clone();
pv.Seed = (ushort)(pv.Seed + 1); // «собранный» предмет устарел - соберётся заново (NeedsRebuild)
held.physicalItemValue = pv;
inv.SetHoldingItemIdx(inv.holdingItemIdx);
return held.item.Name + ": хват " + was + " -> " + _hold;
}
public static string F(float _v) => _v.ToString("0.###", CultureInfo.InvariantCulture); 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 static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
} }
/// <summary> /// <summary>
/// necroheld - посадка модели в руке: /// necroheld - посадка модели в руке:
/// pos x y z (см) | add x y z (см) | rot x y z (градусы) | scale s | off | show /// pos x y z (см) | add x y z (см) | rot x y z (градусы) | scale s | hold N (хват, HoldType) | off | show
/// </summary> /// </summary>
[Preserve] [Preserve]
public class ConsoleCmdNecroHeld : ConsoleCmdAbstract public class ConsoleCmdNecroHeld : ConsoleCmdAbstract
{ {
public override string[] getCommands() => new[] { "necroheld" }; public override string[] getCommands() => new[] { "necroheld" };
public override string getDescription() => "NecromancerTome: посадка модели в руке - pos x y z (см)|add x y z (см)|rot x y z|scale s|off|show"; public override string getDescription() => "NecromancerTome: посадка модели в руке - pos x y z (см)|add x y z (см)|rot x y z|scale s|hold N|off|show";
static bool Num(List<string> _p, int _i, out float _v) static bool Num(List<string> _p, int _i, out float _v)
{ {
@@ -117,6 +142,16 @@ namespace NecromancerTome
case "scale" when Num(_params, 1, out float s) && s > 0f: case "scale" when Num(_params, 1, out float s) && s > 0f:
HeldTweak.Scale = s; HeldTweak.Scale = s;
break; break;
case "tphold" when Num(_params, 1, out float th):
ThreadKnifeBody.TpHold = (int)th;
Out("хват тела для ножа-для-нитей: " + ThreadKnifeBody.TpHold + (ThreadKnifeBody.TpHold > 0 ? "" : " (выкл - как у рук)") + "; место в руке - при следующем доставании");
return;
case "hold" when Num(_params, 1, out float h):
Out(HeldTweak.SetHold((int)h));
return;
case "hold":
Out("хват предмета в руке: " + (GameManager.Instance?.World?.GetPrimaryPlayer()?.inventory?.holdingItem?.HoldType.Value.ToString() ?? "нет"));
return;
case "off": case "off":
HeldTweak.Enabled = false; HeldTweak.Enabled = false;
HeldTweak.Pos = Vector3.zero; HeldTweak.Pos = Vector3.zero;
+694
View File
@@ -0,0 +1,694 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Правка аниматора ТЕЛА игрока (вид от третьего лица, его же видят другие) прямо в игре (16.10: «Добавь все необходимые
/// команды для аниматора тела, чтобы можно было править прямо в игре»). Пара к necropose/necrobones, которые - для рук
/// первого лица. Всё держится до necrobody off или выхода из мира; итог переносится в код (necrobody save).
/// Параметры ставятся после Update аватара (иначе игра перезапишет их до расчёта аниматора), кости - в LateUpdate.
/// </summary>
public static class NecroBody
{
/// <summary>Параметры аниматора тела, которые держим своими (имя -> значение; bool - 0/1).</summary>
public static readonly Dictionary<string, float> Params = new Dictionary<string, float>();
/// <summary>Локальные повороты костей тела поверх анимации.</summary>
public static readonly Dictionary<string, Vector3> Pose = new Dictionary<string, Vector3>();
public static bool Freeze;
public static float Speed = 1f;
/// <summary>Место предмета в руке тела (поверх смещения игры по хвату), если задано.</summary>
public static Vector3? ItemPos, ItemRot;
static Animator _anim;
static readonly Dictionary<string, Transform> _bones = new Dictionary<string, Transform>();
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 int _playerId = -1;
/// <summary>Любая загруженная анимация на теле (16.10: «Может и танцевать сможешь заставить?.. сильно бы помогло в
/// создании контента»): клип играется поверх контроллера через Playables, по кругу. Танцы есть у зомби (twitch_dance) -
/// их клипы в памяти, пока в мире есть зомби с таким аниматором.</summary>
static PlayableGraph _graph;
static AnimationClipPlayable _clip;
public static string Playing;
public static List<AnimationClip> FindClips(string _filter)
{
var list = new List<AnimationClip>();
var seen = new HashSet<string>();
foreach (AnimationClip c in Resources.FindObjectsOfTypeAll<AnimationClip>())
{
if (c != null && (_filter.Length == 0 || c.name.IndexOf(_filter, System.StringComparison.OrdinalIgnoreCase) >= 0) && seen.Add(c.name))
{
list.Add(c);
}
}
list.Sort((x, y) => string.CompareOrdinal(x.name, y.name));
return list;
}
public static string Play(string _name, float _speed)
{
Animator a = Anim();
if (a == null)
{
return "нет аниматора тела";
}
AnimationClip clip = null;
foreach (AnimationClip c in FindClips(_name))
{
if (c.name == _name)
{
clip = c;
break;
}
clip = clip ?? c;
}
if (clip == null)
{
return "нет загруженной анимации «" + _name + "» (necrobody anims слово)";
}
StopPlay();
_graph = PlayableGraph.Create("NecroBodyPlay");
_graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
var output = AnimationPlayableOutput.Create(_graph, "body", a);
_clip = AnimationClipPlayable.Create(_graph, clip);
_clip.SetSpeed(_speed);
output.SetSourcePlayable(_clip);
_graph.Play();
Playing = clip.name;
return "играет " + clip.name + " (" + NecroBody.F(clip.length) + " с" + (clip.isLooping ? ", петля" : "") + (clip.humanMotion ? ", гуманоид" : ", не гуманоид - может лечь криво") + "), скорость " + NecroBody.F(_speed);
}
/// <summary>Тело повторяет движения другого существа (16.10: танец зомби не лёг на тело игрока через Playables - клип
/// гуманоидный, а тело игрока - нет). Каждый кадр локальные повороты костей источника копируются на кости тела с теми же
/// именами (Hips, Spine, LeftArm...); у бёдер - ещё и сдвиг. Источник - зомби, которого necrobody zdance заставляет танцевать.</summary>
static EntityAlive _mimic;
static readonly List<Transform> _mimicSrc = new List<Transform>();
static readonly List<Transform> _mimicDst = new List<Transform>();
public static EntityAlive Nearest(float _radius, bool _zombiesOnly)
{
EntityPlayerLocal pl = Player();
if (pl == null)
{
return null;
}
EntityAlive best = null;
float bestD = _radius * _radius;
foreach (Entity e in GameManager.Instance.World.Entities.list)
{
if (e is EntityAlive a && a != pl && !a.IsDead() && (!_zombiesOnly || a is EntityZombie))
{
float d = (a.position - pl.position).sqrMagnitude;
if (d < bestD)
{
bestD = d;
best = a;
}
}
}
return best;
}
static Transform ModelRoot(EntityAlive _e)
{
if (_e == null || _e.emodel == null)
{
return null;
}
if (_e.emodel.avatarController is AvatarCharacterController ac && ac.CharacterBody?.Animator != null)
{
return ac.CharacterBody.Animator.transform;
}
return _e.emodel.GetModelTransform();
}
public static string Mimic(EntityAlive _src)
{
StopMimic();
Animator a = Anim();
Transform src = ModelRoot(_src);
if (a == null || src == null)
{
return "нет тела игрока или источника";
}
var byName = new Dictionary<string, Transform>();
foreach (Transform t in src.GetComponentsInChildren<Transform>(true))
{
if (!byName.ContainsKey(t.name))
{
byName[t.name] = t;
}
}
foreach (Transform t in a.GetComponentsInChildren<Transform>(true))
{
if (t != a.transform && byName.TryGetValue(t.name, out Transform s2) && s2 != src)
{
_mimicDst.Add(t);
_mimicSrc.Add(s2);
}
}
_mimic = _src;
return "тело повторяет за " + _src.EntityName + " (id " + _src.entityId + "): общих костей " + _mimicDst.Count;
}
public static void StopMimic()
{
_mimic = null;
_mimicSrc.Clear();
_mimicDst.Clear();
}
static void ApplyMimic()
{
if (_mimic == null)
{
return;
}
if (_mimic.IsDead() || _mimicSrc.Count == 0 || _mimicSrc[0] == null)
{
StopMimic();
return;
}
for (int i = 0; i < _mimicDst.Count; i++)
{
Transform s = _mimicSrc[i], d = _mimicDst[i];
if (s == null || d == null)
{
continue;
}
d.localRotation = s.localRotation;
if (d.name == "Hips")
{
d.localPosition = s.localPosition;
}
}
}
public static void StopPlay()
{
if (_graph.IsValid())
{
_graph.Destroy();
}
Playing = null;
}
/// <summary>Петля для клипов без своей петли.</summary>
static void LoopPlay()
{
if (!_graph.IsValid())
{
return;
}
AnimationClip c = _clip.GetAnimationClip();
if (c != null && !c.isLooping && _clip.GetTime() >= c.length)
{
_clip.SetTime(0);
}
}
public static EntityPlayerLocal Player() => GameManager.Instance?.World?.GetPrimaryPlayer();
public static BodyAnimator Body()
{
return (Player()?.emodel?.avatarController as AvatarCharacterController)?.CharacterBody;
}
public static Animator Anim() => Body()?.Animator;
public static bool Active => Params.Count > 0 || Pose.Count > 0 || Freeze || Speed != 1f || ItemPos.HasValue || ItemRot.HasValue;
public static Transform Bone(string _name)
{
Animator a = Anim();
if (a == null)
{
return null;
}
if (a != _anim)
{
_anim = a;
_bones.Clear();
_frozen.Clear();
foreach (Transform t in a.GetComponentsInChildren<Transform>(true))
{
if (!_bones.ContainsKey(t.name))
{
_bones[t.name] = t;
}
}
}
return _bones.TryGetValue(_name, out Transform b) ? b : null;
}
public static void Reset()
{
StopPlay();
StopMimic();
Params.Clear();
Pose.Clear();
Freeze = false;
_frozen.Clear();
ItemPos = null;
ItemRot = null;
if (Speed != 1f)
{
Speed = 1f;
Animator a = Anim();
if (a != null)
{
a.speed = 1f;
}
}
}
public static void Unfreeze()
{
_frozen.Clear();
_frozenPos.Clear();
_frozenRot.Clear();
}
/// <summary>Параметр по имени: тип берётся из аниматора. false - нет такого.</summary>
public static bool SetParam(Animator _a, string _name, float _v)
{
foreach (AnimatorControllerParameter p in _a.parameters)
{
if (p.name != _name)
{
continue;
}
switch (p.type)
{
case AnimatorControllerParameterType.Float: _a.SetFloat(p.nameHash, _v); break;
case AnimatorControllerParameterType.Int: _a.SetInteger(p.nameHash, (int)_v); break;
case AnimatorControllerParameterType.Bool: _a.SetBool(p.nameHash, _v != 0f); break;
case AnimatorControllerParameterType.Trigger: _a.SetTrigger(p.nameHash); break;
}
return true;
}
return false;
}
/// <summary>После Update аватара (заплатка ниже) - до расчёта аниматора в этом кадре.</summary>
public static void ApplyParams()
{
if (Params.Count == 0 && Speed == 1f)
{
return;
}
Animator a = Anim();
if (a == null)
{
return;
}
foreach (KeyValuePair<string, float> kv in Params)
{
SetParam(a, kv.Key, kv.Value);
}
a.speed = Speed;
}
/// <summary>Из HandPoseDriver.LateUpdate.</summary>
public static void LateUpdate()
{
EntityPlayerLocal pl = Player();
int id = pl != null ? pl.entityId : -1;
if (id != _playerId)
{
_playerId = id;
Reset();
_anim = null;
_bones.Clear();
return;
}
LoopPlay();
ApplyMimic();
if (!Active || Bone("") == null && _anim == null)
{
return;
}
if (Freeze)
{
if (_frozen.Count == 0 || _frozen[0] == null)
{
Unfreeze();
foreach (Transform t in _anim.GetComponentsInChildren<Transform>(true))
{
_frozen.Add(t);
_frozenPos.Add(t.localPosition);
_frozenRot.Add(t.localRotation);
}
Debug.Log("[NecromancerTome] necrobody: заморожено костей " + _frozen.Count);
}
else
{
for (int i = 0; i < _frozen.Count; i++)
{
if (_frozen[i] != null)
{
_frozen[i].localPosition = _frozenPos[i];
_frozen[i].localRotation = _frozenRot[i];
}
}
}
}
foreach (KeyValuePair<string, Vector3> kv in Pose)
{
Transform b = Bone(kv.Key);
if (b != null)
{
b.localRotation = Quaternion.Euler(kv.Value);
}
}
if ((ItemPos.HasValue || ItemRot.HasValue) && pl.emodel != null && !pl.emodel.IsFPV)
{
Transform item = (pl.emodel.avatarController as AvatarMultiBodyController)?.heldItemTransform;
if (item != null)
{
if (ItemPos.HasValue)
{
item.localPosition = ItemPos.Value;
}
if (ItemRot.HasValue)
{
item.localEulerAngles = ItemRot.Value;
}
}
}
}
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);
}
[HarmonyPatch(typeof(AvatarMultiBodyController), "Update")]
public static class Patch_AvatarMultiBodyController_Update_NecroBody
{
public static void Postfix(AvatarMultiBodyController __instance)
{
if (NecroBody.Params.Count > 0 || NecroBody.Speed != 1f)
{
EntityPlayerLocal pl = NecroBody.Player();
if (pl != null && __instance.Entity == pl)
{
NecroBody.ApplyParams();
}
}
}
}
/// <summary>
/// necrobody - аниматор тела игрока (вид от третьего лица):
/// params [фильтр] - параметры аниматора и значения
/// clips - что играет в каждом слое (клип, вес слоя)
/// set &lt;параметр&gt; &lt;число&gt; - держать значение (bool 0/1); unset &lt;параметр&gt;; trigger &lt;параметр&gt;
/// speed &lt;k&gt; - скорость аниматора (0 - пауза)
/// freeze [off] - замереть в текущем кадре (все кости)
/// bones [фильтр] - кости тела с локальными поворотами в лог
/// show &lt;кость&gt;; pose &lt;кость&gt; x y z; add &lt;кость&gt; dx dy dz; unpose &lt;кость&gt;
/// item pos x y z (см) | rot x y z | off | show - место предмета в руке тела
/// holdoffset [N] - смещение игры для хвата N (по умолчанию - предмета в руке)
/// save - всё заданное строками в лог; off - всё снять
/// </summary>
[Preserve]
public class ConsoleCmdNecroBody : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrobody" };
public override string getDescription() => "NecromancerTome: аниматор тела - params [ф]|clips|set п v|unset п|trigger п|speed k|freeze [off]|bones [ф]|show к|pose к x y z|add к dx dy dz|unpose к|item pos|rot|off|show|holdoffset [N]|anims [слово]|play имя [скорость]|play off|zdance [off]|mimic [id]|mimic off|save|off";
static bool Num(List<string> _p, int _i, out float _v)
{
_v = 0f;
return _p.Count > _i && float.TryParse(_p[_i].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
}
static bool Vec(List<string> _p, int _i, out Vector3 _v)
{
_v = Vector3.zero;
if (Num(_p, _i, out float x) && Num(_p, _i + 1, out float y) && Num(_p, _i + 2, out float z))
{
_v = new Vector3(x, y, z);
return true;
}
return false;
}
static void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
foreach (string line in _s.Split('\n'))
{
Debug.Log("[NecromancerTome] necrobody: " + line);
}
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
Animator a = NecroBody.Anim();
if (a == null)
{
Out("нет аниматора тела (игрок не в мире?)");
return;
}
string cmd = _params.Count > 0 ? _params[0] : "";
string arg = _params.Count > 1 ? _params[1] : "";
switch (cmd)
{
case "params":
{
var sb = new StringBuilder("аниматор " + a.name + ", контроллер " + (a.runtimeAnimatorController != null ? a.runtimeAnimatorController.name : "нет"));
foreach (AnimatorControllerParameter p in a.parameters)
{
if (arg.Length > 0 && p.name.IndexOf(arg, System.StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
string v = p.type == AnimatorControllerParameterType.Float ? NecroBody.F(a.GetFloat(p.nameHash))
: p.type == AnimatorControllerParameterType.Int ? a.GetInteger(p.nameHash).ToString()
: p.type == AnimatorControllerParameterType.Bool ? (a.GetBool(p.nameHash) ? "1" : "0") : "trigger";
sb.Append("\n ").Append(p.name).Append(" (").Append(p.type).Append(") = ").Append(v)
.Append(NecroBody.Params.ContainsKey(p.name) ? " [держим]" : "");
}
Out(sb.ToString());
return;
}
case "clips":
{
var sb = new StringBuilder("слоёв " + a.layerCount);
for (int l = 0; l < a.layerCount; l++)
{
string names = "";
foreach (AnimatorClipInfo c in a.GetCurrentAnimatorClipInfo(l))
{
names += (names.Length > 0 ? ", " : "") + (c.clip != null ? c.clip.name : "?") + " x" + c.weight.ToString("0.##");
}
sb.Append("\n ").Append(l).Append(' ').Append(a.GetLayerName(l)).Append(" вес ").Append(a.GetLayerWeight(l).ToString("0.##")).Append(": ").Append(names);
}
Out(sb.ToString());
return;
}
case "set" when Num(_params, 2, out float v):
if (!NecroBody.SetParam(a, arg, v))
{
Out("нет параметра " + arg + " (necrobody params)");
return;
}
NecroBody.Params[arg] = v;
Out(arg + " = " + NecroBody.F(v) + " (держим; unset - отпустить)");
return;
case "unset":
Out(NecroBody.Params.Remove(arg) ? arg + " отпущен" : arg + " не был задан");
return;
case "trigger":
Out(NecroBody.SetParam(a, arg, 1f) ? "сработал " + arg : "нет параметра " + arg);
return;
case "speed" when Num(_params, 1, out float k):
NecroBody.Speed = k;
a.speed = k;
Out("скорость аниматора тела " + NecroBody.F(k));
return;
case "freeze":
NecroBody.Freeze = arg != "off";
NecroBody.Unfreeze();
Out("заморозка тела " + (NecroBody.Freeze ? "включена (снимок - в этом кадре)" : "выключена"));
return;
case "bones":
{
var sb = new StringBuilder();
int n = 0;
Dump(a.transform, 0, arg, sb, ref n);
Out("костей " + n + (arg.Length > 0 ? " (фильтр " + arg + ")" : "") + sb);
return;
}
case "show":
{
Transform b = NecroBody.Bone(arg);
Out(b == null ? "нет кости " + arg : arg + " = " + NecroBody.F(b.localEulerAngles) + (NecroBody.Pose.ContainsKey(arg) ? " (задана)" : " (от аниматора)") + ", поз. " + NecroBody.F(b.localPosition));
return;
}
case "pose" when Vec(_params, 2, out Vector3 r):
if (NecroBody.Bone(arg) == null)
{
Out("нет кости " + arg);
return;
}
NecroBody.Pose[arg] = r;
Out(arg + " = " + NecroBody.F(r));
return;
case "add" when Vec(_params, 2, out Vector3 d):
{
Transform b = NecroBody.Bone(arg);
if (b == null)
{
Out("нет кости " + arg);
return;
}
Vector3 cur = NecroBody.Pose.TryGetValue(arg, out Vector3 was) ? was : b.localEulerAngles;
NecroBody.Pose[arg] = cur + d;
Out(arg + " = " + NecroBody.F(NecroBody.Pose[arg]));
return;
}
case "unpose":
Out(NecroBody.Pose.Remove(arg) ? arg + " отпущена" : arg + " не была задана");
return;
case "item":
{
Transform it = (NecroBody.Player()?.emodel?.avatarController as AvatarMultiBodyController)?.heldItemTransform;
if (arg == "pos" && Vec(_params, 2, out Vector3 p))
{
NecroBody.ItemPos = p / 100f;
}
else if (arg == "rot" && Vec(_params, 2, out Vector3 r2))
{
NecroBody.ItemRot = r2;
}
else if (arg == "off")
{
NecroBody.ItemPos = null;
NecroBody.ItemRot = null;
Out("место предмета - как у игры (при следующем доставании)");
return;
}
Out("предмет в руке тела " + (it != null ? it.name + ": поз. (см) " + NecroBody.F(it.localPosition * 100f) + ", пов. " + NecroBody.F(it.localEulerAngles) : "нет") +
(NecroBody.ItemPos.HasValue || NecroBody.ItemRot.HasValue ? " [держим]" : ""));
return;
}
case "holdoffset":
{
int h = Num(_params, 1, out float hv) ? (int)hv : NecroBody.Player()?.inventory?.holdingItem?.HoldType.Value ?? 0;
var o = AnimationGunjointOffsetData.AnimationGunjointOffset[h];
Out("хват " + h + ": смещение игры поз. (см) " + NecroBody.F(o.position * 100f) + ", пов. " + NecroBody.F(o.rotation));
return;
}
case "save":
{
var sb = new StringBuilder("заданное:");
foreach (var kv in NecroBody.Params)
{
sb.Append("\n param ").Append(kv.Key).Append(" = ").Append(NecroBody.F(kv.Value));
}
foreach (var kv in NecroBody.Pose)
{
sb.Append("\n Pose[\"").Append(kv.Key).Append("\"] = new Vector3(").Append(NecroBody.F(kv.Value.x)).Append("f, ")
.Append(NecroBody.F(kv.Value.y)).Append("f, ").Append(NecroBody.F(kv.Value.z)).Append("f);");
}
if (NecroBody.ItemPos.HasValue)
{
sb.Append("\n item pos (см) ").Append(NecroBody.F(NecroBody.ItemPos.Value * 100f));
}
if (NecroBody.ItemRot.HasValue)
{
sb.Append("\n item rot ").Append(NecroBody.F(NecroBody.ItemRot.Value));
}
sb.Append("\n speed ").Append(NecroBody.F(NecroBody.Speed)).Append(", freeze ").Append(NecroBody.Freeze);
Out(sb.ToString());
return;
}
case "anims":
{
List<AnimationClip> l = NecroBody.FindClips(arg);
var sb = new StringBuilder("анимаций в памяти" + (arg.Length > 0 ? " со словом " + arg : "") + ": " + l.Count);
int shown = 0;
foreach (AnimationClip c in l)
{
if (shown++ >= 200)
{
sb.Append("\n ... (уточните слово)");
break;
}
sb.Append("\n ").Append(c.name).Append(" ").Append(NecroBody.F(c.length)).Append(" с").Append(c.humanMotion ? "" : " [не гуманоид]");
}
Out(sb.ToString());
return;
}
case "zdance":
{
EntityAlive z = NecroBody.Nearest(30f, true);
if (z == null)
{
Out("рядом (30 м) нет зомби");
return;
}
z.SetDancing(arg != "off");
Out(z.EntityName + " (id " + z.entityId + ") " + (arg != "off" ? "танцует" : "перестал танцевать"));
return;
}
case "mimic" when arg == "off":
NecroBody.StopMimic();
Out("тело больше не повторяет");
return;
case "mimic":
{
EntityAlive src = null;
if (Num(_params, 1, out float mid))
{
src = GameManager.Instance.World.GetEntity((int)mid) as EntityAlive;
}
else
{
src = NecroBody.Nearest(30f, false);
}
Out(src == null ? "нет источника (id или существо в 30 м)" : NecroBody.Mimic(src));
return;
}
case "play" when arg == "off":
NecroBody.StopPlay();
Out("анимация снята");
return;
case "play" when arg.Length > 0:
Out(NecroBody.Play(arg, Num(_params, 2, out float ps) ? ps : 1f));
return;
case "off":
NecroBody.Reset();
Out("тело - как у игры");
return;
}
Out(getDescription());
}
static void Dump(Transform _t, int _depth, string _filter, StringBuilder _sb, ref int _n)
{
if (_filter.Length == 0 || _t.name.IndexOf(_filter, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
_n++;
_sb.Append('\n').Append(new string(' ', _depth * 2)).Append(_t.name).Append(_t.gameObject.activeSelf ? "" : " [выкл]")
.Append(" пов. ").Append(NecroBody.F(_t.localEulerAngles)).Append(" поз. ").Append(NecroBody.F(_t.localPosition));
}
for (int i = 0; i < _t.childCount; i++)
{
Dump(_t.GetChild(i), _depth + 1, _filter, _sb, ref _n);
}
}
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Подрастить растения вокруг игрока (16.10, для проверки работы духа урны на грядках): каждое растущее растение
/// (BlockPlantGrowing) в радиусе переходит на следующие стадии родным BlockPlantGrowing.ForceNextGrowStage.
/// </summary>
[Preserve]
public class ConsoleCmdNecroGrow : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => false;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necrogrow" };
}
public override string getDescription()
{
return "Necromancer's Tome: grow the plants around you by one or more stages (testing).";
}
public override string getHelp()
{
return "necrogrow [radius] [stages] - advance every growing plant within radius (default 8) by stages (default 9 = ripe)";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
World world = GameManager.Instance.World;
EntityPlayer player = null;
if (_senderInfo.RemoteClientInfo != null)
{
world.Players.dict.TryGetValue(_senderInfo.RemoteClientInfo.entityId, out player);
}
else
{
player = world.GetPrimaryPlayer();
}
if (player == null)
{
con.Output("necrogrow: no player");
return;
}
int radius = 8, stages = 9;
if (_params.Count >= 1)
{
int.TryParse(_params[0], out radius);
}
if (_params.Count >= 2)
{
int.TryParse(_params[1], out stages);
}
radius = Mathf.Clamp(radius, 1, 32);
Vector3i c = World.worldToBlockPos(player.GetPosition());
int plants = 0, steps = 0;
for (int dx = -radius; dx <= radius; dx++)
{
for (int dz = -radius; dz <= radius; dz++)
{
for (int dy = -4; dy <= 4; dy++)
{
Vector3i p = new Vector3i(c.x + dx, c.y + dy, c.z + dz);
BlockValue bv = world.GetBlock(p);
if (bv.ischild || !(bv.Block is BlockPlantGrowing))
{
continue;
}
plants++;
for (int s = 0; s < stages && bv.Block is BlockPlantGrowing g && !g.nextPlant.isair; s++)
{
BlockValue next = g.ForceNextGrowStage(world, p, bv);
if (next.type == bv.type)
{
break;
}
world.SetBlockRPC(p, next);
bv = next;
steps++;
}
}
}
}
con.Output("necrogrow: " + plants + " growing plant(s) within " + radius + ", " + steps + " stage(s) advanced");
}
}
}
+271
View File
@@ -0,0 +1,271 @@
using System.Collections.Generic;
using System.Globalization;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Осмотр рук от первого лица со стороны (16.10: «зафиксировать командой позу игрока и осматривать его камерой... хват
/// от первого лица не позволяет видеть всех нюансов»). Руки первого лица (baseRigFP) - отдельная модель под камерой
/// игрока, в виде от третьего лица их нет, а у тела свои анимации. Поэтому вид остаётся от первого лица, а поверх
/// рисует своя камера, которая облетает руки. Её место задано в осях камеры игрока: повернёшь голову - вид на руки
/// не меняется. Поза замирает (снимок всех костей, как у хватов мода).
/// Управление - цифровой блок: 4/6 вокруг, 8/2 выше/ниже, +/- ближе/дальше, 5 - сначала; или командой necroview.
/// </summary>
public static class NecroView
{
public static bool On;
public static float Yaw, Pitch, Dist = 0.6f;
/// <summary>Точка, вокруг которой летает камера (между кистями), - в осях камеры игрока.</summary>
static Vector3 _target;
static Camera _cam;
const float TurnSpeed = 60f, ZoomSpeed = 0.4f;
/// <summary>Мышь облетает руки, персонаж стоит (16.10: «я поворачиваю мышь, а туловище проворачивается следом, и я
/// вижу тело только с одной позиции»). Поворот игрока каждый кадр возвращается к снимку при включении.</summary>
public static bool Mouse = true;
public static float MouseSpeed = 3f;
static Vector3 _lockRot;
static Quaternion _lockCam;
static bool _mouseBroken;
/// <summary>«Выйти из тела» (16.10: «временно мог выйти из тела, когда попрошу? Чтобы мог ходить вокруг тела и
/// осматривать»): камера летает свободно - WASD, мышь, пробел/Ctrl - выше/ниже, Shift - быстрее; тело стоит
/// (PlayerMoveController.Update пропускается, как при одержимости), поза рук замерла. Выход - End или necroview off.</summary>
public static bool Free;
static Vector3 _freePos;
static float _freeYaw, _freePitch;
const float FlySpeed = 1.5f;
public static string StartFree()
{
string r = Start(0f, 0f, 0.6f);
Transform pc = HandPose.Camera();
if (!On || pc == null)
{
return r;
}
Free = true;
// Руки первого лица (слой HoldingItem) вне тела не рисуем: 16.10 они висели над телом без головы.
_cam.cullingMask &= ~(1 << LayerMask.NameToLayer("HoldingItem"));
_freePos = pc.position;
Vector3 e = pc.eulerAngles;
_freeYaw = e.y;
_freePitch = e.x > 180f ? e.x - 360f : e.x;
return "вне тела: WASD, мышь, пробел/Ctrl - выше/ниже, Shift - быстрее, End - вернуться";
}
static void UpdateFree(float _dt)
{
PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
if (!_mouseBroken)
{
try
{
_freeYaw += Input.GetAxis("Mouse X") * sens.x;
_freePitch = Mathf.Clamp(_freePitch - Input.GetAxis("Mouse Y") * sens.y, -89f, 89f);
}
catch (System.Exception e)
{
_mouseBroken = true;
Debug.LogWarning("[NecromancerTome] necroview: мышь не читается (" + e.Message + ")");
}
}
Quaternion rot = Quaternion.Euler(_freePitch, _freeYaw, 0f);
Vector3 move = Vector3.zero;
if (Input.GetKey(KeyCode.W)) move += Vector3.forward;
if (Input.GetKey(KeyCode.S)) move += Vector3.back;
if (Input.GetKey(KeyCode.A)) move += Vector3.left;
if (Input.GetKey(KeyCode.D)) move += Vector3.right;
Vector3 world = rot * move;
if (Input.GetKey(KeyCode.Space)) world += Vector3.up;
if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.C)) world += Vector3.down;
float speed = FlySpeed * (Input.GetKey(KeyCode.LeftShift) ? 4f : 1f);
_freePos += world * speed * _dt;
_cam.transform.SetPositionAndRotation(_freePos, rot);
}
public static string Start(float _yaw, float _pitch, float _dist)
{
Transform pc = HandPose.Camera();
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pc == null || pl?.playerCamera == null)
{
return "нет камеры игрока";
}
Transform l = HandPose.Bone("LeftHand"), r = HandPose.Bone("RightHand");
Vector3 mid = l != null && r != null ? (l.position + r.position) * 0.5f : pc.position + pc.forward * 0.4f;
_target = pc.InverseTransformPoint(mid);
Yaw = _yaw;
Pitch = _pitch;
Dist = _dist;
if (_cam == null)
{
GameObject go = new GameObject("NecroViewCam");
Object.DontDestroyOnLoad(go);
_cam = go.AddComponent<Camera>();
}
_cam.CopyFrom(pl.playerCamera);
// Сразу над камерой игрока, но ПОД камерами интерфейса (16.10: с +50 рисовала поверх меню - «мне из игры не выйти даже»).
float above = float.MaxValue;
foreach (Camera c in Camera.allCameras)
{
if (c != _cam && c.depth > pl.playerCamera.depth && c.depth < above)
{
above = c.depth;
}
}
_cam.depth = above < float.MaxValue ? (pl.playerCamera.depth + above) * 0.5f : pl.playerCamera.depth + 1f;
_cam.nearClipPlane = 0.01f;
_cam.gameObject.SetActive(true);
HandPose.HoldStill = true;
_lockRot = pl.rotation;
_lockCam = pc.localRotation;
On = true;
return "осмотр включён: вокруг " + Yaw + ", высота " + Pitch + ", расстояние " + Dist;
}
public static void Stop()
{
On = false;
Free = false;
HandPose.HoldStill = false;
if (_cam != null)
{
Object.Destroy(_cam.gameObject);
_cam = null;
}
}
/// <summary>Из HandPoseDriver.LateUpdate - после позы.</summary>
public static void Update()
{
if (!On)
{
return;
}
Transform pc = HandPose.Camera();
if (pc == null || _cam == null)
{
Stop();
return;
}
float dt = Time.unscaledDeltaTime;
if (Free)
{
if (Input.GetKeyDown(KeyCode.End))
{
Stop();
return;
}
UpdateFree(dt);
return;
}
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (Mouse && pl != null && !_mouseBroken)
{
try
{
Yaw += Input.GetAxis("Mouse X") * MouseSpeed;
Pitch = Mathf.Clamp(Pitch - Input.GetAxis("Mouse Y") * MouseSpeed, -85f, 85f);
}
catch (System.Exception e)
{
_mouseBroken = true; // нет оси мыши в старом вводе - остаётся цифровой блок
Debug.LogWarning("[NecromancerTome] necroview: мышь не читается (" + e.Message + ")");
}
pl.rotation = _lockRot;
pc.localRotation = _lockCam;
}
if (Input.GetKey(KeyCode.Keypad4)) Yaw -= TurnSpeed * dt;
if (Input.GetKey(KeyCode.Keypad6)) Yaw += TurnSpeed * dt;
if (Input.GetKey(KeyCode.Keypad8)) Pitch = Mathf.Min(85f, Pitch + TurnSpeed * dt);
if (Input.GetKey(KeyCode.Keypad2)) Pitch = Mathf.Max(-85f, Pitch - TurnSpeed * dt);
if (Input.GetKey(KeyCode.KeypadPlus)) Dist = Mathf.Max(0.1f, Dist - ZoomSpeed * dt);
if (Input.GetKey(KeyCode.KeypadMinus)) Dist = Mathf.Min(3f, Dist + ZoomSpeed * dt);
if (Input.GetKeyDown(KeyCode.Keypad5))
{
Yaw = 0f;
Pitch = 0f;
Dist = 0.6f;
}
// Yaw 0, Pitch 0 - смотрим на руки со стороны глаз игрока (сзади), 180 - спереди.
Quaternion local = Quaternion.Euler(Pitch, Yaw, 0f);
Vector3 offset = local * new Vector3(0f, 0f, -Dist);
Vector3 t = pc.TransformPoint(_target);
_cam.transform.SetPositionAndRotation(t + pc.rotation * offset, pc.rotation * local);
}
public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture);
}
/// <summary>necroview on [вокруг высота расстояние] | off | set вокруг высота расстояние | show</summary>
[Preserve]
public class ConsoleCmdNecroView : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroview" };
public override string getDescription() => "NecromancerTome: осмотр рук со стороны - on [вокруг высота расстояние]|off|set вокруг высота расстояние|free (выйти из тела, End - назад)|mouse [off]|show; мышь или цифровой блок 4/6 8/2 +/- 5";
static bool Num(List<string> _p, int _i, out float _v)
{
_v = 0f;
return _p.Count > _i && float.TryParse(_p[_i].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
}
void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necroview: " + _s);
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "";
switch (cmd)
{
case "on":
Out(NecroView.Start(Num(_params, 1, out float y) ? y : 150f, Num(_params, 2, out float p) ? p : 10f, Num(_params, 3, out float d) ? d : 0.6f));
return;
case "off":
NecroView.Stop();
Out("осмотр выключен");
return;
case "set" when NecroView.On && Num(_params, 1, out float y2) && Num(_params, 2, out float p2) && Num(_params, 3, out float d2):
NecroView.Yaw = y2;
NecroView.Pitch = p2;
NecroView.Dist = d2;
break;
case "free":
Out(NecroView.StartFree());
return;
case "mouse":
NecroView.Mouse = !(_params.Count > 1 && _params[1] == "off");
Out("мышь " + (NecroView.Mouse ? "облетает руки" : "крутит персонажа, облёт - цифровой блок"));
return;
case "show":
break;
default:
Out(getDescription());
return;
}
Out(NecroView.On ? "вокруг " + NecroView.F(NecroView.Yaw) + ", высота " + NecroView.F(NecroView.Pitch) + ", расстояние " + NecroView.F(NecroView.Dist) : "осмотр выключен");
}
}
/// <summary>Вне тела (NecroView.Free) тело не ходит и не крутится - как при одержимости.</summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_NecroView
{
public static bool Prefix(PlayerMoveController __instance)
{
if (!NecroView.Free)
{
return true;
}
__instance.stopMoving();
return false;
}
}
}
+43 -1
View File
@@ -825,6 +825,11 @@ namespace NecromancerTome
} }
if (IsPet && RatModel.Is(z)) if (IsPet && RatModel.Is(z))
{ {
// E на зомби в прицеле - дух переходит в него, крыса развеивается (указание 16.10).
if (detonate && TryRatJump(world, z))
{
return;
}
// E - осмотреть контейнер без права взять (указание 08.10: крыса - разведчик). // E - осмотреть контейнер без права взять (указание 08.10: крыса - разведчик).
RatLoot.Tick(world, _player, z, detonate); RatLoot.Tick(world, _player, z, detonate);
// ПКМ (держать) - сквозь постройки, без полёта (BACKLOG, «Могильная крыса» п.4). // ПКМ (держать) - сквозь постройки, без полёта (BACKLOG, «Могильная крыса» п.4).
@@ -1524,6 +1529,42 @@ namespace NecromancerTome
return best; return best;
} }
/// <summary>Перенос духа из крысы в зомби под прицелом (решения 16.10): E, зомби не дальше RatJumpRange от
/// крысы и в прямой видимости (GroundAimTarget не видит сквозь стены, во время прохода - не работает);
/// цена - как у «пиф-пафа» (никакой). Крыса отзывается и развеивается; цепочки нет - после зомби дух
/// вернётся в тело ГГ (Exit). True - E израсходована: перенос сделан или начат.</summary>
static bool TryRatJump(World _world, EntityAlive _rat)
{
if (RatPhase.On)
{
return false;
}
EntityAlive victim = GroundAimTarget(_world, _rat);
if (victim == null || !CanPossess(victim) ||
(victim.position - _rat.position).sqrMagnitude > RatJumpRange * RatJumpRange)
{
return false;
}
EntityPlayerLocal player = _player;
int ratId = _rat.entityId;
Exit("крыса: дух переходит в " + victim.EntityName + " (" + victim.entityId + ")");
if (!Enter(player, victim))
{
Debug.Log("[NecromancerTome] Possession: перенос из крысы не удался - дух в теле, крыса осталась");
return true;
}
if (NecroBurst.EnsureRegistered())
{
ParticleEffect pe = new ParticleEffect(NecroBurst.ParticleName, _rat.position,
_world.GetLightBrightness(_rat.GetBlockPosition()), Color.white, null, null, _OLDCreateColliders: false);
GameManager.Instance.SpawnParticleEffectServer(pe, ratId);
}
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(player, ratId, null, null); // без подсказки «отозвана» - её место у подсказки вселения
return true;
}
public const float RatJumpRange = 3f;
public const float GroundAimRange = 30f; // зрение зомби, как TauntRange public const float GroundAimRange = 30f; // зрение зомби, как TauntRange
public const float GroundAimRadius = 0.6f; public const float GroundAimRadius = 0.6f;
@@ -1678,7 +1719,8 @@ namespace NecromancerTome
} }
if (IsPet && RatModel.Is(_z)) if (IsPet && RatModel.Is(_z))
{ {
text += ", " + Localization.Get("necroPossessHintLook") + ", " + Localization.Get("necroPossessHintPhase"); text += ", " + Localization.Get("necroPossessHintLook") + ", " + Localization.Get("necroPossessHintRatJump") + ", " +
Localization.Get("necroPossessHintPhase");
} }
if (IsPet && DogSense.Is(_z)) if (IsPet && DogSense.Is(_z))
{ {
+31 -1
View File
@@ -356,8 +356,22 @@ namespace NecromancerTome
} }
} }
public const int GripThreadIdx = 3;
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context) public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{ {
EntityPlayerLocal pl = _sender.xui.playerUI.entityPlayer;
// Хват ножа (16.10, нити цинерария): 3 - хват для нитей; 0 у ножа-для-нитей - обычный хват.
if (_commandIndex == GripThreadIdx)
{
CinerariumThreads.Convert(pl, CinerariumThreads.ThreadKnifeName);
return;
}
if (_commandIndex == 0 && CinerariumThreads.IsThreadKnife(pl?.inventory?.holdingItem))
{
CinerariumThreads.Convert(pl, CinerariumThreads.KnifeName);
return;
}
if (_commandIndex == 1) if (_commandIndex == 1)
{ {
Enter(_sender.xui.playerUI.entityPlayer); Enter(_sender.xui.playerUI.entityPlayer);
@@ -374,7 +388,8 @@ namespace NecromancerTome
{ {
public static void Postfix(ItemAction __instance, ref bool __result) public static void Postfix(ItemAction __instance, ref bool __result)
{ {
if (!__result && !SnakeMode.Active && !MindPalace.Active && SnakeMode.IsKnife(__instance.item)) if (!__result && !SnakeMode.Active && !MindPalace.Active &&
(SnakeMode.IsKnife(__instance.item) || CinerariumThreads.IsThreadKnife(__instance.item)))
{ {
__result = true; __result = true;
} }
@@ -389,6 +404,18 @@ namespace NecromancerTome
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl) public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{ {
if (CinerariumThreads.IsThreadKnife(__instance.item))
{
// Нож хватом для нитей: вернуть обычный хват (0) или остаться (3).
_xuiRadialWindow.ResetRadialEntries();
_xuiRadialWindow.CreateRadialEntry(0, "NecromancersKnife", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripNormal"), _highlighted: false);
_xuiRadialWindow.CreateRadialEntry(SnakeMode.GripThreadIdx, "resourceSewingKit", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripThread"), _highlighted: true);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, SnakeMode.GripThreadIdx);
return false;
}
if (!SnakeMode.IsKnife(__instance.item)) if (!SnakeMode.IsKnife(__instance.item))
{ {
return true; return true;
@@ -417,6 +444,9 @@ namespace NecromancerTome
_xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "", _xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false); Localization.Get("necroSnakeModeDay"), _highlighted: false);
} }
// Хват для нитей (16.10: «Есть в игре пиктограмма ниток, катушки?» - набор для шитья) - в любое время суток.
_xuiRadialWindow.CreateRadialEntry(SnakeMode.GripThreadIdx, "resourceSewingKit", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripThread"), _highlighted: false);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload), _xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0); SnakeMode.OnRadialCommand, null, 0);
return false; return false;
+1 -1
View File
@@ -562,7 +562,7 @@ namespace NecromancerTome
{ {
GiveBackSummonItem(owner, summonItemName); GiveBackSummonItem(owner, summonItemName);
} }
if (world != null) if (world != null && recalledTooltipKey != null) // null - без подсказки (перенос духа крысой, 16.10)
{ {
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), recalledTooltipKey); GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), recalledTooltipKey);
} }
+138
View File
@@ -0,0 +1,138 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Тело (вид от третьего лица и для других игроков) держит нож-для-нитей обычным хватом ножа, а не луком (16.10, скрин
/// от третьего лица: «у персонажа запястье вывихнуто» - анимация деревянного лука тянет правую кисть с ножом). Хват
/// предмета один на все модели (HoldType 68 нужен рукам первого лица - основа позы HandPose.PresetThread), поэтому
/// подменяется только параметр аниматора WeaponHoldType/WeaponCarry у тел, кроме рук первого лица, и место ножа в
/// руке тела. Подбор на лету: necroheld tphold N (0 - выключить подмену).
/// </summary>
public static class ThreadKnifeBody
{
/// <summary>0 - тело держит хватом предмета (68, лук). 16.10 в игре (necrobody) пользователь выбрал именно его: нож в левой
/// руке тела, «Нож был нормальным», нужна только поправка кисти и места ножа (ниже). Подмена на 36 - на пробу командой.</summary>
public static int TpHold = 0;
/// <summary>Поправка тела, подобранная 16.10 командами necrobody: левая кисть к ладони на 20° («лучезапястный сустав
/// выгнуло... словно перелом»: LeftHand Z 302 -> 322), нож в кулаке - item pos 3 -4 -2 см («Вот так идеально»).</summary>
public static float WristBend = 20f;
public static Vector3 ItemPos = new Vector3(0.03f, -0.04f, -0.02f);
static readonly Dictionary<Animator, Transform> _leftHand = new Dictionary<Animator, Transform>();
/// <summary>Из HandPoseDriver.LateUpdate: у всех игроков в мире с ножом-для-нитей, кроме своего вида от первого лица.</summary>
public static void LateUpdate()
{
World w = GameManager.Instance?.World;
if (w?.Players?.list == null || TpHold > 0)
{
return;
}
foreach (EntityPlayer p in w.Players.list)
{
if (p == null || p.inventory?.holdingItem?.Name != CinerariumThreads.ThreadKnifeName || p.emodel == null ||
p.emodel.IsFPV && !p.isEntityRemote)
{
continue;
}
AvatarCharacterController ac = p.emodel.avatarController as AvatarCharacterController;
Animator a = ac?.CharacterBody?.Animator;
if (a == null)
{
continue;
}
if (!_leftHand.TryGetValue(a, out Transform lh) || lh == null)
{
lh = null;
foreach (Transform t in a.GetComponentsInChildren<Transform>(true))
{
if (t.name == "LeftHand")
{
lh = t;
break;
}
}
if (_leftHand.Count > 16)
{
_leftHand.Clear();
}
_leftHand[a] = lh;
}
if (lh != null)
{
lh.localRotation *= Quaternion.Euler(0f, 0f, WristBend);
}
if (ac.heldItemTransform != null && !NecroBody.ItemPos.HasValue)
{
ac.heldItemTransform.localPosition = ItemPos;
}
}
}
public static bool Applies(EntityAlive _e)
{
return TpHold > 0 && _e != null && _e.inventory?.holdingItem?.Name == CinerariumThreads.ThreadKnifeName;
}
}
[HarmonyPatch(typeof(AvatarMultiBodyController), nameof(AvatarMultiBodyController._setInt), new[] { typeof(int), typeof(int), typeof(bool) })]
public static class Patch_AvatarMultiBodyController_setInt_ThreadKnife
{
public static bool Prefix(AvatarMultiBodyController __instance, int _propertyHash, int _value)
{
bool hold = _propertyHash == AvatarController.weaponHoldTypeHash;
if (!hold && _propertyHash != AvatarController.weaponCarryHash || !ThreadKnifeBody.Applies(__instance.Entity))
{
return true;
}
BodyAnimator fp = (__instance as AvatarLocalPlayerController)?.FPSArms;
int tp = hold ? ThreadKnifeBody.TpHold : AnimationDelayData.AnimationDelay[ThreadKnifeBody.TpHold].Carry;
foreach (BodyAnimator b in __instance.BodyAnimators)
{
Animator a = b.Animator;
int v = b == fp ? _value : tp;
if (a != null && a.GetInteger(_propertyHash) != v)
{
a.SetInteger(_propertyHash, v);
}
}
return false; // без рассылки по сети: у других игроков мод ставит то же сам
}
}
/// <summary>Место ножа в руке тела - по хвату ножа (игра берёт смещение по HoldType предмета).</summary>
[HarmonyPatch(typeof(AvatarMultiBodyController), nameof(AvatarMultiBodyController.SetInRightHand))]
public static class Patch_AvatarMultiBodyController_SetInRightHand_ThreadKnife
{
public static void Postfix(AvatarMultiBodyController __instance, Transform _transform)
{
EntityAlive e = __instance.Entity;
if (_transform == null || !ThreadKnifeBody.Applies(e) || e.emodel.IsFPV && !e.isEntityRemote)
{
return;
}
AnimationGunjointOffsetData.AnimationGunjointOffsets o = AnimationGunjointOffsetData.AnimationGunjointOffset[ThreadKnifeBody.TpHold];
_transform.localPosition = o.position;
_transform.localRotation = Quaternion.Euler(o.rotation);
}
}
[HarmonyPatch(typeof(AvatarMultiBodyController), nameof(AvatarMultiBodyController.SwitchModelAndView))]
public static class Patch_AvatarMultiBodyController_SwitchModelAndView_ThreadKnife
{
public static void Postfix(AvatarMultiBodyController __instance, bool _bFPV)
{
Transform t = __instance.heldItemTransform;
if (t == null || _bFPV || !ThreadKnifeBody.Applies(__instance.Entity))
{
return;
}
AnimationGunjointOffsetData.AnimationGunjointOffsets o = AnimationGunjointOffsetData.AnimationGunjointOffset[ThreadKnifeBody.TpHold];
t.localPosition = o.position;
t.localEulerAngles = o.rotation;
}
}
}
+1 -1
View File
@@ -4,6 +4,6 @@
<DisplayName value="Necromancer's Tome" /> <DisplayName value="Necromancer's Tome" />
<Description value="A dark necromancy progression for 7 Days to Die 3.3: a skill that grows from the dead you put to rest, a bone knife, undead servants, possession of zombies and beasts, the Snake Spirit, the Mind Palace, the Blood Tide and a story-ending Black Portal. Fully localized into 13 languages. Single-player; requires EAC off." /> <Description value="A dark necromancy progression for 7 Days to Die 3.3: a skill that grows from the dead you put to rest, a bone knife, undead servants, possession of zombies and beasts, the Snake Spirit, the Mind Palace, the Blood Tide and a story-ending Black Portal. Fully localized into 13 languages. Single-player; requires EAC off." />
<Author value="Alex Cube" /> <Author value="Alex Cube" />
<Version value="1.6.2" /> <Version value="1.7.0" />
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" /> <Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
</xml> </xml>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
namespace NecromancerTome
{
/// <summary>
/// Запись состояния урны-цинерария (TEFeatureCinerarium, HarmonySrc/Cinerarium.cs). Здесь, а не в
/// основном проекте, по той же причине, что и PyramidWardWriteHelper: PooledBinaryWriter.Write
/// оттуда не компилируется (см. NecromancerTEPersistence.csproj). Первым байтом - версия, чтобы
/// поля урны (дух, его работа) можно было дописывать, не ломая старые сейвы.
/// </summary>
public static class CinerariumWriteHelper
{
public static void Write(PooledBinaryWriter _bw, byte _version, bool _hasSkull, bool _zoneShown)
{
_bw.Write(_version);
_bw.Write(_hasSkull);
_bw.Write(_zoneShown);
}
/// <summary>Версия 2 (16.10, нити): число, затем сами числа - координаты нитей (см. TEFeatureCinerarium.Write).</summary>
public static void WriteInts(PooledBinaryWriter _bw, int[] _values)
{
_bw.Write(_values.Length);
foreach (int v in _values)
{
_bw.Write(v);
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB