43 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5.5 8b8d1d0feb Волк: кнопки окон сумки работают или скрыты; ссылка на Telegram в описании
Багажник (Tab): только «Сортировать», перенос в рюкзак тела и замок скрыты.
При обыске над сумкой «Положить всё» / «Положить похожие» выкладывают в открытый
ящик или мешок, «Сортировать» сортирует сумку; ванильный перенос копий ячеек
(мог раздвоить вещи) выключен.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:15:14 +03:00
AlexCubeandClaude Opus 5.5 fd4545d1a8 DLL: исправлена выкладка из сумки волка
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:17:45 +03:00
AlexCubeandClaude Opus 5.5 aa19299cf1 Волк: NRE при выкладке из сумки (лог читал очищенную ячейку)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:16:27 +03:00
AlexCubeandClaude Opus 5.5 d845fa1108 Волк: щелчок по сумке кладёт в ящик/мешок; Tab в волке открывает его сумку
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:04:17 +03:00
AlexCubeandClaude Opus 5.5 3aeeae04ae DLL: сумка волка в окне рюкзака, подбор с земли
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:30:05 +03:00
AlexCubeandClaude Opus 5.5 654b888d15 Волк: сумка видна в окне рюкзака при обыске, E подбирает предметы с земли в сумку
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:25:48 +03:00
AlexCubeandClaude Opus 5.5 eaf06e1cd9 DLL: глава прилива с 5-го дня
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:10:48 +03:00
AlexCubeandClaude Opus 5.5 f7b37ba490 Релиз 1.6.0: версия 1.6.0, глава прилива с 5-го дня, упоминания 1.7.0 -> 1.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:10:24 +03:00
AlexCubeandClaude Opus 5.5 0d55f895de Убран замер фризов FreezeProbe; закоммичены звук, пирамида и necroboneshiv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:05:08 +03:00
AlexCubeandClaude Opus 5.5 0c1235b079 Череп: итоговая посадка в руке; DLL с горящими трупами
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:01:39 +03:00
AlexCubeandClaude Opus 5.5 bf4b1819da Красные трупы горят последние 30 с (рядом с игроком, до 6, обжигают); necroheld - только для своего предмета
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:32:23 +03:00
AlexCubeandClaude Opus 5.5 0efa1b8db4 necroheld: подбор посадки модели в руке прямо в игре
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:21:42 +03:00
AlexCubeandClaude Opus 5.5 bed74dad76 Останки шире: масштаб 1.6 x 1.3 x 1.2 (ширина, высота, длина)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:44:30 +03:00
AlexCubeandClaude Opus 5.5 82e0f8de7f Череп: обратно макушкой вверх, поднят основанием на ладонь (поворот был ошибкой)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:44:01 +03:00
AlexCubeandClaude Opus 5.5 d8eb78c8b3 BundleShaderFix: список исправленных материалов очищается при выходе из мира
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:36:15 +03:00
AlexCubeandClaude Opus 5.5 6028252cd6 Останки: встроенный шейдер вместо бандлового, краснее, x1.2
Тест 10.10: останки в игре бледно-бежевые, хотя в Unity тот же префаб тёмный -
игра рисует копию Standard из бандла иначе (как было у крысы). BundleShaderFix
ставит встроенный Standard останкам, черепу и ножу-мечу (очередь сохраняется).
Плёнка краснее (указание: «больше красного на не-кости»), модель x1.2,
иконка перерисована.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:34:29 +03:00
AlexCubeandClaude Opus 5.5 f0b326b7e5 Череп в руке: основанием на ладонь, как книга (было - рука в висок)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:25:58 +03:00
AlexCubeandClaude Opus 5.5 b790c34653 Иконка останков: перерисована с игровой модели, темнее
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:17:57 +03:00
AlexCubeandClaude Opus 5.5 16db4cbd1e Череп: своя модель, иконка, хват книги
CC0-череп Blender Studio (Human Base Meshes), окрашен в стиле останков:
копоть в швах и глазницах, засохшая кровь; игровой вариант ~4000
треугольников с запечёнными цветом и нормалями, бандл necroskull.
Хват 21, как у книг призыва; своя иконка вместо ванильной.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:11:19 +03:00
AlexCubeandClaude Opus 5.5 8ad2f25c37 Останки красного трупа: модель, сущность, истлевание через 2 минуты
Красный труп «Жертвы» истлевает в лёгкий контейнер necroRemains (своя модель:
остов, обтянутый обугленной плёнкой, бандл necroremains) через 2 минуты,
раньше - если игрок дальше 80 м или трупов больше 30. Лут и остаток срока
переходят, звук разбитого пятна крови (organicdestroy). Команда necroremains
истлевает трупы рядом сразу (для проверки).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:00:11 +03:00
AlexCubeandClaude Opus 5.5 eba772123f Иконка останков красного трупа
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:54:59 +03:00
AlexCubeandClaude Opus 5.5 d63cf988b5 Кровавый прилив: модель ножа-меча в бандле necroknife
necroKnifeTidePrefab = нож + tideBlade: плоский прозрачный фиолетовый меч
ромбического сечения (Standard Fade + свечение, текстура с тёмными
вкраплениями), собран NecroTideSetup.cs (ItemsUnityProject). На ночь
прилива MeshFile ножа подменяется на него (путь - из пути обычного ножа).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:17:38 +03:00
AlexCubeandClaude Opus 5.5 523b9edcd9 Прилив: нож пересоздаётся только при свободной руке, сбой не ломает руку
Тест 10.10: NRE в Hand.Unequip оставлял Hand.isSwitching = true - рука
переставала брать что-либо до перезапуска. Теперь: ножи в других слотах
помечаются к пересборке, нож в руке пересоздаётся, когда нет смены
предмета и удара; сбой перехватывается, isSwitching снимается, Reconcile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:09:08 +03:00
AlexCubeandClaude Opus 5.5 7d87903302 Прилив: пересоздание ножа без NRE в Hand.Unequip (копия с другим Seed вместо ItemValue.None); DLL с меловой доской
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:05:40 +03:00
AlexCubeandClaude Opus 5.5 3780a69d93 Меловая доска игрока: рецепт, записи в обычной игре
Отдельный блок necroChalkboardPlayer (модель и надпись сюжетной доски):
крафт с начала игры - краска 2, дерево 8, гвозди 4, волокна 3, кости 2;
писать можно в обычной игре, записи свои у каждой доски; можно подобрать.
Сюжетная necroChalkboard (чертоги) по-прежнему пишется только модом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:04:48 +03:00
AlexCubeandClaude Opus 5.5 197df1d307 Чертоги: фризы - область у тела сжата; замер фризов в лог
В чертогах игра держала две полные области мира (у чертогов и у тела).
Тело теперь сжато до 3 участков, как у змеи и одержимого; при выходе
возвращается. FreezeProbe пишет каждый кадр дольше 100 мс: длительность,
сборки мусора за кадр, режим, число наблюдателей областей, куча.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:52:15 +03:00
AlexCubeandClaude Opus 5.5 4f742069ec DLL: оглавление в два столбца
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:50:16 +03:00
AlexCubeandClaude Opus 5.5 2fa1985a3c Книга: оглавление в один столбец до 10 глав, в два - до 20 на страницу
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:49:19 +03:00
AlexCubeandClaude Opus 5.5 7fd0d405cc Утечки: картинки глав книги, материал кольца пирамиды, мелочи прилива
- Книга: картинка главы при смене главы не удалялась (~2 МБ за смену) -
  удаляем сами при смене, у главы без картинки и при закрытии окна.
- Пирамида: кольцо зоны создавало новый материал при каждой перезагрузке
  чанка - теперь один общий.
- Прилив: не держать стат игрока после выхода из мира; забывать полосы
  пересобранного HUD.
- SpatialVaultPatch: исправлен устаревший комментарий про очистку кэша.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:39:29 +03:00
AlexCubeandClaude Opus 5.5 75e122b6a4 Кровавый прилив: свой крит меча - 25% обычных ударов x2 урона
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:35:17 +03:00
AlexCubeandClaude Opus 5.5 c169003cf0 Кровавый прилив: меч расчленяет втрое чаще мачете
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:32:18 +03:00
AlexCubeandClaude Opus 5.5 b13759908e Кровавый прилив: нож-меч (статы, хват мачете), бег, восстановление, пульс, сердце
В прилив: урон, скорость ударов и вампиризм ножа вдвое, плата за силовую
вдвое меньше, выносливость вчетверо меньше, дальность 2.8, хват мачете
(модели меча пока нет). С ножом в руке: бег +50%, восстановление здоровья
и выносливости вдвое, полоски пульсируют фиолетовым, сердце 120 уд/мин.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:21:46 +03:00
AlexCubeandClaude Opus 5.5 8d1fe16cf2 Книга: оглавление по страницам; лист прилива без лучей, пыль вверх
Оглавление показывает по 10 глав и листается «Назад/Далее»; «Оглавление»
из главы возвращает на ту же страницу. На листе Fig. VII у луны убраны лучи
(походила на солнце), некропыль поднимается просто вверх, со стрелками.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:08:30 +03:00
AlexCubeandClaude Opus 5.5 6e1a6c0720 Кровавый прилив: глава книги и надпись на доске чертогов
Глава с листом-схемой (Fig. VII, подписи на эсперанто), пока открыта с 1 дня
для теста (по замыслу - с 5). Оглавление - до 10 глав. Доска чертогов на 5 день:
«Осторожно! Приближается лунный прилив!» вместо «излучения красной луны».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:04:11 +03:00
AlexCubeandClaude Opus 5.5 8541c7abdf Кровавый прилив: в ночь кровавой луны дух не выходит из тела
Нет змеи, чертогов, одержимости зомби и питомцев; кто вне тела - выброшен
в тело; «коза» без эффекта. Подмешивание Стирателей в орду отключено.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:56:52 +03:00
AlexCubeandClaude Opus 5.5 555d9c9b48 Книга: главы собаки, стервятника, волка, медведя видны с рецептом гримуара
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:46:29 +03:00
AlexCubeandClaude Opus 5.5 bdca1bdba5 Книга: главы собаки, стервятника, волка, медведя; гримуары; чертоги 25x13x25 с нумерованными дверями
- Главы питомцев с чертежами (Fig. III-VI), команда necroobj для выгрузки моделей
- Книги призыва питомцев переименованы в гримуары (13 языков)
- Чертоги: второй этаж; таблички necroDoorNumber (только в редакторе) нумеруют двери,
  дверь с номером закрыта до события (SetDoorOpen, necropalace door N open|close),
  с начала открыты 2, 4, 5, 6
- Дух: плавный шаг по ступенькам, проход в проёмы с порогом
- Стервятник: снижение только C, без Ctrl-переключателя

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:43:25 +03:00
AlexCubeandClaude Opus 5.5 32065dacac Чертоги: доска - информация из подсознания (комментарий)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:26:39 +03:00
AlexCubeandClaude Opus 5.5 4e14a0562b Книга: главы ножа и Могильной крысы; плакат ножа, грифельная доска, дверь-шкаф; чертоги 25x8x25; плата силовой ножа 20/40
- Книга некроманта: главы «Нож некроманта» и «Могильная крыса» - лист-чертёж сверху (Textures/,
  три вида из Blender, подписи на эсперанто), управление и лор; окно книги непрозрачное, текст с контуром
- «Дух крысы» переименован в «Могильную крысу» на 13 языках (ключи прежние)
- Блоки (Dev): плакат-синька ножа (necroPosterKnife, 3:2 под полотно рамки), грифельная доска
  necroChalkboard (надписи - TEFeatureSignable, писать только в редакторе/творческом; шрифт Neucha OFL,
  свой шейдер текста с тестом глубины), дверь-книжный шкаф necroBookDoor (своя модель с аниматором,
  хитбокс створки через RootTransformRefParent)
- Чертоги: префаб 25x8x25 (промежуточный); открываются только межкомнатные двери; надписи на досках
  по дням игры: «Отомсти архимагу!», с 5-го дня «Берегись излучения красной луны»
- Нож: силовая атака стоит 20 здоровья, с «Мёртвой бурей» 40; не хватает - itembreak, «Недостаточно
  здоровья» и обычный удар; описания на 13 языках
- Консольная команда necromodel - устройство модели блока в лог

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:26:17 +03:00
AlexCubeandClaude Opus 5.5 e94e967b8e Одержимость: прицел-перекрестие с покраснением, кнопки E/F, лут мешков и багажник волка, медведь на дыбы
- Прицел всех одержимых - всегда перекрестие (свой guiDrawCrosshair: точка бралась из проверки
  камеры от 3-го лица у тела); краснеет на враге: у стервятника - цель рывка, у наземных - враг
  на луче прицела до 30 м, не за стеной
- Падаль: отхил вдвое меньше (EatHealFrac 0.125); ест на F стервятник, медведь, собака, волк и
  одержимые зомби
- E - действие: стервятник - клич (был F), крыса/волк - обыск, медведь - на дыбы (враги до 3.5 м
  сбиты с ног и отброшены, откат 6 с)
- Рёв медведя: надписи «на медведя идут N» и откат, мимо очереди подсказок
- Крыса и волк обыскивают мешки с зомби (EntityLootContainer), волк берёт в сумку
- Багажник волка: окно сумки как у браслета, без рюкзака; выброс - у волка
- Отзыв волка: мешок на месте и метка «Сумка волка» на карте, снимается с мешком
- Тексты на 13 языках

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:55 +03:00
AlexCubeandClaude Opus 5.5 31aa99860c Одержимость питомцев: роли медведя, волка, собаки и крысы; ноша стервятника; череп при обыске
- Могильная крыса: своя модель без волчьей шерсти; стоит на земле (центр капсулы);
  E - осмотр контейнеров без права взять (окно игры, рюкзак скрыт, отказ «инвентарь
  заполнен», контейнер не разрушается); ПКМ - проход сквозь постройки, полупрозрачная.
- Стервятник: курица в когтях - слепок с обвисшими крыльями и шеей, без хитбокса.
- Зомбособака: нюх - все существа рядом на компасе и карте.
- Медведь: толстая шкура (вдвое меньше урона), рёв-провокация, удар лапой по блокам.
- Лютоволк: бросок на цель с опрокидыванием, след крови раненых, сумка на 3 ячейки
  (лут, выгрузка у тела, выпадает мешком при гибели и отзыве; отдельный файл сейва).
- Череп (останки): 2% при обычном обыске красного трупа вместо браслета.
- Прицел книги не рисуется в одержимости; подсказки вселения на 13 языках.
- Замер EntityLeakProbe: удалённые, но живые в памяти сущности - в лог.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:40 +03:00
AlexCubeandClaude Opus 5.5 beab1640b0 Одержимость питомцев, стервятник-разведчик, метки на карте, череп, глава письма
- Одержимость питомца: ПКМ с книгой - вселиться, E - отозвать (и изнутри питомца),
  пояс скрыт, надпись о враге у тела.
- Одержимый стервятник: полёт, рывок на ЛКМ с 10 м с прямым уроном, звуком и
  кровотечением, обычный прицел краснеет на цели; посадка в позу 0 и взлёт на пробел;
  ПКМ - клич-приманка (паникующая курица, откат 0.5 с); F - питаться трупами и
  блоками-падалью, доеденное исчезает.
- Дух змеи атакует только нежить; фиолетовая метка на карте у змеи и одержимого.
- Череп из красного трупа (этап 1 цинерария); глава книги «Письмо архимага» с видео.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:09:28 +03:00
AlexCubeandClaude Opus 5.5 a79bb085ab Пустая консервная банка: хват стеклянной банки с анимацией набора воды
HoldType 14 (консерва) не имеет анимации набора воды, банка набирала
воду без движения рук. Ставим 87 - хват drinkJarEmpty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:40:04 +03:00
85 changed files with 7010 additions and 169 deletions
+101 -24
View File
File diff suppressed because one or more lines are too long
+31 -12
View File
@@ -122,19 +122,38 @@
чертогах (BlockNecroMemoryBook.Open) и консольная команда necrotome. -->
<append xpath="/windows">
<window name="necroTome" anchor="Center" pos="-650,420" width="1300" height="840" cursor_area="true" depth="1001" lock_navigation="true" controller="NecromancerTome.NecroTome, NecromancerHarmony">
<sprite depth="0" name="bg" sprite="menu_empty" color="14,10,10,250" type="sliced" />
<sprite depth="1" name="frame" sprite="menu_empty2px" color="120,18,14,255" type="sliced" fillcenter="false" />
<sprite depth="0" name="bg" sprite="menu_empty" color="14,10,10,250" type="sliced" globalopacitymod="0" />
<sprite depth="1" name="frame" sprite="menu_empty2px" color="120,18,14,255" type="sliced" fillcenter="false" globalopacitymod="0" />
<label depth="2" name="lblTitle" pos="0,-22" width="1300" height="50" justify="center" font_size="38" color="205,40,30,255" effect="outline" upper_case="false" />
<sprite depth="1" name="rule" pos="200,-82" width="900" height="2" sprite="menu_empty" color="120,18,14,255" />
<label depth="2" name="lblText" pos="60,-104" width="1180" height="640" justify="left" font_size="27" color="215,205,190,255" overflow="shrinkcontent" upper_case="false" />
<simplebutton name="btnCh1" depth="3" pos="350,-130" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh2" depth="3" pos="350,-190" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh3" depth="3" pos="350,-250" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh4" depth="3" pos="350,-310" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh5" depth="3" pos="350,-370" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh6" depth="3" pos="350,-430" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh7" depth="3" pos="350,-490" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh8" depth="3" pos="350,-550" width="600" height="48" font_size="30" visible="false" />
<sprite depth="1" name="rule" pos="200,-82" width="900" height="2" sprite="menu_empty" color="120,18,14,255" globalopacitymod="0" />
<label depth="2" name="lblText" pos="60,-104" width="1180" height="640" justify="left" font_size="27" color="215,205,190,255" overflow="shrinkcontent" upper_case="false" effect="outline" effect_color="0,0,0,255" />
<!-- Иллюстрация страницы справа (Chapter.Image / Chapter.Video в NecroTome.cs); при ней
текст сужается до левой половины. Видео - тот же XUiV_Video, что фон главного меню. -->
<!-- globalopacitymod="0" (09.10): иначе фон и иллюстрация берут настройку прозрачности интерфейса и
просвечивают обоями чертогов - красные заголовки сливались, картинка была полупрозрачной. -->
<texture depth="2" name="imgPage" pos="680,-104" width="560" height="640" visible="false" globalopacitymod="0" />
<video depth="2" name="vidPage" pos="680,-104" width="560" height="640" videoaspect="FitInside" loop="true" visible="false" />
<!-- Кнопки глав: до 10 - один столбец по центру, больше - два столбца по 10 (10.10); места ставит XUiC_NecroTome.ShowToc. -->
<simplebutton name="btnCh1" depth="3" pos="370,-130" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh2" depth="3" pos="370,-190" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh3" depth="3" pos="370,-250" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh4" depth="3" pos="370,-310" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh5" depth="3" pos="370,-370" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh6" depth="3" pos="370,-430" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh7" depth="3" pos="370,-490" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh8" depth="3" pos="370,-550" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh9" depth="3" pos="370,-610" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh10" depth="3" pos="370,-670" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh11" depth="3" pos="370,-130" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh12" depth="3" pos="370,-190" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh13" depth="3" pos="370,-250" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh14" depth="3" pos="370,-310" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh15" depth="3" pos="370,-370" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh16" depth="3" pos="370,-430" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh17" depth="3" pos="370,-490" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh18" depth="3" pos="370,-550" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh19" depth="3" pos="370,-610" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh20" depth="3" pos="370,-670" width="560" height="48" font_size="30" visible="false" />
<simplebutton name="btnToc" depth="3" pos="40,-790" width="260" height="46" font_size="26" caption_key="necroTomeToc" />
<simplebutton name="btnPrev" depth="3" pos="440,-790" width="160" height="46" font_size="26" caption_key="necroTomePrev" />
<label depth="2" name="lblPage" pos="600,-772" width="100" height="40" justify="center" font_size="24" color="160,150,140,255" />
+78 -1
View File
@@ -184,7 +184,7 @@
<property name="SellableToTrader" value="false" />
<drop event="Destroy" name="resourceScrapIron" count="10,20" />
</block>
<!-- Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере.
<!-- Гео-оссуарий (1.6.0, 2026-09-30): станция добычи ресурсов на кровавой сфере.
Тело - ванильный коллектор росы (Class=Collector): окно, топливо, таймеры, работа без
игрока рядом - всё его. Своего кода два места, оба в HarmonySrc/GeoOssuaryPatch.cs:
- каждая из трёх ячеек модификаций принимает любой из пяти ресурсов (ваниль даёт
@@ -283,6 +283,83 @@
necroposters (на каждый плакат свой префаб с тем же багетом). Исходник рамки:
BlenderWork/necroSchemeFramePrefab/build_frame.py. -->
<append xpath="/blocks">
<block name="necroBookDoor">
<!-- Дверь-книжный шкаф 1x2 (09.10.2026): «замаскирована под книжную полку, закрыта, откроется только по событию».
Механика, звуки и прочность - ванильной межкомнатной двери; модель своя (бандл necroposters, собирает
ItemsUnityProject Assets/Editor/NecroBookDoor.cs): створка 0.98x1.98x0.12 с полками на обеих сторонах, петля
слева, Animator IsOpen/OpenTrigger, состояния Open/Close (как ждёт TEFeatureDoor). Чертоги её не открывают
(MindPalace.OpenDoors трогает только exteriorHouseDoorWhite). -->
<property name="Extends" value="interiorHouseDoorWhite" param1="TintColor,CustomIconTint,MeshDamage"/>
<!-- MeshDamage не наследуем: он ищет в модели Door/DMG0..DMG3 (у ванильной двери они есть, у нашей нет) - тест 09.10:
NullReferenceException в BlockShapeModelEntity.UpdateDamageState при постановке. -->
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="necroBookDoor"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroBookDoorPrefab.prefab"/>
<property name="ModelOffset" value="0,0,0"/>
<property name="MultiBlockDim" value="1,2,1"/>
</block>
<block name="necroDoorNumber">
<!-- Табличка с номером двери чертогов (09.10.2026): ставится в редакторе префабов рядом с дверью или над ней,
в ней пишется номер. В чертоги не ставится - вместо неё воздух (MindPalace.Expected), видна только в
редакторе. Дверь с номером закрыта, пока событие не откроет её (MindPalace.SetDoorOpen, necropalace door). -->
<property name="Extends" value="playerSignWood1x1"/>
<property name="CreativeMode" value="Dev"/>
</block>
<block name="necroChalkboard">
<!-- Грифельная доска (09.10.2026): «как рамка, только тёмная винтажная доска; вписаться в 2x2; надписи как
на ящиках - для сюжета». Модель - бандл necroposters (собирает ItemsUnityProject Assets/Editor/NecroChalkboard.cs):
рама 1.90x1.40 м, полочка для мела, дочерний TextMesh (шрифт Neucha, OFL). Надпись - TEFeatureSignable,
писать можно только в редакторе префабов / творческом режиме / отладке (ChalkboardPatch.cs).
Размер букв: em = FontSize x 0.01 / 10 м; 110 -> ~9 строк по ~30 знаков. -->
<property name="Extends" value="pictureFrame_01a"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="necroChalkboard"/>
<property name="Material" value="Mwood_weak"/>
<property name="Class" value="CompositeTileEntity"/>
<property class="CompositeFeatures">
<property class="TEFeatureSignable">
<property name="FontSize" value="110"/>
<property name="LineCount" value="9"/>
<property name="LineWidth" value="1.68"/>
<property name="LineSpacing" value="1"/>
</property>
</property>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroPosterChalkboardPrefab.prefab"/>
<property name="ModelOffset" value="0,0,-0.5"/>
<property name="MultiBlockDim" value="2,2,1"/>
<property name="OnlySimpleRotations" value="true"/>
<property name="CanPickup" value="false"/>
</block>
<block name="necroChalkboardPlayer">
<!-- Меловая доска игрока (указание 10.10: «мне нравится использовать её во время игры, делая записи»).
Та же модель и надпись, что у сюжетной necroChalkboard, но крафтится и пишется в обычной игре. Отдельный
блок, чтобы сюжетная доска чертогов (на ней пишет мод, MindPalace.UpdateBoards) осталась неизменяемой.
Записи - TEFeatureSignable: свои у каждой доски, хранятся в чанке, как надписи на ящиках. -->
<property name="Extends" value="necroChalkboard"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroChalkboardPlayerDesc"/>
<property name="Class" value="CompositeTileEntity"/>
<property class="CompositeFeatures">
<property class="TEFeatureSignable">
<property name="FontSize" value="110"/>
<property name="LineCount" value="9"/>
<property name="LineWidth" value="1.68"/>
<property name="LineSpacing" value="1"/>
</property>
</property>
<property name="CanPickup" value="true"/>
</block>
<block name="necroPosterKnife">
<!-- Схема ножа некроманта (09.10.2026): синька в стиле ванильного чертежа пистолета, три вида из Blender
(BlenderWork/necroTomePets/make_knife.py poster). Тот же багет; пользователь повесит сам в чертогах. -->
<property name="Extends" value="pictureFrame_01a"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="necroPosterKnife"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroPosterKnifePrefab.prefab"/>
<property name="ModelOffset" value="0,0,-0.5"/>
<property name="MultiBlockDim" value="2,1,1"/>
<property name="CanPickup" value="false"/>
</block>
<block name="necroPosterAnatomy">
<property name="Extends" value="pictureFrame_01a"/>
<property name="CreativeMode" value="Dev"/>
+15
View File
@@ -96,6 +96,21 @@
death too (onSelfDied) even though the buff FLAG itself survives death on purpose
(remove_on_death="false" above) - the glow is cosmetic and shouldn't linger on a
corpse; VictimPatch.cs's own HasBuff check doesn't care whether the particle is gone. -->
<!-- Кровавый прилив (указание 10.10): ставит и снимает BloodTide.cs - на игроке, пока идёт ночь кровавой
луны (SnakeMode.IsBloodTide). Сам по себе ничего не меняет; нож читает его в effect_group
necroKnifeBloodTide (items.xml). Здесь - только то, что зависит от ножа в руке: бег на 50% быстрее.
Восстановление здоровья и выносливости вдвое (только положительное, кровотечение не трогаем),
пульс полосок и сердцебиение - BloodTide.cs. -->
<buff name="buffNecroBloodTide" icon="ui_game_symbol_skull" icon_color="170,60,255" name_key="buffNecroBloodTideName" description_key="buffNecroBloodTideDesc" remove_on_death="false">
<stack_type value="ignore"/>
<effect_group>
<requirement name="HoldingItemHasTags" tags="necroKnife"/>
<passive_effect name="WalkSpeed" operation="perc_add" value=".5"/>
<passive_effect name="RunSpeed" operation="perc_add" value=".5"/>
<passive_effect name="CrouchSpeed" operation="perc_add" value=".5"/>
</effect_group>
</buff>
<buff name="buffNecroVictim" hidden="true" remove_on_death="false">
<stack_type value="ignore"/>
<duration value="0"/>
+1 -1
View File
@@ -141,7 +141,7 @@
<objective type="Craft" item="thrownStonePortalBlue"/>
</challenge>
<!-- ИСПЫТАНИЯ 1.7.0 (01.10.2026, указание: «добавь испытания по линии некромантии, сам
<!-- ИСПЫТАНИЯ 1.6.0 (01.10.2026, указание: «добавь испытания по линии некромантии, сам
придумай; часть - первое создание новых предметов»). Пять - первое создание нового
(скакун, оссуарий, запас сфер, камень, свиток крысы), два - вещи, на которые испытаний
ещё не было (браслет, пирамида). Награды по тому же счёту, что выше: 1000 - быстрые
+26 -2
View File
@@ -57,6 +57,10 @@
<!-- User request 2026-08-28: bite applies buffInjurySlow, same debuff the Insect Swarm
originally had before its own rework - see necroMeleeHandZombieDog in items.xml. -->
<property name="HandItem" value="necroMeleeHandZombieDog"/>
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
<property name="NavObject" value="necroRatSpiritPet"/>
<!-- ИИ ПЕРЕПИСАН 2026-09-18 ПОД ОБРАЗЕЦ ДУХА КРЫСЫ, прямое указание: "поведение
других призванных животных зомби тоже подгони под крысу. По сути это те же
@@ -292,6 +296,10 @@
<property name="IsEnemyEntity" value="false"/>
<property name="Faction" value="none"/>
<property name="HandItem" value="necroMeleeHandZombieGriffin"/>
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
<property name="NavObject" value="necroRatSpiritPet"/>
<effect_group name="Base Effects">
<!-- У ванильного зомбостервятника 30 HP - для питомца, которого не отозвать до
следующего призыва, это ничто. 750 оставлено с прежней, наземной версии. -->
@@ -356,7 +364,7 @@
<property name="SoundAttack" value="rabbitpain"/>
<property name="SoundStepType" value="animallightstep"/>
<!-- Жёлтая метка на карте и компасе - указание 2026-09-18. Класс метки наш,
<!-- Метка на карте и компасе - указание 2026-09-18 (жёлтая, с 08.10 зелёная, как у всех питомцев). Класс метки наш,
Config/nav_objects.xml; ванильные animaltracking_* не подошли, они завязаны
на перк следопыта, а не на прямое назначение. -->
<property name="NavObject" value="necroRatSpiritPet"/>
@@ -431,7 +439,7 @@
</entity_class>
</append>
<!-- Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей. Модель - бандл necrobonesteed
<!-- Костяной скакун (1.6.0, 01.10.2026): мотоцикл из костей. Модель - бандл necrobonesteed
(копия ванильного VMotorcycleP, переделанная в Blender); теги коллайдеров и шейдер
ставит BoneSteedPatch с ванильного мотоцикла, топливо - кровавая сфера (там же). -->
<append xpath="/entity_classes">
@@ -474,5 +482,21 @@
</property>
</property>
</entity_class>
<!-- Останки красного трупа (10.10.2026): через 2 минуты (раньше - если игрок ушёл далеко или трупов больше
предела) красный труп «Жертвы» истлевает в лёгкий контейнер со своей моделью - остов, обтянутый обугленной
плёнкой (бандл necroremains). Лут и остаток срока переходят от трупа (RemainsDecay.cs). Сохраняется с
участком силами игры, как мешок. TimeStayAfterDeath ставит код (остаток срока трупа). -->
<entity_class name="necroRemains">
<property name="Class" value="EntityLootContainer"/>
<property name="UserSpawnType" value="Console"/>
<property name="Mesh" value="#@modfolder(NecromancerTome):Resources/necroremains?necroRemainsPrefab.prefab"/>
<property name="ModelType" value="Custom"/>
<property name="Prefab" value="Backpack"/>
<property name="Parent" value="Backpack"/>
<property name="IsEnemyEntity" value="false"/>
<property name="TimeStayAfterDeath" value="300"/>
<property name="LootList" value="zPackVictimRemains"/>
<property name="Faction" value="none"/>
</entity_class>
</append>
</config>
+11 -2
View File
@@ -274,8 +274,9 @@
<!-- Вдвое дороже по стамине, только силовая. -->
<passive_effect name="StaminaLoss" operation="perc_add" value="1" tags="secondary"/>
<!-- Вторая половина платы: базовые 5 + эти 5 = 10 HP. -->
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="5">
<!-- Вторая половина платы: базовые 20 + эти 20 = 40 HP (было 5 + 5; указание 09.10). Не хватает 40 -
KnifePowerCostPatch.cs меняет силовую на обычный удар. -->
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="20">
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
<!-- "Health GT 0" на цели - тот же гейт на трупы, что у самого ножа, чтобы
силовой удар по трупу не брал 5 HP впустую; подробный разбор в items.xml
@@ -283,6 +284,14 @@
отсеивает мёртвых. -->
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="5"/>
<requirement name="!HasBuff" buff="buffNecroBloodTide"/>
</triggered_effect>
<!-- Кровавый прилив (10.10): плата вдвое меньше - 10 + 10 = 20. -->
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="10">
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="5"/>
<requirement name="HasBuff" buff="buffNecroBloodTide"/>
</triggered_effect>
<!-- AOE вокруг задетой цели. Базовое сбивание с ног у ножа одиночное, это его
+59 -4
View File
@@ -37,7 +37,9 @@
открытая банка. Бандл necrocan; текстура пока ванильная cannedfood01 - до релиза
перерисовать (гигиена ассетов). Банки с водой - свои модели с водой внутри. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanPrefab.prefab"/>
<property name="HoldType" value="14"/>
<!-- HoldType 87 = хват пустой стеклянной банки (drinkJarEmpty), единственный с анимацией набора воды;
консервный 14 её не имеет - банка набирала воду без движения рук (05.10.2026). -->
<property name="HoldType" value="87"/>
<property name="Material" value="Mmetal"/>
<property class="Action1"> <!-- UseAction -->
<property name="Class" value="CollectWater"/>
@@ -132,6 +134,14 @@
<property name="Create_item" value="tinCanEmpty"/>
</append>
<!-- Письмо архимага (ванильная записка noteDuke01) прочитано -> открыта глава «Письмо архимага»
в книге с оглавлением (HarmonySrc/NecroTome.cs). Ваниль сама ставит .dukeNoteRead, но cvar
с точкой в сейв не пишется (EntityBuffs.Write пропускает key[0]=='.', декомпиляция 3.3) -
поэтому свой cvar без точки. -->
<append xpath="/items/item[@name='noteDuke01']/effect_group">
<triggered_effect trigger="onSelfPrimaryActionEnd" action="ModifyCVar" cvar="necroTomeLetterRead" operation="set" value="1"/>
</append>
<!-- "Камень духов" (Spirit Stone): the first necromancer item, replacing the earlier
"Девиатор" (Deviator) placeholder book with a real thrown object - a vanilla small rock,
reused via resourceRockSmall's own mesh/icon, tinted green (TintColor for the 3D mesh,
@@ -1533,10 +1543,39 @@
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
</triggered_effect>
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="5">
<!-- Плата 20 (было 5; указание 09.10: лечение удара - убийства/20, прежние 5 окупались одним ударом).
Не хватает здоровья - KnifePowerCostPatch.cs меняет силовую на обычный удар ещё до замаха. -->
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="20">
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="5"/>
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="20"/>
<requirement name="!HasBuff" buff="buffNecroBloodTide"/>
</triggered_effect>
</effect_group>
<!-- КРОВАВЫЙ ПРИЛИВ (указание 10.10). Некроэнергии в воздухе столько, что нож пьёт её сам: пока на
игроке buffNecroBloodTide (ставит BloodTide.cs на ночь кровавой луны) - урон и скорость ударов
вдвое, вампиризм вдвое (второе такое же лечение), плата за силовую вдвое меньше (10 вместо 20;
обычная плата выше выключена !HasBuff), выносливость на удар вчетверо меньше, дальность -
как у меча (у мачете 2.3, у ножа 2.0). Хват и модель меча, скорость бега и восстановление -
BloodTide.cs и сам бафф. -->
<effect_group name="necroKnifeBloodTide">
<requirement name="HasBuff" buff="buffNecroBloodTide"/>
<passive_effect name="EntityDamage" operation="perc_add" value="1"/>
<passive_effect name="AttacksPerMinute" operation="perc_add" value="1"/>
<passive_effect name="StaminaLoss" operation="perc_add" value="-.75"/>
<passive_effect name="MaxRange" operation="base_set" value="2.8"/>
<!-- Расчленение (10.10: «почти джедайская светошашка»). У игры шанс = DismemberChance x доля урона от
здоровья x множитель конечности зомби (EntityAlive.GetDismemberChance); у мачете 1, у ножа 0 - здесь втрое больше мачете. -->
<passive_effect name="DismemberChance" operation="base_add" value="3"/>
<triggered_effect trigger="onSelfPrimaryActionRayHit" action="ModifyStats" stat="Health" operation="add" value="@$necroKnifeHealCVar">
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
</triggered_effect>
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="ModifyStats" stat="Health" operation="subtract" value="10">
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
<requirement name="StatCompareCurrent" stat="Health" operation="GT" value="10"/>
</triggered_effect>
</effect_group>
</item>
@@ -1899,7 +1938,7 @@
</item>
</append>
<!-- Костяной скакун - предмет-транспорт (1.7.0), ванильная система: в руке призрачный
<!-- Костяной скакун - предмет-транспорт (1.6.0), ванильная система: в руке призрачный
предпросмотр (ItemActionSpawnVehicle грузит его из Meshfile - поэтому Meshfile = модель
скакуна), применение ставит, подбор возвращает предмет с прочностью и модами. Решение
01.10: «не книга призыва, а та же система, что у ванильного транспорта». -->
@@ -1951,6 +1990,22 @@
<property name="SoundPlace" value="plantfibers_place"/>
</item>
</append>
<!-- Череп (06.10.2026, «Некротехнолог v2»): находка в красном трупе (2%, HarmonySrc/NecroSkull.cs), кладётся в
цинерарий (урна) - дух-работник на грядках. С 10.10 - своя модель (CC0-череп Blender Studio, окрашен в стиле
останков: копоть, засохшая кровь; бандл necroskull, Unity NecroSkullSetup.cs) и своя иконка; хват книги (21). -->
<append xpath="/items">
<item name="necroSkull">
<property name="DescriptionKey" value="necroSkullDesc"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroskull?necroSkullPrefab.prefab"/>
<property name="CustomIcon" value="necroSkull"/>
<property name="Material" value="MresourceBone"/>
<property name="Stacknumber" value="50"/>
<property name="EconomicValue" value="500"/>
<property name="SellableToTrader" value="false"/>
<property name="Group" value="Resources"/>
</item>
</append>
<!-- Сок чёрной розы (05.10.2026): банка, на ступке из 4 роз. Заменяет кровь некроманта в копиях
рецептов (recipes.xml). Наследует кровь (стак, звуки, хват банки); модель - necroJuicePrefab в бандле
necroblood (копия банки крови, текстуры make_blood_jar_textures.py juice; Unity: NecroJuiceSetup.cs).
+5
View File
@@ -39,5 +39,10 @@
<lootcontainer name="zPackVictim" count="1" size="6,3" sound_open="UseActions/open_backpack" sound_close="UseActions/close_backpack" open_time="1" loot_quality_template="qualBaseTemplate">
<item group="groupZpackVictim"/>
</lootcontainer>
<!-- Останки красного трупа (10.10): та же добыча, но контейнер исчезает, когда опустошён (труп убирает
CorpseLoot.RemoveEmptiedCorpses, а останки - обычный контейнер игры). -->
<lootcontainer name="zPackVictimRemains" count="1" size="6,3" sound_open="UseActions/open_backpack" sound_close="UseActions/close_backpack" open_time="1" destroy_on_close="empty" loot_quality_template="qualBaseTemplate">
<item group="groupZpackVictim"/>
</lootcontainer>
</append>
</config>
+27 -7
View File
@@ -14,7 +14,8 @@
животных (перк следопыта), а не через прямое назначение. -->
<append xpath="/nav_object_classes">
<!-- Сам питомец - ЖЁЛТЫМ, по прямому указанию.
<!-- Сам питомец - ЗЕЛЁНЫМ (указание 08.10: «пусть значки питомцев на карте будут зелёными»;
до 08.10 был жёлтым 255,220,0 - тоже по указанию).
СПРАЙТ: "воскрешение" (пробуем, указание 2026-09-18 - "для крысы попробуй иконку
воскрешения, проверим как смотрится"). Кроличий, стоявший тут сначала, не подошёл по
@@ -22,22 +23,24 @@
имён вида ui_game_symbol_* из Data/Config и из самой сборки; ближайшее по названию
было specters_grace (бафф "Призрачная грация", перк Ловкости), но у воскрешения
смысл к некромантии ближе. Если в игре окажется невнятно, запасные - specters_grace
и skull (череп, он же значок самой Некромантии), меняется одна строка. -->
и skull (череп, он же значок самой Некромантии), меняется одна строка.
С 08.10 - значок дебаффа «Почти смерть» (ui_game_symbol_near_death_trauma, вешается игроку
после гибели): «больше похож на смерть с косой» (указание 08.10), как у одержимого. -->
<nav_object_class name="necroRatSpiritPet">
<map_settings>
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/>
<property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
<property name="min_distance" value="0"/>
<property name="max_distance" value="-1"/>
<property name="color" value="255,220,0,255"/>
<property name="color" value="70,220,70,255"/>
<property name="has_pulse" value="false"/>
</map_settings>
<compass_settings>
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/>
<property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
<property name="min_distance" value="0"/>
<property name="max_distance" value="1024"/>
<property name="max_scale_distance" value="32"/>
<property name="color" value="255,220,0,255"/>
<property name="color" value="70,220,70,255"/>
<property name="has_pulse" value="false"/>
<property name="icon_clamped" value="false"/>
</compass_settings>
@@ -85,9 +88,26 @@
</nav_object_class>
</append>
<!-- Одержимый на карте (08.10): питомец или зомби, в которого вселился игрок, - ЗЕЛЁНЫЙ значок
«Почти смерть» (ui_game_symbol_near_death_trauma, выбран 06.10). Раньше одержимый носил класс
змеи с подменой значка, а подмена у класса не была включена (нужен use_override_icon) - на карте
была змея. Своему классу подмена не нужна: значок прямо здесь. Только карта, как у змеи. -->
<append xpath="/nav_object_classes">
<nav_object_class name="necroPossessed">
<map_settings>
<property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
<property name="min_distance" value="0"/>
<property name="max_distance" value="-1"/>
<property name="color" value="70,220,70,255"/>
<property name="has_pulse" value="false"/>
</map_settings>
</nav_object_class>
</append>
<!-- Дух змеи на карте (02.10.2026, просьба пользователя): фиолетовая змея - иконка следа змеи
(ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же
змеи бессмысленна. Вешается в SnakeControl.Begin, снимается в End. -->
змеи бессмысленна. (С 06.10 по 08.10 этот класс носил и одержимый - теперь у него necroPossessed.) Вешает NecroMapMarker.cs отдельной меткой на Transform модели, как игра
вешает метку транспорта (через Entity.AddNavObject метка змеи так и не показалась). -->
<append xpath="/nav_object_classes">
<nav_object_class name="necroSnakeSpirit">
<map_settings>
+11 -2
View File
@@ -473,7 +473,7 @@
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
</append>
<!-- Гео-оссуарий (1.7.0, 2026-09-30). Рецепт придуман по указанию «туда явно нужен флакон
<!-- Гео-оссуарий (1.6.0, 2026-09-30). Рецепт придуман по указанию «туда явно нужен флакон
крови некроманта и много костей»; дерево и волокна - корни, которыми
оплетены кости (добавлено тем же вечером). Порог - «Подмастерье» (уровень 25, как Гримуар
девиации): к этому времени игрок строит базу и кузницу, и камень с железом ему нужны
@@ -489,7 +489,7 @@
</recipe>
</append>
<!-- Костяной скакун (1.7.0, 01.10.2026). Порог - «Подмастерье» (уровень 25), как у
<!-- Костяной скакун (1.6.0, 01.10.2026). Порог - «Подмастерье» (уровень 25), как у
гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по
указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. -->
<append xpath="/recipes">
@@ -518,6 +518,15 @@
<ingredient name="resourceRockSmall" count="20"/>
<ingredient name="resourceWood" count="2"/>
</recipe>
<!-- Меловая доска игрока (10.10): чёрная краска, дерево, гвозди, немного травяных волокон, кости. Краска - обычная
resourcePaint: чёрный краситель modDyeBlack в игре не крафтится (только находка и торговцы). С начала игры. -->
<recipe name="necroChalkboardPlayer" count="1" craft_time="20" tags="packMuleCrafting">
<ingredient name="resourcePaint" count="2"/>
<ingredient name="resourceWood" count="8"/>
<ingredient name="resourceNail" count="4"/>
<ingredient name="resourceYuccaFibers" count="3"/>
<ingredient name="resourceBone" count="2"/>
</recipe>
<recipe name="resourceNecroBlackRoseJuice" count="1" craft_time="10" craft_area="necroMortar">
<ingredient name="resourceNecroBlackRose" count="4"/>
<ingredient name="drinkJarEmpty" count="1"/>
+47
View File
@@ -0,0 +1,47 @@
<config>
<!-- ПЕРВЫЙ СОБСТВЕННЫЙ ЗВУК МОДА (2026-09-10). До этого весь мод переиспользовал ванильные
звуковые id: разбор 2026-08-30 показал, что новый звук нельзя просто положить файлом
рядом с XML. Аудио в этой версии игры - единственный тип ассета, который идёт через
DataLoader.LoadAsset<AudioClip> -> Resources.Load, а тот читает только запечённое в
сборку самой игры; ванильные "@:Sounds/..." идут вообще третьим путём, через
Addressables, чей каталог модам недоступен. Единственная работающая дорога для НОВОГО
аудио - синтаксис "#bundlePath?assetName", то есть настоящий Unity AssetBundle.
Бандл собирается из Unity-проекта в _private/Extracted/ItemsUnityProject скриптом
Assets/Editor/NecroSoundSetup.cs (копия - _private/tools/UnityEditorScripts/) и кладётся
в Resources/necrosounds. Исходники звуков и настройки импорта - там же, здесь только
ссылка. Пересобрать: Unity.exe -batchmode -nographics -quit -projectPath <проект>
-executeMethod NecroSoundSetup.SetupAndBuild -logFile <лог>.
ЕСЛИ ЗВУКА НЕТ В ИГРЕ: отказ здесь ТИХИЙ - ни краша, ни ошибки загрузки XML, только
строка "AudioManager LoadAudio failed to load audio clip" в логе игры. Проверять надо
именно лог, а не "ну наверное не слышно за другими звуками".
-->
<append xpath="/Sounds">
<!-- Камень духов: попадание по зомби (то есть попадание, которое реально накладывает
charm). Вешается на triggered_effect в items.xml, там же и отсечка "только зомби".
AudioSource_Impact.prefab - ровно тот же ванильный источник, что у сиблинга
stonehitorganic, который звучит на этом же событии: одинаковые кривые затухания и
та же группа микшера, поэтому два звука удара не разъезжаются по громкости.
Имя клипа С РАСШИРЕНИЕМ (soulstone.mp3) - не опечатка. Игра ищет ассет в бандле по
имени файла, а не по пути внутри Unity-проекта; ровно эта форма уже подтверждена в
игре на ноже ("...necroknife?necroKnifePrefab.prefab").
Узла <Noise> здесь сознательно НЕТ. Шум для стелса на этом попадании уже создаёт
stonehitorganic (Noise ID=1, noise=7), и второй узел просто удвоил бы привлечение
зомби на ровном месте - звук чармa не должен быть громче в смысле AI, чем сам удар
камня. -->
<SoundDataNode name="necroSpiritStoneHit">
<AudioSource name="@:Sounds/Prefabs/AudioSource_Impact.prefab"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?soulstone.mp3"/>
<MaxVoices value="3"/>
<MaxRepeatRate value="0.1"/>
</SoundDataNode>
</append>
</config>
+1 -1
View File
@@ -1,5 +1,5 @@
<config>
<!-- Костяной скакун (1.7.0, 01.10.2026). vehicles.xml наследования не знает - это копия
<!-- Костяной скакун (1.6.0, 01.10.2026). vehicles.xml наследования не знает - это копия
ванильного vehicleMotorcycle (игра 3.3) под именем сущности necroBoneSteed. Отличия:
recipeName -> предмет necroBoneSteedPlaceable (его и возвращает подбор скакуна), без
honkEvent (гудок открывает двери торговца - скакуну незачем). Мотор молчит
+171
View File
@@ -0,0 +1,171 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Одержимый медведь - танк (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
/// - толстая шкура: пока в нём игрок, урон по нему вдвое меньше (вне одержимости медведь прежний);
/// - рёв на ПКМ: зомби ближе RoarRange бросают свои цели и идут на медведя, спящие просыпаются -
/// увести толпу от тела или базы. Та же провокация, что каждую секунду делает одержимость
/// (Possession.Taunt), только шире, без «видит ли» и на 20 с;
/// - удар лапой по блоку: ЛКМ по блоку под прицелом бьёт его на SmashDamage (дерево и двери за 2-3
/// удара, бетон дольше);
/// - на дыбы на E (указание 09.10: «на E - действие»): враги ближе RearRange сбиты с ног на RearStun с
/// и отброшены от медведя - передышка в толпе. Откат RearCooldown.
/// </summary>
public static class BearControl
{
public const string BearClass = "necroZombieBear";
public const float RoarRange = 15f;
public const float RoarCooldown = 8f;
public const int RoarTicks = 20 * 20;
public const string RoarSound = "bearalert";
/// <summary>Урон блоку за удар лапой. Для сравнения: удар ванильного зомби-медведя по блокам - десятки.</summary>
public const int SmashDamage = 300;
/// <summary>Досягаемость лапы от головы медведя, м.</summary>
public const float SmashReach = 2.6f;
public const float SkinFactor = 0.5f;
static float _nextRoar;
public const float RearRange = 3.5f;
public const float RearStun = 2f;
public const float RearCooldown = 6f;
/// <summary>Толчок от медведя, м за тик (как motion существ; гаснет сам).</summary>
public const float RearPush = 0.5f;
public const string RearSound = "bearattack";
static float _nextRear;
public static bool Is(EntityAlive _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
cls.entityClassName == BearClass;
}
public static void Roar(World _world, EntityAlive _bear)
{
if (Time.time < _nextRoar)
{
// Рёв в толпе не отличить от удара (тест 09.10) - пишем, сколько ждать и сколько пошло на медведя.
Tip(string.Format(Localization.Get("necroBearRoarCooldown"), Mathf.CeilToInt(_nextRoar - Time.time)));
return;
}
_nextRoar = Time.time + RoarCooldown;
_bear.PlayOneShot(RoarSound);
_bear.emodel?.avatarController?.StartAnimationAttack();
int n = 0;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _bear || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _bear.position).sqrMagnitude > RoarRange * RoarRange)
{
continue;
}
if (e.IsSleeping)
{
e.ConditionalTriggerSleeperWakeUp();
}
Possession.AllowZombieTarget(e, _bear);
e.SetAttackTarget(_bear, RoarTicks);
n++;
}
Debug.Log("[NecromancerTome] BearControl: рёв - на медведя идут " + n);
Tip(string.Format(Localization.Get("necroBearRoarResult"), n));
}
/// <summary>Сразу, мимо очереди подсказок: очередь игры (XUiC_PopupToolTip) отсеивает только одинаковый текст,
/// и «через 7 с», «через 6 с»... при частых ПКМ копились бы и всплывали с опозданием.</summary>
static void Tip(string _text)
{
if (Possession.Player != null)
{
GameManager.ShowTooltip(Possession.Player, _text, true);
}
}
/// <summary>E: на дыбы - враги рядом падают (тот же приём, что у броска волка: BeginStun Prone) и
/// отлетают от медведя.</summary>
public static void RearUp(World _world, EntityAlive _bear)
{
if (Time.time < _nextRear)
{
Tip(string.Format(Localization.Get("necroBearRearCooldown"), Mathf.CeilToInt(_nextRear - Time.time)));
return;
}
_nextRear = Time.time + RearCooldown;
_bear.PlayOneShot(RearSound);
_bear.emodel?.avatarController?.StartAnimationAttack();
int n = 0;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, _bear) || (e.position - _bear.position).sqrMagnitude > RearRange * RearRange)
{
continue;
}
Vector3 away = e.position - _bear.position;
away.y = 0f;
away = away.sqrMagnitude > 0.01f ? away.normalized : _bear.GetForwardVector();
e.motion += away * RearPush + Vector3.up * 0.15f;
if (e.bodyDamage.CurrentStun == EnumEntityStunType.None && e.emodel?.avatarController != null)
{
e.emodel.avatarController.BeginStun(EnumEntityStunType.Prone, EnumBodyPartHit.Torso, Utils.EnumHitDirection.Back, false, 0.5f);
e.SetStun(EnumEntityStunType.Prone);
e.bodyDamage.StunDuration = RearStun;
}
n++;
}
Tip(string.Format(Localization.Get("necroBearRearResult"), n));
Debug.Log("[NecromancerTome] BearControl: на дыбы - сбиты с ног " + n);
}
/// <summary>ЛКМ: блок под прицелом в досягаемости лапы (не существо) - урон SmashDamage.</summary>
public static void Smash(World _world, EntityPlayerLocal _player, EntityAlive _bear)
{
Camera cam = _player.playerCamera;
if (cam == null)
{
return;
}
Vector3 head = _bear.getHeadPosition();
Vector3 from = cam.transform.position + Origin.position;
Ray ray = new Ray(from, cam.transform.forward);
// С существами: если первым на луче стоит существо - это удар по нему, а не по блоку.
if (!Voxel.Raycast(_world, ray, Vector3.Distance(from, head) + SmashReach + 1f, -555528221, 69, 0f))
{
return;
}
WorldRayHitInfo hit = Voxel.voxelRayHitInfo;
if (hit.transform != null && GameUtils.GetHitRootTransform(hit.tag, hit.transform)?.GetComponent<Entity>() != null)
{
return;
}
if ((hit.hit.pos - head).sqrMagnitude > SmashReach * SmashReach)
{
return;
}
Vector3i pos = hit.hit.blockPos;
BlockValue bv = _world.GetBlock(pos);
if (bv.isair || bv.Block == null)
{
return;
}
bv.Block.DamageBlock(_world, new BlockValueRef(pos), bv, SmashDamage, _bear.entityId);
Debug.Log("[NecromancerTome] BearControl: лапой по " + bv.Block.GetBlockName() + " " + pos + " - " + SmashDamage +
" (прочность " + (bv.Block.MaxDamage - bv.damage) + " из " + bv.Block.MaxDamage + ")");
}
}
/// <summary>Толстая шкура одержимого медведя - вдвое меньше урона.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.DamageEntity))]
public static class Patch_EntityAlive_DamageEntity_BearSkin
{
public static void Prefix(EntityAlive __instance, ref int _strength)
{
if (_strength > 1 && Possession.IsPossessed(__instance) && BearControl.Is(__instance))
{
_strength = Mathf.Max(1, Mathf.RoundToInt(_strength * BearControl.SkinFactor));
}
}
}
}
+389
View File
@@ -0,0 +1,389 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Кровавый прилив и нож (указание 10.10): «раз некроэнергии становится много, она должна воздействовать не
/// только на зомби и некроманта». Пока идёт прилив (SnakeMode.IsBloodTide - ночь кровавой луны):
/// - на игроке бафф buffNecroBloodTide (buffs.xml): по нему нож (effect_group necroKnifeBloodTide, items.xml) бьёт
/// вдвое сильнее и вдвое чаще, лечит вдвое, плата за силовую вдвое меньше, выносливость на удар вчетверо
/// меньше, дальность - как у меча; с ножом в руке бег на 50% быстрее (сам бафф);
/// - хват ножа - как у мачете, модель - нож в фиолетовом мече (ItemClass ножа подменяется на всю ночь,
/// нож в руке пересоздаётся);
/// и, только если нож в руке:
/// - восстановление здоровья и выносливости вдвое (лишь положительное - кровотечение и прочий урон не трогаем);
/// - полоски здоровья и выносливости пульсируют фиолетовым в такт сердцу;
/// - фоном бьётся сердце, 120 ударов в минуту (синтез в коде: два низких удара синусом, «лаб» и «даб»,
/// с затуханием и спадом тона - по образцу, одобренному пользователем 10.10).
/// Всё - у локального игрока (одиночная игра и хост).
/// </summary>
public static class BloodTide
{
public const string BuffName = "buffNecroBloodTide";
/// <summary>Хват мачете (items.xml игры, meleeWpnBladeT3Machete HoldType=47); у ножа - 36.</summary>
public const int TideHoldType = 47;
/// <summary>Модель меча на прилив (10.10): префаб в том же бандле necroknife (ItemsUnityProject, NecroTideSetup.cs) -
/// путь берётся из пути обычного ножа (уже разобранного игрой, @modfolder) заменой имени префаба.</summary>
public const string KnifePrefab = "necroKnifePrefab.prefab", TidePrefab = "necroKnifeTidePrefab.prefab";
static string TideMeshFile => _origMesh != null && _origMesh.Contains(KnifePrefab) ? _origMesh.Replace(KnifePrefab, TidePrefab) : null;
public const float HeartBpm = 120f;
public const float HeartVolume = 0.55f;
/// <summary>Цвет пульса полосок - фиолетовый меча.</summary>
public static readonly Color PulseColor = new Color(0.67f, 0.24f, 1f);
/// <summary>Идёт прилив (кэш на кадр).</summary>
public static bool Active;
/// <summary>Прилив и нож в руке.</summary>
public static bool Armed;
static bool _swapped;
static int _origHoldType = -1;
static string _origMesh;
static float _nextBuffCheck;
static GameObject _heartGo;
static AudioSource _heart;
static AudioClip _heartClip;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
if (world == null || player == null)
{
Active = Armed = false;
_swapped = false; // классы предметов игра грузит заново с миром
_recreate = false;
_origHoldType = -1;
Heart(false);
Patch_XUiC_HUDStatBar_Update_BloodTide.Forget();
Patch_Stat_Tick_BloodTide.Next = null; // не держать стат (а через него игрока) после выхода из мира
return;
}
bool tide = SnakeMode.IsBloodTide();
if (tide != Active)
{
Active = tide;
Debug.Log("[NecromancerTome] BloodTide: " + (tide ? "прилив начался" : "прилив схлынул"));
}
SwapKnife(player, Active);
TryRecreate(player);
if (Time.time >= _nextBuffCheck && !player.IsDead())
{
_nextBuffCheck = Time.time + 0.5f;
bool has = player.Buffs.HasBuff(BuffName);
if (Active && !has)
{
player.Buffs.AddBuff(BuffName);
}
else if (!Active && has)
{
player.Buffs.RemoveBuff(BuffName);
}
}
Armed = Active && !player.IsDead() && SnakeMode.IsKnife(player.inventory?.holdingItem);
Heart(Armed && !GameManager.Instance.IsPaused());
}
/// <summary>Хват и модель ножа на прилив и обратно. Подмена - в общем ItemClass, поэтому видна у любого
/// ножа; нож в руке пересоздаём (снять и достать тот же слот), иначе он до смены оружия остался бы прежним.</summary>
static void SwapKnife(EntityPlayerLocal _player, bool _tide)
{
if (_tide == _swapped)
{
return;
}
ItemClass knife = ItemClass.GetItemClass(SnakeMode.KnifeName);
if (knife == null)
{
return;
}
if (_origHoldType < 0)
{
_origHoldType = knife.HoldType.Value;
_origMesh = knife.MeshFile;
}
knife.HoldType.Value = _tide ? TideHoldType : _origHoldType;
knife.MeshFile = _tide && !string.IsNullOrEmpty(TideMeshFile) ? TideMeshFile : _origMesh;
_swapped = _tide;
Hand hand = _player.Hand;
// Ножи в других слотах пояса: «собранная» копия помечается устаревшей - соберутся заново (с новыми хватом и
// моделью), когда их возьмут. Нож в руке - в TryRecreate, когда рука свободна.
if (hand?.slotDatas != null)
{
foreach (ItemInventoryData d in hand.slotDatas)
{
if (d != null && d.item == knife && d != hand.equipped && d.physicalItemValue != null)
{
MarkStale(d);
}
}
}
_recreate = true;
Debug.Log("[NecromancerTome] BloodTide: нож - хват " + knife.HoldType.Value);
}
/// <summary>Нож в руке ждёт пересоздания (сменились хват/модель).</summary>
static bool _recreate;
/// <summary>«Собранный» предмет перестаёт совпадать со слотом (NeedsRebuild): копия того же ножа с другим Seed.
/// Не ItemValue.None (тест 10.10): Hand.Unequip читает класс предмета у собранного - NRE.</summary>
static void MarkStale(ItemInventoryData _d)
{
ItemValue pv = (_d.physicalItemValue ?? _d.itemValue).Clone();
pv.Seed = (ushort)(pv.Seed + 1);
_d.physicalItemValue = pv;
}
/// <summary>
/// Пересоздать нож в руке: повторный выбор того же слота снимает его и собирает заново (Hand.SelectHoldingMode).
/// Только когда рука свободна - не идёт смена предмета и удар; иначе ждём кадр. Тест 10.10: при сбое внутри
/// SelectHoldingMode игра оставляла Hand.isSwitching = true навсегда, и рука переставала брать что-либо до
/// перезапуска («после ошибки не получается переключиться на что-либо ещё»). Поэтому сбой перехватываем и
/// возвращаем руку в рабочее состояние.
/// </summary>
static void TryRecreate(EntityPlayerLocal _player)
{
if (!_recreate)
{
return;
}
Inventory inv = _player.inventory;
Hand hand = _player.Hand;
ItemInventoryData held = inv?.holdingItemData;
if (hand == null || held == null || !SnakeMode.IsKnife(held.item))
{
_recreate = false; // ножа в руке нет - возьмут, тогда и соберётся (слоты помечены)
return;
}
if (hand.isSwitching || inv.IsHoldingItemActionRunning() || _player.IsDead())
{
return;
}
_recreate = false;
ItemValue before = held.physicalItemValue;
try
{
MarkStale(held);
inv.SetHoldingItemIdx(inv.holdingItemIdx);
Debug.Log("[NecromancerTome] BloodTide: нож в руке пересоздан, хват " + held.item.HoldType.Value);
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] BloodTide: нож не пересоздан - " + ex.Message + "; рука восстановлена");
held.physicalItemValue = before;
hand.isSwitching = false;
try
{
hand.Reconcile();
}
catch (System.Exception ex2)
{
Debug.LogWarning("[NecromancerTome] BloodTide: Reconcile - " + ex2.Message);
}
}
}
// ---------------------------------------------------------------- сердце
static void Heart(bool _on)
{
if (!_on)
{
if (_heart != null && _heart.isPlaying)
{
_heart.Stop();
}
return;
}
if (_heart == null)
{
_heartGo = new GameObject("NecroBloodTideHeart");
Object.DontDestroyOnLoad(_heartGo);
_heart = _heartGo.AddComponent<AudioSource>();
_heart.clip = _heartClip ?? (_heartClip = MakeHeartClip());
_heart.loop = true;
_heart.spatialBlend = 0f;
_heart.playOnAwake = false;
}
_heart.volume = HeartVolume * GamePrefs.GetFloat(EnumGamePrefs.OptionsOverallAudioVolumeLevel);
if (!_heart.isPlaying)
{
_heart.Play();
}
}
/// <summary>Доля удара сердца для пульса полосок: 1 в момент «лаба», дальше спад; без звука - 0.</summary>
public static float Beat()
{
if (_heart == null || !_heart.isPlaying)
{
return 0f;
}
float t = _heart.time;
float dub = 0.17f;
return Mathf.Max(Mathf.Exp(-9f * t), t >= dub ? 0.6f * Mathf.Exp(-12f * (t - dub)) : 0f);
}
/// <summary>Один цикл сердца (60/HeartBpm с): «лаб» 62 Гц и «даб» 52 Гц через 0.17 с, каждый - синус с
/// второй гармоникой, атака 4 мс, экспоненциальное затухание, тон к концу падает на треть.</summary>
static AudioClip MakeHeartClip()
{
const int sr = 44100;
int n = Mathf.RoundToInt(sr * 60f / HeartBpm);
float[] buf = new float[n];
Thump(buf, 0, 62f, 0.16f, 1f, 22f, sr);
Thump(buf, Mathf.RoundToInt(sr * 0.17f), 52f, 0.12f, 0.6f, 28f, sr);
float max = 0f;
foreach (float v in buf)
{
max = Mathf.Max(max, Mathf.Abs(v));
}
for (int i = 0; i < n; i++)
{
buf[i] = buf[i] / max * 0.9f;
}
AudioClip clip = AudioClip.Create("necroHeartbeat", n, 1, sr, false);
clip.SetData(buf, 0);
return clip;
}
static void Thump(float[] _buf, int _start, float _freq, float _dur, float _amp, float _decay, int _sr)
{
int n = Mathf.Min(Mathf.RoundToInt(_sr * _dur), _buf.Length - _start);
double ph = 0;
for (int i = 0; i < n; i++)
{
float t = (float)i / _sr;
float env = Mathf.Min(t / 0.004f, 1f) * Mathf.Exp(-_decay * t);
ph += 2 * Mathf.PI * _freq * (1f - 0.35f * t / _dur) / _sr;
_buf[_start + i] += _amp * env * (float)(System.Math.Sin(ph) + 0.25 * System.Math.Sin(2 * ph));
}
}
}
/// <summary>Восстановление вдвое (прилив и нож в руке). Игра сама задаёт RegenerationAmount и вызывает Stat.Tick в
/// PlayerEntityStats.UpdatePlayerHealthOT/StaminaOT; удваиваем только свежее положительное восстановление этого
/// вызова (у здоровья оно считается, лишь когда здоровье не полное и HealthChangeOT больше нуля - иначе в поле
/// остаётся прошлая разница, её трогать нельзя).</summary>
[HarmonyPatch(typeof(PlayerEntityStats), nameof(PlayerEntityStats.UpdatePlayerHealthOT))]
public static class Patch_PlayerEntityStats_UpdatePlayerHealthOT_BloodTide
{
public static void Prefix(PlayerEntityStats __instance)
{
Patch_Stat_Tick_BloodTide.Next = null;
if (BloodTide.Armed && __instance.m_entity is EntityPlayerLocal p && __instance.Health.ValuePercent < 1f &&
EffectManager.GetValue(PassiveEffects.HealthChangeOT, null, 0f, p) > 0f)
{
Patch_Stat_Tick_BloodTide.Next = __instance.Health;
}
}
}
[HarmonyPatch(typeof(PlayerEntityStats), nameof(PlayerEntityStats.UpdatePlayerStaminaOT))]
public static class Patch_PlayerEntityStats_UpdatePlayerStaminaOT_BloodTide
{
public static void Prefix(PlayerEntityStats __instance)
{
Patch_Stat_Tick_BloodTide.Next = BloodTide.Armed && __instance.m_entity is EntityPlayerLocal ? __instance.Stamina : null;
}
}
[HarmonyPatch(typeof(Stat), nameof(Stat.Tick))]
public static class Patch_Stat_Tick_BloodTide
{
/// <summary>Стат, чей следующий Tick удвоить (ставят префиксы выше).</summary>
public static Stat Next;
public static void Prefix(Stat __instance)
{
if (Next == null || Next != __instance)
{
return;
}
Next = null;
if (__instance.regenAmount > 0f)
{
__instance.regenAmount *= 2f;
}
}
}
/// <summary>
/// Крит меча (указание 10.10: «шанс на критический удар бы тоже повысить»). Случайного крита у ближнего боя в игре
/// нет: ItemActionDynamic помечает критическим каждый силовой удар (действие 1) и ни одного обычного, а
/// CriticalChance уходит в параметр _criticalHitChanceOLD функции Hit, которую та не читает. Сам крит урон не
/// поднимает - только чаще сбивает с ног и ставит на колено, вдвое быстрее копит урон по ногам, красит цифру урона.
/// Наш крит: в прилив обычный удар ножом по существу с шансом CritChance - критический, урон x CritMultiplier.
/// </summary>
[HarmonyPatch(typeof(ItemActionAttack), nameof(ItemActionAttack.Hit))]
public static class Patch_ItemActionAttack_Hit_BloodTideCrit
{
public const float CritChance = 0.25f;
public const float CritMultiplier = 2f;
public static void Prefix(WorldRayHitInfo hitInfo, int _attackerEntityId, ref float _entityDamage,
ItemActionAttack.AttackHitInfo _attackDetails, ItemValue damagingItemValue, bool _isGrazingHit)
{
if (!BloodTide.Armed || _isGrazingHit || _attackDetails == null || _attackDetails.isCriticalHit || _entityDamage <= 0f ||
hitInfo?.tag == null || !hitInfo.tag.StartsWith("E_") || damagingItemValue?.ItemClass?.Name != SnakeMode.KnifeName)
{
return;
}
World world = GameManager.Instance?.World;
if (world == null || world.GetPrimaryPlayer()?.entityId != _attackerEntityId || Random.value >= CritChance)
{
return;
}
_entityDamage *= CritMultiplier;
_attackDetails.isCriticalHit = true;
}
}
/// <summary>Пульс полосок здоровья и выносливости (прилив и нож в руке): цвет полосы (BarContent в HUDLeftStatBars
/// игры, у здоровья 255,0,0,160, у выносливости 0,54,255,160) уходит в фиолетовый на каждом ударе сердца.</summary>
[HarmonyPatch(typeof(XUiC_HUDStatBar), nameof(XUiC_HUDStatBar.Update))]
public static class Patch_XUiC_HUDStatBar_Update_BloodTide
{
static readonly Dictionary<XUiC_HUDStatBar, (XUiV_Sprite bar, Color orig)> _bars = new Dictionary<XUiC_HUDStatBar, (XUiV_Sprite, Color)>();
public static void Forget()
{
_bars.Clear();
}
public static void Postfix(XUiC_HUDStatBar __instance)
{
if (__instance.statType != HUDStatTypes.Health && __instance.statType != HUDStatTypes.Stamina)
{
return;
}
if (_bars.TryGetValue(__instance, out var e) && e.bar.UiTransform == null)
{
_bars.Remove(__instance); // окно пересобрано - старая полоса удалена
}
if (!_bars.TryGetValue(__instance, out e))
{
if (!BloodTide.Armed || !(__instance.GetChildById("BarContent")?.ViewComponent is XUiV_Sprite s))
{
return;
}
e = (s, s.Color);
_bars[__instance] = e;
}
Color c = BloodTide.Armed
? Color.Lerp(e.orig, new Color(BloodTide.PulseColor.r, BloodTide.PulseColor.g, BloodTide.PulseColor.b, Mathf.Max(e.orig.a, 0.8f)), BloodTide.Beat())
: e.orig;
if (e.bar.Color != c)
{
e.bar.Color = c;
}
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей, призывается книгой, ездит на
/// Костяной скакун (1.6.0, 01.10.2026): мотоцикл из костей, призывается книгой, ездит на
/// кровавых сферах. Сущность necroBoneSteed в entityclasses.xml - наследник vehicleMotorcycle
/// со своей моделью (бандл necrobonesteed, собран из копии ванильного VMotorcycleP.prefab).
///
+96
View File
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Шейдер Standard из наших бандлов (10.10.2026). Тест в игре: останки красного трупа вышли бледно-бежевыми, хотя
/// в Unity тот же префаб тёмный и багровый (снимок NecroPreviewOne) - игра рисует копию Standard, приехавшую
/// в бандле, не так, как свой Standard. То же раньше было у крысы (RatModel.cs подменяет шейдер на свой) и у
/// пирамиды (маджента издалека). Здесь - общая подмена для новых моделей: останки (сущность necroRemains),
/// череп и нож-меч прилива (модели предметов). Материалы бандла общие - правится один раз на материал.
/// Очередь отрисовки сохраняется (у меча прилива - прозрачная, Fade).
/// </summary>
public static class BundleShaderFix
{
static Shader _std;
static readonly HashSet<int> _done = new HashSet<int>();
/// <summary>Выход из мира: бандлы при следующей загрузке дадут новые материалы - старые номера не нужны
/// (вызывает RemainsDecay, когда мира нет).</summary>
public static void Forget()
{
_done.Clear();
}
public static void Fix(Transform _root)
{
if (_root == null)
{
return;
}
if (_std == null)
{
_std = Shader.Find("Standard");
if (_std == null)
{
return;
}
}
foreach (Renderer r in _root.GetComponentsInChildren<Renderer>(true))
{
foreach (Material m in r.sharedMaterials)
{
if (m == null || m.shader == null || m.shader == _std || m.shader.name != "Standard" || !_done.Add(m.GetInstanceID()))
{
continue;
}
int queue = m.renderQueue;
m.shader = _std;
m.renderQueue = queue;
Debug.Log("[NecromancerTome] BundleShaderFix: материал '" + m.name + "' - встроенный Standard (очередь " + queue + ")");
}
}
}
}
/// <summary>Останки красного трупа - модель сущности.</summary>
[HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))]
public static class Patch_EModelBase_createModel_BundleShaderFix
{
static int _cls = int.MinValue;
public static void Postfix(EModelBase __instance)
{
if (_cls == int.MinValue)
{
_cls = EntityClass.FromString(RemainsDecay.EntityClassName);
}
Entity e = __instance.entity;
if (e != null && e.entityClass == _cls)
{
BundleShaderFix.Fix(__instance.GetModelTransform());
}
}
}
/// <summary>Череп и нож (меч прилива) - модели предметов: в руке и на земле.</summary>
[HarmonyPatch(typeof(ItemClass), nameof(ItemClass.CloneModel))]
public static class Patch_ItemClass_CloneModel_BundleShaderFix
{
public static void Postfix(ItemClass __instance, AssetManagement.InstanceHandle<GameObject> __result)
{
string n = __instance?.Name;
if (n != "necroSkull" && n != SnakeMode.KnifeName)
{
return;
}
GameObject go = __result.Instance;
if (go != null)
{
BundleShaderFix.Fix(go.transform);
}
}
}
}
+339
View File
@@ -0,0 +1,339 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Ноша в когтях одержимого стервятника (указание 08.10: «сделать курицу в когтях стервятника без хитбокса
/// и отдельным элементом, чтобы крылья и шея свисали вниз»).
///
/// Раньше под лапы каждый кадр переставлялась сама курица, и было две беды. Рывки: модель сущности
/// догоняет её позицию с задержкой (Entity.updateTransform - Lerp), а позиция стервятника меняется
/// 20 раз в секунду - курица отставала и прыгала. Твёрдость: её коллайдеры мешали спускаться, и
/// стервятник садился на неё.
///
/// Теперь при захвате с курицы снимается слепок: кости крыльев и шеи развёрнуты вниз, сетки запечены
/// (BakeMesh) в неподвижные MeshRenderer без коллайдеров. Слепок висит под стервятником по его
/// видимой (сглаженной) позиции. Сама курица спрятана (forceRenderingOff - его не трогает
/// avatarController.SetVisible), коллайдеры выключены, ИИ стоит; её позиция идёт за слепком,
/// и когда стервятник её отпускает, она появляется ровно там, где висел слепок.
/// </summary>
public static class CarryVisual
{
static GameObject _holder;
static readonly List<Mesh> _meshes = new List<Mesh>();
static readonly List<Renderer> _hidden = new List<Renderer>();
static readonly List<Collider> _off = new List<Collider>();
/// <summary>Верх и низ слепка относительно его опоры, м.</summary>
static float _top, _bottom;
/// <summary>Сколько крыло отводится в сторону от вертикали и шея - вперёд.</summary>
const float WingOut = 0.35f;
const float NeckForward = 0.35f;
static bool _loggedBones;
public static void Begin(EntityAlive _c)
{
Clear();
Transform model = _c.emodel != null ? _c.emodel.GetModelTransform() : null;
if (model == null)
{
Debug.LogWarning("[NecromancerTome] CarryVisual: у " + _c.EntityName + " нет модели - несём как есть");
return;
}
// Не только свои: физическое тело для толкания (physicsRBT) игра выносит из иерархии сущности
// (Entity.PhysicsInit - SetParent наружу), и на нём стервятник «сидел» в воздухе (тест 09.10).
foreach (Transform root in new[] { _c.transform, _c.PhysicsTransform, _c.physicsRBT })
{
if (root == null)
{
continue;
}
foreach (Collider col in root.GetComponentsInChildren<Collider>(true))
{
if (col != null && col.enabled)
{
col.enabled = false;
_off.Add(col);
}
}
}
Limp(_c, model);
_holder = new GameObject("necroCarry_" + _c.entityId);
_holder.transform.SetPositionAndRotation(_c.transform.position, Quaternion.Euler(0f, _c.transform.eulerAngles.y, 0f));
bool any = false;
_top = float.MinValue;
_bottom = float.MaxValue;
foreach (Renderer r in model.GetComponentsInChildren<Renderer>(false))
{
if (r == null || !r.enabled || r.forceRenderingOff)
{
continue;
}
Mesh mesh;
if (r is SkinnedMeshRenderer smr)
{
if (smr.sharedMesh == null)
{
continue;
}
mesh = new Mesh();
smr.BakeMesh(mesh, true);
_meshes.Add(mesh);
}
else if (r is MeshRenderer && r.TryGetComponent(out MeshFilter mf) && mf.sharedMesh != null)
{
mesh = mf.sharedMesh;
}
else
{
continue;
}
GameObject part = new GameObject(r.name);
part.transform.SetPositionAndRotation(r.transform.position, r.transform.rotation);
part.transform.SetParent(_holder.transform, true);
if (!(r is SkinnedMeshRenderer))
{
part.transform.localScale = r.transform.lossyScale;
}
part.AddComponent<MeshFilter>().sharedMesh = mesh;
MeshRenderer mr = part.AddComponent<MeshRenderer>();
mr.sharedMaterials = r.sharedMaterials;
mr.shadowCastingMode = r.shadowCastingMode;
// Высота - по самой запечённой сетке: r.bounds у SkinnedMeshRenderer - рамка с запасом, по ней
// курица висела далеко под лапами (тест 09.10, «высота 0.99 м»).
foreach (Vector3 v in mesh.vertices)
{
float y = part.transform.TransformPoint(v).y - _holder.transform.position.y;
_top = Mathf.Max(_top, y);
_bottom = Mathf.Min(_bottom, y);
}
any = true;
}
if (!any)
{
_top = 0.3f;
_bottom = 0f;
}
foreach (Renderer r in _c.GetComponentsInChildren<Renderer>(true))
{
if (r != null && !r.forceRenderingOff)
{
r.forceRenderingOff = true;
_hidden.Add(r);
}
}
Debug.Log("[NecromancerTome] CarryVisual: слепок " + _c.EntityName + " - частей " + _holder.transform.childCount +
", высота " + (_top - _bottom).ToString("F2") + " м, выключено коллайдеров " + _off.Count);
}
/// <summary>Каждый кадр: слепок под лапами стервятника по его видимой позиции; у земли - стоит на ней.</summary>
public static void Place(EntityAlive _v, EntityAlive _c, float _gap)
{
Vector3 feet = Claws(_v);
Quaternion yaw = Quaternion.Euler(0f, _v.transform.eulerAngles.y, 0f);
float y = feet.y - _top - _gap;
if (Physics.Raycast(feet + Vector3.up * 0.1f, Vector3.down, out RaycastHit hit, _top - _bottom + _gap + 0.1f,
SnakeControl.WallMask, QueryTriggerInteraction.Ignore) && hit.point.y > y + _bottom)
{
y = hit.point.y - _bottom;
}
Vector3 at = new Vector3(feet.x, y, feet.z);
if (_holder != null)
{
_holder.transform.SetPositionAndRotation(at, yaw);
}
_c.SetPosition(at + Origin.position);
}
static int _clawsOf = -1;
static readonly List<Transform> _claws = new List<Transform>();
/// <summary>Точка хвата - середина между лапами стервятника (кости с «toe»/«foot» в имени, а без них - «leg»),
/// по анимации, каждый кадр (тест 08.10: «курица висит под стервятником, а не в его когтях»). Костей нет -
/// низ его модели.</summary>
static Vector3 Claws(EntityAlive _v)
{
if (_clawsOf != _v.entityId)
{
_clawsOf = _v.entityId;
_claws.Clear();
Transform model = _v.emodel != null ? _v.emodel.GetModelTransform() : null;
if (model != null)
{
List<string> all = new List<string>();
foreach (string key in new[] { "toe", "foot", "leg" })
{
foreach (Transform t in model.GetComponentsInChildren<Transform>(true))
{
string n = t.name.ToLowerInvariant();
if (key == "toe" || key == "foot" ? n.Contains(key) : n.Contains(key) && !n.Contains("up") && !n.Contains("thigh"))
{
_claws.Add(t);
}
}
if (_claws.Count > 0)
{
break;
}
}
foreach (Transform t in model.GetComponentsInChildren<Transform>(true))
{
all.Add(t.name);
}
Debug.Log("[NecromancerTome] CarryVisual: лапы " + _v.EntityName + ": " +
(_claws.Count > 0 ? string.Join(", ", _claws.ConvertAll(t => t.name)) : "не найдены") +
"; все кости: " + string.Join(", ", all));
}
}
if (_claws.Count == 0)
{
return _v.transform.position;
}
Vector3 sum = Vector3.zero;
float low = float.MaxValue;
foreach (Transform t in _claws)
{
sum += t.position;
low = Mathf.Min(low, t.position.y);
}
Vector3 c = sum / _claws.Count;
c.y = low;
return c;
}
/// <summary>Отпустить: курица снова видна и тверда там, где висел слепок.</summary>
public static void End(EntityAlive _c)
{
if (_c != null && _holder != null && !_c.IsDead())
{
Vector3 at = _holder.transform.position;
_c.SetPosition(at + Origin.position);
_c.transform.position = at;
}
foreach (Renderer r in _hidden)
{
if (r != null)
{
r.forceRenderingOff = false;
}
}
foreach (Collider col in _off)
{
if (col != null)
{
col.enabled = true;
}
}
_hidden.Clear();
_off.Clear();
Clear();
}
/// <summary>Убрать слепок, ничего не возвращая (выход из мира).</summary>
public static void Clear()
{
if (_holder != null)
{
Object.Destroy(_holder);
}
_holder = null;
foreach (Mesh m in _meshes)
{
if (m != null)
{
Object.Destroy(m);
}
}
_meshes.Clear();
}
/// <summary>Обвисшая поза: каждая кость крыла и шеи (от корня к концу) разворачивается так, чтобы её
/// дочерняя кость оказалась под ней - крылья свисают чуть в стороны, шея с головой - чуть вперёд.
/// Аниматор следующим кадром позу вернёт, но слепок уже запечён, а сама курица спрятана.</summary>
static void Limp(EntityAlive _c, Transform _model)
{
HashSet<Transform> bones = new HashSet<Transform>();
foreach (SkinnedMeshRenderer smr in _model.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (smr.bones == null)
{
continue;
}
foreach (Transform b in smr.bones)
{
if (b != null)
{
bones.Add(b);
}
}
}
List<Transform> order = new List<Transform>(bones);
order.Sort((a, b) => Depth(a).CompareTo(Depth(b)));
if (!_loggedBones)
{
_loggedBones = true;
List<string> names = new List<string>();
foreach (Transform b in order)
{
names.Add(b.name);
}
Debug.Log("[NecromancerTome] CarryVisual: кости " + _c.EntityName + ": " + string.Join(", ", names));
}
Vector3 fwd = _c.transform.forward;
Vector3 right = _c.transform.right;
Vector3 center = _c.transform.position;
int wings = 0, necks = 0;
foreach (Transform b in order)
{
string n = b.name.ToLowerInvariant();
Vector3 want;
if (n.Contains("wing"))
{
float side = Mathf.Sign(Vector3.Dot(b.position - center, right));
want = (Vector3.down + right * side * WingOut).normalized;
wings++;
}
else if (n.Contains("neck") || n.Contains("head"))
{
want = (Vector3.down + fwd * NeckForward).normalized;
necks++;
}
else
{
continue;
}
Transform child = null;
for (int i = 0; i < b.childCount; i++)
{
if (bones.Contains(b.GetChild(i)))
{
child = b.GetChild(i);
break;
}
}
if (child == null)
{
continue;
}
Vector3 dir = child.position - b.position;
if (dir.sqrMagnitude < 1e-6f)
{
continue;
}
b.rotation = Quaternion.FromToRotation(dir, want) * b.rotation;
}
Debug.Log("[NecromancerTome] CarryVisual: обвисли костей крыльев " + wings + ", шеи/головы " + necks);
}
static int Depth(Transform _t)
{
int d = 0;
for (Transform p = _t.parent; p != null; p = p.parent)
{
d++;
}
return d;
}
}
}
+122
View File
@@ -0,0 +1,122 @@
using System;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Грифельная доска (necroChalkboard, 09.10.2026): надписи мелом - как на табличках (TEFeatureSignable), но
/// «игрок этого делать не будет, а нам для сюжета пригодится». Поэтому команда «написать» у доски есть
/// только в редакторе префабов, в творческом режиме и в режиме отладки (так мы и подписываем её - текст
/// сохраняется в префабе локации, IFeatureSavedInPrefab). В обычной игре доску только читают: без команды
/// нет и подсказки «E - использовать».
/// </summary>
public static class Chalkboard
{
/// <summary>Сюжетная доска (чертоги, локации): пишет мод или автор префаба.</summary>
public const string BlockName = "necroChalkboard";
/// <summary>Доска игрока (10.10): крафтится, пишется в обычной игре - записи свои у каждой доски.</summary>
public const string PlayerBlockName = "necroChalkboardPlayer";
static string NameOf(TEFeatureSignable _sign)
{
return _sign?.Parent?.TeData?.Block?.GetBlockName();
}
/// <summary>Любая из досок - для материала надписи.</summary>
public static bool Is(TEFeatureSignable _sign)
{
string n = NameOf(_sign);
return n == BlockName || n == PlayerBlockName;
}
/// <summary>Сюжетная доска - писать только в редакторе, творческом режиме, отладке.</summary>
public static bool IsLocked(TEFeatureSignable _sign)
{
return NameOf(_sign) == BlockName && !CanWrite();
}
/// <summary>Кто может писать: редактор префабов, творческий режим, отладка.</summary>
public static bool CanWrite()
{
return GameManager.Instance != null && GameManager.Instance.IsEditMode() ||
GameStats.GetBool(EnumGameStats.IsCreativeMenuEnabled) ||
GamePrefs.GetBool(EnumGamePrefs.DebugMenuEnabled);
}
/// <summary>Материал надписи - из бандла, свой шейдер NecroTome/ChalkText с ZTest LEqual (тест 09.10: надпись
/// просвечивала сквозь стены - встроенный GUI/Text Shader берёт ZTest из глобального unity_GUIZTestMode, который
/// интерфейс держит «Always», и переопределение в материале не помогло). Шрифт динамический: его текстура
/// растёт и пересоздаётся при новых буквах - держим её в материале надписи (один материал на все доски).</summary>
static Material _mat;
static Font _font;
static bool _hooked;
public static void BindText(TextMesh _tm, MeshRenderer _r)
{
_mat = _r.sharedMaterial;
_font = _tm.font;
if (_mat == null || _font == null)
{
return;
}
_mat.mainTexture = _font.material.mainTexture;
if (!_hooked)
{
_hooked = true;
Font.textureRebuilt += _f =>
{
if (_mat != null && _f == _font)
{
_mat.mainTexture = _f.material.mainTexture;
}
};
}
}
}
[HarmonyPatch(typeof(TEFeatureSignable), nameof(TEFeatureSignable.SetBlockEntityData))]
public static class Patch_TEFeatureSignable_SetBlockEntityData_Chalkboard
{
public static void Postfix(TEFeatureSignable __instance, BlockEntityData _blockEntityData)
{
if (_blockEntityData == null || !_blockEntityData.bHasTransform || !Chalkboard.Is(__instance))
{
return;
}
foreach (TextMesh tm in _blockEntityData.transform.GetComponentsInChildren<TextMesh>())
{
MeshRenderer r = tm.GetComponent<MeshRenderer>();
if (r != null && tm.font != null)
{
Chalkboard.BindText(tm, r);
}
}
}
}
[HarmonyPatch(typeof(TEFeatureSignable), nameof(TEFeatureSignable.AllowBlockActivationCommand))]
public static class Patch_TEFeatureSignable_AllowBlockActivationCommand_Chalkboard
{
public static void Postfix(TEFeatureSignable __instance, ITileEntityFeature _module, ReadOnlySpan<char> _commandName, ref bool __result)
{
if (__result && __instance.Equals(_module) && _commandName.SequenceEqual("edit".AsSpan()) &&
Chalkboard.IsLocked(__instance))
{
__result = false;
}
}
}
[HarmonyPatch(typeof(TEFeatureSignable), nameof(TEFeatureSignable.GetActivationText))]
public static class Patch_TEFeatureSignable_GetActivationText_Chalkboard
{
public static void Postfix(TEFeatureSignable __instance, ref string __result)
{
if (Chalkboard.IsLocked(__instance))
{
__result = null;
}
}
}
}
+1
View File
@@ -646,6 +646,7 @@ namespace NecromancerTome
if (!__instance.bag.Touched)
{
GameManager.Instance.lootManager.LootBagOpened(__instance.bag, __instance, _lockingPlayerID);
NecroSkull.Roll(__instance, __instance.bag); // 2% - череп (останки), с 09.10 вместо браслета
}
__instance.timeStayAfterDeath = (int)(CorpseLoot.OpenedCorpseSecondsAfterDeath * 20f);
Debug.Log("[NecromancerTome] CorpseLoot: труп " + __instance.entityId + " открыт");
+16 -1
View File
@@ -20,7 +20,7 @@ namespace NecromancerTome
static bool Ours(string _name)
{
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster");
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster") || _name == "necroBookDoorPrefab";
}
public static void Postfix(Transform __result)
@@ -37,6 +37,21 @@ namespace NecromancerTome
}
__result.tag = vanilla.tag;
__result.gameObject.layer = vanilla.gameObject.layer;
// Дверь-шкаф (09.10): коллайдер на створке (ходит с петлёй), а не на корне - тег и ей.
foreach (Collider c in __result.GetComponentsInChildren<Collider>(true))
{
c.gameObject.tag = vanilla.tag;
c.gameObject.layer = vanilla.gameObject.layer;
}
// По попаданию в дочерний коллайдер игра ищет блок через RootTransformRefParent.FindRoot (Voxel ->
// GameUtils.FindMasterBlockForEntityModelBlock): без него луч по створке «не находил» блок - двери не было
// хитбокса (тест 09.10). Как у ванильной двери - компонент на корне со ссылкой на корень; при копировании
// модели Unity переводит ссылку на корень копии.
if (__result.GetComponentInChildren<Collider>(true) != null && __result.GetComponent<Collider>() == null)
{
RootTransformRefParent rr = __result.GetComponent<RootTransformRefParent>() ?? __result.gameObject.AddComponent<RootTransformRefParent>();
rr.RootTransform = __result;
}
Debug.Log("[NecromancerTome] DecorCollider: '" + __result.name + "' тег " + vanilla.tag + ", слой " + vanilla.gameObject.layer +
", коллайдер " + (__result.GetComponent<Collider>() != null ? "есть" : "НЕТ"));
}
+80
View File
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Нюх одержимой зомбособаки (указание 09.10: «добавь зомбособаке отображение противников, как в Тёмном чутье.
/// Типа у собаки нюх»; решено: только в одержимости, чует всех существ). Как Тёмное чутьё змеи
/// (SnakeControl.Sense): раз в секунду существа ближе SenseRange к собаке получают метку на компасе и карте,
/// дальше SenseDropRange, мёртвые и выгруженные её теряют. В отличие от чутья - не только нежить: звери,
/// бандиты, зомби. Не метятся: тело игрока и другие игроки, питомцы мода (призрачные), торговцы.
/// </summary>
public static class DogSense
{
public const string DogClass = "necroZombieDog";
static readonly HashSet<int> _sensed = new HashSet<int>();
static float _check;
public static bool Is(EntityAlive _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
cls.entityClassName == DogClass;
}
static bool Smells(EntityAlive _e, EntityAlive _dog)
{
return _e != null && _e != _dog && !_e.IsDead() && !(_e is EntityPlayer) && !(_e is EntityTrader) &&
!SnakeControl.IsNecroEntity(_e) && !GhostTraderPatch.ShouldBeGhost(_e);
}
public static void Tick(World _world, EntityAlive _dog, float _dt)
{
_check -= _dt;
if (_check > 0f)
{
return;
}
_check = 1f;
Vector3 at = _dog.position;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null)
{
continue;
}
float d2 = (e.position - at).sqrMagnitude;
bool smell = Smells(e, _dog);
if (smell && d2 < SnakeControl.SenseRange * SnakeControl.SenseRange)
{
if (_sensed.Add(e.entityId))
{
e.AddNavObject(SnakeControl.SenseNavObject, null, null);
}
}
else if (_sensed.Contains(e.entityId) && (!smell || d2 > SnakeControl.SenseDropRange * SnakeControl.SenseDropRange))
{
e.RemoveNavObject(SnakeControl.SenseNavObject);
_sensed.Remove(e.entityId);
}
}
_sensed.RemoveWhere(id => _world.GetEntity(id) == null);
}
/// <summary>Выход из собаки - снять все метки.</summary>
public static void Clear()
{
World world = GameManager.Instance?.World;
if (world != null)
{
foreach (int id in _sensed)
{
(world.GetEntity(id) as EntityAlive)?.RemoveNavObject(SnakeControl.SenseNavObject);
}
}
_sensed.Clear();
_check = 0f;
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using HarmonyLib;
using Debug = UnityEngine.Debug;
namespace NecromancerTome
{
/// <summary>
/// Замер утечки сущностей (указание 09.10: «добавь замер сразу; если не будет тормозить, пригодится для
/// анализа»). Повод - лог 09.10: в строке игры «Ent: 7 (31)» живых сущностей 7, а экземпляров в памяти 31
/// (Entity.InstanceCount уменьшается только в финализаторе), и разрыв рос за сессию.
///
/// Как работает. Каждое удаление сущности из мира проходит через World.unloadEntity (смерть, отзыв,
/// выгрузка чанка, чёрный разрыв NecroBurst). Там удалённая сущность запоминается СЛАБОЙ ссылкой
/// (WeakReference сама не держит объект), с классом, причиной и источником - первым методом мода в стеке
/// вызова (или «игра»). Раз в минуту: если с удаления прошло не меньше двух сборок мусора (GC.CollectionCount),
/// а объект всё ещё жив, - его кто-то держит. В лог идёт строка «EntityLeakProbe» со сводкой по классу и
/// источнику; ничего не держится - строки нет.
///
/// Нагрузка: удаления редки (единицы в минуту), стек снимается только в этот момент; раз в минуту - проход
/// по короткому списку. GC принудительно НЕ вызывается (дал бы рывок). Вышедшие из памяти записи
/// выбрасываются, список не растёт.
/// </summary>
public static class EntityLeakProbe
{
class Rec
{
public WeakReference Ref;
public string What;
public int Gc;
}
static readonly List<Rec> _recs = new List<Rec>();
static readonly Dictionary<string, int> _sum = new Dictionary<string, int>();
static float _next;
const float Period = 60f;
public static void OnUnload(Entity _e, EnumRemoveEntityReason _reason)
{
if (_e == null || _e is EntityPlayerLocal)
{
return;
}
string cls = EntityClass.list.TryGetValue(_e.entityClass, out EntityClass ec) && ec != null ? ec.entityClassName : _e.GetType().Name;
_recs.Add(new Rec
{
Ref = new WeakReference(_e),
What = cls + " / " + _reason + " / " + Origin(),
Gc = GC.CollectionCount(0)
});
}
/// <summary>Первый метод мода в стеке (кроме этого класса и патчей Harmony) - кто удалил; иначе «игра».</summary>
static string Origin()
{
StackTrace st = new StackTrace(2, false);
for (int i = 0; i < st.FrameCount && i < 24; i++)
{
System.Reflection.MethodBase m = st.GetFrame(i)?.GetMethod();
Type t = m?.DeclaringType;
if (t == null || t == typeof(EntityLeakProbe) || t.Namespace != nameof(NecromancerTome) || t.Name.StartsWith("Patch_World_unloadEntity"))
{
continue;
}
return (t.DeclaringType != null ? t.DeclaringType.Name + "." : "") + t.Name + "." + m.Name;
}
return "игра";
}
/// <summary>Раз в минуту (из ModEvents.UnityUpdate мода).</summary>
public static void Tick()
{
float now = UnityEngine.Time.time;
if (now < _next)
{
return;
}
_next = now + Period;
int gc = GC.CollectionCount(0);
_sum.Clear();
int held = 0;
for (int i = _recs.Count - 1; i >= 0; i--)
{
Rec r = _recs[i];
object o = r.Ref.Target;
if (o == null)
{
_recs.RemoveAt(i);
continue;
}
if (gc - r.Gc < 2)
{
continue; // сборок мусора ещё не было - рано судить
}
held++;
_sum.TryGetValue(r.What, out int n);
_sum[r.What] = n + 1;
}
if (held == 0)
{
return;
}
StringBuilder sb = new StringBuilder();
sb.Append("[NecromancerTome] EntityLeakProbe: удалённых, но живых в памяти ").Append(held)
.Append(" (Entity.InstanceCount ").Append(Entity.InstanceCount).Append(", сборок мусора ").Append(gc).Append("):");
foreach (KeyValuePair<string, int> kv in _sum)
{
sb.Append("\n ").Append(kv.Value).Append(" x ").Append(kv.Key);
}
Debug.Log(sb.ToString());
}
/// <summary>Выход из мира - забыть всё.</summary>
public static void Reset()
{
_recs.Clear();
_next = 0f;
}
}
[HarmonyPatch(typeof(World), nameof(World.unloadEntity))]
public static class Patch_World_unloadEntity_LeakProbe
{
public static void Prefix(Entity _e, EnumRemoveEntityReason _reason)
{
try
{
EntityLeakProbe.OnUnload(_e, _reason);
}
catch (Exception)
{
// Замер не должен ломать удаление сущности ни при каких условиях.
}
}
}
}
+5
View File
@@ -97,6 +97,10 @@ namespace NecromancerTome
}
}
// ОТКЛЮЧЕНО 10.10 (новая концепция кровавой луны - «кровавый прилив», SnakeMode.IsBloodTide): в эту ночь
// дух не выходит из тела, а кто был змеёй - выброшен в тело. Игрока-змеи в орде больше не бывает, и
// античит не нужен. Сущность и её вид (выше и ниже) оставлены - вернуть подмешивание можно, сняв комментарий.
/*
/// <summary>
/// Подмешивание в орду. Ванильный образец - AIDirectorBloodMoonParty.SpawnZombie: игрок в
/// транспорте получает в орду стервятников с шансом 0.5. Класс зомби там берётся из
@@ -139,6 +143,7 @@ namespace NecromancerTome
}
}
}
*/
/// <summary>
/// Вид Стирателя (02.10): тело - ванильная модель толстого гавайца (скелет, физика, теги,
+1 -1
View File
@@ -4,7 +4,7 @@ using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере. Блок -
/// Гео-оссуарий (1.6.0, 2026-09-30): станция добычи ресурсов на кровавой сфере. Блок -
/// necroGeoOssuary в blocks.xml, наследник ванильного коллектора росы; всё, что коллектор
/// умеет сам (окно, топливо, таймеры, работа без игрока рядом), остаётся его. Здесь только то,
/// чего ваниль не умеет, - разобрано по декомпилятору 3.3:
+3
View File
@@ -310,6 +310,9 @@ namespace NecromancerTome
// обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и
// голова, что и выглядело жутко.
"necroZombieGriffin",
// Могильная крыса (08.10.2026): «серой, не полупрозрачной» - у неё теперь своя модель
// из угольной глины (RatModel.cs), призрачный вид её бы испортил.
"necroRatSpirit",
};
public static bool ShouldBeGhost(EntityAlive _entity)
+11
View File
@@ -157,6 +157,12 @@ namespace NecromancerTome
HandPose.Jab(); // толчок к прицелу, как у «пиф-пафа» (05.10)
_player.PlayOneShot(DenySound);
World world = _player.world;
if (SnakeMode.IsBloodTide())
{
// Кровавый прилив (10.10): леденящий ужас тонет в насыщенном воздухе - жест и звук есть, бегства нет.
SnakeMode.BloodTideHint(_player);
return;
}
if (world.IsRemote())
{
return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно
@@ -317,6 +323,11 @@ namespace NecromancerTome
public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
{
__state = null;
// В одержимости прицел красит Possession.AimTint по цели одержимого, а не хват тела.
if (Possession.Active)
{
return;
}
int hold = HandHolds.Current(__instance);
EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
// «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
+1 -1
View File
@@ -6,7 +6,7 @@ using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.7.0). Свой хват -
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.6.0). Свой хват -
/// это неподвижная поза рук от первого лица, как у браслета (кулак к противнику, без движения
/// при действии): кости рига ставятся в нужную комбинацию после аниматора. Сначала надо знать
/// кости: команда пишет в лог дерево рига рук от первого лица (EModelSDCS.baseRigFP, его создаёт
+2 -1
View File
@@ -7,7 +7,7 @@ using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.7.0). Хват - неподвижная
/// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.6.0). Хват - неподвижная
/// поза: после аниматора (LateUpdate) кости рига EModelSDCS.baseRigFP ставятся в заданные локальные
/// повороты. Аниматор игры не трогаем. Снимки necrobones 03.10: кулак браслета (FP_Block_Hold,
/// HoldType 7) - суставы пальцев повёрнуты по локальной Z на 77-92°, у пустой руки 8-38°;
@@ -323,6 +323,7 @@ namespace NecromancerTome
{
HandHolds.Update();
HandPose.Apply();
HeldTweak.Apply();
}
}
+152
View File
@@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Подбор посадки модели предмета в руке прямо в игре (10.10.2026, череп): как necropose для жестов. Сдвиг, поворот
/// и масштаб применяются к дочерним узлам модели в руке (корень модели держит игра) каждый кадр - переживают смену
/// слота. Итог - строкой в лог, затем переносится в настройку модели (NecroSkullSetup и т.п.) и бандл пересобирается
/// при закрытой игре.
/// </summary>
public static class HeldTweak
{
public static bool Enabled;
public static Vector3 Pos; // метры, в осях модели
public static Vector3 Rot; // градусы, добавка к исходному повороту
public static float Scale = 1f;
/// <summary>Предмет, для которого подбирается посадка (тот, что был в руке при первой команде). Другие предметы
/// не трогаются: 10.10 без этого поворот черепа применился и к книге, взятой следом.</summary>
public static string Item;
/// <summary>Исходные локальные значения детей модели (по экземпляру узла).</summary>
static readonly Dictionary<int, (Vector3 p, Quaternion r, Vector3 s)> _base = new Dictionary<int, (Vector3, Quaternion, Vector3)>();
public static Transform Model()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
return pl?.inventory?.GetHoldingItemTransform();
}
public static string HeldName()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
return pl?.inventory?.holdingItem?.Name;
}
public static void Apply()
{
Transform m = Model();
if (m == null || (Enabled && HeldName() != Item))
{
return;
}
for (int i = 0; i < m.childCount; i++)
{
Transform c = m.GetChild(i);
int id = c.GetInstanceID();
if (!_base.TryGetValue(id, out var b))
{
if (!Enabled)
{
continue;
}
b = (c.localPosition, c.localRotation, c.localScale);
_base[id] = b;
}
if (Enabled)
{
c.localPosition = Scale * b.p + Pos;
c.localRotation = Quaternion.Euler(Rot) * b.r;
c.localScale = b.s * Scale;
}
else
{
c.localPosition = b.p;
c.localRotation = b.r;
c.localScale = b.s;
_base.Remove(id);
}
}
}
public static string F(float _v) => _v.ToString("0.###", CultureInfo.InvariantCulture);
public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
}
/// <summary>
/// necroheld - посадка модели в руке:
/// pos x y z (см) | add x y z (см) | rot x y z (градусы) | scale s | off | show
/// </summary>
[Preserve]
public class ConsoleCmdNecroHeld : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroheld" };
public override string getDescription() => "NecromancerTome: посадка модели в руке - pos x y z (см)|add x y z (см)|rot x y z|scale s|off|show";
static bool Num(List<string> _p, int _i, out float _v)
{
_v = 0f;
return _p.Count > _i && float.TryParse(_p[_i].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
}
static bool Vec(List<string> _p, out Vector3 _v)
{
bool ok = Num(_p, 1, out float x) & Num(_p, 2, out float y) & Num(_p, 3, out float z);
_v = new Vector3(x, y, z);
return ok;
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "show";
Vector3 v;
switch (cmd)
{
case "pos" when Vec(_params, out v):
HeldTweak.Pos = v / 100f;
break;
case "add" when Vec(_params, out v):
HeldTweak.Pos += v / 100f;
break;
case "rot" when Vec(_params, out v):
HeldTweak.Rot = v;
break;
case "scale" when Num(_params, 1, out float s) && s > 0f:
HeldTweak.Scale = s;
break;
case "off":
HeldTweak.Enabled = false;
HeldTweak.Pos = Vector3.zero;
HeldTweak.Rot = Vector3.zero;
HeldTweak.Scale = 1f;
Out("посадка сброшена");
return;
case "show":
break;
default:
Out(getDescription());
return;
}
if (cmd != "show")
{
if (!HeldTweak.Enabled)
{
HeldTweak.Item = HeldTweak.HeldName();
}
HeldTweak.Enabled = true;
}
Transform m = HeldTweak.Model();
Out("предмет " + HeldTweak.Item + ", модель " + (m != null ? m.name : "нет") + ": сдвиг (см) " + HeldTweak.F(HeldTweak.Pos * 100f) + ", поворот " +
HeldTweak.F(HeldTweak.Rot) + ", масштаб " + HeldTweak.F(HeldTweak.Scale) + (HeldTweak.Enabled ? "" : " (выключено)"));
}
static void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necroheld: " + _s);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Плата здоровьем за силовую атаку Ножа некроманта (указание 09.10.2026: «обычная силовая - 20 хп, с модификацией
/// 40; если здоровья на штраф не хватает - звук сломавшегося инвентаря, а силовая заменится обычной»).
/// Саму плату снимает XML (items.xml - 20 при попадании силовой, item_modifiers.xml «Мёртвая буря» - ещё 20).
/// Здесь - только проверка до удара: ItemClass.ExecuteAction запускает и обычный удар (действие 0), и силовую
/// (действие 1); не хватает здоровья - номер действия меняется на 0 (тот же удар с той же анимацией обычного),
/// а при нажатии звучит ванильный itembreak и надпись «Недостаточно здоровья».
/// Почему был нужен штраф побольше: лечение обычным ударом - убийства/20, и уже со 100 убийств один удар
/// окупал прежнюю плату в 5 здоровья.
/// </summary>
[HarmonyPatch(typeof(ItemClass), nameof(ItemClass.ExecuteAction))]
public static class Patch_ItemClass_ExecuteAction_KnifePowerCost
{
public const string KnifeName = "necroWpnBladeNecroKnife";
public const string StormMod = "necroModKnifeDeadStorm";
/// <summary>Должно совпадать с items.xml и item_modifiers.xml.</summary>
public const int Cost = 20, StormCost = 40;
public static void Prefix(ItemClass __instance, ref int _actionIdx, ItemInventoryData _data, bool _bReleased)
{
if (_actionIdx != 1 || __instance.Name != KnifeName || !(_data?.holdingEntity is EntityPlayerLocal player))
{
return;
}
int cost = HasStorm(_data.itemValue) ? StormCost : Cost;
if (BloodTide.Active)
{
cost /= 2; // кровавый прилив (10.10): плата вдвое меньше - как в items.xml/item_modifiers.xml
}
if (player.Health > cost)
{
return;
}
_actionIdx = 0;
if (!_bReleased)
{
Audio.Manager.PlayInsidePlayerHead("itembreak");
// Надпись (указание 09.10) - сразу, мимо очереди подсказок, чтобы при частых нажатиях не копилась.
GameManager.ShowTooltip(player, Localization.Get("necroKnifeNoHealth"), true);
Debug.Log("[NecromancerTome] KnifePowerCost: здоровья " + player.Health + ", плата " + cost + " - вместо силовой обычный удар");
}
}
static bool HasStorm(ItemValue _iv)
{
if (_iv?.modifications == null)
{
return false;
}
foreach (ItemValue m in _iv.modifications)
{
if (m != null && !m.IsEmpty() && m.ItemClass?.Name == StormMod)
{
return true;
}
}
return false;
}
}
}
+52 -6
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
@@ -33,6 +34,10 @@ namespace NecromancerTome
static Vector3 _feet;
static Vector3 _spawn;
static float _yaw, _pitch, _vy;
/// <summary>Высота камеры (ног) со сглаживанием: ступенька поднимает ноги рывком, камера догоняет
/// плавно (тест 09.10 на лестнице чертогов: «двигается рывками»).</summary>
static float _camY;
public const float CamSmooth = 18f;
static bool _grounded;
public static bool HasFocus;
static EntityPlayerLocal _player;
@@ -44,6 +49,7 @@ namespace NecromancerTome
_player = _p;
_feet = _at;
_spawn = _at;
_camY = _at.y;
_yaw = _atYaw;
_pitch = 0f;
_vy = 0f;
@@ -86,6 +92,27 @@ namespace NecromancerTome
return Physics.CheckCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
static float _blockLogAt;
/// <summary>Диагностика (09.10): во что упёрся дух - раз в 2 с, имена коллайдеров.</summary>
static void LogBlocker(Vector3 _p)
{
if (Time.time < _blockLogAt)
{
return;
}
_blockLogAt = Time.time + 2f;
Vector3 a = _p + Vector3.up * (Radius + 0.05f) - Origin.position;
Vector3 b = _p + Vector3.up * (Height - Radius) - Origin.position;
var names = new List<string>();
foreach (Collider col in Physics.OverlapCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
{
Transform r = col.transform.root;
names.Add(col.name + "@" + (r != null ? r.name : "?") + " " + col.bounds.min.y.ToString("0.00") + ".." + col.bounds.max.y.ToString("0.00"));
}
Debug.Log("[NecromancerTome] MindControl: упёрся у " + _p + " (ноги " + (_p.y - Origin.position.y).ToString("0.00") + "): " + string.Join(", ", names));
}
static bool TryMove(Vector3 _d)
{
Vector3 c = _feet + _d;
@@ -94,12 +121,27 @@ namespace NecromancerTome
_feet = c;
return true;
}
// Ступенька: на полблока выше свободно - шагаем вверх.
if (_grounded && !Blocked(c + Vector3.up * StepUp))
// Ступенька: шагаем вверх на наименьшую высоту (до StepUp), где свободно, ноги - на верх ступеньки
// (не выше: иначе на следующем кадре проседали - рывок). Сразу на StepUp нельзя: в проёме с порогом
// и низкой притолокой (сейфовая дверь, тест 09.10: «не получается пройти в проём») голова упиралась.
if (_grounded)
{
_feet = c + Vector3.up * StepUp;
return true;
for (float h = StepUp / 4f; h <= StepUp + 0.001f; h += StepUp / 4f)
{
if (Blocked(c + Vector3.up * h))
{
continue;
}
Vector3 up = c + Vector3.up * h;
if (Physics.Raycast(up + Vector3.up * 0.05f - Origin.position, Vector3.down, out RaycastHit hit, h + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore))
{
up.y = hit.point.y + Origin.position.y;
}
_feet = up;
return true;
}
}
LogBlocker(c);
return false;
}
@@ -161,7 +203,9 @@ namespace NecromancerTome
_vy = 0f;
dy = 0f;
}
if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
// Стоял на полу и сошёл со ступеньки - прилипаем к нижней (до StepUp), а не падаем с неё скачками.
float snap = _grounded && _vy <= 0f ? StepUp : 0f;
if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f + snap, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
{
_feet.y = hit.point.y + Origin.position.y;
_vy = 0f;
@@ -184,7 +228,9 @@ namespace NecromancerTome
/// <summary>Из патчей камеры режима змеи: глаза духа и поиск книги под прицелом.</summary>
public static void PlaceCamera(Transform _camera)
{
Vector3 eye = _feet + Vector3.up * EyeHeight - Origin.position;
// Камера догоняет ноги плавно; большой перепад (падение, возврат на точку появления) - сразу.
_camY = Mathf.Abs(_feet.y - _camY) > 1.2f ? _feet.y : Mathf.Lerp(_camY, _feet.y, 1f - Mathf.Exp(-CamSmooth * Time.deltaTime));
Vector3 eye = new Vector3(_feet.x, _camY, _feet.z) + Vector3.up * EyeHeight - Origin.position;
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
_camera.SetPositionAndRotation(eye, rot);
HasFocus = FindBook(_camera.position + Origin.position, _camera.forward);
+297 -15
View File
@@ -27,7 +27,9 @@ namespace NecromancerTome
/// (woodShapes:cube) -&gt; terrBedrock (в меню творчества его нет, рисовали деревом),
/// одиночная книга на столе -&gt; necroMemoryBook, на стол с реактивами - нож-декорация,
/// кровать (SleepingBag) -&gt; necroPalaceBed: иначе игра делает её точкой возрождения игрока.
/// Построены ли - по книге: нет её - ставим заново (самопочинка).
/// Построены ли - сверка с префабом каждой клетки (Mismatches: тип, плотность, покраска) при каждом входе:
/// расхождение - ставим заново (самопочинка; заодно новый, более широкий префаб сам перестраивает старые
/// чертоги в сейвах). Надписи на досках из префаба НЕ переносятся (указание 09.10: «текст будет добавлять мод по событиям»).
///
/// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
/// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
@@ -45,6 +47,18 @@ namespace NecromancerTome
public const string BedBlock = "necroPalaceBed";
public const string ShellFrom = "woodShapes:cube";
public const string ShellTo = "terrBedrock";
/// <summary>Межкомнатные двери - их чертоги открывают сами при входе (кроме входной и дверей с номером).</summary>
public const string PassageDoor = "exteriorHouseDoorWhite";
/// <summary>Табличка с номером двери (указание 09.10): ставится в редакторе рядом с дверью или над ней,
/// в ней пишется номер (до двух цифр). В чертоги не ставится (вместо неё воздух) - видна только в редакторе.
/// Дверь с номером закрыта, пока событие не откроет её (SetDoorOpen).</summary>
public const string DoorTagBlock = "necroDoorNumber";
/// <summary>Двери с номером, открытые с самого начала (указание 09.10): 2 туалет, 4 ванная, 5 прихожая,
/// 6 лаборатория. Остальные закрыты до события: 1 входная - бутафорская, не открывается никогда; 3 лестница,
/// 7 шкаф-тайник, 8 за ним, 9 решётка, 10-11 сейфовые, 12, 14-17 второй этаж.</summary>
public static readonly HashSet<int> DefaultOpenDoors = new HashSet<int> { 2, 4, 5, 6 };
/// <summary>Как далеко от таблички искать её дверь (клеток по каждой оси; над дверью - 2).</summary>
public const int DoorTagReach = 2;
/// <summary>Одиночная книга на обеденном столе (cntBookPile09 в префабе).</summary>
public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
/// <summary>Место ножа на столе с реактивами (указание пользователя).</summary>
@@ -313,6 +327,12 @@ namespace NecromancerTome
_bv = bed;
_d = MarchingCubes.DensityAir;
}
else if (!_bv.isair && _bv.Block.GetBlockName() == DoorTagBlock)
{
// Табличка номера двери - только для редактора.
_bv = BlockValue.Air;
_d = MarchingCubes.DensityAir;
}
else if (_bv.isair || !_bv.Block.shape.IsTerrain())
{
_d = MarchingCubes.DensityAir;
@@ -408,31 +428,242 @@ namespace NecromancerTome
return Mismatches(_world, _o) == 0;
}
/// <summary>Все двери чертогов - открыты (в редакторе сохранились закрытыми, а дух дверей не
/// открывает). Состояние двери - в её TEFeatureDoor, не в блоке.</summary>
/// <summary>Номера дверей из табличек префаба: локальная клетка таблички -> номер.</summary>
static Dictionary<Vector3i, int> DoorTags(Prefab _p)
{
var tags = new Dictionary<Vector3i, int>();
foreach (var kv in _p.tileEntities)
{
Vector3i lp = kv.Key;
if (_p.GetBlock(lp.x, lp.y, lp.z).Block?.GetBlockName() != DoorTagBlock ||
!(kv.Value is TileEntityComposite tec) || !tec.TryGetSelfOrFeature(out TEFeatureSignable sign))
{
continue;
}
string text = (sign.GetAuthoredText()?.Text ?? "").Trim();
if (int.TryParse(text, out int n))
{
tags[lp] = n;
}
else
{
Debug.LogWarning("[NecromancerTome] MindPalace: табличка двери " + lp + " без номера: «" + text + "»");
}
}
return tags;
}
/// <summary>Двери с номерами: номер -> локальная клетка двери (ближайшая к табличке дверь в пределах DoorTagReach).</summary>
public static Dictionary<int, Vector3i> NumberedDoors(World _world, Vector3i _o)
{
var doors = new Dictionary<int, Vector3i>();
Prefab p = LoadPrefab();
if (p == null)
{
return doors;
}
foreach (var kv in DoorTags(p))
{
Vector3i best = default;
int bestD = int.MaxValue;
for (int dy = -DoorTagReach; dy <= DoorTagReach; dy++)
for (int dz = -DoorTagReach; dz <= DoorTagReach; dz++)
for (int dx = -DoorTagReach; dx <= DoorTagReach; dx++)
{
Vector3i lp = kv.Key + new Vector3i(dx, dy, dz);
int d = dx * dx + dy * dy + dz * dz;
if (d < bestD && _world.GetTileEntity(_o + lp) is TileEntityComposite tec && tec.GetFeature<TEFeatureDoor>() != null)
{
best = lp;
bestD = d;
}
}
if (bestD == int.MaxValue)
{
Debug.LogWarning("[NecromancerTome] MindPalace: у таблички " + kv.Key + " (дверь " + kv.Value + ") рядом нет двери");
}
else if (doors.ContainsKey(kv.Value))
{
Debug.LogWarning("[NecromancerTome] MindPalace: номер двери " + kv.Value + " повторяется (" + doors[kv.Value] + " и " + best + ")");
}
else
{
doors[kv.Value] = best;
}
}
return doors;
}
/// <summary>Состояние дверей чертогов при каждом входе. Дверь с номером (табличка DoorTagBlock) открыта, только
/// если событие её открыло (SetDoorOpen). Межкомнатные двери без номера (PassageDoor) открыты (в редакторе
/// сохранились закрытыми, а дух дверей не открывает), входная - закрыта. Прочие двери без номера не трогаем.
/// Состояние двери - в её TEFeatureDoor.</summary>
public static void OpenDoors(World _world, Vector3i _o)
{
var numbered = new Dictionary<Vector3i, int>();
foreach (var kv in NumberedDoors(_world, _o))
{
numbered[kv.Value] = kv.Key;
}
HashSet<int> opened = OpenedDoors(_world.GetPrimaryPlayer());
Vector3i s = Size;
int opened = 0;
int switched = 0;
for (int y = 0; y < s.y; y++)
for (int z = 0; z < s.z; z++)
for (int x = 0; x < s.x; x++)
{
if (_world.GetTileEntity(_o + new Vector3i(x, y, z)) is TileEntityComposite tec)
Vector3i lp = new Vector3i(x, y, z);
if (!(_world.GetTileEntity(_o + lp) is TileEntityComposite tec) || !(tec.GetFeature<TEFeatureDoor>() is TEFeatureDoor door))
{
continue;
}
bool wantOpen;
if (numbered.TryGetValue(lp, out int n))
{
// В файле: n - открыта событием, -n - закрыта событием (для открытых с начала).
wantOpen = opened.Contains(n) || DefaultOpenDoors.Contains(n) && !opened.Contains(-n);
}
else if (_world.GetBlock(_o + lp).Block.GetBlockName() == PassageDoor)
{
TEFeatureDoor door = tec.GetFeature<TEFeatureDoor>();
// Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
bool wantOpen = new Vector3i(x, y, z) != EntranceDoorAt;
if (door != null && door.IsOpen() != wantOpen)
{
door.SetOpen(wantOpen);
opened++;
}
wantOpen = lp != EntranceDoorAt;
}
else
{
continue;
}
if (door.IsOpen() != wantOpen)
{
door.SetOpen(wantOpen);
switched++;
}
}
if (opened > 0)
if (switched > 0)
{
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + opened);
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + switched + ", с номерами " + numbered.Count + ", открыты по событиям: " + string.Join(",", opened));
}
}
// Открытые событиями двери - по игрокам, в файле в папке сохранения мира: «игрок номер номер ...».
static string DoorsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaceDoors.txt");
static readonly Dictionary<string, HashSet<int>> _openedDoors = new Dictionary<string, HashSet<int>>();
static string _openedFor;
static HashSet<int> OpenedDoors(EntityPlayer _player)
{
string dir = GameIO.GetSaveGameDir();
if (_openedFor != dir)
{
_openedFor = dir;
_openedDoors.Clear();
try
{
if (File.Exists(DoorsFile))
{
foreach (string line in File.ReadAllLines(DoorsFile))
{
string[] parts = line.Split(' ');
var set = new HashSet<int>();
for (int i = 1; i < parts.Length; i++)
{
if (int.TryParse(parts[i], out int n))
{
set.Add(n);
}
}
_openedDoors[parts[0]] = set;
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать двери - " + ex.Message);
}
}
string key = _player != null ? PlayerKey(_player) : "local";
if (!_openedDoors.TryGetValue(key, out HashSet<int> opened))
{
opened = new HashSet<int>();
_openedDoors[key] = opened;
}
return opened;
}
/// <summary>Открыть/закрыть дверь чертогов с номером (для событий). Запоминается в сейве; если игрок сейчас
/// в чертогах - дверь переключается сразу.</summary>
public static void SetDoorOpen(EntityPlayer _player, int _number, bool _open)
{
HashSet<int> opened = OpenedDoors(_player);
bool changed = _open ? opened.Remove(-_number) | opened.Add(_number) : opened.Remove(_number) | DefaultOpenDoors.Contains(_number) && opened.Add(-_number);
if (!changed)
{
return;
}
try
{
var lines = new List<string>();
foreach (var kv in _openedDoors)
{
lines.Add(kv.Key + (kv.Value.Count > 0 ? " " + string.Join(" ", kv.Value) : ""));
}
File.WriteAllLines(DoorsFile, lines);
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не записать двери - " + ex.Message);
}
World world = GameManager.Instance.World;
if (_stage == Stage.Ready && world != null && _player == world.GetPrimaryPlayer())
{
OpenDoors(world, _origin);
}
}
/// <summary>Надписи на досках чертогов (указание 09.10). Лор: некроманта бросили в этот мир умирать - раздели,
/// лишили артефактов (и вживлённых тоже) и ментальной магией стёрли память, «закрыв знания, опасные для общества».
/// Доска показывает информацию из подсознания, которую герой пока не помнит (указание 09.10); строки прибавляются по событиям игры. Пока событие -
/// только день: (с какого дня, ключ локализации). Остальные строки - позднее.</summary>
public static readonly (int day, string key)[] BoardLines =
{
(1, "necroPalaceBoardRevenge"),
(5, "necroPalaceBoardTide"), // было «излучение красной луны»; 10.10 - кровавый прилив
};
/// <summary>При каждом входе: на каждой доске (necroChalkboard) чертогов - все наступившие строки, на языке игрока.
/// Текст из префаба в чертоги не идёт (Stamp ставит только блоки) - пишет только мод.</summary>
public static void UpdateBoards(World _world, Vector3i _o)
{
int day = GameUtils.WorldTimeToDays(_world.worldTime);
var lines = new List<string>();
foreach (var l in BoardLines)
{
if (day >= l.day)
{
lines.Add(Localization.Get(l.key));
}
}
string text = string.Join("\n", lines);
Vector3i s = Size;
int n = 0;
for (int y = 0; y < s.y; y++)
for (int z = 0; z < s.z; z++)
for (int x = 0; x < s.x; x++)
{
Vector3i wp = _o + new Vector3i(x, y, z);
if (_world.GetBlock(wp).Block?.GetBlockName() != Chalkboard.BlockName ||
!(_world.GetTileEntity(wp) is TileEntityComposite tec) || !tec.TryGetSelfOrFeature(out TEFeatureSignable sign))
{
continue;
}
if ((sign.GetAuthoredText()?.Text ?? "") != text)
{
sign.SetText(text, true);
n++;
}
}
if (n > 0)
{
Debug.Log("[NecromancerTome] MindPalace: надпись на досках (день " + day + ", строк " + lines.Count + "), досок " + n);
}
}
@@ -577,6 +808,12 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] MindPalace: днём чертоги недоступны");
return;
}
if (SnakeMode.IsBloodTide())
{
SnakeMode.BloodTideHint(_player);
Debug.Log("[NecromancerTome] MindPalace: кровавый прилив - дух не покидает тело");
return;
}
World world = GameManager.Instance.World;
if (world.IsRemote())
{
@@ -592,6 +829,7 @@ namespace NecromancerTome
// Дальность как у змеи: при радиусе 2 игра не строила коллизии участка (тест 03.10:
// «чертоги не построились вовремя» - ждали коллизий 30 с).
_observer = GameManager.Instance.AddChunkObserver(center, true, GameUtils.GetViewDistance(), -1);
ShrinkBody(_player);
_stage = Stage.Loading;
_deadline = Time.time + 30f;
Active = true;
@@ -631,6 +869,27 @@ namespace NecromancerTome
GameManager.Instance.RemoveChunkObserver(_observer);
}
_observer = null;
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (_bodyViewDim > 0 && p?.ChunkObserver != null)
{
p.ChunkObserver.viewDim = _bodyViewDim;
}
_bodyViewDim = -1;
}
/// <summary>Область мира у тела до входа (-1 - не сжата). Фризы в чертогах (жалоба 10.10): игра держала две
/// полные области - у чертогов на краю карты и у лежащего тела; подгрузка и пересборка участков - мусор
/// и паузы его сборки. Тело сжимаем до Possession.BodyViewDim, как у змеи и одержимого.</summary>
static int _bodyViewDim = -1;
static void ShrinkBody(EntityPlayerLocal _player)
{
ChunkManager.ChunkObserver body = _player?.ChunkObserver;
if (body != null && _bodyViewDim < 0 && body.viewDim > Possession.BodyViewDim)
{
_bodyViewDim = body.viewDim;
body.viewDim = Possession.BodyViewDim;
}
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
@@ -643,6 +902,7 @@ namespace NecromancerTome
_observer = null;
MindControl.End(null);
_slotsFor = null;
_bodyViewDim = -1;
return;
}
EntityPlayerLocal player = world.GetPrimaryPlayer();
@@ -672,6 +932,13 @@ namespace NecromancerTome
Exit(player);
return;
}
if (SnakeMode.IsBloodTide())
{
SnakeMode.BloodTideHint(player);
Debug.Log("[NecromancerTome] MindPalace: кровавый прилив - дух выброшен в тело");
Exit(player);
return;
}
Body(player);
switch (_stage)
{
@@ -707,6 +974,7 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] MindPalace: коллизии " + (world.IsChunkAreaCollidersLoaded(spawn) ? "готовы" : "НЕ готовы, пускаем по таймеру"));
_stage = Stage.Ready;
OpenDoors(world, _origin);
UpdateBoards(world, _origin);
LogCell(world, _origin + KnifeAt, "нож");
LogCell(world, _origin + BookAt, "книга");
MindControl.Begin(player, spawn, SpawnYaw);
@@ -842,7 +1110,7 @@ namespace NecromancerTome
public class ConsoleCmdNecroPalace : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necropalace" };
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N";
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N | door [N open|close]";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
@@ -878,6 +1146,20 @@ namespace NecromancerTome
GameManager.Instance.StartCoroutine(MindPalace.StressTest(world, o));
SdtdConsole.Instance.Output("проверка: заливка гравием, через 2 с - постановка (итог - в логе)");
}
else if (cmd == "door" && _params.Count > 2 && int.TryParse(_params[1], out int dn))
{
MindPalace.SetDoorOpen(player, dn, _params[2] == "open");
SdtdConsole.Instance.Output("дверь " + dn + ": " + (_params[2] == "open" ? "открыта" : "закрыта"));
}
else if (cmd == "door")
{
var doors = MindPalace.NumberedDoors(world, o);
SdtdConsole.Instance.Output("дверей с номерами: " + doors.Count);
foreach (var kv in doors)
{
SdtdConsole.Instance.Output(" " + kv.Key + " - " + world.GetBlock(o + kv.Value).Block.GetBlockName() + " " + kv.Value);
}
}
else if (cmd == "rebuild")
{
MindPalace.Stamp(world, o);
+2
View File
@@ -65,6 +65,8 @@ namespace NecromancerTome
HandPose.Init();
NecroMailbox.Init();
Possession.Init();
BloodTide.Init();
RemainsDecay.Init();
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
+128
View File
@@ -0,0 +1,128 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// necromodel &lt;блок&gt; - выписать в лог устройство модели блока (09.10.2026, для своей двери-книжного
/// шкафа: где у ванильной двери петля, куда она открывается, какие коллайдеры). Иерархия с местом, поворотом,
/// масштабом, мешами и коллайдерами; у аниматоров - параметры и клипы, а створка в состояниях «Open» и
/// «Close» - пробой клипа на копии префаба. Копия создаётся и тут же уничтожается.
/// </summary>
[Preserve]
public class ConsoleCmdNecroModel : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necromodel" };
public override string getDescription() => "NecromancerTome: устройство модели блока в лог (necromodel <имя блока>)";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
if (_params.Count < 1)
{
SdtdConsole.Instance.Output("necromodel <имя блока>");
return;
}
Block block = Block.GetBlockByName(_params[0]);
string model = block?.Properties.GetString("Model");
if (string.IsNullOrEmpty(model))
{
SdtdConsole.Instance.Output("Нет блока или у него нет Model: " + _params[0]);
return;
}
if (!model.StartsWith("@") && !model.StartsWith("#"))
{
model = "@:" + model;
}
GameObject prefab = DataLoader.LoadAsset<GameObject>(model);
if (prefab == null)
{
SdtdConsole.Instance.Output("Модель не загрузилась: " + model);
return;
}
var sb = new StringBuilder();
sb.AppendLine("[NecromancerTome] necromodel " + _params[0] + " = " + model + ", ModelOffset " +
block.Properties.GetString("ModelOffset") + ", MultiBlockDim " + block.Properties.GetString("MultiBlockDim"));
Dump(prefab.transform, 0, sb);
GameObject copy = Object.Instantiate(prefab);
try
{
foreach (Animator a in copy.GetComponentsInChildren<Animator>(true))
{
RuntimeAnimatorController rc = a.runtimeAnimatorController;
sb.AppendLine("Animator на " + a.name + ": " + (rc != null ? rc.name : "нет контроллера"));
foreach (AnimatorControllerParameter p in a.parameters)
{
sb.AppendLine(" параметр " + p.name + " " + p.type);
}
if (rc == null)
{
continue;
}
foreach (AnimationClip clip in rc.animationClips)
{
sb.AppendLine(" клип " + clip.name + ", " + clip.length.ToString("F2") + " с");
clip.SampleAnimation(copy, clip.length);
foreach (Transform t in copy.GetComponentsInChildren<Transform>(true))
{
sb.AppendLine(" в конце: " + Path(t, copy.transform) + " pos " + t.localPosition.ToString("F3") +
" rot " + t.localEulerAngles.ToString("F1"));
}
}
}
}
finally
{
Object.Destroy(copy);
}
Debug.Log(sb.ToString());
SdtdConsole.Instance.Output("necromodel: выписано в лог");
}
static void Dump(Transform _t, int _depth, StringBuilder _sb)
{
var line = new StringBuilder(new string(' ', _depth * 2));
line.Append(_t.name).Append(" pos ").Append(_t.localPosition.ToString("F3")).Append(" rot ")
.Append(_t.localEulerAngles.ToString("F1")).Append(" scale ").Append(_t.localScale.ToString("F2"))
.Append(" tag ").Append(_t.tag).Append(" layer ").Append(_t.gameObject.layer);
MeshFilter mf = _t.GetComponent<MeshFilter>();
if (mf != null && mf.sharedMesh != null)
{
line.Append(" mesh ").Append(mf.sharedMesh.name).Append(" bounds c").Append(mf.sharedMesh.bounds.center.ToString("F3"))
.Append(" size").Append(mf.sharedMesh.bounds.size.ToString("F3"));
}
foreach (Collider c in _t.GetComponents<Collider>())
{
line.Append(" | ").Append(c.GetType().Name).Append(c.isTrigger ? "(trigger)" : "");
if (c is BoxCollider b)
{
line.Append(" c").Append(b.center.ToString("F3")).Append(" s").Append(b.size.ToString("F3"));
}
}
foreach (Component comp in _t.GetComponents<Component>())
{
if (!(comp is Transform) && !(comp is MeshFilter) && !(comp is MeshRenderer) && !(comp is Collider))
{
line.Append(" [").Append(comp != null ? comp.GetType().Name : "null").Append("]");
}
}
_sb.AppendLine(line.ToString());
foreach (Transform ch in _t)
{
Dump(ch, _depth + 1, _sb);
}
}
static string Path(Transform _t, Transform _root)
{
string p = _t.name;
while (_t.parent != null && _t != _root)
{
_t = _t.parent;
p = _t.name + "/" + p;
}
return p;
}
}
}
+144
View File
@@ -0,0 +1,144 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// necroobj &lt;префаб&gt; &lt;имя&gt; - выгрузить модель префаба в OBJ (09.10.2026, для чертежей питомцев в книге:
/// медведь, волк, собака, стервятник - ванильные модели). Скелетные меши «запекаются» в позе префаба (BakeMesh),
/// у LODGroup - только LOD0. Координаты - от корня префаба, оси Unity переведены в правые (x -&gt; -x, обход граней
/// развёрнут). Файл - %APPDATA%\7DaysToDie\NecroMailbox\obj\&lt;имя&gt;.obj, группы «o» по рендерерам, «usemtl» -
/// имя материала. Это сырьё для рендера в Blender: в мод и в репозиторий модели игры не идут (гигиена ассетов).
/// </summary>
[Preserve]
public class ConsoleCmdNecroObj : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroobj" };
public override string getDescription() => "NecromancerTome: модель префаба в OBJ (necroobj <префаб> <имя>)";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
if (_params.Count < 2)
{
SdtdConsole.Instance.Output("necroobj <префаб, например @:Entities/Animals/Bear/animalBearZombiePrefab.prefab> <имя>");
return;
}
GameObject prefab = DataLoader.LoadAsset<GameObject>(_params[0]);
if (prefab == null)
{
SdtdConsole.Instance.Output("Префаб не загрузился: " + _params[0]);
return;
}
GameObject copy = Object.Instantiate(prefab);
copy.transform.position = Vector3.zero;
copy.transform.rotation = Quaternion.identity;
try
{
var renderers = new List<Renderer>();
var lod = copy.GetComponentInChildren<LODGroup>(true);
if (lod != null && lod.GetLODs().Length > 0)
{
renderers.AddRange(lod.GetLODs()[0].renderers);
}
if (renderers.Count == 0)
{
foreach (Renderer r in copy.GetComponentsInChildren<Renderer>(true))
{
if (r is SkinnedMeshRenderer || r is MeshRenderer)
{
renderers.Add(r);
}
}
}
var sb = new StringBuilder();
var ci = CultureInfo.InvariantCulture;
int vBase = 1, tris = 0;
Matrix4x4 toRoot = copy.transform.worldToLocalMatrix;
foreach (Renderer r in renderers)
{
if (r == null)
{
continue;
}
Mesh mesh;
Matrix4x4 m;
if (r is SkinnedMeshRenderer smr)
{
mesh = new Mesh();
smr.BakeMesh(mesh, true);
// Запечённый меш - в пространстве рендерера без его масштаба (useScale учтён в вершинах).
m = toRoot * Matrix4x4.TRS(smr.transform.position, smr.transform.rotation, Vector3.one);
}
else
{
MeshFilter mf = r.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null || !mf.sharedMesh.isReadable)
{
continue;
}
mesh = mf.sharedMesh;
m = toRoot * r.transform.localToWorldMatrix;
}
if (!mesh.isReadable && !(r is SkinnedMeshRenderer))
{
continue;
}
Vector3[] v = mesh.vertices;
Vector2[] uv = mesh.uv;
sb.Append("o ").Append(r.name).Append('\n');
foreach (Vector3 p0 in v)
{
Vector3 p = m.MultiplyPoint3x4(p0);
sb.Append("v ").Append((-p.x).ToString("F5", ci)).Append(' ').Append(p.y.ToString("F5", ci)).Append(' ')
.Append(p.z.ToString("F5", ci)).Append('\n');
}
bool hasUv = uv != null && uv.Length == v.Length;
if (hasUv)
{
foreach (Vector2 t in uv)
{
sb.Append("vt ").Append(t.x.ToString("F5", ci)).Append(' ').Append(t.y.ToString("F5", ci)).Append('\n');
}
}
Material[] mats = r.sharedMaterials;
for (int s = 0; s < mesh.subMeshCount; s++)
{
sb.Append("usemtl ").Append(s < mats.Length && mats[s] != null ? mats[s].name.Replace(' ', '_') : "none").Append('\n');
int[] idx = mesh.GetTriangles(s);
for (int i = 0; i + 2 < idx.Length; i += 3)
{
// обход развёрнут из-за смены руки осей
int a = idx[i] + vBase, b = idx[i + 2] + vBase, c = idx[i + 1] + vBase;
if (hasUv)
{
sb.Append("f ").Append(a).Append('/').Append(a).Append(' ').Append(b).Append('/').Append(b).Append(' ')
.Append(c).Append('/').Append(c).Append('\n');
}
else
{
sb.Append("f ").Append(a).Append(' ').Append(b).Append(' ').Append(c).Append('\n');
}
tris++;
}
}
vBase += v.Length;
}
string dir = Path.Combine(GameIO.GetUserGameDataDir(), "NecroMailbox", "obj");
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, _params[1] + ".obj");
File.WriteAllText(path, sb.ToString());
string msg = "necroobj: " + _params[1] + " - рендереров " + renderers.Count + ", вершин " + (vBase - 1) + ", треуг. " + tris + " -> " + path;
Debug.Log("[NecromancerTome] " + msg);
SdtdConsole.Instance.Output(msg);
}
finally
{
Object.Destroy(copy);
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Метка на карте для того, кем сейчас управляет игрок: дух змеи и одержимый (зомби или питомец).
/// Указание 06.10: «Змея всё ещё не появляется на карте. Подозреваю, что питомцы в режиме одержимости тоже».
///
/// ПОЧЕМУ НЕ Entity.AddNavObject (так было у змеи с 02.10). У сущности одна общая NavObject на все её
/// классы меток; у змеи от ванильного animalSnake там уже лежат twitch_spawn, animaltracking_snake,
/// clear_sleeper. AddNavObject только дописывает класс в этот список, а какой из них показывать,
/// игра решает сама (NavObject.HandleActiveNavClass), и карта (XUiC_MapArea.updateNavObjectList)
/// заводит спрайт метки один раз по её ключу. Метка змеи так и не появилась ни разу.
///
/// ЗДЕСЬ - как у транспорта (EntityVehicle.HandleNavObject: RegisterNavObject(класс,
/// vehicle.GetMeshTransform()), разбор 06.10): своя, отдельная NavObject, привязанная к Transform (перегрузка
/// NavObjectManager.RegisterNavObject(className, Transform)): своя запись, свой ключ, один класс.
/// Позиция - transform.position (NavObject.GetPosition для TrackTypes.Transform), т.е. сама модель.
/// Снимается UnRegisterNavObject; если сущность исчезла, игра снимет метку сама (IsValid).
///
/// ЧУЖУЮ МЕТКУ НЕ ТРОГАТЬ (проверка на утечки 06.10). Снятые метки игра кладёт в пул
/// (removedNavObjectPool) и раздаёт заново. Если модель исчезла раньше нашего Hide, игра сама сняла
/// метку, и пул мог уже отдать этот же объект другому (метка чутья, транспорт). Поэтому снимаем, только
/// если объект ещё в NavObjectList и следит за НАШЕЙ моделью; иначе просто забываем ссылку.
/// Классы (nav_objects.xml), только карта: necroSnakeSpirit - фиолетовая змея, necroPossessed - зелёный
/// значок одержимого. Значок задан в классе: OverrideSpriteName игра берёт, только если у класса
/// use_override_icon (NavObject.GetSpriteName) - без него у одержимого была змея (тест 08.10).
/// hiddenOnCompass не ставим: карта красит такие метки серым (XUiC_MapArea.updateNavObjects), а на
/// компас эти классы и так не попадают - compass_settings у них нет.
/// </summary>
public static class NecroMapMarker
{
public const string SnakeClass = "necroSnakeSpirit";
public const string PossessedClass = "necroPossessed";
/// <summary>Метка и модель, за которой она следит.</summary>
public class Marker
{
public NavObject Nav;
public Transform Tracked;
}
public static Marker Show(EntityAlive _e, string _navClass)
{
if (_e == null || _e.transform == null || NavObjectManager.Instance == null)
{
return null;
}
NavObject nav = NavObjectManager.Instance.RegisterNavObject(_navClass, _e.transform);
Debug.Log("[NecromancerTome] NecroMapMarker: метка на карте для " + _e.EntityName + " (" + _e.entityId + ") - " + nav);
return new Marker { Nav = nav, Tracked = _e.transform };
}
public static void Hide(ref Marker _m)
{
NavObjectManager manager = NavObjectManager.Instance;
if (_m != null && _m.Nav != null && manager != null && _m.Tracked != null &&
manager.NavObjectList.Contains(_m.Nav) && _m.Nav.IsTrackedTransform(_m.Tracked))
{
manager.UnRegisterNavObject(_m.Nav);
}
_m = null;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Череп (останки) из красного трупа - сырьё для цинерария (дух-работник).
///
/// С 09.10 - находка при обычном обыске (указание: «браслет больше не собирает красные трупы,
/// выпиливаем; останки дропаются с шансом 2% при обычном лутании красного трупа»). Раньше (06.10) череп
/// вырывали пространственным браслетом: зажатая атака по трупу, три секунды канала, при неудаче труп
/// уходил в чёрный разрыв. Сняли из-за подозрения на утечку памяти на этом пути (тест 09.10: после
/// разрывов в памяти копились экземпляры сущностей - 31 при 7 живых).
///
/// Бросок - один раз на труп, при первом открытии его сумки, на сервере, сразу после того, как игра
/// наполнила её добычей (Patch_EntityAlive_OnLockRequestServer_Corpse). Шанс прежний - 2%
/// («так ресурс станет ценнее»).
/// </summary>
public static class NecroSkull
{
public const string ItemName = "necroSkull";
public const float Chance = 0.02f;
/// <summary>Бросок на череп в только что наполненную сумку трупа. Места нет - сумка на ячейку больше.</summary>
public static void Roll(EntityAlive _corpse, Bag _bag)
{
ItemValue skull = ItemClass.GetItem(ItemName);
if (_bag == null || skull == null || skull.IsEmpty())
{
return;
}
float roll = Random.value;
bool success = roll < Chance;
Debug.Log("[NecromancerTome] NecroSkull: труп " + _corpse.entityId + " - бросок " + roll.ToString("0.000") +
" против " + Chance.ToString("0.00") + (success ? " -> ЧЕРЕП" : ""));
if (!success)
{
return;
}
ItemStack stack = new ItemStack(skull, 1);
if (!_bag.AddItem(stack.Clone()))
{
_bag.Resize(new Vector2i(_bag.SlotCount + 1, 1));
_bag.AddItem(stack);
}
}
}
}
+234 -12
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
using UnityEngine.Video;
namespace NecromancerTome
{
@@ -24,19 +25,114 @@ namespace NecromancerTome
public string TitleKey;
public string[] PageKeys;
public Func<EntityPlayerLocal, bool> Unlocked;
/// <summary>Иллюстрация справа от текста на всех страницах главы: картинка (путь вида
/// "@modfolder(NecromancerTome):Textures/x.png") или зацикленное видео без звука. Ни того,
/// ни другого - текст на всю ширину.</summary>
public string Image, Video;
/// <summary>Картинка полосой сверху во всю ширину, текст под ней (главы питомцев, указание 09.10:
/// «под картинкой - управление схематично, под ним - описание питомца»).</summary>
public bool ImageTop;
}
/// <summary>cvar «письмо архимага прочитано» - ставит записка noteDuke01 (Config/items.xml).</summary>
public const string LetterReadCVar = "necroTomeLetterRead";
public static readonly List<Chapter> Chapters = new List<Chapter>
{
// Глава 1 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
// Глава 1 (указание 06.10): письмо архимага - та самая стартовая записка, текст берётся из её
// же ключа. Открывается ТОЛЬКО когда записку прочли («появляется в книге, когда прочитано
// письмо», 06.10): cvar ставит сама записка по «ОК» (Config/items.xml). Запасного условия по
// уровню нет - в старых сейвах, где записку прочли до 1.6.0, главы не будет.
new Chapter
{
TitleKey = "necroTomeLetterTitle",
PageKeys = new[] { "noteDuke01Desc" },
Video = Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback.NoteFlashbackVideoPath,
Unlocked = _p => _p.Buffs.GetCustomVar(LetterReadCVar) > 0f
},
// Глава 2 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
new Chapter
{
TitleKey = "necroTomeCh1Title",
PageKeys = new[] { "necroTomeCh1P1", "necroTomeCh1P2", "necroTomeCh1P3", "necroTomeCh1P4" },
Unlocked = _p => true
},
// Нож некроманта (указание 09.10): лист-чертёж ножа (тот же рецепт, что у крысы), управление ножом и духом
// змеи, описание. Перед питомцами - «основной инструмент», с него всё начинается.
new Chapter
{
TitleKey = "necroTomeKnifeTitle",
PageKeys = new[] { "necroTomeKnifeP1", "necroTomeKnifeP2", "necroTomeKnifeP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeKnife.png",
ImageTop = true,
Unlocked = _p => true
},
// Главы питомцев (указание 09.10): лист-чертёж модели (три вида из Blender, подписи на эсперанто) сверху, на первой
// странице - управление, дальше - описание. Крыса - учебная модель, глава открыта сразу; прочие - с рецептом гримуара.
new Chapter
{
TitleKey = "necroTomeRatTitle",
PageKeys = new[] { "necroTomeRatP1", "necroTomeRatP2", "necroTomeRatP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeRat.png",
ImageTop = true,
Unlocked = _p => true
},
new Chapter
{
TitleKey = "necroTomeDogTitle",
PageKeys = new[] { "necroTomeDogP1", "necroTomeDogP2", "necroTomeDogP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeDog.png",
ImageTop = true,
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieDog")
},
new Chapter
{
TitleKey = "necroTomeVultureTitle",
PageKeys = new[] { "necroTomeVultureP1", "necroTomeVultureP2", "necroTomeVultureP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeVulture.png",
ImageTop = true,
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieGriffin")
},
new Chapter
{
TitleKey = "necroTomeWolfTitle",
PageKeys = new[] { "necroTomeWolfP1", "necroTomeWolfP2", "necroTomeWolfP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeWolf.png",
ImageTop = true,
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieWolf")
},
new Chapter
{
TitleKey = "necroTomeBearTitle",
PageKeys = new[] { "necroTomeBearP1", "necroTomeBearP2", "necroTomeBearP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeBear.png",
ImageTop = true,
Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieBear")
},
// Кровавый прилив (указание 10.10): предупреждение - в ночь кровавой луны дух не выходит из тела
// (SnakeMode.IsBloodTide). Лист-схема: луна, горы, некропыль из земли; подписи на эсперанто.
new Chapter
{
TitleKey = "necroTomeTideTitle",
PageKeys = new[] { "necroTomeTideP1", "necroTomeTideP2", "necroTomeTideP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeTide.png",
ImageTop = true,
Unlocked = _p => GameUtils.WorldTimeToDays(_p.world.worldTime) >= TideChapterDay
},
};
/// <summary>С какого дня видна глава о кровавом приливе. По замыслу - 5 (тогда же надпись на доске
/// чертогов, MindPalace.BoardLines); на время теста было 1, возвращено к релизу 1.6.0 (10.10).</summary>
public const int TideChapterDay = 5;
/// <summary>Глава питомца видна, когда игроку открыт рецепт его гримуара (указание 09.10, после проверки глав).
/// Крыса - учебная модель, её глава открыта сразу.</summary>
static bool RecipeUnlocked(EntityPlayerLocal _p, string _item)
{
Recipe r = CraftingManager.GetRecipe(_item);
return r != null && r.IsUnlocked(_p);
}
public static void Open(EntityPlayerLocal _player)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
@@ -47,14 +143,31 @@ namespace NecromancerTome
[Preserve]
public class XUiC_NecroTome : XUiController
{
const int MaxButtons = 8;
/// <summary>Кнопки глав в оглавлении (btnCh1..20 в windows.xml). Глав будет много (указание 10.10): до Rows - один
/// столбец по центру, больше - два столбца по Rows («я бы их сделал в две колонки»), ещё больше - страницы,
/// листаются теми же «Назад/Далее», что и главы.</summary>
const int MaxButtons = 20, Rows = 10;
/// <summary>Места кнопок (windows.xml: ширина 560, шаг строк 60 от y -130): столбец по центру окна 1300 и два столбца.</summary>
const int ColCenterX = 370, ColLeftX = 60, ColRightX = 680, RowTopY = -130, RowStep = 60;
XUiV_Label _title, _text, _page;
XUiV_Texture _img;
XUiV_Video _vid;
/// <summary>Ширина текста без иллюстрации и с ней (windows.xml: lblText 1180, картинка с x 680).</summary>
const int TextWidthFull = 1180, TextWidthHalf = 590;
/// <summary>Раскладка «картинка сверху»: лист-чертёж 1180x400 (как Textures/necroTomeRat.png), текст ниже.</summary>
static readonly Vector2i ImgSidePos = new Vector2i(680, -104), ImgSideSize = new Vector2i(560, 640);
static readonly Vector2i ImgTopPos = new Vector2i(60, -104), ImgTopSize = new Vector2i(1180, 400);
static readonly Vector2i TextPos = new Vector2i(60, -104), TextTopPos = new Vector2i(60, -518);
const int TextHeightFull = 640, TextHeightTop = 245;
XUiC_SimpleButton _toc, _prev, _next, _close;
readonly XUiC_SimpleButton[] _chapterButtons = new XUiC_SimpleButton[MaxButtons];
/// <summary>Глава по кнопке оглавления (только открытые главы, по порядку).</summary>
readonly int[] _buttonChapter = new int[MaxButtons];
int _chapter = -1, _pageIndex;
/// <summary>Страница оглавления; «Оглавление» из главы возвращает на неё.</summary>
int _tocPage;
readonly List<int> _open = new List<int>();
public override void Init()
{
@@ -62,11 +175,13 @@ namespace NecromancerTome
_title = GetChildById("lblTitle")?.ViewComponent as XUiV_Label;
_text = GetChildById("lblText")?.ViewComponent as XUiV_Label;
_page = GetChildById("lblPage")?.ViewComponent as XUiV_Label;
_img = GetChildById("imgPage")?.ViewComponent as XUiV_Texture;
_vid = GetChildById("vidPage")?.ViewComponent as XUiV_Video;
_toc = GetChildById("btnToc") as XUiC_SimpleButton;
_prev = GetChildById("btnPrev") as XUiC_SimpleButton;
_next = GetChildById("btnNext") as XUiC_SimpleButton;
_close = GetChildById("btnClose") as XUiC_SimpleButton;
if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc();
if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc(_tocPage);
if (_prev != null) _prev.OnPressed += (_s, _m) => Turn(-1);
if (_next != null) _next.OnPressed += (_s, _m) => Turn(1);
if (_close != null) _close.OnPressed += (_s, _m) => xui.playerUI.windowManager.Close(NecroTomeBook.WindowGroup);
@@ -84,27 +199,41 @@ namespace NecromancerTome
public override void OnOpen()
{
base.OnOpen();
ShowToc();
ShowToc(0);
}
void ShowToc()
void ShowToc(int _p)
{
_chapter = -1;
SetLabel(_title, Localization.Get("necroTomeToc"));
SetLabel(_text, "");
SetLabel(_page, "");
SetMedia(null, null);
EntityPlayerLocal player = xui.playerUI.entityPlayer;
int b = 0;
for (int c = 0; c < NecroTomeBook.Chapters.Count && b < MaxButtons; c++)
// Открытые главы по порядку; на странице - MaxButtons из них.
_open.Clear();
for (int c = 0; c < NecroTomeBook.Chapters.Count; c++)
{
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
if (ch.Unlocked != null && !ch.Unlocked(player))
if (ch.Unlocked == null || ch.Unlocked(player))
{
continue;
_open.Add(c);
}
}
bool twoCols = _open.Count > Rows;
int perPage = twoCols ? MaxButtons : Rows;
int pages = Mathf.Max(1, (_open.Count + perPage - 1) / perPage);
_tocPage = Mathf.Clamp(_p, 0, pages - 1);
int b = 0;
for (int i = _tocPage * perPage; i < _open.Count && b < perPage; i++)
{
int c = _open[i];
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
_buttonChapter[b] = c;
if (_chapterButtons[b] != null)
{
// Сначала левый столбец сверху вниз, потом правый.
int x = !twoCols ? ColCenterX : b < Rows ? ColLeftX : ColRightX;
_chapterButtons[b].ViewComponent.Position = new Vector2i(x, RowTopY - RowStep * (b % Rows));
_chapterButtons[b].Text = Localization.Get(ch.TitleKey);
_chapterButtons[b].ViewComponent.IsVisible = true;
}
@@ -118,8 +247,13 @@ namespace NecromancerTome
}
}
SetVisible(_toc, false);
SetVisible(_prev, false);
SetVisible(_next, false);
// Листать оглавление - только если страниц больше одной.
bool many = pages > 1;
SetLabel(_page, many ? (_tocPage + 1) + " / " + pages : "");
SetVisible(_prev, many);
SetVisible(_next, many);
if (_prev != null) _prev.Enabled = _tocPage > 0;
if (_next != null) _next.Enabled = _tocPage < pages - 1;
}
void ShowChapter(int _c, int _p)
@@ -138,6 +272,7 @@ namespace NecromancerTome
SetLabel(_title, Localization.Get(ch.TitleKey));
SetLabel(_text, Localization.Get(ch.PageKeys[_pageIndex]));
SetLabel(_page, (_pageIndex + 1) + " / " + ch.PageKeys.Length);
SetMedia(ch.Image, ch.Video, ch.ImageTop);
SetVisible(_toc, true);
SetVisible(_prev, true);
SetVisible(_next, true);
@@ -145,12 +280,99 @@ namespace NecromancerTome
if (_next != null) _next.Enabled = _pageIndex < ch.PageKeys.Length - 1;
}
public override void OnClose()
{
SetMedia(null, null);
base.OnClose();
}
/// <summary>Показать картинку или видео справа (или убрать обе). Видео задаётся только здесь,
/// а не в вёрстке: XUiV_Video сам запускает ролик при открытии окна, и в оглавлении он играл
/// бы невидимым. Пустой uri - XUiV_Video.startVideo сам освобождает свою текстуру.</summary>
/// <summary>Путь загруженной картинки (null - не загружена). Свой, а не _img.Path - см. ниже.</summary>
string _imgPath;
void ReleaseImage()
{
if (_img == null || _imgPath == null)
{
return;
}
Texture t = _img.Texture;
_img.Path = null; // TextureUris = пусто, Texture = null - без выгрузки (autounload выключен)
if (t != null)
{
UnityEngine.Object.Destroy(t);
}
_imgPath = null;
}
void SetMedia(string _image, string _video, bool _top = false)
{
bool img = !string.IsNullOrEmpty(_image), vid = !img && !string.IsNullOrEmpty(_video);
if (_img != null)
{
// Без чтения _img.Path: у пустой картинки (в вёрстке texture не задан) его геттер берёт
// textureUris[0] из пустого массива - IndexOutOfRange (тест 09.10). Сеттер сам сравнивает
// с текущим путём и не перезагружает тот же файл.
// Утечка (проверка 10.10): картинка из файла мода - новая текстура (XUiV_Texture.FetchWwwTexture, клон),
// и при смене пути старая сама не удаляется (autounload в вёрстке не включён - в этом режиме игра на
// загрузке стирает путь новой картинки). Каждая смена главы оставляла ~2 МБ. Удаляем сами: при смене
// картинки, у главы без картинки и при закрытии окна (SetMedia(null, null)).
if (_imgPath != _image)
{
ReleaseImage();
}
if (img && _imgPath == null)
{
_img.Path = _image;
_imgPath = _image;
}
_img.IsVisible = img;
}
if (_vid != null)
{
string uri = vid ? _video : "";
if (_vid.VideoUri != uri)
{
_vid.VideoUri = uri;
}
if (_vid.videoPlayer != null)
{
_vid.videoPlayer.audioOutputMode = VideoAudioOutputMode.None;
// Проверка на утечки 06.10: XUiV_Video гасит ролик при смене uri, только если тот уже
// играет; ушли в оглавление во время подготовки - догрузился бы и играл невидимым до
// закрытия окна. Останавливаем сами (Stop освобождает декодер).
if (!vid)
{
_vid.videoPlayer.Stop();
}
}
_vid.IsVisible = vid;
}
if (_img != null)
{
_img.Position = _top ? ImgTopPos : ImgSidePos;
_img.Size = _top ? ImgTopSize : ImgSideSize;
}
if (_text != null)
{
bool top = _top && img;
_text.Position = top ? TextTopPos : TextPos;
_text.Size = new Vector2i(top || !(img || vid) ? TextWidthFull : TextWidthHalf, top ? TextHeightTop : TextHeightFull);
}
}
void Turn(int _d)
{
if (_chapter >= 0)
{
ShowChapter(_chapter, _pageIndex + _d);
}
else
{
ShowToc(_tocPage + _d);
}
}
static void SetLabel(XUiV_Label _l, string _s)
+11
View File
@@ -46,9 +46,20 @@
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.PhysicsModule.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- AudioSource/AudioClip.Create - сердцебиение кровавого прилива (BloodTide.cs, 10.10). -->
<Reference Include="UnityEngine.AudioModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AudioModule.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- EntityAlive.PlayOneShot(string)'s own overload set touches AnimationEvent (2026-08-29,
BansheePatch.cs) - needed even though this mod never uses AnimationEvent directly, just
to satisfy the compiler's reference-resolution for that overload. -->
<!-- NecroTome.cs (2026-10-06): видео-иллюстрация в книге играет без звука -
VideoPlayer.audioOutputMode живёт в VideoModule. -->
<Reference Include="UnityEngine.VideoModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.VideoModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.AnimationModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
<Private>false</Private>
+4 -1
View File
@@ -127,7 +127,10 @@ namespace NecromancerTome
public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen)
{
if (__instance.inventory == null)
// В одержимости тело держит книгу питомца, но цель под прицелом тела не обновляется (управление тела
// выключено) - прицел книги застывал красным (тест 09.10: «у стервятника прицел всё время красный»).
// Прицел одержимого - обычный, со своей подсветкой (Possession.AimTint).
if (__instance.inventory == null || Possession.Active)
{
return true;
}
+7
View File
@@ -519,6 +519,13 @@ namespace NecromancerTome
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
}
// Одержимый питомец (указание 06.10) ведётся игроком: без охраны, поводка, «дома» и
// телепорта - удаляться можно на любое расстояние. После выхода всё это вернёт его к хозяину.
if (Possession.IsPossessed(pet))
{
continue;
}
// Охрана и поводок 40 м - до блока про «дом» летающих: тот в этом же тике должен
// увидеть уже назначенную или снятую цель. Рой (не спутник) сюда не попадает.
if (tracked.Guards || tracked.OwnFollowTask || tracked.FlyingHomeRadius > 0)
+10
View File
@@ -6,6 +6,8 @@ namespace NecromancerTome
{
/// <summary>
/// УБИЙСТВА ПИТОМЦЕМ ТЕПЕРЬ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ - 2026-09-18.
/// Одержимый питомец (06.10) - тоже питомец: его убийства идут хозяину этим же префиксом.
/// А его СОБСТВЕННАЯ гибель не засчитывается никому (06.10, первая проверка префикса).
///
/// ПОЧЕМУ РАНЬШЕ НЕ ЗАСЧИТЫВАЛИСЬ. Ваниль решает, чей это фраг, ровно в одном месте:
///
@@ -53,6 +55,14 @@ namespace NecromancerTome
{
public static bool Prefix(EntityAlive __instance, DamageSource _source, ref EntityAlive _killingEntity)
{
// Гибель СВОЕГО существа (питомцы, рой, дух змеи - все классы necro*) игроку в плюс не идёт
// никогда (указание 06.10): ни опыт, ни некромантия - даже если добил его сам игрок. Оригинал
// пропускаем целиком, он раздал бы опыт убийце.
if (__instance != null && SnakeControl.IsNecroEntity(__instance))
{
Debug.Log("[NecromancerTome] PetKillCredit: гибель своего существа " + __instance.EntityName + " (" + __instance.entityId + ") - не засчитывается");
return false;
}
if (_source == null)
{
return true;
+1204 -13
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -319,6 +319,7 @@ namespace NecromancerTome
// светящееся фиолетовое кольцо по земле - LineRenderer, точки опущены на рельеф лучом.
// Дочерний объект модели блока - поэтому сдвиги Origin и выгрузка чанка его не теряют.
GameObject ring;
static Material _ringMat;
public void SpawnZoneRing()
{
@@ -332,8 +333,13 @@ namespace NecromancerTome
const int n = 96;
lr.positionCount = n;
lr.widthMultiplier = 0.14f;
var mat = new Material(Shader.Find("Sprites/Default"));
lr.material = mat;
// Один материал на все кольца (проверка утечек 10.10): материал - ассет и с объектом кольца не удаляется,
// а кольцо пересоздаётся при каждой перезагрузке чанка (модель блока - новая). Цвет - вершинный (startColor).
if (_ringMat == null)
{
_ringMat = new Material(Shader.Find("Sprites/Default")) { name = "NecroWardRing" };
}
lr.sharedMaterial = _ringMat;
Color col = new Color(0.75f, 0.25f, 1f, 0.85f);
lr.startColor = col; lr.endColor = col;
lr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; lr.receiveShadows = false;
+565
View File
@@ -0,0 +1,565 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Обыск одержимой крысой (указание 08.10). Крыса - разведчик: «она может осмотреть ящики, и если там
/// что-то интересное, персонаж придёт и сам залутает». Поэтому - только смотреть.
///
/// Окно ванильное, ровно как у персонажа (таймер первого обыска, сетка, звуки): контейнер открывается
/// той же цепочкой LockManager.LockRequestLocal -> OnLockResponseLocal -> XUiC_LootWindowGroup.OpenLooting.
/// Отличия на время такого просмотра (Viewing):
/// - рюкзак персонажа не показывается («сумку персонажа вообще не показывать») - openContainer
/// открывает его сам, закрываем сразу же;
/// - ячейки контейнера заперты (XUiC_ItemStack.IsLocked), «Взять всё» (R) и сортировка не работают;
/// попытка взять - отказ, как при переполненном рюкзаке (указание: «делал бы вид, что сумки
/// переполнены»): звук ui_denied и xuiInventoryFullForPickup;
/// - окно не закрывается из-за дальности до ТЕЛА: XUiC_LootWindow.Update меряет её от персонажа.
/// Подсказку «E: обыскать» рисует управление игрока, а оно в одержимости выключено - выводим сами.
///
/// МЕШКИ (указание 09.10: «лутать жёлтые мешочки, и синие, и красные, падающие с зомби»). Мешок - сущность
/// EntityLootContainer, открывается той же цепочкой замка (LockRequestLocal -> Entity.OnLockResponseLocal), но
/// окно другое - XUiC_BagStorageWindowGroup; для него те же отличия: рюкзак закрыт, ячейки заперты, дальность
/// от крысы. Рюкзак погибшего игрока (EntityBackpack) - не мешок, его не трогаем.
/// </summary>
public static class RatLoot
{
/// <summary>Открыто (или открывается) окно просмотра крысой.</summary>
public static bool Viewing;
/// <summary>Окно открыл волк: щелчок и «Взять всё» кладут в его сумку (WolfBag), а не отказ.</summary>
public static bool Taking;
/// <summary>Контейнер, который осматривает крыса, и до какого времени после закрытия его не разрушать.</summary>
static Vector3i _sparePos;
static float _spareUntil;
/// <summary>Досягаемость контейнера от крысы, м.</summary>
const float Reach = 2.5f;
/// <summary>Мешок - не дальше стольких градусов от луча камеры (мешок мал, точно навести трудно).</summary>
const float BagAimDeg = 25f;
/// <summary>Открытый на просмотр мешок - для щелчков по ячейкам и «Взять всё».</summary>
static EntityLootContainer _viewBag;
/// <summary>Кто осматривает - для лога (у волка тот же обыск).</summary>
static string Who => Possession.Zombie != null && WolfControl.Is(Possession.Zombie) ? "волк" : "крыса";
/// <summary>Мешок перед крысой: ближе Reach к ней и ближе всех к лучу камеры, не дальше BagAimDeg.</summary>
public static EntityLootContainer FindBag(World _world, EntityPlayerLocal _player, EntityAlive _rat)
{
Transform cam = _player.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 from = cam.position + Origin.position;
EntityLootContainer best = null;
float bestDeg = BagAimDeg;
for (int i = 0; i < _world.Entities.list.Count; i++)
{
if (!(_world.Entities.list[i] is EntityLootContainer bag) || bag.IsMarkedForUnload() || bag.bag == null ||
(bag.position - _rat.position).sqrMagnitude > Reach * Reach)
{
continue;
}
float deg = Vector3.Angle(cam.forward, bag.position + Vector3.up * 0.2f - from);
if (deg < bestDeg)
{
bestDeg = deg;
best = bag;
}
}
return best;
}
/// <summary>Контейнер перед крысой по лучу камеры: блок с хранилищем ближе Reach к крысе; иначе null.</summary>
public static TEFeatureStorage Find(World _world, EntityPlayerLocal _player, EntityAlive _rat, out Vector3i _pos, out BlockValue _bv)
{
_pos = Vector3i.zero;
_bv = BlockValue.Air;
Camera cam = _player.playerCamera;
if (cam == null)
{
return null;
}
Vector3 eye = _rat.getHeadPosition();
Vector3 from = cam.transform.position + Origin.position;
float dist = Vector3.Distance(from, eye) + Reach + 1f;
Ray ray = new Ray(from, cam.transform.forward);
if (!Voxel.Raycast(_world, ray, dist, -555266077, 69, 0f))
{
return null;
}
WorldRayHitInfo hit = Voxel.voxelRayHitInfo;
if ((hit.hit.pos - eye).sqrMagnitude > Reach * Reach)
{
return null;
}
Vector3i pos = hit.hit.blockPos;
BlockValue bv = hit.hit.blockValue;
Block block = bv.Block;
if (block == null || bv.isair)
{
return null;
}
if (bv.ischild && block.multiBlockPos != null)
{
pos = block.multiBlockPos.GetParentPos(pos, bv);
bv = _world.GetBlock(pos);
}
TileEntity te = _world.GetTileEntity(pos);
TEFeatureStorage st = te != null ? te.GetSelfOrFeature<TEFeatureStorage>() : null;
if (st == null)
{
return null;
}
_pos = pos;
_bv = bv;
return st;
}
/// <summary>Каждый кадр одержимости крысой: подсказка и E - открыть. true - нажатие E ушло на контейнер или
/// мешок (волку тогда не открывать свою сумку).</summary>
public static bool Tick(World _world, EntityPlayerLocal _player, EntityAlive _rat, bool _activate, bool _take = false)
{
LocalPlayerUI ui = _player.PlayerUI;
if (ui == null || Viewing || ui.windowManager.IsModalWindowOpen())
{
return false;
}
PlayerActionsLocal input = _player.playerInput;
string key = input.Activate.GetBindingXuiMarkupString() + input.PermanentActions.Activate.GetBindingXuiMarkupString();
TEFeatureStorage st = Find(_world, _player, _rat, out Vector3i pos, out BlockValue bv);
if (st == null)
{
EntityLootContainer bag = FindBag(_world, _player, _rat);
if (bag == null)
{
return false;
}
XUiC_InteractionPrompt.SetText(ui, string.Format(Localization.Get("necroPossessSearchBag"), key, bag.LocalizedEntityName));
if (!_activate)
{
return false;
}
Viewing = true;
Taking = _take;
_viewBag = bag;
XUiC_InteractionPrompt.SetText(ui, null);
_lockRequest = _lockRequest ?? AccessTools.Method(typeof(LockManager), nameof(LockManager.LockRequestLocal), new[] { typeof(ILockTarget), typeof(ushort) });
_lockRequest.Invoke(LockManager.Instance, new object[] { bag, (ushort)0 });
Debug.Log("[NecromancerTome] RatLoot: " + Who + " осматривает мешок " + EntityClass.GetEntityClassName(bag.entityClass) + " (" + bag.entityId + ")");
return true;
}
string text = st.GetActivationText(_world, pos, bv, _rat, key, bv.Block.GetLocalizedBlockName());
if (!string.IsNullOrEmpty(text))
{
XUiC_InteractionPrompt.SetText(ui, text);
}
if (!_activate)
{
return false;
}
bool locked = st.lockpickFeature != null && st.lockpickFeature.NeedsLockpicking() ||
st.lockFeature != null && st.lockFeature.IsLocked() && !st.lockFeature.IsUserAllowed(Platform.PlatformManager.InternalLocalUserIdentifier) ||
st.isJammed;
if (locked)
{
Audio.Manager.BroadcastPlayByLocalPlayer(pos.ToVector3() + Vector3.one * 0.5f, "Misc/locked");
return true;
}
Viewing = true;
Taking = _take;
_sparePos = pos;
_spareUntil = float.MaxValue;
XUiC_InteractionPrompt.SetText(ui, null);
// Через отражение: у LockRequestLocal есть перегрузка с ReadOnlySpan, которой нет в справочных сборках проекта.
_lockRequest = _lockRequest ?? AccessTools.Method(typeof(LockManager), nameof(LockManager.LockRequestLocal), new[] { typeof(ILockTarget), typeof(ushort) });
_lockRequest.Invoke(LockManager.Instance, new object[] { st, (ushort)0 });
Debug.Log("[NecromancerTome] RatLoot: " + Who + " осматривает " + bv.Block.GetBlockName() + " " + pos);
return true;
}
static System.Reflection.MethodInfo _lockRequest;
/// <summary>Отказ «рюкзак полон».</summary>
public static void Deny()
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
if (ui?.entityPlayer != null)
{
GameManager.ShowTooltip(ui.entityPlayer, Localization.Get("xuiInventoryFullForPickup"), string.Empty, "ui_denied");
}
}
/// <summary>Выход из одержимости при открытом окне - закрыть его.</summary>
public static void CloseIfViewing()
{
if (!Viewing)
{
return;
}
LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
ui?.windowManager.CloseAllOpenModalWindows();
Viewing = false;
_viewBag = null;
_spareUntil = Time.time + 5f;
}
static void LockSlots(XUiC_LootWindow _w, bool _lock)
{
LockSlots(_w?.lootContainer, _lock);
}
static void LockSlots(XUiC_ItemStackGrid grid, bool _lock)
{
if (grid?.itemControllers == null)
{
return;
}
foreach (XUiC_ItemStack s in grid.itemControllers)
{
if (s != null)
{
s.IsLocked = _lock;
}
}
}
[HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.openContainer))]
public static class Patch_openContainer
{
public static void Postfix(XUiC_LootWindowGroup __instance)
{
if (!Viewing)
{
return;
}
if (!Taking)
{
__instance.xui.playerUI.windowManager.Close("backpack"); // у волка рюкзак показывает его сумку (WolfBagBackpack)
}
LockSlots(__instance.lootWindow, true);
}
}
/// <summary>Контейнеры с destroy_on_close (loot.xml: «empty» - 94 вида, мусор, кучи, холодильники, рюкзаки...;
/// «true» - аэродроп и коробки Twitch, это сущности, крыса их не открывает) разрушаются при снятии замка
/// после закрытия окна. Осмотр крысой их не разрушает (указание 08.10).</summary>
[HarmonyPatch(typeof(GameManager), nameof(GameManager.CheckDestroyTileEntity))]
public static class Patch_CheckDestroyTileEntity
{
public static bool Prefix(Vector3i _blockPos)
{
if (_blockPos != _sparePos || Time.time > _spareUntil)
{
return true;
}
_spareUntil = 0f;
Debug.Log("[NecromancerTome] RatLoot: " + _blockPos + " после осмотра крысой не разрушен");
return false;
}
}
/// <summary>Таймер первого обыска прерван - просмотра нет.</summary>
[HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.openTimerClosedManually))]
public static class Patch_openTimerClosedManually
{
public static void Postfix()
{
if (Viewing)
{
Viewing = false;
_spareUntil = Time.time + 5f;
}
}
}
/// <summary>Отказ замка (контейнер занят другим) - просмотра нет.</summary>
[HarmonyPatch(typeof(TEFeatureStorage), nameof(TEFeatureStorage.OnLockResponseLocal))]
public static class Patch_OnLockResponseLocal
{
public static void Postfix(bool _success)
{
if (!_success && Viewing)
{
Viewing = false;
_spareUntil = 0f;
}
}
}
[HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.OnClose))]
public static class Patch_OnClose
{
public static void Postfix(XUiC_LootWindowGroup __instance)
{
LockSlots(__instance.lootWindow, false); // всегда: запертые ячейки не должны достаться персонажу
if (Viewing)
{
Viewing = false;
_spareUntil = Time.time + 5f; // снятие замка (и проверка разрушения) - следом, может прийти пакетом
}
}
}
/// <summary>Дальность окна - от крысы, не от тела: XUiC_LootWindow.Update меряет её от entityPlayer.position,
/// поэтому на время его Update персонаж «стоит» на месте крысы (поле, ничего больше не трогается), а
/// Finalizer возвращает позицию при любом исходе. (Тест 08.10: прежний вариант обнулял te - и закрытие окна
/// по E/ESC падало в XUiC_LootWindow.OnClose, окно застревало и после выхода из одержимости.)</summary>
[HarmonyPatch(typeof(XUiC_LootWindow), nameof(XUiC_LootWindow.Update))]
public static class Patch_LootWindow_Update
{
public static void Prefix(XUiC_LootWindow __instance, out Vector3? __state)
{
__state = null;
EntityPlayerLocal p = __instance.xui.playerUI.entityPlayer;
EntityAlive rat = Possession.Zombie;
if (!Viewing || p == null || rat == null)
{
return;
}
__state = p.position;
p.position = rat.position;
}
public static void Finalizer(XUiC_LootWindow __instance, Vector3? __state)
{
if (__state.HasValue)
{
__instance.xui.playerUI.entityPlayer.position = __state.Value;
}
}
}
/// <summary>Окно мешка открылось на просмотр - рюкзак закрыть, ячейки запереть (как у ящика).</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnOpen))]
public static class Patch_Bag_OnOpen
{
public static void Postfix(XUiC_BagStorageWindowGroup __instance)
{
if (!Viewing || _viewBag == null)
{
return;
}
if (!Taking)
{
__instance.xui.playerUI.windowManager.Close("backpack");
}
LockSlots(__instance.containerWindow, true);
}
}
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnClose))]
public static class Patch_Bag_OnClose
{
public static void Prefix(XUiC_BagStorageWindowGroup __instance)
{
LockSlots(__instance.containerWindow, false); // всегда: запертые ячейки не должны достаться персонажу
if (Viewing && _viewBag != null)
{
Viewing = false;
_viewBag = null;
}
}
}
/// <summary>Таймер обыска мешка прерван - просмотра нет.</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.CancelOpen))]
public static class Patch_Bag_CancelOpen
{
public static void Postfix()
{
if (Viewing && _viewBag != null)
{
Viewing = false;
_viewBag = null;
}
}
}
/// <summary>Замок мешка не дан (занят другим) - просмотра нет.</summary>
[HarmonyPatch(typeof(Entity), nameof(Entity.OnLockResponseLocal))]
public static class Patch_Entity_OnLockResponseLocal
{
public static void Postfix(Entity __instance, bool __result)
{
if (!__result && Viewing && _viewBag != null && __instance == _viewBag)
{
Viewing = false;
_viewBag = null;
}
}
}
/// <summary>Дальность окна мешка - от крысы: проверка окна меряет её до entityPlayer, поэтому на время Update
/// персонаж «стоит» на месте крысы, как у окна ящика.</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.Update))]
public static class Patch_Bag_Update
{
public static void Prefix(XUiC_BagStorageWindowGroup __instance, out Vector3? __state)
{
__state = null;
EntityPlayerLocal p = __instance.xui.playerUI.entityPlayer;
EntityAlive rat = Possession.Zombie;
if (!Viewing || _viewBag == null || p == null || rat == null)
{
return;
}
__state = p.position;
p.position = rat.position;
}
public static void Finalizer(XUiC_BagStorageWindowGroup __instance, Vector3? __state)
{
if (__state.HasValue)
{
__instance.xui.playerUI.entityPlayer.position = __state.Value;
}
}
}
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveAll))]
public static class Patch_MoveAll
{
public static bool Prefix(XUiC_ContainerStandardControls __instance)
{
if (!Viewing)
{
return true;
}
if (WolfBagButton(__instance, false))
{
return false;
}
TEFeatureStorage te = __instance.GetParentByType<XUiC_LootWindow>()?.te;
if (Taking && te != null)
{
WolfBag.TakeAll(te);
}
else if (Taking && _viewBag != null)
{
WolfBag.TakeAll(_viewBag);
}
else
{
Deny();
}
return false;
}
}
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.Sort))]
public static class Patch_Sort
{
public static bool Prefix(XUiC_ContainerStandardControls __instance)
{
if (Viewing && WolfBagBackpack.On && __instance.GetParentByType<XUiC_BackpackWindow>() != null)
{
WolfBag.Sort();
}
return !Viewing;
}
}
/// <summary>Кнопки окна рюкзака, показывающего сумку волка: «Положить всё» / «Положить похожие» - в открытый
/// ящик или мешок (как щелчок по ячейке сумки). True - кнопка сумки волка, обработана.</summary>
static bool WolfBagButton(XUiC_ContainerStandardControls _c, bool _similarOnly)
{
if (!WolfBagBackpack.On || _c.GetParentByType<XUiC_BackpackWindow>() == null)
{
return false;
}
XUiC_LootWindow lw = _c.xui.GetChildByType<XUiC_LootWindow>();
TEFeatureStorage dst = lw != null && lw.ViewComponent.IsVisible ? lw.te : null;
if (dst != null || _viewBag != null)
{
WolfBag.PutAll(dst, dst == null ? _viewBag : null, _similarOnly);
}
return true;
}
/// <summary>«Положить похожие» у сумки волка; прочие переносы и замок ячеек при обыске - выключены (ячейки окна -
/// копии сумки, ванильный перенос их раздвоил бы).</summary>
[HarmonyPatch(typeof(XUiC_ContainerStandardControls))]
public static class Patch_OtherMoves
{
static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillAndSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillStacks));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.ToggleLockMode));
}
public static bool Prefix(XUiC_ContainerStandardControls __instance, System.Reflection.MethodBase __originalMethod)
{
if (!Viewing)
{
return true;
}
if (__originalMethod.Name == nameof(XUiC_ContainerStandardControls.MoveFillAndSmart))
{
WolfBagButton(__instance, true);
}
return false;
}
}
/// <summary>Щелчок по запертой ячейке контейнера - отказ «рюкзак полон».</summary>
[HarmonyPatch(typeof(XUiC_ItemStack), nameof(XUiC_ItemStack.Update))]
public static class Patch_ItemStack_Update
{
public static void Postfix(XUiC_ItemStack __instance)
{
if (!Viewing || !__instance.isOver || __instance.ItemStack == null || __instance.ItemStack.IsEmpty())
{
return;
}
// Ячейка сумки волка (окно рюкзака при обыске) - в ящик или мешок (указание 10.10).
XUiC_Backpack bp = __instance.GetParentByType<XUiC_Backpack>();
if (bp != null)
{
CursorControllerAbs c = __instance.xui.playerUI.CursorController;
if (WolfBagBackpack.On && (c.GetMouseButtonDown(UICamera.MouseButton.LeftButton) || c.GetMouseButtonDown(UICamera.MouseButton.RightButton)))
{
int bi = bp.itemControllers != null ? System.Array.IndexOf(bp.itemControllers, __instance) : -1;
XUiC_LootWindow lw = __instance.xui.GetChildByType<XUiC_LootWindow>();
TEFeatureStorage dst = lw != null && lw.ViewComponent.IsVisible ? lw.te : null;
if (bi >= 0 && dst != null)
{
WolfBag.PutTo(dst, bi);
}
else if (bi >= 0 && _viewBag != null)
{
WolfBag.PutTo(_viewBag, bi);
}
}
return;
}
// Ячейка контейнера-блока (XUiC_LootContainer) или мешка (XUiC_BagContainer), не рюкзака.
XUiC_ItemStackGrid grid = (XUiC_ItemStackGrid)__instance.GetParentByType<XUiC_LootContainer>() ?? __instance.GetParentByType<XUiC_BagContainer>();
if (grid == null)
{
return;
}
CursorControllerAbs cur = __instance.xui.playerUI.CursorController;
if (cur.GetMouseButtonDown(UICamera.MouseButton.LeftButton) || cur.GetMouseButtonDown(UICamera.MouseButton.RightButton))
{
TEFeatureStorage te = __instance.GetParentByType<XUiC_LootWindow>()?.te;
int idx = grid.itemControllers != null ? System.Array.IndexOf(grid.itemControllers, __instance) : -1;
if (Taking && te != null && idx >= 0)
{
WolfBag.TakeFrom(te, idx);
}
else if (Taking && _viewBag != null && grid is XUiC_BagContainer && idx >= 0)
{
WolfBag.TakeFrom(_viewBag, idx);
}
else
{
Deny();
}
}
}
}
}
}
+250
View File
@@ -0,0 +1,250 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Своя модель Могильной крысы (08.10.2026). Сущность necroRatSpirit по-прежнему стоит на префабе
/// лютоволка (Prefab в entityclasses.xml): его кости, аниматор, рэгдолл и коллайдеры остаются
/// ванильными. Меняется только то, что видно: в рендерер DireWolf кладём нашу сетку из бандла
/// Resources/necrorat, шерсть (DireWolfHair) прячем.
///
/// Сетка сделана с нуля в Blender, веса - по костям волка; привязку (bindposes) и номера костей
/// посчитал редактор Unity по самому префабу волка (NecroRatSkin.cs в DonorUnityProject),
/// поэтому smr.bones менять не нужно - порядок костей тот же. Проверка ниже на число костей
/// ловит случай, если игра однажды поменяет скелет волка: тогда остаётся волчья шкура, а не
/// изломанная крыса.
///
/// Шейдер - Standard (тот же, что у тела зомбопса). Свечение глаз - _EmissionMap и
/// _EmissionColor в материале necroRat_eye, фиолетовое, как у PetEyeGlow.
/// </summary>
public static class RatModel
{
public const string ClassName = "necroRatSpirit";
const string BundleFile = "Resources/necrorat";
static readonly string[] MatNames = { "necroRat_body", "necroRat_jaw", "necroRat_eye", "necroRat_incisor", "necroRat_claw" };
static bool _tried;
static Mesh _mesh;
static Material[] _mats;
public static bool Is(Entity _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) &&
cls != null && cls.entityClassName == ClassName;
}
static bool Load()
{
if (_tried)
{
return _mesh != null;
}
_tried = true;
if (ModEntry.Instance == null)
{
return false;
}
string path = System.IO.Path.Combine(ModEntry.Instance.Path, BundleFile);
AssetBundle b = System.IO.File.Exists(path) ? AssetBundle.LoadFromFile(path) : null;
if (b == null)
{
Debug.LogWarning("[NecromancerTome] RatModel: бандл не загружен: " + path);
return false;
}
_mesh = b.LoadAsset<Mesh>("necroRatMesh");
_mats = new Material[MatNames.Length];
Shader std = Shader.Find("Standard");
for (int i = 0; i < MatNames.Length; i++)
{
_mats[i] = b.LoadAsset<Material>(MatNames[i]);
if (_mats[i] != null && std != null && _mats[i].shader != std)
{
_mats[i].shader = std;
}
}
Debug.Log("[NecromancerTome] RatModel: сетка " + (_mesh != null ? _mesh.vertexCount + " вершин, костей " + _mesh.bindposes.Length : "НЕТ") +
", шейдер " + (std != null ? std.name : "нет Standard"));
return _mesh != null;
}
/// <summary>Прозрачность крысы во время прохода сквозь постройки (указание 09.10: «пока зажата ПКМ, делать
/// крысу полупрозрачной»).</summary>
public const float GhostAlpha = 0.35f;
static Material[] _ghostMats;
/// <summary>Полупрозрачные копии материалов (Standard в режиме Fade) - один раз на игру; глаза
/// (necroRat_eye) остаются непрозрачными и светятся. Переключение - подмена sharedMaterials у рендерера
/// DireWolf, без пересоздания материалов.</summary>
public static void SetGhost(EntityAlive _rat, bool _ghost)
{
if (_rat?.emodel == null || _mats == null)
{
return;
}
if (_ghost && _ghostMats == null)
{
_ghostMats = new Material[_mats.Length];
for (int i = 0; i < _mats.Length; i++)
{
Material src = _mats[i];
if (src == null || MatNames[i] == "necroRat_eye")
{
_ghostMats[i] = src;
continue;
}
Material m = new Material(src) { name = src.name + "_ghost" };
m.SetFloat("_Mode", 2f);
m.SetOverrideTag("RenderType", "Transparent");
m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
m.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
m.SetInt("_ZWrite", 0);
m.DisableKeyword("_ALPHATEST_ON");
m.EnableKeyword("_ALPHABLEND_ON");
m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
if (m.HasProperty("_Color"))
{
Color c = m.color;
c.a = GhostAlpha;
m.color = c;
}
_ghostMats[i] = m;
}
}
foreach (SkinnedMeshRenderer r in _rat.emodel.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (r.name == "DireWolf" && r.sharedMesh == _mesh)
{
r.sharedMaterials = _ghost ? _ghostMats : _mats;
}
}
}
public static void Apply(EModelBase _model)
{
if (!Load())
{
return;
}
bool done = false;
foreach (SkinnedMeshRenderer r in _model.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (r.name == "DireWolf")
{
if (r.bones == null || r.bones.Length != _mesh.bindposes.Length)
{
Debug.LogWarning("[NecromancerTome] RatModel: у волка " + (r.bones?.Length ?? 0) + " костей, у крысы " +
_mesh.bindposes.Length + " - модель не поставлена");
return;
}
r.sharedMesh = _mesh;
r.sharedMaterials = _mats;
r.localBounds = _mesh.bounds;
done = true;
}
else if (r.name == "DireWolfHair")
{
// Не enabled = false: при появлении питомца (renderFade) игра через avatarController.SetVisible
// включает все рендереры заново, и шерсть ложилась поверх крысы (тест 08.10: «модель крысы
// применилась, но почему-то в шерсти»). forceRenderingOff игра не трогает.
r.enabled = false;
r.forceRenderingOff = true;
}
}
Debug.Log("[NecromancerTome] RatModel: " + (done ? "модель крысы поставлена на " : "рендерер DireWolf не найден у ") + _model.entity?.entityId);
}
}
/// <summary>Крыса бегала, не касаясь земли (тест 08.10). Entity.SetCCScale уменьшает высоту капсулы движения
/// в SizeScale раз, а радиус - только увеличивает (FastMax(scale, 1)). У крысы (0.28 от волка) высота
/// стала меньше двух радиусов, капсула выродилась в шар волчьего радиуса, и его низ ушёл под ноги -
/// контроллер поднимал крысу над землёй. Радиус ужимаем так же, как высоту.</summary>
[HarmonyPatch(typeof(Entity), nameof(Entity.SetScale))]
public static class Patch_Entity_SetScale_RatModel
{
public static void Postfix(Entity __instance, float scale)
{
CharacterControllerAbstract cc = __instance.m_characterController;
if (cc == null || scale >= 1f || !RatModel.Is(__instance))
{
return;
}
float h = cc.GetHeight();
float r = Mathf.Min(cc.GetRadius() * scale, h * 0.5f);
// Центр - ровно половина высоты, иначе низ капсулы уходит под ноги. Замер 08.10 (RatProbe): центр 0.16
// при высоте 0.48 - низ на 8 см ниже точки сущности, земля ровно на -0.084, крыса висит. Волку игра
// центр выравнивает сама (SetCCScale: высота 1.89-2.2 -> 1.89, центр = половина), крысе - нет.
Vector3 c = cc.GetCenter();
c.y = h * 0.5f;
cc.SetSize(c, h, r);
Debug.Log("[NecromancerTome] RatModel: капсула крысы " + __instance.entityId + " - высота " + h.ToString("F2") +
", радиус " + r.ToString("F2") + ", центр " + c.y.ToString("F2"));
}
}
/// <summary>ЗАМЕР (08.10, временный): крыса всё ещё «не достаёт до земли» после правки капсулы - раз в 3 с пишем,
/// где сущность, где её видимая модель, физическое тело, низ костей и земля под ней. По логу видно, что
/// именно приподнято. Убрать после починки.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.LateUpdate))]
public static class Patch_EntityAlive_LateUpdate_RatProbe
{
static int _ratClass = int.MinValue;
static float _next;
public static void Postfix(EntityAlive __instance)
{
if (_ratClass == int.MinValue)
{
_ratClass = EntityClass.FromString(RatModel.ClassName);
}
if (__instance.entityClass != _ratClass || Time.time < _next || __instance.IsDead())
{
return;
}
_next = Time.time + 3f;
Vector3 p = __instance.position - Origin.position;
string ground = Physics.Raycast(p + Vector3.up, Vector3.down, out RaycastHit hit, 4f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore)
? (hit.point.y - p.y).ToString("F3") : "нет";
float low = float.MaxValue, rmin = float.MaxValue;
string lowName = "";
foreach (SkinnedMeshRenderer r in __instance.GetComponentsInChildren<SkinnedMeshRenderer>(false))
{
if (r.forceRenderingOff || r.bones == null)
{
continue;
}
rmin = Mathf.Min(rmin, r.bounds.min.y - p.y);
foreach (Transform b in r.bones)
{
if (b != null && b.position.y - p.y < low)
{
low = b.position.y - p.y;
lowName = b.name;
}
}
}
CharacterControllerAbstract cc = __instance.m_characterController;
Transform mt = __instance.emodel != null ? __instance.emodel.GetModelTransform() : null;
Debug.Log("[NecromancerTome] RatProbe " + __instance.entityId + ": земля " + ground +
", transform " + (__instance.transform.position.y - p.y).ToString("F3") +
", model " + (mt != null ? (mt.position.y - p.y).ToString("F3") + " scale " + mt.lossyScale.y.ToString("F2") : "нет") +
", низ кости " + low.ToString("F3") + " (" + lowName + "), низ сетки " + rmin.ToString("F3") +
", physicsRB " + (__instance.physicsRBT != null ? (__instance.physicsRBT.position.y - __instance.physicsBasePos.y - p.y).ToString("F3") : "нет") +
", onGround " + __instance.onGround +
(cc != null ? ", cc c" + cc.GetCenter().y.ToString("F2") + " h" + cc.GetHeight().ToString("F2") + " r" + cc.GetRadius().ToString("F2") : ""));
}
}
[HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))]
public static class Patch_EModelBase_createModel_RatModel
{
public static void Postfix(EModelBase __instance)
{
if (RatModel.Is(__instance.entity))
{
RatModel.Apply(__instance);
}
}
}
}
+173
View File
@@ -0,0 +1,173 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Проход одержимой крысы сквозь постройки (BACKLOG, «Могильная крыса» п.4; тест 09.10: «почему не срабатывает
/// проход сквозь текстуры по ПКМ»). Пока держишь ПКМ - как дух змеи, но без полёта:
/// - стены, двери, мебель не держат: поднят Entity.IsStuck, и её собственный шаг (тот же бег по WASD)
/// идёт мимо контроллера столкновений (Entity.ccEntityCollisionStart);
/// - высоту ведём сами: IsStuck снимает и пол, поэтому каждый кадр крыса стоит на поверхности под собой
/// (луч вниз с полуметра - ступенька по пути, как у змеи), а над пустотой падает с ускорением на неё;
/// - грунт (земля, песок, камень) не пускает - как у змеи: шаг в грунт отменяется;
/// - отпустил ПКМ внутри построек - выталкивает на ближайшее свободное место (вбок, вверх, вниз - дороже).
/// </summary>
public static class RatPhase
{
public static bool On;
static Vector3 _last;
static float _vy;
const float Gravity = 20f;
/// <summary>Точка тела крысы для проверки «внутри» и радиус шара свободного места.</summary>
const float BodyUp = 0.2f;
const float BodyRadius = 0.12f;
/// <summary>С какой высоты над ногами ищем пол: всё ниже - ступенька, на которую она взбегает.</summary>
const float StepUp = 0.5f;
static Vector3[] _ejectOffsets;
public static void Tick(World _world, EntityAlive _rat, bool _held)
{
if (_held && !On)
{
On = true;
_vy = 0f;
_last = _rat.position;
_rat.IsStuck = true;
RatModel.SetGhost(_rat, true);
Debug.Log("[NecromancerTome] RatPhase: проход включён");
}
else if (!_held && On)
{
Stop(_world, _rat, "ПКМ отпущена");
return;
}
if (!On)
{
return;
}
_rat.IsStuck = true;
_rat.fallDistance = 0f;
Vector3 p = _rat.position;
if (!_world.IsChunkAreaCollidersLoaded(p))
{
_rat.SetPosition(_last);
return;
}
// В грунт - нельзя: шаг вбок отменяем.
if (SnakeControl.Inside(_world, p + Vector3.up * BodyUp, SnakeControl.TerrainMask, out bool ground) && ground)
{
p.x = _last.x;
p.z = _last.z;
}
// Высота - только наша: собственная гравитация сущности с IsStuck пронесла бы её сквозь пол.
float dt = Time.deltaTime;
p.y = _last.y;
if (SnakeControl.Ground(p + Vector3.up * StepUp, StepUp + 300f, out float floor))
{
if (floor >= p.y - 0.02f)
{
p.y = floor;
_vy = 0f;
}
else
{
_vy -= Gravity * dt;
p.y = Mathf.Max(floor, p.y + _vy * dt);
if (p.y <= floor)
{
_vy = 0f;
}
}
}
Vector3 m = _rat.motion;
m.y = 0f;
_rat.motion = m;
_rat.SetPosition(p);
_last = p;
}
/// <summary>Выключить проход; внутри построек - вытолкнуть наружу.</summary>
public static void Stop(World _world, EntityAlive _rat, string _why)
{
if (!On)
{
return;
}
On = false;
if (_rat == null)
{
return;
}
_rat.IsStuck = false;
_rat.motion = Vector3.zero;
RatModel.SetGhost(_rat, false);
Vector3 p = _rat.position;
if (_world != null && !Free(_world, p))
{
Vector3? to = Eject(_world, p);
if (to.HasValue)
{
p = to.Value;
Debug.Log("[NecromancerTome] RatPhase: вытолкнута из построек, сдвиг " + (p - _rat.position).magnitude.ToString("F1") + " м");
}
else
{
p = _world.FindSupportingBlockPos(p);
Debug.Log("[NecromancerTome] RatPhase: свободного места рядом нет - на опорный блок");
}
_rat.SetPosition(p);
}
Debug.Log("[NecromancerTome] RatPhase: проход выключен - " + _why);
}
static bool Free(World _world, Vector3 _p)
{
Vector3 body = _p + Vector3.up * BodyUp;
return _world.IsChunkAreaCollidersLoaded(_p) &&
!SnakeControl.Inside(_world, body, SnakeControl.WallMask, out _) &&
!Physics.CheckSphere(body - Origin.position, BodyRadius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
/// <summary>Ближайшее свободное место с опорой: до 4 м вбок, до 1 м вниз, до 4 м вверх; вниз дороже.</summary>
static Vector3? Eject(World _world, Vector3 _p)
{
if (_ejectOffsets == null)
{
List<Vector3> list = new List<Vector3>();
for (float r = 0f; r <= 4f; r += 0.25f)
{
int dirs = r == 0f ? 1 : 16;
for (float h = -1f; h <= 4f; h += 0.5f)
{
for (int i = 0; i < dirs; i++)
{
list.Add(Quaternion.Euler(0f, i * 22.5f, 0f) * Vector3.forward * r + Vector3.up * h);
}
}
}
list.Sort((x, y) => (x.magnitude + (x.y < 0f ? -x.y : 0f)).CompareTo(y.magnitude + (y.y < 0f ? -y.y : 0f)));
_ejectOffsets = list.ToArray();
}
foreach (Vector3 o in _ejectOffsets)
{
Vector3 c = _p + o;
if (!Free(_world, c))
{
continue;
}
// Посадить на пол под этим местом; если пол сам внутри построек - место не годится.
if (SnakeControl.Ground(c + Vector3.up * 0.3f, 6f, out float y))
{
c.y = y;
if (!Free(_world, c))
{
continue;
}
}
return c;
}
return null;
}
}
}
+351
View File
@@ -0,0 +1,351 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Истлевание красного трупа в останки (указание 10.10.2026). Красный труп «Жертвы» - полноценный мёртвый зомби
/// (модель со скелетом, физика, перекрашенные текстуры): много таких трупов в загруженной зоне - много памяти, а при
/// выгрузке участка игра трупы не сохраняет (лут пропадал). Поэтому труп истлевает в лёгкий контейнер necroRemains
/// (entityclasses.xml, своя модель: остов, обтянутый обугленной плёнкой; бандл necroremains):
/// - через DecaySeconds после того, как стал красным трупом (пользователь: «2 минуты будет более чем достаточно»);
/// - раньше, если до ближайшего игрока дальше FarDistance - участок скоро выгрузится;
/// - раньше, если красных трупов больше MaxCorpses - сначала самые старые.
/// Лут переходит как у ванильного DropBagServer (Entity.cs): нетронутый - список zPackVictimRemains (наполнится при
/// первом открытии), открытый - предметы и отметка «трогали». Остаток срока трупа переходит в срок останков.
/// Звук - разбитое пятно крови (блоки bloodDecor из материала Morganic: organicdestroy). Только сервер/одиночная игра.
/// Последние BurnSeconds труп горит (ванильное пламя p_onFire по форме тела, как у горящего зомби) и обжигает
/// стоящих вплотную (ванильный buffBurningEnvironment - как костёр). Ради нагрузки горят только трупы ближе BurnNear
/// к игроку и не больше MaxBurning сразу (ближайшие); ушёл игрок - пламя гаснет. Пламя видно у хоста (сервер).
/// </summary>
public static class RemainsDecay
{
public const string EntityClassName = "necroRemains";
public const string RemainsLootList = "zPackVictimRemains";
public const float DecaySeconds = 120f;
public const float FarDistance = 80f;
public const int MaxCorpses = 30;
public const string Sound = "organicdestroy";
public const float BurnSeconds = 30f;
public const float BurnNear = 25f;
public const int MaxBurning = 6;
public const float BurnRadius = 1.3f;
const string FireParticle = "p_onFire";
const string FireName = "Ptl_" + FireParticle;
const string BurnBuff = "buffBurningEnvironment";
/// <summary>Когда мод впервые увидел красный труп (Time.time).</summary>
static readonly Dictionary<int, float> _since = new Dictionary<int, float>();
static float _next;
static readonly List<int> _tmp = new List<int>();
static readonly HashSet<int> _burning = new HashSet<int>();
static readonly List<KeyValuePair<float, EntityAlive>> _cand = new List<KeyValuePair<float, EntityAlive>>();
static GameObject _fire;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
if (world == null)
{
_since.Clear();
_burning.Clear();
BundleShaderFix.Forget();
return;
}
if (Time.time < _next || !SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer)
{
return;
}
_next = Time.time + 1f;
// забыть ушедшие
_tmp.Clear();
foreach (int id in _since.Keys)
{
if (!CorpseLoot.SearchableCorpses.Contains(id))
{
_tmp.Add(id);
}
}
foreach (int id in _tmp)
{
_since.Remove(id);
_burning.Remove(id);
}
foreach (int id in CorpseLoot.SearchableCorpses)
{
if (!_since.ContainsKey(id))
{
_since[id] = Time.time;
}
}
// кого истлеть
_tmp.Clear();
foreach (var kv in _since)
{
if (!(world.GetEntity(kv.Key) is EntityAlive c) || !c.IsDead())
{
continue;
}
if (Time.time - kv.Value >= DecaySeconds || FarFromPlayers(world, c.position))
{
_tmp.Add(kv.Key);
}
}
if (_since.Count - _tmp.Count > MaxCorpses)
{
var rest = new List<KeyValuePair<int, float>>();
foreach (var kv in _since)
{
if (!_tmp.Contains(kv.Key))
{
rest.Add(kv);
}
}
rest.Sort((a, b) => a.Value.CompareTo(b.Value));
for (int i = 0; i < rest.Count - MaxCorpses; i++)
{
_tmp.Add(rest[i].Key);
}
}
foreach (int id in _tmp)
{
if (world.GetEntity(id) is EntityAlive c)
{
Convert(world, c);
}
_since.Remove(id);
_burning.Remove(id);
}
Burn(world);
}
/// <summary>Тест: все красные трупы сразу в последние BurnSeconds (necroremains burn).</summary>
public static int IgniteAll()
{
_tmp.Clear();
_tmp.AddRange(_since.Keys);
foreach (int id in _tmp)
{
_since[id] = Mathf.Min(_since[id], Time.time - (DecaySeconds - BurnSeconds));
}
_next = 0f;
return _tmp.Count;
}
static void Burn(World _world)
{
_cand.Clear();
foreach (var kv in _since)
{
if (Time.time - kv.Value < DecaySeconds - BurnSeconds || !(_world.GetEntity(kv.Key) is EntityAlive c) || !c.IsDead())
{
continue;
}
float d = NearestPlayer(_world, c.position);
if (d < BurnNear)
{
_cand.Add(new KeyValuePair<float, EntityAlive>(d, c));
}
}
_cand.Sort((a, b) => a.Key.CompareTo(b.Key));
_tmp.Clear();
for (int i = 0; i < _cand.Count && i < MaxBurning; i++)
{
_tmp.Add(_cand[i].Value.entityId);
}
// погасить лишние
foreach (int id in new List<int>(_burning))
{
if (!_tmp.Contains(id))
{
(_world.GetEntity(id) as EntityAlive)?.RemoveParticle(FireName);
_burning.Remove(id);
}
}
for (int i = 0; i < _cand.Count && i < MaxBurning; i++)
{
EntityAlive c = _cand[i].Value;
if (!_burning.Contains(c.entityId) && Ignite(c))
{
_burning.Add(c.entityId);
}
if (!_burning.Contains(c.entityId))
{
continue;
}
// обжечь вплотную стоящих (как костёр: от 3 касаний подряд - горит сам)
Vector3 hip = c.emodel != null ? c.emodel.GetHipPosition() : c.position;
float r2 = BurnRadius * BurnRadius;
foreach (EntityPlayer pl in _world.Players.list)
{
if (pl != null && !pl.IsDead() && (pl.position - hip).sqrMagnitude < r2)
{
pl.Buffs.AddBuff(BurnBuff);
}
}
}
}
static float NearestPlayer(World _world, Vector3 _p)
{
float best = float.MaxValue;
foreach (EntityPlayer pl in _world.Players.list)
{
if (pl != null)
{
best = Mathf.Min(best, (pl.position - _p).magnitude);
}
}
return best;
}
/// <summary>Пламя по форме тела - как MinEventActionAttachParticleEffectToEntity (parent_transform LOD0, shape_mesh).</summary>
static bool Ignite(EntityAlive _c)
{
Transform parent = _c.emodel?.meshTransform;
if (parent == null)
{
return false;
}
if (_fire == null)
{
_fire = LoadManager.LoadAssetFromAddressables<GameObject>("ParticleEffects/" + FireParticle + ".prefab", null, null, _deferLoading: false, _loadSync: true).Asset;
if (_fire == null)
{
return false;
}
}
if (parent.Find(FireName) != null)
{
return true;
}
GameObject go = Object.Instantiate(_fire);
go.name = FireName;
Utils.SetLayerRecursively(go, parent.gameObject.layer);
Transform t = go.transform;
t.SetParent(parent, false);
t.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity);
_c.AddParticle(FireName, t);
SkinnedMeshRenderer smr = parent.GetComponentInChildren<SkinnedMeshRenderer>();
foreach (ParticleSystem ps in go.GetComponentsInChildren<ParticleSystem>())
{
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
ParticleSystem.ShapeModule sh = ps.shape;
if (sh.shapeType == ParticleSystemShapeType.SkinnedMeshRenderer || sh.shapeType == ParticleSystemShapeType.Mesh)
{
if (smr != null)
{
sh.skinnedMeshRenderer = smr;
}
else
{
sh.shapeType = ParticleSystemShapeType.Sphere;
}
}
ParticleSystem.MainModule main = ps.main;
main.duration = 900000f;
ps.Play();
}
return true;
}
static bool FarFromPlayers(World _world, Vector3 _p)
{
float far2 = FarDistance * FarDistance;
for (int i = 0; i < _world.Players.list.Count; i++)
{
EntityPlayer pl = _world.Players.list[i];
if (pl != null && (pl.position - _p).sqrMagnitude < far2)
{
return false;
}
}
return _world.Players.list.Count > 0;
}
/// <summary>Красный труп -> останки. True - получилось (труп убран).</summary>
public static bool Convert(World _world, EntityAlive _corpse)
{
int cls = EntityClass.FromString(EntityClassName);
if (cls == -1 || _corpse == null || _corpse.IsMarkedForUnload())
{
return false;
}
// где и как лежит тело: таз и направление таз -> голова (у модели останков голова - в сторону -Z)
Vector3 pos = _corpse.emodel != null ? _corpse.emodel.GetHipPosition() : _corpse.position;
Vector3 head = _corpse.emodel?.GetHeadTransform() != null ? _corpse.emodel.GetHeadTransform().position + Origin.position : pos + _corpse.transform.forward;
Vector3 dir = head - pos; dir.y = 0f;
float yaw = dir.sqrMagnitude > 1e-4f ? Mathf.Atan2(-dir.x, -dir.z) * Mathf.Rad2Deg : _corpse.rotation.y;
// на землю: луч вниз от таза
Vector3 g = pos;
if (Physics.Raycast(pos + Vector3.up * 0.6f - Origin.position, Vector3.down, out RaycastHit hit, 3f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore))
{
g.y = hit.point.y + Origin.position.y;
}
// центр модели останков сдвинут от таза к груди: отодвинуть назад на полкорпуса, чтобы таз остался на месте
Vector3 back = Quaternion.Euler(0f, yaw, 0f) * Vector3.back;
g += back * 0.19f; // модель x1.2 (NecroRemainsSetup.Scale)
if (!(EntityFactory.CreateEntity(cls, g, new Vector3(0f, yaw, 0f)) is EntityLootContainer rem))
{
return false;
}
rem.OverrideLootList = RemainsLootList;
Bag bag = _corpse.bag;
bool touched = bag != null && bag.Touched;
if (touched)
{
rem.bag.Resize(new Vector2i(bag.SlotCount, 1));
rem.bag.ItemGrid.CopyItemsFrom(bag.ItemGrid);
rem.bag.ItemGrid.WorldTimeTouched = bag.ItemGrid.WorldTimeTouched;
}
_world.SpawnEntityInWorld(rem);
int left = Mathf.Max(20 * 30, _corpse.timeStayAfterDeath - _corpse.deathUpdateTime);
rem.timeStayAfterDeath = left;
rem.deathUpdateTime = 0;
Audio.Manager.BroadcastPlay(g, Sound);
CorpseLoot.SearchableCorpses.Remove(_corpse.entityId);
_world.RemoveEntity(_corpse.entityId, EnumRemoveEntityReason.Despawned);
Debug.Log("[NecromancerTome] RemainsDecay: труп " + _corpse.entityId + " -> останки " + rem.entityId +
(touched ? " (лут перенесён)" : " (нетронутый)") + ", срок " + left / 20 + " с");
return true;
}
}
/// <summary>necroremains - мгновенно истлеть все красные трупы рядом (проверка без ожидания 2 минут).</summary>
[UnityEngine.Scripting.Preserve]
public class ConsoleCmdNecroRemains : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroremains" };
public override string getDescription() => "NecromancerTome: красные трупы сразу истлевают в останки; burn - сразу последние 30 с (горят)";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
World world = GameManager.Instance?.World;
if (world == null)
{
return;
}
if (_params.Count > 0 && _params[0] == "burn")
{
SdtdConsole.Instance.Output("подожжено трупов (последние " + RemainsDecay.BurnSeconds + " с): " + RemainsDecay.IgniteAll());
return;
}
var ids = new List<int>(CorpseLoot.SearchableCorpses);
int n = 0;
foreach (int id in ids)
{
if (world.GetEntity(id) is EntityAlive c && c.IsDead() && RemainsDecay.Convert(world, c))
{
n++;
}
}
SdtdConsole.Instance.Output("истлело трупов: " + n);
}
}
}
+24 -18
View File
@@ -116,8 +116,7 @@ namespace NecromancerTome
static readonly System.Collections.Generic.Queue<int> _fearQueue = new System.Collections.Generic.Queue<int>();
static readonly System.Collections.Generic.HashSet<int> _fearQueued = new System.Collections.Generic.HashSet<int>();
static float _fearCheck;
public const string SnakeMapNavObject = "necroSnakeSpirit";
static EntityAlive _mapSnake;
static NecroMapMarker.Marker _mapNav;
public const float FearInterval = 0.25f;
/// <summary>Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
/// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.</summary>
@@ -180,9 +179,10 @@ namespace NecromancerTome
_fearQueue.Clear();
_fearQueued.Clear();
_fearCheck = 0f;
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10.
_snake.AddNavObject(SnakeMapNavObject, null, null);
_mapSnake = _snake;
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10. С 06.10 - своя
// метка по Transform (NecroMapMarker): через AddNavObject она так и не показалась.
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
_mapNav = NecroMapMarker.Show(_snake, NecroMapMarker.SnakeClass);
Target = null;
_radiationCheck = 0f;
BodyDanger = false;
@@ -248,11 +248,7 @@ namespace NecromancerTome
public static void End()
{
ClearSense();
if (_mapSnake != null)
{
_mapSnake.RemoveNavObject(SnakeMapNavObject);
_mapSnake = null;
}
NecroMapMarker.Hide(ref _mapNav);
_fearQueue.Clear();
_fearQueued.Clear();
Controlling = false;
@@ -432,7 +428,7 @@ namespace NecromancerTome
_fearQueued.Remove(id);
EntityAlive e = _world.GetEntity(id) as EntityAlive;
// Ушёл из чутья, умер, стал своим, началась луна - пропускаем и берём следующего.
if (e == null || bloodMoon || _world.IsRemote() || !IsFoe(e, _snake) ||
if (e == null || bloodMoon || _world.IsRemote() || !IsFoe(e, _snake) || !IsUndead(e) ||
(e.position - _pos).sqrMagnitude > SenseRange * SenseRange)
{
continue;
@@ -598,7 +594,8 @@ namespace NecromancerTome
continue;
}
float d2 = (e.position - _pos).sqrMagnitude;
bool foe = IsFoe(e, _snake);
// Чутьё и ужас - власть над мёртвыми (указание 06.10): только нежить, живых змея не чует и не пугает.
bool foe = IsFoe(e, _snake) && IsUndead(e);
if (foe && d2 < SenseRange * SenseRange)
{
// Только наземные: зомби и звери-зомби. Стервятникам пафайндер не нужен.
@@ -654,6 +651,13 @@ namespace NecromancerTome
!GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
}
/// <summary>Нежить - поднятые мертвецы: всё с меткой zombie (зомби-люди, зомби-собаки, -медведи,
/// -кабаны, стервятники, Стиратель). Живые звери, бандиты, игроки и торговцы - не нежить. Та же
/// метка, по которой считаются убийства в Некромантию (NecromancyKillCreditPatch.ZombieTag).</summary>
static readonly FastTags<TagGroup.Global> UndeadTag = FastTags<TagGroup.Global>.Parse("zombie");
public static bool IsUndead(EntityAlive _e) => _e != null && _e.HasAnyTags(UndeadTag);
/// <summary>Питомцы и змея мода - тоже EntityEnemy (зомби-основа), и без этой проверки
/// охрана выбирала целью самого питомца: он ближе всех к хозяину (тест 30.09, лог
/// «pet 3838 идёт на 3838»). Все наши классы сущностей начинаются с necro.</summary>
@@ -880,7 +884,7 @@ namespace NecromancerTome
return _o.magnitude + (_o.y < 0f ? -_o.y : 0f);
}
static bool Free(World _world, Vector3 _p, bool _groundOnly)
public static bool Free(World _world, Vector3 _p, bool _groundOnly)
{
if (!_world.IsChunkAreaCollidersLoaded(_p))
{
@@ -901,7 +905,7 @@ namespace NecromancerTome
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
/// точке), а не постройка.</summary>
static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
public static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
{
_ground = false;
Vector3 o = _p - Origin.position;
@@ -930,7 +934,7 @@ namespace NecromancerTome
return _world.GetBlock(World.worldToBlockPos(_p)).Block.IsCollideMovement;
}
static bool Ground(Vector3 _from, float _dist, out float _y)
public static bool Ground(Vector3 _from, float _dist, out float _y)
{
if (Physics.Raycast(_from - Origin.position, Vector3.down, out RaycastHit hit, _dist, TerrainMask))
{
@@ -1002,7 +1006,8 @@ namespace NecromancerTome
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (!IsFoe(e, _snake))
// Атака духа змеи - только по нежити (указание 06.10): живые звери и люди ей не подвластны.
if (!IsFoe(e, _snake) || !IsUndead(e))
{
continue;
}
@@ -1340,7 +1345,8 @@ namespace NecromancerTome
}
}
/// <summary>Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»). Игра каждый
/// <summary>Пояс в режиме змеи скрыт (указание 28.09: «прятать оверлей с поясом»), так же в
/// чертогах и у одержимого питомца (06.10). Игра каждый
/// кадр в EntityPlayerLocal.OnHUD открывает окно "toolbelt", если оно закрыто, - поэтому не
/// закрываем его раз за разом, а не даём открыться: все Open сходятся в openInternal.</summary>
[HarmonyPatch(typeof(GUIWindowManager), "openInternal")]
@@ -1348,7 +1354,7 @@ namespace NecromancerTome
{
public static bool Prefix(GUIWindow _w)
{
return !((SnakeControl.Controlling || MindControl.Controlling) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
return !((SnakeControl.Controlling || MindControl.Controlling || (Possession.Active && Possession.IsPet)) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
}
}
+44
View File
@@ -65,6 +65,37 @@ namespace NecromancerTome
return world != null && world.IsDark();
}
/// <summary>
/// «Кровавый прилив» (указание 10.10, новая концепция ночи кровавой луны). Луна в этой фазе тянет из
/// земли некротлен - прах умерших за годы эпидемии, - и плотность некроэнергии в воздухе скачком
/// сравнивается с плотностью в теле некроманта. Дух выходит из тела по перепаду плотностей; перепада
/// нет - выйти нечем. Поэтому до утра: нет духа змеи, чертогов разума, одержимости (и зомби, и своих
/// питомцев), а кто был вне тела к началу - выброшен в тело. «Коза» (леденящий ужас) тонет в
/// насыщенном воздухе: жест есть, эффекта нет.
/// Время - ванильное GameUtils.IsBloodMoonTime (от заката дня луны до рассвета), оно считается по часам
/// мира и одинаково у хоста и клиента; AIDirector с его BloodMoonActive есть только у сервера.
/// </summary>
public static bool IsBloodTide()
{
World world = GameManager.Instance?.World;
return world != null && GameUtils.IsBloodMoonTime(world.worldTime, (world.DuskHour, world.DawnHour),
GameStats.GetInt(EnumGameStats.BloodMoonDay));
}
/// <summary>Надпись «кровавого прилива» - не чаще раза в BloodTideHintPeriod с.</summary>
public const float BloodTideHintPeriod = 2f;
static float _bloodTideHintAt = -100f;
public static void BloodTideHint(EntityPlayerLocal _player)
{
if (_player == null || Time.time - _bloodTideHintAt < BloodTideHintPeriod)
{
return;
}
_bloodTideHintAt = Time.time;
GameManager.ShowTooltip(_player, Localization.Get("necroBloodTide"));
}
public static bool IsKnife(ItemClass _item)
{
return _item != null && _item.Name == KnifeName;
@@ -81,6 +112,12 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] SnakeMode: днём дух змеи недоступен");
return;
}
if (IsBloodTide())
{
BloodTideHint(_player);
Debug.Log("[NecromancerTome] SnakeMode: кровавый прилив - дух не покидает тело");
return;
}
World world = GameManager.Instance.World;
EntityClass snakeClass = EntityClass.list[EntityClass.FromString(SnakeClassName)];
if (snakeClass == null)
@@ -247,6 +284,13 @@ namespace NecromancerTome
Exit(player);
return;
}
if (IsBloodTide())
{
BloodTideHint(player);
Debug.Log("[NecromancerTome] SnakeMode: кровавый прилив - дух выброшен в тело");
Exit(player);
return;
}
if (_placeSnake && PlaceSnake(player, snake))
{
_placeSnake = false;
+4 -2
View File
@@ -68,8 +68,10 @@ namespace NecromancerTome
public const float ShoveDistance = 6f;
/// <summary>Session cache only - the durable copy lives on disk, see
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
/// SpatialVaultPersistence.cs. NOT cleared on WorldShuttingDown any more (that event fires before
/// the final player save and wiped vaults - see SpatialVaultPersistence.cs); a different save cannot
/// inherit this world's vault because ToPlayer overwrites or removes the entry on load. Leftover entries
/// are at most one per world loaded in a session - not a growing leak (checked 10.10).</summary>
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
+89 -18
View File
@@ -316,14 +316,9 @@ namespace NecromancerTome
if (_actionData.indexInEntityOfAction == RecallActionIndex)
{
if (owned.Count > 0)
{
RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null);
}
else if (holdingEntity.world != null)
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey);
}
// С 06.10 ПКМ - одержимость питомца (Patch_..._ExecuteAction_InstantRecall делает её на
// нажатии, сюда доходит только если та пропустила); отзыв - на E (PetRecallKey).
TryPossessPet(holdingEntity, owned, tooltips);
return false;
}
@@ -436,11 +431,56 @@ namespace NecromancerTome
/// <summary>summonItemName is null when this species doesn't consume its book on summon
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
/// <summary>ПКМ с книгой призыва (указание 06.10): вселиться в призванного питомца этой книги.
/// Нет питомца - надпись necroPetNothingToPossess.</summary>
public static void TryPossessPet(EntityAlive holdingEntity, List<OwnedEntityData> owned, PetInfo tooltips)
{
EntityPlayerLocal local = holdingEntity as EntityPlayerLocal;
if (local == null || tooltips.RecalledKey == null)
{
return;
}
EntityAlive pet = owned.Count > 0 && holdingEntity.world != null ? holdingEntity.world.GetEntity(owned[0].Id) as EntityAlive : null;
if (pet == null || pet.IsDead())
{
GameManager.ShowTooltip(local, "necroPetNothingToPossess");
return;
}
if (!Possession.EnterPet(local, pet))
{
Debug.Log("[NecromancerTome] SummonPatch: вселиться в питомца " + pet.entityId + " нельзя (змея, чертоги, транспорт или уже одержимость)");
}
}
/// <summary>Отозвать питомца книги, которую держит игрок (E, PetRecallKey).</summary>
public static void RecallHeldBookPet(EntityAlive holdingEntity, string entityToSpawn, PetInfo tooltips)
{
int petClassId = EntityClass.GetId(entityToSpawn);
if (petClassId == -1)
{
return;
}
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
if (owned.Count > 0)
{
RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null);
}
else if (holdingEntity.world != null)
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey);
}
}
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
{
if (Possession.Active && Possession.Zombie != null && Possession.Zombie.entityId == petEntityId)
{
Possession.Exit("питомец отозван");
}
World world = owner.world;
if (world != null)
{
WolfBag.DropFrom(world.GetEntity(petEntityId) as EntityAlive, "отозван"); // сумка волка - мешком на месте
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
}
owner.RemoveOwnedEntity(petEntityId);
@@ -551,17 +591,9 @@ namespace NecromancerTome
}
lastRecallTime = Time.time;
// С 06.10 ПКМ - одержимость питомца, отзыв переехал на E (PetRecallKey).
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
if (owned.Count > 0)
{
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(holdingEntity, owned[0].Id,
petInfo.RecalledKey, petInfo.ConsumesBook ? petInfo.SummonItemName : null);
holdingEntity.PlayOneShot(__instance.soundWarn);
}
else
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), petInfo.NothingToRecallKey);
}
Patch_ItemActionSpawnEntity_Spawn_PetLimit.TryPossessPet(holdingEntity, owned, petInfo);
return false;
}
}
@@ -619,6 +651,40 @@ namespace NecromancerTome
}
}
/// <summary>
/// ОТЗЫВ НА E - указание 06.10: «Отозвать питомца снимаем с ПКМ и ставим на E» (ПКМ отдана
/// одержимости питомца). E у игрока - «взаимодействие»: PlayerMoveController.HandleInteraction
/// получает _activate и открывает то, что под прицелом (блок, предмет, сущность). Отзываем только
/// когда под прицелом взаимодействовать не с чем - иначе E с книгой в руке перестала бы открывать
/// двери и ящики. «Не с чем» = у игры нет подсказки «Нажмите E…» (strTextLabelPointingTo пуст: на
/// кадре нажатия она не обновляется и держит прошлый кадр - то, куда игрок смотрел).
/// </summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.HandleInteraction))]
public static class Patch_PlayerMoveController_HandleInteraction_PetRecallKey
{
public static void Postfix(PlayerMoveController __instance, bool _activate)
{
if (!_activate || Possession.Active || !string.IsNullOrEmpty(__instance.strTextLabelPointingTo))
{
return;
}
EntityPlayerLocal player = __instance.entityPlayerLocal;
if (player == null || player.AttachedToEntity != null || player.IsDead() || player.inventory == null)
{
return;
}
ItemClass held = player.inventory.holdingItem;
if (held?.Actions == null || held.Actions.Length == 0 || !(held.Actions[0] is ItemActionSpawnEntity spawn) ||
!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(spawn.entityToSpawn, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo info) ||
info.RecalledKey == null)
{
return;
}
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallHeldBookPet(player, spawn.entityToSpawn, info);
player.PlayOneShot(spawn.soundWarn);
}
}
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
public static class Patch_EntityFactory_CreateEntity_PetOwnership
{
@@ -683,6 +749,11 @@ namespace NecromancerTome
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
petInfo.FlyingHomeRadius, petInfo.Guards);
AttachFollowTask(owner, __result, petInfo);
// Указание 06.10: при призыве ненадолго - «Отозвать питомца - E». Рой не отзывается.
if (petInfo.RecalledKey != null && owner is EntityPlayerLocal ownerLocal)
{
GameManager.ShowTooltip(ownerLocal, "necroPetRecallHint");
}
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
// поддерживают (на Рое это выяснилось ещё 28.08 - он только засорял лог). Призрачный
+197
View File
@@ -0,0 +1,197 @@
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Шея одержимого стервятника (указание 08.10). Сидя он стоит в позе 0 ванильного спящего
/// (sleeperSit) - голова опущена, как во сне. Это верно для еды, а бодрствующему нужна голова
/// вперёд: шея и голова - посередине между полётом и сном.
///
/// КАК. Модель и анимации игры не трогаем (в мод не идёт ни одного ассета игры): после аниматора
/// доворачиваем кости шеи и головы. В полёте запоминаем их локальные повороты (сглаженно - крылья
/// машут), сидя - каждый кадр берём то, что поставил аниматор (поза сна), и ставим долю Mix пути к
/// полётным. Вход и выход - плавно за BlendTime. Зовётся из Possession.PlaceCamera (постфикс
/// vp_FPCamera.LateUpdate) - после аниматора и до того, как камера читает положение головы.
///
/// ПАМЯТЬ. Ничего не сохраняется; ссылки на кости - только пока стервятник одержим (Reset на выходе).
/// </summary>
public static class VultureNeck
{
/// <summary>Доля пути от позы сна к полётной (0.5 - посередине). Подбор - necroneck mix.</summary>
public static float Mix = 0.5f;
public static bool Enabled = true;
public const float BlendTime = 0.3f;
/// <summary>Полётную позу берём, когда стервятник летит хотя бы столько (без переходов анимации).</summary>
public const float FlightSettle = 0.5f;
/// <summary>Если костей с «neck» в имени нет - голова и столько её родителей.</summary>
public const int FallbackChain = 3;
static EntityAlive _v;
static Transform[] _chain;
static Quaternion[] _flight;
static bool _hasFlight;
static float _flyTime;
static float _weight;
public static void Reset()
{
_v = null;
_chain = null;
_flight = null;
_hasFlight = false;
_flyTime = 0f;
_weight = 0f;
}
/// <summary>Каждый кадр одержимости стервятником, после аниматора. _awake - сидит, не ест.</summary>
public static void Apply(EntityAlive _vulture, bool _sitting, bool _awake)
{
if (_vulture != _v)
{
Reset();
_v = _vulture;
}
if (_chain == null && !FindChain())
{
return;
}
if (!_sitting)
{
_flyTime += Time.deltaTime;
if (_flyTime >= FlightSettle)
{
float k = _hasFlight ? Mathf.Min(1f, Time.deltaTime * 4f) : 1f;
for (int i = 0; i < _chain.Length; i++)
{
if (_chain[i] == null)
{
_chain = null; // модель пересобрана - найти кости заново
return;
}
_flight[i] = Quaternion.Slerp(_flight[i], _chain[i].localRotation, k);
}
_hasFlight = true;
}
}
else
{
_flyTime = 0f;
}
float want = Enabled && _sitting && _awake && _hasFlight ? 1f : 0f;
_weight = Mathf.MoveTowards(_weight, want, Time.deltaTime / BlendTime);
if (_weight <= 0f || !_sitting)
{
return;
}
float t = Mix * Mathf.SmoothStep(0f, 1f, _weight);
for (int i = 0; i < _chain.Length; i++)
{
if (_chain[i] == null)
{
_chain = null;
return;
}
_chain[i].localRotation = Quaternion.Slerp(_chain[i].localRotation, _flight[i], t);
}
}
/// <summary>Цепочка от головы вверх: все кости с «neck» в имени (без них - FallbackChain родителей).</summary>
static bool FindChain()
{
Transform head = _v?.emodel != null ? _v.emodel.GetHeadTransform() : null;
if (head == null)
{
return false;
}
var list = new List<Transform> { head };
for (Transform p = head.parent; p != null && p.name.ToLowerInvariant().Contains("neck"); p = p.parent)
{
list.Add(p);
}
if (list.Count == 1)
{
for (Transform p = head.parent; p != null && list.Count <= FallbackChain; p = p.parent)
{
list.Add(p);
}
}
_chain = list.ToArray();
if (_flight == null || _flight.Length != _chain.Length)
{
_flight = new Quaternion[_chain.Length];
_hasFlight = false;
}
string names = "";
foreach (Transform t in _chain)
{
names += (names.Length > 0 ? " < " : "") + t.name;
}
Debug.Log("[NecromancerTome] VultureNeck: кости " + names);
return true;
}
public static string Dump()
{
if (_chain == null)
{
return "костей нет - вселитесь в стервятника";
}
string s = "mix " + F(Mix) + (Enabled ? "" : " (выкл)") + ", вес " + F(_weight) + ", полёт " + (_hasFlight ? "снят" : "не снят");
for (int i = 0; i < _chain.Length; i++)
{
if (_chain[i] != null)
{
s += "\n " + _chain[i].name + " сейчас " + HandPose.F(_chain[i].localEulerAngles) + ", в полёте " + HandPose.F(_flight[i].eulerAngles);
}
}
return s;
}
static string F(float _f) => _f.ToString("0.##", CultureInfo.InvariantCulture);
}
/// <summary>
/// necroneck - подбор шеи сидящего одержимого стервятника:
/// mix &lt;0..1&gt; - доля пути от позы сна к полётной (0 - как спит, 1 - как в полёте)
/// on | off - включить / выключить доворот
/// show - кости и их повороты (в консоль и лог)
/// </summary>
[Preserve]
public class ConsoleCmdNecroNeck : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroneck" };
public override string getDescription() => "NecromancerTome: шея сидящего одержимого стервятника - mix <0..1>|on|off|show";
void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necroneck: " + _s);
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "show";
switch (cmd)
{
case "on":
case "off":
VultureNeck.Enabled = cmd == "on";
Out("доворот шеи " + (VultureNeck.Enabled ? "включён" : "выключен"));
return;
case "mix":
if (_params.Count > 1 && float.TryParse(_params[1].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out float m))
{
VultureNeck.Mix = Mathf.Clamp(m, -0.5f, 1.5f);
}
Out("mix " + VultureNeck.Mix.ToString("0.##", CultureInfo.InvariantCulture));
return;
default:
Out(VultureNeck.Dump());
return;
}
}
}
}
+783
View File
@@ -0,0 +1,783 @@
using System.Collections.Generic;
using System.IO;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Сумка одержимого лютоволка на 3 ячейки (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
/// - волк лутает: окно контейнера то же, что у крысы (RatLoot, рюкзак персонажа не показывается),
/// но щелчок по предмету (и «Взять всё») кладёт его в сумку волка; места нет - «Сумка полна»;
/// - выгрузка - принести к телу: рядом с телом E перекладывает сумку в рюкзак персонажа, что не
/// влезло - мешком на землю рядом;
/// - волк погиб или отозван - сумка выпадает мешком на месте; при отзыве (указание 09.10) на карте метка
/// «Сумка волка», чтобы за ним прийти; мешок подобран или истлел - метка снята (Patch_removeBackpack);
/// - «багажник» (указание 09.10: «почти как у пространственного хранилища»): E не на контейнер и не у тела -
/// окно сумки, то же ванильное окно, что у браслета (XUiC_BagStorageWindowGroup, шаблон roboticDrone), но
/// без рюкзака персонажа: внутри перекладывать, вытащить за окно - выбросить у волка (Patch_GetDropPosition);
/// - мешки с зомби волк лутает так же, как ящики (RatLoot, TakeFrom(EntityLootContainer));
/// - тест 10.10: «волк - это ещё одно пространственное хранилище: берёт игрок, но положить может только в эти
/// 3 ячейки» - при обыске рядом с ящиком окно рюкзака показывает сумку волка (WolfBagBackpack: первые 3
/// ячейки, остальные скрыты, ячейки заперты - перекладка щелчком: по ящику - в сумку, по сумке - в ящик,
/// PutTo), E на предмет на земле кладёт его в сумку (TryPickup), Tab в волке - окно сумки, не рюкзак
/// персонажа (Possession: windowpaging закрывается).
///
/// ХРАНЕНИЕ. Сумку сущности игра не сохраняет (Entity.bag нет в Entity.Write), поэтому она своя.
/// Упаковка - та же, что у пространственного хранилища (SpatialVaultPersistence.BuildPayload/ParsePayload,
/// проверена на сейвах 3.2 и 3.3), но в ОТДЕЛЬНОМ файле папки сейва, как у Чертогов разума
/// (NecromancerTome_palaces.txt): файл игрока не меняется (сейвы - главный риск мода). Пишется сразу
/// при каждом изменении - переживает и сохранение, и вылет. Волк у игрока один (один спутник за раз),
/// поэтому сумка одна, без привязки к номеру сущности.
/// </summary>
public static class WolfBag
{
public const int Slots = 3;
const string FileName = "NecromancerTome_wolfbag.bin";
public const float UnloadRange = 3f;
/// <summary>Метка выпавшей сумки: имя - ключ локализации, по нему и находим свою метку среди меток игрока.</summary>
public const string WaypointName = "necroWolfBagWaypoint";
public const string WaypointIcon = "ui_game_symbol_backpack";
/// <summary>Открыто окно сумки («багажник»).</summary>
public static bool WindowOpen;
/// <summary>Счётчик изменений сумки - окно рюкзака перерисовывает её по нему.</summary>
public static int Version;
/// <summary>Предмет на земле - не дальше стольких метров от волка и градусов от луча камеры.</summary>
const float PickupReach = 2.5f;
const float PickupAimDeg = 25f;
static Bag _bag;
static string _dir;
static string FilePath => Path.Combine(GameIO.GetSaveGameDir(), FileName);
/// <summary>Сумка текущего сейва (из файла при первом обращении в этом сейве).</summary>
public static Bag Get()
{
string dir = GameIO.GetSaveGameDir();
if (_bag != null && _dir == dir)
{
return _bag;
}
_dir = dir;
_bag = null;
try
{
if (File.Exists(FilePath))
{
_bag = SpatialVaultPersistence.ParsePayload(File.ReadAllBytes(FilePath));
}
}
catch (System.Exception e)
{
Debug.LogWarning("[NecromancerTome] WolfBag: файл сумки не прочитан (" + e.Message + ") - оставлен как есть, сумка пустая");
_bag = new Bag(new Vector2i(Slots, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
return _bag;
}
if (_bag == null)
{
_bag = new Bag(new Vector2i(Slots, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
}
else if (_bag.SlotCount < Slots)
{
_bag.Resize(new Vector2i(Slots, 1));
}
Debug.Log("[NecromancerTome] WolfBag: сумка загружена - " + Describe());
return _bag;
}
static void Save()
{
Version++;
try
{
if (_bag == null || _bag.IsEmpty())
{
if (File.Exists(FilePath))
{
File.Delete(FilePath);
}
return;
}
File.WriteAllBytes(FilePath, SpatialVaultPersistence.BuildPayload(_bag));
}
catch (System.Exception e)
{
Debug.LogWarning("[NecromancerTome] WolfBag: сумка не записана - " + e.Message);
}
}
public static string Describe()
{
Bag b = Get();
List<string> parts = new List<string>();
foreach (ItemStack s in b.ItemGrid.items)
{
if (s != null && !s.IsEmpty())
{
parts.Add(s.itemValue.ItemClass.GetLocalizedItemName() + (s.count > 1 ? " x" + s.count : ""));
}
}
return parts.Count == 0 ? Localization.Get("necroWolfBagEmpty") : string.Join(", ", parts);
}
static void Show(string _key)
{
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p != null)
{
GameManager.ShowTooltip(p, string.Format(Localization.Get(_key), Describe(), Get().GetUsedSlotCount(), Slots));
}
}
/// <summary>Переложить ячейку контейнера в сумку. Без раздвоения: сперва в неполные стопки, остаток -
/// в пустую ячейку; в контейнере остаётся ровно то, что не влезло.</summary>
public static bool TakeFrom(TEFeatureStorage _te, int _idx)
{
ItemStack rest = Take(_te.ItemGrid.GetItem(_idx));
if (rest == null)
{
return false;
}
_te.UpdateSlot(_idx, rest);
_te.SetModified();
_te.Parent?.NotifyListeners(); // окно контейнера перерисует ячейку (как ванильная сортировка)
return true;
}
/// <summary>То же из мешка, упавшего с зомби. Окно мешка перерисуется по событию сумки (SetSlot).</summary>
public static bool TakeFrom(EntityLootContainer _src, int _idx)
{
if (_idx >= _src.bag.SlotCount)
{
return false;
}
ItemStack rest = Take(_src.bag.ItemGrid.items[_idx]);
if (rest == null)
{
return false;
}
_src.bag.SetSlot(_idx, rest);
_src.OnBagModified(); // на клиенте - отправить сумку мешка серверу, как делает окно
return true;
}
/// <summary>Переложить стопку в сумку; что осталось в источнике (пустая стопка, если всё), или null - не влезло.</summary>
static ItemStack Take(ItemStack src)
{
if (src == null || src.IsEmpty())
{
return null;
}
Bag b = Get();
ItemStack moving = src.Clone();
int before = moving.count;
b.TryStackItem(0, moving);
if (moving.count > 0 && b.AddItem(moving.Clone()))
{
moving.count = 0;
}
int moved = before - moving.count;
if (moved <= 0)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
Show("necroWolfBagFull");
return null;
}
ItemStack rest = src.Clone();
rest.count = moving.count;
Save();
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
Show("necroWolfBagTook");
Debug.Log("[NecromancerTome] WolfBag: в сумку " + src.itemValue.ItemClass.GetItemName() + " x" + moved + " - " + Describe());
return rest.count > 0 ? rest : ItemStack.Empty.Clone();
}
/// <summary>Ячейку сумки - в ящик (щелчок по сумке в окне рюкзака при обыске, тест 10.10: «выложить в ящик не
/// получается - это неправильно»). Сперва в неполные стопки, остаток - в пустые ячейки; что не влезло -
/// остаётся в сумке.</summary>
public static bool PutTo(TEFeatureStorage _te, int _idx)
{
ItemStack[] g = _te.ItemGrid.items;
List<int> changed = new List<int>();
if (!Give(_idx, g, changed))
{
return false;
}
foreach (int i in changed)
{
_te.UpdateSlot(i, g[i].Clone());
}
_te.SetModified();
_te.Parent?.NotifyListeners();
return true;
}
/// <summary>То же в мешок с зомби.</summary>
public static bool PutTo(EntityLootContainer _dst, int _idx)
{
ItemStack[] g = _dst.bag.ItemGrid.items;
List<int> changed = new List<int>();
if (!Give(_idx, g, changed))
{
return false;
}
foreach (int i in changed)
{
_dst.bag.SetSlot(i, g[i].Clone());
}
_dst.OnBagModified();
return true;
}
/// <summary>Кнопки окна сумки при обыске (указание 12.10): «Положить всё» и «Положить похожие» - в открытый
/// ящик или мешок; похожие - только то, что в контейнере уже лежит.</summary>
public static void PutAll(TEFeatureStorage _te, EntityLootContainer _bag, bool _similarOnly)
{
bool any = false;
ItemStack[] slots = Get().ItemGrid.items;
for (int i = 0; i < slots.Length; i++)
{
if (slots[i] == null || slots[i].IsEmpty())
{
continue;
}
ItemStack[] dst = _te != null ? _te.ItemGrid.items : _bag?.bag.ItemGrid.items;
if (dst == null || (_similarOnly && System.Array.FindIndex(dst, s => s != null && !s.IsEmpty() && s.itemValue.type == slots[i].itemValue.type) < 0))
{
continue;
}
any |= _te != null ? PutTo(_te, i) : PutTo(_bag, i);
}
if (!any)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
}
}
/// <summary>«Сортировать» - по самой сумке.</summary>
public static void Sort()
{
Get().ItemGrid.Sort();
Save();
}
/// <summary>Переложить ячейку сумки в массив ячеек контейнера (сами ячейки меняются, номера - в _changed).</summary>
static bool Give(int _idx, ItemStack[] _g, List<int> _changed)
{
Bag b = Get();
ItemStack src = _idx < b.SlotCount ? b.ItemGrid.items[_idx] : null;
if (src == null || src.IsEmpty())
{
return false;
}
int left = src.count;
int max = src.itemValue.ItemClass?.Stacknumber.Value ?? 1;
for (int i = 0; i < _g.Length && left > 0 && max > 1; i++)
{
if (_g[i] != null && !_g[i].IsEmpty() && _g[i].itemValue.type == src.itemValue.type && _g[i].count < max)
{
int n = Mathf.Min(left, max - _g[i].count);
_g[i] = _g[i].Clone();
_g[i].count += n;
left -= n;
_changed.Add(i);
}
}
for (int i = 0; i < _g.Length && left > 0; i++)
{
if (_g[i] == null || _g[i].IsEmpty())
{
_g[i] = new ItemStack(src.itemValue.Clone(), left);
left = 0;
_changed.Add(i);
}
}
int moved = src.count - left;
if (moved <= 0)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
return false;
}
string name = src.itemValue.ItemClass?.GetItemName(); // до SetSlot: src - сама ячейка сумки, очистится
ItemStack rest = src.Clone();
rest.count = left;
b.SetSlot(_idx, left > 0 ? rest : ItemStack.Empty.Clone());
Save();
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
Debug.Log("[NecromancerTome] WolfBag: из сумки в контейнер " + name + " x" + moved + " - " + Describe());
return true;
}
/// <summary>E на предмет, лежащий на земле, - в сумку (как игрок подбирает: что не влезло, остаётся лежать).
/// True - предмет перед волком был (взят или «сумка полна»).</summary>
public static bool TryPickup(World _world, EntityPlayerLocal _player, EntityAlive _wolf)
{
Transform cam = _player.cameraTransform;
if (cam == null || _world.IsRemote())
{
return false;
}
Vector3 from = cam.position + Origin.position;
EntityItem best = null;
float bestDeg = PickupAimDeg;
for (int i = 0; i < _world.Entities.list.Count; i++)
{
if (!(_world.Entities.list[i] is EntityItem it) || it.IsDead() || it.IsMarkedForUnload() || it.itemStack == null ||
it.itemStack.IsEmpty() || !it.CanCollect() || (it.position - _wolf.position).sqrMagnitude > PickupReach * PickupReach)
{
continue;
}
float deg = Vector3.Angle(cam.forward, it.position - from);
if (deg < bestDeg)
{
bestDeg = deg;
best = it;
}
}
if (best == null)
{
return false;
}
ItemStack rest = Take(best.itemStack);
if (rest == null)
{
return true;
}
if (rest.IsEmpty())
{
_world.RemoveEntity(best.entityId, EnumRemoveEntityReason.Killed);
}
else
{
best.itemStack.count = rest.count;
}
return true;
}
/// <summary>«Взять всё» - по ячейкам, пока есть место.</summary>
public static void TakeAll(TEFeatureStorage _te)
{
bool any = false;
for (int i = 0; i < _te.ItemGrid.items.Length; i++)
{
if (!_te.ItemGrid.items[i].IsEmpty() && Get().CanTakeItem(_te.ItemGrid.items[i]))
{
any |= TakeFrom(_te, i);
}
}
if (!any)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
Show("necroWolfBagFull");
}
}
/// <summary>«Взять всё» из мешка - по ячейкам, пока есть место.</summary>
public static void TakeAll(EntityLootContainer _src)
{
bool any = false;
ItemStack[] slots = _src.bag.ItemGrid.items;
for (int i = 0; i < slots.Length; i++)
{
if (!slots[i].IsEmpty() && Get().CanTakeItem(slots[i]))
{
any |= TakeFrom(_src, i);
}
}
if (!any)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
Show("necroWolfBagFull");
}
}
/// <summary>«Багажник»: окно сумки, как у пространственного браслета (шаблон дрона), без рюкзака персонажа.
/// Перекладка внутри пишется сразу (Save на каждое изменение и на закрытие).</summary>
public static void OpenWindow(EntityPlayerLocal _player, EntityAlive _wolf)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
if (ui == null)
{
return;
}
WindowOpen = true;
XUiC_BagStorageWindowGroup.Open(ui.xui, _wolf, Get(), LootContainer.GetLootContainer("roboticDrone"),
Localization.Get("necroWolfBagWindowTitle"), Save, () =>
{
WindowOpen = false;
Save();
});
Debug.Log("[NecromancerTome] WolfBag: окно сумки - " + Describe());
}
/// <summary>Выход из одержимости при открытом окне сумки - закрыть его.</summary>
public static void CloseWindow()
{
if (WindowOpen)
{
LocalPlayerUI.GetUIForPrimaryPlayer()?.windowManager.CloseAllOpenModalWindows();
WindowOpen = false;
}
}
/// <summary>E рядом с телом: сумка - в рюкзак, что не влезло - мешком у тела.</summary>
public static bool TryUnload(EntityPlayerLocal _body, EntityAlive _wolf)
{
if (_body == null || (_body.position - _wolf.position).sqrMagnitude > UnloadRange * UnloadRange)
{
return false;
}
Bag b = Get();
if (b.IsEmpty())
{
Show("necroWolfBagShow");
return true;
}
List<ItemStack> left = new List<ItemStack>();
ItemStack[] slots = b.ItemGrid.items;
for (int i = 0; i < slots.Length; i++)
{
ItemStack s = slots[i]?.Clone();
if (s == null || s.IsEmpty())
{
continue;
}
_body.bag.TryStackItem(0, s);
if (s.count > 0 && _body.bag.AddItem(s.Clone()))
{
s.count = 0;
}
if (s.count > 0)
{
left.Add(s);
}
}
b.Clear();
Save();
if (left.Count > 0)
{
Drop(_body.position + _body.GetForwardVector() * 0.8f, left);
}
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
GameManager.ShowTooltip(_body, Localization.Get(left.Count > 0 ? "necroWolfBagUnloadedPart" : "necroWolfBagUnloaded"));
Debug.Log("[NecromancerTome] WolfBag: выгружена у тела" + (left.Count > 0 ? ", не влезло " + left.Count + " - мешком" : ""));
return true;
}
/// <summary>Волк погиб или отозван - сумка мешком на месте.</summary>
public static void DropFrom(EntityAlive _wolf, string _why)
{
if (_wolf == null || !WolfControl.Is(_wolf))
{
return;
}
Bag b = Get();
if (b.IsEmpty())
{
return;
}
List<ItemStack> items = new List<ItemStack>();
foreach (ItemStack s in b.ItemGrid.items)
{
if (s != null && !s.IsEmpty())
{
items.Add(s.Clone());
}
}
b.Clear();
Save();
Drop(_wolf.position, items);
if (_why == "отозван")
{
AddWaypoint(_wolf.position);
}
Debug.Log("[NecromancerTome] WolfBag: волк " + _why + " - сумка выпала мешком (" + items.Count + ")");
}
/// <summary>Метка на карте и компасе - как игра ставит метку транспорта (XUiC_MapArea.RefreshVehiclePositionWaypoint),
/// но обычная, не «последнее место сущности»: номер мешка после перезахода другой. Метка - в списке меток игрока,
/// сохраняется игрой вместе с ним; удалить можно и вручную.</summary>
static void AddWaypoint(Vector3 _at)
{
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p?.Waypoints == null)
{
return;
}
Waypoint w = new Waypoint();
w.pos = World.worldToBlockPos(_at);
w.icon = WaypointIcon;
w.ownerId = Platform.PlatformManager.InternalLocalUserIdentifier;
w.name.Update(WaypointName, Platform.PlatformManager.MultiPlatform.User.PlatformUserId);
w.bUsingLocalizationId = true;
p.Waypoints.Collection.Add(w);
w.navObject = NavObjectManager.Instance.RegisterNavObject("waypoint", w.pos.ToVector3(), w.icon);
w.navObject.IsActive = false;
w.navObject.name = w.name.Text;
w.navObject.usingLocalizationId = true;
Debug.Log("[NecromancerTome] WolfBag: метка сумки на карте " + w.pos);
}
/// <summary>Мешок исчез (опустел или истлел) - снять метку сумки, стоящую рядом с ним.</summary>
public static void RemoveWaypointAt(Vector3 _at)
{
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p?.Waypoints == null)
{
return;
}
List<Waypoint> list = p.Waypoints.Collection.list;
for (int i = list.Count - 1; i >= 0; i--)
{
Waypoint w = list[i];
if (w.name.Text == WaypointName && (w.pos.ToVector3() - _at).sqrMagnitude < 3f * 3f)
{
p.Waypoints.Collection.Remove(w);
if (w.navObject != null)
{
NavObjectManager.Instance.UnRegisterNavObject(w.navObject);
}
Debug.Log("[NecromancerTome] WolfBag: мешок сумки подобран - метка снята " + w.pos);
}
}
}
static void Drop(Vector3 _at, List<ItemStack> _items)
{
GameManager.Instance.DropContentInLootContainerServer(-1, "DroppedLootContainer", _at + Vector3.up * 0.5f, _items.ToArray());
}
/// <summary>Выход из мира - забыть (следующий сейв прочитает свой файл).</summary>
public static void Forget()
{
_bag = null;
_dir = null;
}
}
/// <summary>Мешок исчез (пустой после обыска - «empty», или истлел - «old») - снять метку сумки волка рядом.</summary>
[HarmonyPatch(typeof(EntityContainerAbs), nameof(EntityContainerAbs.removeBackpack))]
public static class Patch_EntityContainerAbs_removeBackpack_WolfBag
{
public static void Postfix(EntityContainerAbs __instance)
{
if (__instance is EntityLootContainer)
{
WolfBag.RemoveWaypointAt(__instance.position);
}
}
}
/// <summary>«Багажник» без рюкзака персонажа: окно мешка открывает его само (OnOpen), закрываем сразу же.</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnOpen))]
public static class Patch_XUiC_BagStorageWindowGroup_OnOpen_WolfBag
{
public static void Postfix(XUiC_BagStorageWindowGroup __instance)
{
bool wolf = WolfBag.WindowOpen && Possession.Active;
if (wolf)
{
__instance.xui.playerUI.windowManager.Close("backpack");
}
// «Взять всё» / «Взять похожие» кладут в рюкзак тела, а оно далеко - у волка кнопок нет; у дрона и браслета - вернуть
XUiC_ContainerStandardControls c = __instance.GetChildByType<XUiC_BagContainer>()?.standardControls;
foreach (string id in WolfBagControls.Hidden)
{
XUiController b = c?.GetChildById(id);
if (b?.ViewComponent != null)
{
b.ViewComponent.IsVisible = !wolf;
}
}
}
}
/// <summary>Окно сумки волка: работает только «Сортировать» (по самой сумке, пишется через onModified).
/// Перенос в рюкзак (кнопки и клавиша R) и замки ячеек (нужны лишь для переноса) - выключены.</summary>
public static class WolfBagControls
{
public static readonly string[] Hidden = { "btnMoveAll", "btnMoveFillAndSmart", "btnMoveFillStacks", "btnMoveSmart" };
public static bool Blocked(XUiController _c)
{
return WolfBag.WindowOpen && Possession.Active && _c?.GetParentByType<XUiC_BagContainer>() != null;
}
}
[HarmonyPatch(typeof(XUiC_ContainerStandardControls))]
public static class Patch_ContainerStandardControls_Move_WolfBag
{
static IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveAll));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillAndSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillStacks));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.ToggleLockMode));
}
public static bool Prefix(XUiC_ContainerStandardControls __instance)
{
return !WolfBagControls.Blocked(__instance);
}
}
/// <summary>Кнопка замка видна по привязке {hasslotlocks} - у волка её нет.</summary>
[HarmonyPatch(typeof(XUiC_BagContainer), nameof(XUiC_BagContainer.GetBindingValueInternal))]
public static class Patch_BagContainer_Binding_WolfBag
{
public static void Postfix(XUiC_BagContainer __instance, ref string _value, string _bindingName, ref bool __result)
{
if (_bindingName == "hasslotlocks" && WolfBagControls.Blocked(__instance))
{
_value = "false";
__result = true;
}
}
}
/// <summary>Предмет, вытащенный за окно сумки волка, падает у волка, а не у тела (XUiM_PlayerInventory.DropItem
/// берёт место у игрока - GetDropPosition).</summary>
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.GetDropPosition))]
public static class Patch_EntityPlayerLocal_GetDropPosition_WolfBag
{
public static void Postfix(EntityPlayerLocal __instance, ref Vector3 __result)
{
EntityAlive wolf = Possession.Zombie;
if (WolfBag.WindowOpen && Possession.Active && __instance == Possession.Player && wolf != null)
{
__result = wolf.position + wolf.GetForwardVector() * 0.8f + Vector3.up * 0.5f;
}
}
}
/// <summary>Окно рюкзака при обыске волком показывает сумку волка (указание 10.10). Рюкзак персонажа при этом
/// не читается и не пишется: UpdateBackend пропущен - в сейв игрока сумка не попадёт.</summary>
public static class WolfBagBackpack
{
public static bool On => RatLoot.Viewing && RatLoot.Taking && Possession.Active;
static int _seen = -1;
static readonly List<XUiC_ItemStack> _hidden = new List<XUiC_ItemStack>();
public static ItemStack[] Slots(int _n)
{
ItemStack[] a = new ItemStack[_n];
ItemStack[] b = WolfBag.Get().ItemGrid.items;
for (int i = 0; i < _n; i++)
{
a[i] = i < b.Length && b[i] != null ? b[i].Clone() : ItemStack.Empty.Clone();
}
return a;
}
public static void Tick(XUiC_Backpack _bp)
{
if (_seen != WolfBag.Version || _hidden.Count == 0)
{
_seen = WolfBag.Version;
_bp.RefreshBackpackSlots();
}
XUiC_ItemStack[] c = _bp.itemControllers;
for (int i = 0; c != null && i < c.Length; i++)
{
c[i].IsLocked = true;
if (i >= WolfBag.Slots && c[i].ViewComponent.IsVisible)
{
c[i].ViewComponent.IsVisible = false;
_hidden.Add(c[i]);
}
}
}
/// <summary>Обыск закончен: ячейки рюкзака снова видны и отперты, рюкзак - снова свой.</summary>
public static void Unlock(XUiC_Backpack _bp)
{
foreach (XUiC_ItemStack s in _hidden)
{
if (s != null)
{
s.ViewComponent.IsVisible = true;
}
}
bool was = _hidden.Count > 0 || _seen != -1;
_hidden.Clear();
_seen = -1;
if (_bp?.itemControllers == null || !was)
{
return;
}
foreach (XUiC_ItemStack s in _bp.itemControllers)
{
if (s != null)
{
s.IsLocked = false;
}
}
_bp.RefreshBackpackSlots();
}
}
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.GetSlots))]
public static class Patch_Backpack_GetSlots_WolfBag
{
public static bool Prefix(XUiC_Backpack __instance, ref ItemStack[] __result)
{
if (!WolfBagBackpack.On)
{
return true;
}
__result = WolfBagBackpack.Slots(__instance.itemControllers?.Length ?? WolfBag.Slots);
return false;
}
}
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.UpdateBackend))]
public static class Patch_Backpack_UpdateBackend_WolfBag
{
public static bool Prefix()
{
return !WolfBagBackpack.On;
}
}
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.Update))]
public static class Patch_Backpack_Update_WolfBag
{
static bool _was;
public static void Postfix(XUiC_Backpack __instance)
{
bool on = WolfBagBackpack.On;
if (on)
{
WolfBagBackpack.Tick(__instance);
}
else if (_was)
{
WolfBagBackpack.Unlock(__instance);
}
_was = on;
}
}
[HarmonyPatch(typeof(XUiC_Backpack), nameof(XUiC_Backpack.OnClose))]
public static class Patch_Backpack_OnClose_WolfBag
{
public static void Postfix(XUiC_Backpack __instance)
{
WolfBagBackpack.Unlock(__instance);
}
}
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.OnEntityDeath))]
public static class Patch_EntityAlive_OnEntityDeath_WolfBag
{
public static void Prefix(EntityAlive __instance)
{
if (!__instance.world.IsRemote() && WolfControl.Is(__instance))
{
WolfBag.DropFrom(__instance, "погиб");
}
}
}
}
+172
View File
@@ -0,0 +1,172 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Одержимый лютоволк - охотник (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
/// - бросок на ПКМ: враг под прицелом не дальше LeapRange - волк разворачивается и прыгает прямо на
/// него (прыжок самой игры: SetJumpDistance на точную дальность и перепад высоты); приземлился рядом -
/// цель падает на LeapStun секунд;
/// - след крови: раненые враги (здоровье ниже BloodHealth) отмечены на компасе и карте до BloodRange,
/// пока не умрут или не залечатся, - отличие от нюха собаки: та чует всех рядом, волк - подранков издалека.
/// </summary>
public static class WolfControl
{
public const string WolfClass = "necroZombieWolf";
public const float LeapRange = 10f;
public const float LeapMin = 2f;
public const float LeapConeDeg = 10f;
public const float LeapCooldown = 3f;
public const float LeapHit = 2.5f;
public const float LeapStun = 2.5f;
public const float BloodHealth = 0.5f;
public const float BloodRange = 150f;
public const float BloodDropRange = 200f;
static EntityAlive _leapTarget;
static float _leapUntil, _leapStart, _nextLeap;
static readonly HashSet<int> _blood = new HashSet<int>();
static float _bloodCheck;
public static bool Is(EntityAlive _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
cls.entityClassName == WolfClass;
}
/// <summary>Каждый кадр одержимости волком.</summary>
public static void Tick(World _world, EntityPlayerLocal _player, EntityAlive _wolf, bool _leap, ref float _bodyYaw)
{
if (_leap && _leapTarget == null && Time.time >= _nextLeap && _wolf.onGround && !_wolf.Jumping)
{
EntityAlive t = Target(_world, _player, _wolf);
if (t != null)
{
Vector3 to = t.position - _wolf.position;
Vector3 flat = new Vector3(to.x, 0f, to.z);
_bodyYaw = Mathf.Atan2(flat.x, flat.z) * Mathf.Rad2Deg;
_wolf.SetRotation(new Vector3(0f, _bodyYaw, 0f));
_wolf.Jumping = true;
_wolf.SetJumpDistance(flat.magnitude, Mathf.Max(0.5f, to.y + 0.5f));
_leapTarget = t;
_leapStart = Time.time;
_leapUntil = Time.time + 2f;
_nextLeap = Time.time + LeapCooldown;
Debug.Log("[NecromancerTome] WolfControl: бросок на " + t.EntityName + " (" + t.entityId + "), " + flat.magnitude.ToString("F1") + " м");
}
}
if (_leapTarget != null)
{
bool landed = Time.time - _leapStart > 0.3f && _wolf.onGround && !_wolf.Jumping;
if (_leapTarget.IsDead() || Time.time > _leapUntil)
{
_leapTarget = null;
}
else if (landed || (_leapTarget.getChestPosition() - _wolf.getHeadPosition()).sqrMagnitude < LeapHit * LeapHit * 0.5f)
{
if ((_leapTarget.position - _wolf.position).sqrMagnitude < LeapHit * LeapHit)
{
KnockDown(_leapTarget, _wolf);
}
_leapTarget = null;
}
}
BloodTick(_world, _wolf);
}
static void KnockDown(EntityAlive _t, EntityAlive _wolf)
{
_wolf.emodel?.avatarController?.StartAnimationAttack();
if (_t.bodyDamage.CurrentStun == EnumEntityStunType.None && _t.emodel?.avatarController != null)
{
_t.emodel.avatarController.BeginStun(EnumEntityStunType.Prone, EnumBodyPartHit.Torso, Utils.EnumHitDirection.Front, false, 0.5f);
_t.SetStun(EnumEntityStunType.Prone);
_t.bodyDamage.StunDuration = LeapStun;
}
Debug.Log("[NecromancerTome] WolfControl: " + _t.EntityName + " сбит с ног");
}
/// <summary>Враг ближе всех к лучу прицела: не дальше LeapRange от волка и в конусе LeapConeDeg.</summary>
static EntityAlive Target(World _world, EntityPlayerLocal _player, EntityAlive _wolf)
{
Transform cam = _player.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 eye = cam.position + Origin.position;
EntityAlive best = null;
float bestAngle = LeapConeDeg;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, _wolf))
{
continue;
}
float d = (e.position - _wolf.position).magnitude;
if (d > LeapRange || d < LeapMin)
{
continue;
}
float angle = Vector3.Angle(cam.forward, e.getChestPosition() - eye);
if (angle < bestAngle)
{
bestAngle = angle;
best = e;
}
}
return best;
}
static void BloodTick(World _world, EntityAlive _wolf)
{
_bloodCheck -= Time.deltaTime;
if (_bloodCheck > 0f)
{
return;
}
_bloodCheck = 1f;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null)
{
continue;
}
float d2 = (e.position - _wolf.position).sqrMagnitude;
bool bleeding = SnakeControl.IsFoe(e, _wolf) && e.Health < e.GetMaxHealth() * BloodHealth;
if (bleeding && d2 < BloodRange * BloodRange)
{
if (_blood.Add(e.entityId))
{
e.AddNavObject(SnakeControl.SenseNavObject, null, null);
}
}
else if (_blood.Contains(e.entityId) && (!bleeding || d2 > BloodDropRange * BloodDropRange))
{
e.RemoveNavObject(SnakeControl.SenseNavObject);
_blood.Remove(e.entityId);
}
}
_blood.RemoveWhere(id => _world.GetEntity(id) == null);
}
/// <summary>Выход из волка - снять метки, забыть бросок.</summary>
public static void Clear()
{
World world = GameManager.Instance?.World;
if (world != null)
{
foreach (int id in _blood)
{
(world.GetEntity(id) as EntityAlive)?.RemoveNavObject(SnakeControl.SenseNavObject);
}
}
_blood.Clear();
_bloodCheck = 0f;
_leapTarget = null;
}
}
}
+1 -1
View File
@@ -4,6 +4,6 @@
<DisplayName value="Necromancer's Tome" />
<Description value="A dark necromancy progression for 7 Days to Die 3.3: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, undead companions that keep station with you and attack what you point at, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
<Author value="Alex Cube" />
<Version value="1.6.1" />
<Version value="1.6.0" />
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
</xml>
+1
View File
@@ -63,3 +63,4 @@ Thanks to [b]The Fun Pimps[/b] for a game that survives this much rewriting, and
Source code, full change history and issue tracker: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
My YouTube channel: [url=https://www.youtube.com/@alex_cube]@alex_cube[/url]
News and previews on Telegram: [url=https://t.me/necromancerstome]t.me/necromancerstome[/url]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+44 -20
View File
@@ -8,25 +8,39 @@
<property name="DifficultyTier" value="0" />
<property name="ShowQuestClearCount" value="1" />
<property name="TraderArea" value="False" />
<property name="PrefabSize" value="17, 8, 17" />
<property name="PrefabSize" value="25, 13, 25" />
<property name="RotationToFaceNorth" value="2" />
<property name="Zoning" value="none" />
<property class="IndexedBlockOffsets">
<property class="TraderOnOff">
<property name="0" value="12, 1, 13" />
<property name="1" value="18, 6, 13" />
<property name="2" value="14, 1, 16" />
<property name="3" value="18, 6, 18" />
<property name="4" value="14, 1, 19" />
</property>
<property class="POILight">
<property name="0" value="11, 3, 9" />
<property name="1" value="12, 3, 9" />
<property name="2" value="8, 4, 10" />
<property name="3" value="12, 4, 10" />
<property name="4" value="12, 4, 12" />
<property name="5" value="9, 3, 13" />
<property name="0" value="14, 9, 12" />
<property name="1" value="16, 4, 12" />
<property name="2" value="18, 4, 12" />
<property name="3" value="20, 4, 12" />
<property name="4" value="18, 9, 13" />
<property name="5" value="20, 9, 13" />
<property name="6" value="16, 4, 14" />
<property name="7" value="18, 4, 14" />
<property name="8" value="20, 4, 14" />
<property name="9" value="16, 4, 16" />
<property name="10" value="18, 4, 16" />
<property name="11" value="20, 4, 16" />
<property name="12" value="15, 9, 18" />
<property name="13" value="18, 9, 18" />
<property name="14" value="20, 9, 18" />
<property name="15" value="15, 4, 19" />
<property name="16" value="15, 9, 19" />
</property>
<property class="challenge_sinks">
<property name="0" value="11, 1, 3" />
<property name="1" value="13, 1, 4" />
</property>
<property class="TraderOnOff">
<property name="0" value="8, 1, 8" />
<property name="1" value="10, 1, 11" />
<property name="0" value="15, 1, 18" />
<property name="1" value="15, 1, 21" />
</property>
<property class="challenge_gore">
<property name="0" value="3, 2, 9" />
@@ -34,18 +48,28 @@
<property class="challenge_mech_parts">
<property name="0" value="6, 1, 13" />
</property>
<property class="challenge_polymers">
<property name="0" value="17, 1, 19" />
<property name="1" value="18, 1, 19" />
<property name="2" value="20, 1, 19" />
<property name="3" value="21, 1, 19" />
<property name="4" value="17, 1, 20" />
<property name="5" value="18, 1, 20" />
<property name="6" value="20, 1, 20" />
<property name="7" value="21, 1, 20" />
</property>
</property>
<property class="Stats">
<property name="LightsVolume" value="5516.637" />
<property name="TotalVertices" value="91813" />
<property name="TotalTriangles" value="61958" />
<property name="LightsVolume" value="42763.35" />
<property name="TotalVertices" value="218641" />
<property name="TotalTriangles" value="114274" />
<property class="BlockEntities">
<property name="Vertices" value="73400" />
<property name="Triangles" value="52089" />
<property name="Vertices" value="169120" />
<property name="Triangles" value="88691" />
</property>
<property class="ChunkMeshes0">
<property name="Vertices" value="18121" />
<property name="Triangles" value="9645" />
<property name="Vertices" value="49229" />
<property name="Triangles" value="25359" />
</property>
<property class="ChunkMeshes1">
<property name="Vertices" value="0" />
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2008-2010 by Jovanny Lemonad (http://www.jovanny.ru)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB