130 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5.5 945b03dfea DLL: группы логиста, рабочий без счёта, без ретрансляторов
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:37:36 +03:00
AlexCubeandClaude Opus 5.5 61a7e35bad Фильтр логиста: группа «Стройматериалы» - древесина, булыжник, цемент, бетонная смесь; «Руда и камень» - только добыча (#18)
Новая группа у старых урн берёт выбор «Материалов»; значок «Материалов» - ткань. Сталь - в «Слитках».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:35:01 +03:00
AlexCubeandClaude Opus 5.5 54cafab4f1 Черепа-ретрансляторы отменены: череп-декор нить не передаёт, нити через черепа снимаются при загрузке; тексты на 13 языках
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:26:09 +03:00
AlexCubeandClaude Opus 5.5 8a5f4fa0e3 Рабочий: делает по образцу без счёта - пока есть ингредиенты и место для готового; описание на 13 языках
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:21:43 +03:00
AlexCubeandClaude Opus 5.5 322dc952d3 Рабочий: в плавку сначала сырьё (камни), сделанное (булыжник) - если сырья нет; топливо - древесина, уголь, прочее
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:18:42 +03:00
AlexCubeandClaude Opus 5.5 60d84ff739 Фильтр логиста: семена (и кровавые) - «Семена»; новые группы «Живность» и «Транспорт» (#18)
Проверка всех предметов: кровавые семена были в «Некромантии» и уходили от сборщика; куры и сложенный
транспорт падали в «Прочее». Новые группы у старых урн берут выбор «Прочего». Пятый ряд значков.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:14:32 +03:00
AlexCubeandClaude Opus 5.5 aa26e97b71 Урны: в лимит входят все урны; блок «всего / доступно / до следующей» в «Содержимом» (#65)
Лимит считает урны любого рода (сборщик, логист, рабочий, ремонтник, лутер, старатель); пересечение зон -
только у сборщика. Блок справа от ячеек урны (windowBagStorage, CinerariumLimitPanel.cs). Тексты описаний
урн и отказа по лимиту - на 13 языках.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:01:17 +03:00
AlexCubeandClaude Opus 5.5 108ff82914 Урны: бюджет дел в секунду с запасом и очередь вместо «3 за кадр» (#78)
Замер necrourn budget: тики всех урн приходят пачкой раз в ~1.4 с в порядке участков от ближних к игроку;
лимит «3 за кадр» доставался трём ближним, 11 урн (сборщики, логист, дальние) не получали шага никогда -
«стою в 19 м - не работают, подхожу - работают». Теперь 30 дел/с, запас 2 с, пополнение по реальному
времени; отказанная урна в очереди и обслуживается первой. После: 578 с, 19 урн по ~128 шагов, отказов 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:15:57 +03:00
AlexCubeandClaude Opus 5.5 a745f4eb4a Логист: кровавая сфера и кровавый камень (модификаторы мода) - в группе «Некромантия»; имя предмета в логе переноса (#18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:48:42 +03:00
AlexCubeandClaude Opus 5.5 054897eb78 necrourn budget [N]: учёт и лимит общего бюджета шагов урн; necrourn info - состояние работы урны
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:42:44 +03:00
AlexCubeandClaude Opus 5.5 ecae8eb6a7 DLL: защита стрелы, necrourn probe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:25:35 +03:00
AlexCubeandClaude Opus 5.5 f372660e39 Стрела: защита от NullReference в GetDismemberChance (+ какое звено пустое - в лог); necrourn probe - что урна видит в зоне и что выбрала; necrofire
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:24:10 +03:00
AlexCubeandClaude Opus 5.5 3ffb90cc57 DLL: диагностика попадания стрелы
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:44:24 +03:00
AlexCubeandClaude Opus 5.5 1ad590943b Диагностика: во что попала стрела при NullReference в ItemActionAttack.Hit (временно)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:37:53 +03:00
AlexCubeandClaude Opus 5.5 6cf305b4c4 Разработка: necroslot - ячейка пояса в руке (проверка без мыши)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:58:41 +03:00
AlexCubeandClaude Opus 5.5 5565bba13f Урны работают, пока игрок далеко: наблюдатель участков на каждую группу урн (#78)
CinerariumKeepAlive: группа - клетка 64x64 по реестру урн сейва, наблюдатель без видимой
геометрии держит 7x7 участков. Консоль: necrournkeep on|off|dim N.
Проверено в test8: игрок в 500 блоках, старатель сделал ходку; 3 наблюдателя, +61 участок,
RSS +110 МБ, куча ровная 2.35-2.5 ГБ, FPS 52-56, исключений 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:24:13 +03:00
AlexCubeandClaude Opus 5.5 c1a5e76777 DLL: логист R (#77), самопочинка шейдера духов (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:01:50 +03:00
AlexCubeandClaude Opus 5.5 aaf22f891c Логист: меню формы по зажатию R - пункт ставится до того, как игра выполнит единственный поворот (#77); диагностика R убрана
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:53:02 +03:00
AlexCubeandClaude Opus 5.5 321f5d4337 Духи урн: сломанный шейдер (пурпурный дух) - материал пересоздаётся, причина - в лог (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:51:23 +03:00
AlexCubeandClaude Opus 5.5 ce4752a04e Питомец дальше 100 блоков после одержимости - сразу отозван (#35); диагностика R у логиста (#77)
PetFarRecall: при выходе из одержимости питомцем - отзыв как книгой, если до тела хозяина дальше
порога; ближе - идёт своим ходом. Консоль: necropetfar <блоки>|off.
Логист: строки LogistForm в логе на нажатие R и на сборку меню блока - временно, до причины #77.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:39:19 +03:00
AlexCubeandClaude Opus 5.5 66ee53c05f Жестяные банки вынесены в отдельный мод ReusableTinCans
Из книги ушли: три банки и патч консервов (items.xml), кипячение на костре (recipes.xml),
6 строк локализации, бандл necrocan, иконки (цветные и серые). Имена предметов прежние -
сейвы находят их по имени (itemmappings.nim), в каком бы моде они ни лежали.
DLL: исправления оссуария (окно топлива) и логиста (банки - в «Напитки»).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:36:02 +03:00
AlexCubeandClaude Opus 5.5 86c833733b Логист: жестяные банки - в «Напитки» к стеклянным, не в «Некромантию» (#18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:26:18 +03:00
AlexCubeandClaude Opus 5.5 cda0aafc79 Оссуарий: окно топлива без NullReference, когда под урной уже нет коллектора (#66)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:25:00 +03:00
AlexCubeandClaude Opus 5.5 05cac9bfac Оссуарий: старый necroOssuaryPrefab убран из бандла (#66)
Бандл necroossuary - только котёл (19.3 -> 8.6 МБ, без копии коллектора росы).
Постфикс шейдера коллектора - только для пирамиды (necroWardPrefab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:46:41 +03:00
AlexCubeandClaude Opus 5.5 a6a5c02b24 Оссуарий: заголовок «Образцы» над ячейками образцов (#66)
22.10: «Переименуй». У коллекторов игры заголовок общий - «Инструменты»
(xuiTools); для котла при открытии окна ставится «Образцы» (13 языков),
у остальных возвращается родной. Проверено в test8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:49:21 +03:00
AlexCubeandClaude Opus 5.5 be7c203abe Оссуарий: три образца - столбец хранилища под каждым; одна ячейка топлива; огонёк в окошки; трассер (#66, #69)
- 22.10: «Пусть можно будет положить 3 разных образца, и тогда в ячейки под
  ними пойдут такие же ресурсы»: выходы os1/os4 - образец 1, os2/os5 - 2,
  os3/os6 - 3. Описание на 13 языках поправлено.
- Лишние ячейки топлива снова показывались: сетка топлива каждый кадр
  выставляет видимость по высоте - прятать в Update. Видна одна.
- Огонёк: окошки на 30°, 90°... (на 0°, 60°... - полосы металла; 22.10
  «Светлячок заходит в полосы металла, и игнорирует окошки»).
- necroossuary wisp trace - линии путей огонька; necroossuary open - окно
  как по E.
Проверено в test8: окно (3 образца, 1 топливо, хранилище 2x3), уголь во
втором образце -> стак угля в 5-й ячейке; трассер - путь через центр окошка.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:41:25 +03:00
AlexCubeandClaude Opus 5.5 ead6823c8a Огонёк оссуария: подъём снаружи котла и дуга точно через центр окошка (#69)
22.10: «Огонёк летит сквозь котёл. А надо чтобы поднимался почти на
уровень окошка, и по дуге летел прямо через его центр». Путь - гладкая
кривая через точки: нутро, центр окошка, дуга снаружи, вертикаль на 25 см
дальше самого широкого места котла (по модели), земля. Скорость по пути
ровная. necroossuary wisp log - вылет/влёт в лог (чтобы снять).
Проверено в test8 снимками по сигналу из лога.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:13:30 +03:00
AlexCubeandClaude Opus 5.5 643c9fa056 DLL: свечение оссуария с пульсацией (#66)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:03:54 +03:00
AlexCubeandClaude Opus 5.5 16fdb8370e Оссуарий: свечение рун ярче, теплее и с пульсацией; вынуть сферу командой (#66)
22.10: «Руны нарисованы красным. Это не выглядит как свечение» - цвет
(2, 0.24, 0.12) -> (6, 1.4, 0.35), медленная пульсация ±30%, у соседних
котлов не в такт. Подбор без перезапуска: necroossuary color r g b,
necroossuary pulse a [speed]. necroossuary put ... none - очистить ячейку.
Проверено в test8: без сферы руны гаснут, сфера с крышки пропадает,
огонёк не летает; сферу вернул - всё загорается.
DLL - после закрытия игры.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:03:45 +03:00
AlexCubeandClaude Opus 5.5 c2610da59a Оссуарий-котёл: одна ячейка образца, хранилище 6, перенос старых ячеек, свечение, огонёк, рецепт в кузне (#66, #69)
- blocks.xml: 6 ячеек хранилища (цикл вдвое дольше - добыча прежняя);
  видно одну ячейку образца и одну топлива, остальные спрятаны (в данных
  их по-прежнему 3 и 6 - сейвы не режутся).
- Перенос: содержимое спрятанных ячеек у уже поставленных оссуариев
  уходит в хранилище (проверено на test8: уголь, камень, сферы).
- Свечение рун и окошек и сфера на крышке - пока работает (OssuaryGlow).
- Огонёк дикого духа: вылет из окошка с уходом в землю и затуханием,
  возврат с другой стороны в противоположное окошко; только пока работает.
  В игре пока не увиден - разобрать (necroossuary wisp on|off).
- Рецепт - кузня, железо вместо дерева и юкки (плавильня / лом).
- Описание на 13 языках под котёл и вольных духов.
- Консоль: necroossuary info / put (отладка ячеек).
- DLL включает и #72, #74.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:56:41 +03:00
AlexCubeandClaude Opus 5.5 e3cdff0323 Стервятник с ношей: курица/кролик выше в когтях, Shift не разгоняет (#74)
CarryGap -0.06 -> -0.15 м; с ношей разгон и планирование по Shift
выключены (перегружен). Подбор без перезапуска - necrocarry gap|run.
DLL закоммитится вместе с оссуарием (#66).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:45:29 +03:00
AlexCubeandClaude Opus 5.5 c9b3e078c3 Вкладка крафта «Некромантия» (#73); пыль из целого хранилища - 10 (#24)
- ui_display.xml: вкладка Necromancy (значок черепа) в рюкзаке, на
  верстаке, в кузне и на химстоле.
- Group всех крафтящихся вещей мода + Necromancy (прежние группы
  оставлены). Моды ножа, сферы и урны раньше не попадали ни в одну
  вкладку рюкзака (умолчание игры - «Mods»/«Decor»).
- windows.xml: 10 вкладок в строку - шаг значков 43 -> 39, значки
  прежнего размера.
- Ступка: целое хранилище магии - 10 пыли (было 30), повреждённое - 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:42:45 +03:00
AlexCubeandClaude Opus 5.5 6ed3dc21bd Рецепты: без дублей кузни в окне навыка, все урны видны (#72)
- Список рецептов по предмету/id/ингредиенту отсеивает версию кузни
  «плавильня/лом» по настройке мира, как список верстака
  (RecipeSmelterFilter, выключатель necrosmelterfilter).
- Урны в окне некромантии - одной иконкой с recipes=: в строке
  14 иконок, было 17, последние три урны не показывались.

DLL с этой правкой закоммитится вместе с оссуарием (#66).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:34:47 +03:00
AlexCubeandClaude Opus 5.5 037bd5fcf5 Оссуарий: новая модель - клёпаный котёл на костяной треноге (#67)
necroOssuaryCauldronPrefab в бандле necroossuary: body (котёл, ноги, прах,
три кости; карты 2048), lid (купол с окошками, ручки, держатель; 1024),
sphere (кровавая сфера), glow (свет внутри, выключен). Свечение рун и
нутра - карта _e, включает код (#66). Блок necroGeoOssuary прежний, сменена
только модель; тег коллайдеру - DecorColliderTagPatch. Старый префаб пока
в бандле.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:50:11 +03:00
AlexCubeandClaude Opus 5.5 171862f9b2 Кролик: ванильная иконка спящего кролика и имя просто «Кролик» (#71)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:37:06 +03:00
AlexCubeandClaude Opus 5.5 dd7f6fb13a Кролик в мешке: разделка через «Разобрать» в рюкзаке вместо ПКМ (#71)
Пункт «Разобрать» мешку даёт сама игра (материал Mleather, вес 5 как у кожи);
разборку перехватываем: вся стопка сразу, на кролика мясо 10, кожа 2,
кости 5. ПКМ убран. Исправлены поля немецкой строки подсказки (запятая).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:19:16 +03:00
AlexCubeandClaude Opus 5.5 ac4c455e29 Живой кролик в мешке: игрок хватает кролика сразу в рюкзак (#71)
Кролику дан ванильный PickupItem (как у курицы); RabbitGrab.cs кладёт
мешок necroRabbitSack в рюкзак, своя подсказка. ЛКМ с мешком - выпустить,
ПКМ - разделать на мясо 10, кожу 2, кости 5. Выключатель necrorabbit.
Иконка пока временная.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:16:32 +03:00
AlexCubeandClaude Opus 5.5 0999fd3afc Цинерарий-старатель (#64): дух добывает ископаемые
Урна necroCinerariumProspector (и ...Skull): в «Содержимом» череп и образец - один из девяти ресурсов (камень, глина,
песок, древесина, железо, свинец, уголь, селитра, сланец), нить к ящику. Дух отлетает на соседнюю свободную клетку и
уходит на 5 блоков под землю, растворяясь; раз в игровой час поднимается так же, кладёт добычу в ящик и уходит снова.
За сутки: камень/глина/песок/древесина 30000, руды 18000, ходка ±20%. Из руды - шанс на алмаз, серебро, золото по
шансам игры на каждый «блок» руды. Ящик полон - дух ждёт с добычей. Без зоны и вне лимита урн.

Рецепт - кузня: латунь 40, прах 100, кости 10, кровь/сок, железная кирка; открывается с остальными урнами.
Консоль necroprospector: on/off, trip, yield, spread, depth, состояние. Тексты - 13 языков; глава книги
«Цинерарии» - лист Fig. VIII с шестой урной и фраза про prospektisto.

Проверено в игре (test8): ходка железа 625 в ящике, спуск и подъём духа, лист и страница книги помещаются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:09:12 +03:00
AlexCubeandClaude Opus 5.5 f1010f98b7 Иконка урны старателя (#64): рендер своей модели, как у остальных урн
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:07:49 +03:00
AlexCubeandClaude Opus 5.5 aad19861a2 Бандл necrocinerarium: урна старателя necroCinerariumProspectorPrefab (#64) - пока ни к чему не подключена
Игровая сетка 10.9 тыс. треуг., запечённые карты 2048 (цвет, нормали, металл/гладкость), без кнопок.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:01:35 +03:00
AlexCubeandClaude Opus 5.5 ff99c90262 necrowisp: огонёк полупрозрачный - мягкое свечение без плотной сердцевины
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:57:13 +03:00
AlexCubeandClaude Opus 5.5 3921c88f64 necrowisp: огонёк крупнее (ореол 15 см, ядро 4 см), сердцевина фиолетовая без пересвета
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:55:45 +03:00
AlexCubeandClaude Opus 5.5 9fc05482f1 necrowisp: огонёк - одна постоянная частица без мерцания
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:50:38 +03:00
AlexCubeandClaude Opus 5.5 e6937c9b59 Бандлы necrobloodsphere (модель кровавой сферы) и necrowisp (огонёк дикого духа) - пока ни к чему не подключены
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:40:02 +03:00
AlexCubeandClaude Opus 5.5 ff78034571 Кровавая сфера: новая иконка - шар праха с прожилками и вдавленным черепом (своя модель, рендер Blender)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:29:04 +03:00
AlexCubeandClaude Opus 5.5 78cc162328 Магическая пыль, хранилище магии и повреждённое хранилище: предметы, иконки, ступка (4/30 пыли), 13 языков
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:48:43 +03:00
AlexCubeandClaude Opus 5.5 05dca19852 Урны: прозрачные духи, понятные отказы в постановке, 50 убийств на урну
- Духи снова полупрозрачные (Solid = false) - поза принята.
- Отказ в постановке - с цифрами: пересечение зон (сколько блоков до соседнего сборщика,
  зоны 5x5); лимит - сколько стоит из скольких, сколько упокоено и сколько ещё до следующей,
  с обоснованием (сила некроманта держит духов). Логист/рабочий/ремонтник/лутер не в лимите.
- Лимит сборщиков: одна урна на 50 упокоенных (было 200); описание урны - 13 языков.
- Команда necrocanplace - текст отказа для клетки.

Проверено в игре: подсказка о пересечении (2 блока, 5x5), логист рядом - можно.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:50:46 +03:00
AlexCubeandClaude Opus 5.5 eaa6b3bbb3 Команды разработки: necrofill (засыпать ямы), necrosmooth (край насыпи скатом)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:26:42 +03:00
AlexCubeandClaude Opus 5.5 dfa7508bda Лутер - железная банка с крышкой-кадилом; ремонтник - шире горлышко; материалы в рецептах
- Лутер: вместо мешка - банка из свёрнутого железного листа (шов внахлёст, пропай свинцом),
  крышка-купол как у кадила с окнами и широким отверстием, внутри кучка пепла (светится
  с черепом), чеканка: девиз, витые пояса, знаки некромантии над звонками, рамки окон.
  Дух лутера - сдвиг вперёд 0.08 (подобран в Blender). Рецепт: в кузне железо 40 и
  свинец 5 (вместо глины), отмычка.
- Ремонтник: горлышко шире (внутри 0.09) - хвост духа входит без касаний; рецепт + латунь 5
  (розетки звонков).
- Иконки обеих урн и лист книги Fig. VIII перерисованы под новые формы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:14:51 +03:00
AlexCubeandClaude Opus 5.5 ab11517029 Рабочий гасит огонь, когда делать нечего; команды necrostation, necrourn contents
Гашение было только в настоящем шаге, а он запускается, лишь когда пробный нашёл дело -
пробный гашение делом не считал, огонь горел до конца топлива. Теперь погасить - дело:
дух летит к станции и гасит.

Проверено в игре: верстак (патроны), химстанция (клей), бетономешалка (песок), кузня
(переплавка лома и глины -> дротики), костёр; огонь гаснет у костра, химстанции и кузни;
разрушение урны - выпадают череп и образец; окно «Содержимое» - череп и образец.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:26:37 +03:00
AlexCubeandClaude Opus 5.5 5ba10eb59b Рабочий: NRE в логе подброса топлива (имя стопки до забора)
Проверено в игре: костёр, сырое мясо -> жареное, остановка по запасу образца (3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:35:45 +03:00
AlexCubeandClaude Opus 5.5 210ad3ebaa Книга некроманта: глава «Цинерарии» с листом Fig. VIII
Три страницы (EN/RU): откуда в урне дух-некротехнолог, пять ремёсел урн (rikoltisto,
portisto, laboristo, riparisto, kolektisto), нити, черепа-ретрансляторы, прилив. Лист -
пять урн эскизом тушью в одном масштабе, подписи на эсперанто, знак некромантии
(BlenderWork/render_urn_sketch.py + make_urn_sheet.py). Открывается вместе с рецептом урны.
Команда necrotome <ключ главы> [страница] - открыть главу без мыши.

Проверено в игре: лист и все страницы помещаются (RU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:32:35 +03:00
AlexCubeandClaude Opus 5.5 76f641dfdb Иконки урн: логист (кадило на подставке и на кронштейне), рабочий, ремонтник, лутер
Рендер своих моделей (BlenderWork/render_urn_icon.py + icon_from_raw.py, как иконка урны
сборщика). Старая зелёная текстура свечения логиста больше не нужна - удалена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:23:26 +03:00
AlexCubeandClaude Opus 5.5 51db24fd2f Ремонтник и лутер: свои модели (фляга со звонками, мешок), проверка добычи
- Модели (BlenderWork/necroCinerariumRepair, necroCinerariumLoot): ремонтник - квадратная
  фляга, лутер - глазурованный мешок с верёвкой; на обоих - четыре латунных звонка с
  черепом-кнопкой (деталь *_button - подсветка состояния) и руны кровью футарком
  («RIPARU ĜIS LA LASTA BRIKO», «KOLEKTU ĜIS LA LASTA PECETO»), светятся по черепу.
- Лутер: цели, из которых за проход нечего взять, второй раз не обыскиваются (не зацикливается).
- Разработка: necroclear (убрать только растения), necrobag с сумкой по списку добычи.

Проверено в игре: мешок с зомби и красный труп (прах, кожа жертвы) - в ящик, проход
заканчивается сам.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:20:31 +03:00
AlexCubeandClaude Opus 5.5 a352a408a4 Урны ремонтника и лутера: большая зона, запуск кнопкой, кнопки-состояния
- necroCinerariumRepair/+Skull и necroCinerariumLoot/+Skull (пока на модели урны сборщика),
  рецепты (урна + ремкомплект / отмычка, на крови и на соке), открываются с урнами.
- Зона - сторона притязания сервера или 31/21/11 (пункт «Размер зоны»), весь столб мира,
  каркас по «Показать границы»; работа - по «Запустить», один проход, «Остановить».
- Ремонтник: обход столбов из общего бюджета клеток, очередь повреждённых блоков с
  RepairItems (зоны пересекаются - блок берёт первый), ремонт раз в 2.5 с материалами из
  ящиков по нитям (доля по повреждению), не хватает - ждёт с мигающей кнопкой.
- Лутер: предметы на земле, мешки с зомби, красные трупы (тем же путём, что игрок, с
  находкой черепа) - в ящик с любой нитью; рюкзаки игроков и аэродроп не трогает.
- Кнопки на модели (рендереры «button»): жёлтая - ждёт, мигает - нехватка/ящик полон,
  зелёная - работа, красная - прилив. Запись урны v6 (зона, проход, курсор, кто нажал).
- Тексты 13 языков; команды разработки necrodamage, necrodrop, necrobag, necrocorpse,
  necrourn run|stop|size.

Проверено в игре: ремонтник (нехватка -> материалы -> починил 2 блока), лутер (предметы
с земли). Мешки и красные трупы - не проверены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:55:33 +03:00
AlexCubeandClaude Opus 5.5 85d8036e81 Урны: череп-ретранслятор, прилив прячет духов, меры нагрузки; проверки без мыши
- Череп-ретранслятор: нить урны проходит через черепа-декор (цепочка), каждый пролёт до
  15 м; черепа хранятся у нити (запись урны v5, старые - без черепов). ПКМ ножом по черепу
  - провести нить, ЛКМ - снять нити через него; убрали череп - нить снимается.
- Прилив: ночью кровавой луны духи всех урн исчезают, работа стоит, утром - сама.
- Нагрузка: общий бюджет дел духов на кадр (3), анимируются только 16 ближайших духов;
  лимит урн сборщика смягчён - одна на 200 убийств (было 500), тексты на 13 языках.
- Описание логиста (кадило, два вида, маленький дух, мигание), черепа-декора (передаёт
  нить), подсказки ножа - на 13 языках.
- Разработка: necrogod, necrofps [uncap], necrotide, necroshot серией, necroclick по клетке,
  necrobox fill, necrourn с черепами на пути.

Проверено в игре (test8): мигание, нить 28 м через череп и её разрыв, прилив, 63 урны
сборщика: 13.0 мс на кадр против 11.8 мс без работы урн.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:42:52 +03:00
AlexCubeandClaude Opus 5.5 6693d96d5e Логист - латунное кадило: два вида, дух 1/3 из окон, мигание; команды разработки
- Блоки логиста на модели кадила: по центру на подставке (necroCinerariumLogist) и новый вид у
  стены на кронштейне (necroCinerariumLogistWall/+Skull); выбор вида - R с урной в руке.
- Дух логиста втрое меньше, в покое сидит в кадиле, на перевозку вылетает через окно крышки
  (у стены - не в стену) и возвращается тем же окном.
- Застрял (ящик «куда» полон) - урна мигает (CinerariumBlink) вместо почерневшего духа;
  прежняя зелёная подмена свечения старой модели убрана.
- Команды разработки без мыши: necrospawn, necroexit [quit], necrolook, necroaim, necroplace,
  necrohold, necrobox, necrourn, necroclick.

Проверено в игре (test8): перевозка обоими видами, вылет духа, вид у стены от стены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:19:50 +03:00
AlexCubeandClaude Opus 5.5 b5bd4e4b2c Урна логиста: модель кадила в бандле necrocinerarium
Латунное кадило с чеканкой (BlenderWork/necroCinerariumLogist): necroCinerariumLogistPrefab - по
центру на подставке, necroCinerariumLogistWallPrefab - у стены на кронштейне (стена - z=-0.5).
Текстуры _d/_n/_m/_e, запечено в 4096, в игре 2048. Блоки пока на старой модели - подключение
вместе с кодом логиста (дух 1/3 только на перевозку, мигание, виды).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:53:08 +03:00
AlexCubeandClaude Opus 5.5 c0acaa86b6 Урна логиста: в рецепт воск 2 (покрытие латуни)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:27:49 +03:00
AlexCubeandClaude Opus 5.5 4535150805 Урна логиста: в рецепт песок 10 и уголь 5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:15:54 +03:00
AlexCubeandClaude Opus 5.5 84179f3b44 Урна логиста: рецепт в кузне из латуни (кадило)
Вместо глины - латунь 50; делается в кузне. Как у кузнечных рецептов игры - две версии:
с плавильней (unit_brass) и без неё (латунный лом), каждая на крови и на соке розы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:08:51 +03:00
AlexCubeandClaude Opus 5.5 fcc8bd9d7d Дух урны: смирно - поза покоя модели
Смирно (pose_attention.json) запечено в позу покоя при выгрузке FBX; там же новые кости
пальцев, веса кисти и хребет. Код больше не ставит руки и пальцы в покое - жест уводит руки
из позы модели и возвращает. Поза покойника (cross) и сведение пальцев кодом убраны.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:27:14 +03:00
AlexCubeandClaude Opus 5.5 f040ebdc62 Урна рабочего: рецепт - урна и молоток, открывается вместе с урнами
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:22:39 +03:00
AlexCubeandClaude Opus 5.5 eba17606d5 Урна рабочего: изготовление по образцу на станциях по нитям (CinerariumCraft), образец во второй ячейке, нити к станциям, топливо и огонь
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:11:56 +03:00
AlexCubeandClaude Opus 5.5 a5f166c161 Духи урн: смирно по умолчанию, поза покойника (cross) на подборе, сомкнутые пальцы, временно непрозрачные
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:50:39 +03:00
AlexCubeandClaude Opus 5.5 0dad901b64 Духи урн: в покое руки скрещены на груди (necrogenie cross); метка коллайдера урны рабочего
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:38:14 +03:00
AlexCubeandClaude Opus 5.5 9a4a86b441 Урна рабочего: модель в бандле necrocinerarium, блок-просмотр (режим разработчика)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:28:26 +03:00
AlexCubeandClaude Opus 5.5 b0ef209d17 Тексты Nexus 1.6.2: торговцы - «совместимость», а не исправление (как на сайте Nexus)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:40:23 +03:00
AlexCubeandClaude Opus 5.5 a3f825303e Слияние релиза 1.6.2 в линию 3.3 (ModInfo 1.7.0 и DLL линии 1.7 остаются)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:26:44 +03:00
AlexCubeandClaude Opus 5.5 17194c652d Релиз 1.6.2: призрачный вид только у торговцев игры (NPC других модов, напр. NPCCore, больше не полупрозрачные)
От 1.6.1 отличается одной строкой кода (GhostTraderPatch.ShouldBeGhost: точный класс EntityTrader) и
документами. DLL собрана из этих исходников против игры 3.3; разбор DLL сверен с выпущенной 1.6.1.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:21:44 +03:00
AlexCubeandClaude Opus 5.5 d0f66c784e Слияние релиза 1.6.1 в линию 3.3 (ModInfo остаётся 1.7.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:09:21 +03:00
AlexCubeandClaude Opus 5.5 16a1eac52c Релиз 1.6.1: исправление роз (бутоны и цветы висели на полклетки); документы, ModInfo, заметки Nexus
Исправление - ModelOffset 0,0,0 у стадий 2 и 3 кровавой розы (ModelEntity по умолчанию поднимает модель
на 0.5). Код (DLL) тот же, что в 1.6.0.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:51:44 +03:00
AlexCubeandClaude Opus 5.5 70a86e82c1 Документы к релизу 1.6.0: README, описание Nexus, сайт, ModInfo, заметки к версии
Описания переписаны короче, крючками вместо перечня механик (указание 12.10:
«привлекать, а не спойлерить»): без цифр, клавиш и рецептов, подробности - в книге
в игре. Версии: 1.6.0 для игры 3.3, 1.4.3 для 3.2. История версий в README свёрнута
до строки на версию. NEXUS_FILE_SUMMARY (244 знака) и NEXUS_CHANGELOG (строка = пункт).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:11:25 +03:00
AlexCubeandClaude Opus 5.5 1f9e62e038 Одержимость: удар по ЛКМ не съедается реакцией на попадание и недоигранным замахом
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:39:53 +03:00
AlexCubeandClaude Opus 5.5 211f0407e2 Одержимость: пояс персонажа скрыт в любом виде, не только от первого лица
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:35:58 +03:00
AlexCubeandClaude Opus 5.5 c1556e1e8d Ступка: ModelOffset 0,0,0 - модель стоит на дне клетки, а не висит на полблока
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:34:03 +03:00
AlexCubeandClaude Opus 5.5 d28e0bf0dd Звуки некротехнолога: шорох, порыв, скрип (свой синтез, бандл necrosounds)
necroTechRustle (3 варианта), necroTechGust (2), necroTechCreak (3) - негромкие звуки духа вместо
зомбячьих, без узла Noise (не привлекают зомби). Пока не назначены - вместе с цинерарием.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:17:24 +03:00
AlexCubeandClaude Opus 5.5 d374b6d679 DLL: череп - режимы предмет/декор
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:37:40 +03:00
AlexCubeandClaude Opus 5.5 99c2f183ad Череп: три режима как у мебели - предмет / декор по центру / декор у стены
R с черепом-предметом - меню; выбор декора превращает стопку в ячейке в блок (превью, повороты
блока). С декором в руке R - обычное меню блока плюс «Просто череп» и второй вид. Действие
PlaceAsBlock у предмета убрано (ПКМ из предмета больше не ставит). Стопка блоков 50, как у черепа.
Описания черепа и декора (13 языков) - под новую схему.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:37:39 +03:00
AlexCubeandClaude Opus 5.5 4cc244a98a DLL: череп декором из руки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:28:04 +03:00
AlexCubeandClaude Opus 5.5 a902b44bdf Череп ставится декором прямо из руки: R - меню вида (просто череп / по центру / у стены), ПКМ - поставить
Предмет necroSkull остаётся ItemClass, добавлено Action1 PlaceAsBlock (в ItemValue не пишется -
сейвы целы). R для черепа ловится постфиксом PlayerMoveController.Update, выбор - cvar
necroSkullDecorMode. Поднятый и разрушенный блок - снова череп. Помощник necroSkullDecor спрятан
(CreativeMode None), рецепт «череп -> декор» убран, обратный оставлен для уже сделанных помощников.
Описание черепа дополнено (13 языков), подсказка и пункт меню «Просто череп».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:28:04 +03:00
AlexCubeandClaude Opus 5.5 8920ee01b8 DLL: necroskulldecor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:04:39 +03:00
AlexCubeandClaude Opus 5.5 59bb0083c9 Команда necroskulldecor: посадка черепа-декорации без перезапуска игры (center|back, pos/add/rot/scale/off/show, хитбокс следом, проверка «в клетке»)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:04:39 +03:00
AlexCubeandClaude Opus 5.5 346cc31041 Череп-декорация: натуральный размер (x1.3 к черепу-предмету, 15 x 22 x 21 см)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:02:02 +03:00
AlexCubeandClaude Opus 5.5 8c786fdf5e Бандл necrotechnologist: модель духа-работника цинерария (пока нигде не используется)
Сетка 7.9k вершин на скелете с именами костей медсестры, глаза на кости Head, материалы тела
(серый + нормали) и глаз (свечение, очередь 3100). Без коллайдеров - у духа нет хитбокса.
Код и XML её пока не подключают; источник - _private/Extracted/BlenderWork/necroTechnologist,
сборка NecroTechnologistSetup.cs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:58:19 +03:00
91 changed files with 11661 additions and 841 deletions
+208 -56
View File
File diff suppressed because one or more lines are too long
+78
View File
@@ -208,4 +208,82 @@
<sprite depth="9" pos="0,20" height="8" color="[black]" sprite="menu_empty2px" globalopacitymod="0" type="sliced" fillcenter="false" />
<sprite depth="4" pos="0,20" height="8" width="750" color="150,60,220,200" globalopacitymod="0" type="filled" fill="{% cvar('necroNecromancyProgressCVar') / 20 }" />
</append>
<!-- Фильтр урны-логиста (16.10.2026, HarmonySrc/CinerariumFilterWindow.cs, CinerariumGroups.cs): значки групп предметов
в окне «Содержимое» рядом с ячейкой черепа. Окно в группе bagStorage (xui.xml) - контроллер прячет его, если окно
открыто не урной-логистом (сумки транспорта и волка). f0..f25 - рамки, g0..g25 - значки (иконки ставит контроллер). -->
<append xpath="/windows">
<window name="necroUrnFilter" width="348" height="335" depth="5" panel="Left" backgroundcolor="[black]" cursor_area="true" controller="NecromancerTome.NecroUrnFilter, NecromancerHarmony">
<rect name="header" height="43" depth="0">
<sprite depth="1" name="headerbackground" sprite="ui_game_panel_header" width="348" height="43" />
<sprite depth="3" name="windowIcon" style="icon32px" pivot="center" pos="21,-21" sprite="ui_game_symbol_sort" />
<label depth="2" name="title" pos="39,-6" width="303" height="32" text_key="necroUrnFilterTitle" font_size="28" />
</rect>
<sprite depth="2" name="f0" pos="6,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g0" pos="8,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f1" pos="62,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g1" pos="64,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f2" pos="118,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g2" pos="120,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f3" pos="174,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g3" pos="176,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f4" pos="230,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g4" pos="232,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f5" pos="286,-49" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g5" pos="288,-51" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f6" pos="6,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g6" pos="8,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f7" pos="62,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g7" pos="64,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f8" pos="118,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g8" pos="120,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f9" pos="174,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g9" pos="176,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f10" pos="230,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g10" pos="232,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f11" pos="286,-105" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g11" pos="288,-107" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f12" pos="6,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g12" pos="8,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f13" pos="62,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g13" pos="64,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f14" pos="118,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g14" pos="120,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f15" pos="174,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g15" pos="176,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f16" pos="230,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g16" pos="232,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f17" pos="286,-161" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g17" pos="288,-163" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f18" pos="6,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g18" pos="8,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f19" pos="62,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g19" pos="64,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f20" pos="118,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g20" pos="120,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f21" pos="174,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g21" pos="176,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f22" pos="230,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g22" pos="232,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f23" pos="286,-217" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g23" pos="288,-219" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f24" pos="6,-273" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g24" pos="8,-275" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
<sprite depth="2" name="f25" pos="62,-273" width="52" height="52" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="g25" pos="64,-275" width="48" height="48" atlas="ItemIconAtlas" sprite="oldCash" on_press="true" sound="[paging_click]" foregroundlayer="true" />
</window>
</append>
<!-- Лимит урн (#65, 23.10.2026; HarmonySrc/CinerariumLimitPanel.cs): блок справа от ячеек урны в «Содержимом» - всего,
доступно, до следующей. Позицию по числу ячеек ставит контроллер; вне урны блок скрыт. -->
<append xpath="/windows/window[@name='windowBagStorage']">
<rect name="necroUrnLimit" pos="87,-49" width="420" height="75" depth="6" controller="NecromancerTome.NecroUrnLimit, NecromancerHarmony">
<sprite depth="6" pos="0,0" width="420" height="75" color="0,0,0,190" sprite="menu_empty2px" type="sliced" />
<label depth="7" name="placed" pos="10,-4" width="404" height="22" font_size="20" color="[white]" />
<label depth="7" name="available" pos="10,-27" width="404" height="22" font_size="20" color="[white]" />
<label depth="7" name="next" pos="10,-50" width="404" height="22" font_size="20" color="200,200,205" />
</rect>
</append>
<!-- Вкладка «Некромантия» (ui_display.xml): в рюкзаке и на верстаке 10 вкладок вместо 9 - шаг значков 43 -> 39, значки прежние (28). -->
<set xpath="/windows/window[@name='windowCraftingList']//grid[@name='categories']/@cols">10</set>
<set xpath="/windows/window[@name='windowCraftingList']//grid[@name='categories']/@cell_width">39</set>
<set xpath="/windows/window[@name='windowCraftingList']//grid[@name='categories']/@pos">20,-21</set>
</windows>
+4
View File
@@ -59,4 +59,8 @@
<window name="windowNonPagingHeader" />
</window_group>
</append>
<!-- Фильтр урны-логиста - в окне хранилища игры (bagStorage), рядом с ячейкой «Содержимого» (windows.xml). -->
<append xpath="/xui/window_group[@name='bagStorage']">
<window name="necroUrnFilter" />
</append>
</xui>
+173 -9
View File
@@ -175,7 +175,7 @@
it on top would recolor it unintentionally). TintColor above is unaffected - that's
the 3D block MESH's tint, a completely separate thing from the 2D inventory icon. -->
<property name="CustomIcon" value="pyramidOfSpirit" />
<property name="Group" value="Decoration" />
<property name="Group" value="Decoration,Necromancy" />
<property name="FilterTags" value="MC_building,SC_decor" />
<property name="Collide" value="movement,melee,bullet,arrow,rocket" />
<property name="StabilitySupport" value="false" />
@@ -201,6 +201,7 @@
necroossuary, собрана из префаба коллектора росы: LODGroup, слои и коллайдер ванильные.
Шейдер материалу ставит GeoOssuaryPatch (шейдер ванильного коллектора). -->
<block name="necroGeoOssuary">
<property name="Group" value="Basics,Food/Cooking,Building,advBuilding,Necromancy"/>
<property name="Extends" value="cntDewCollector" />
<property name="UnlockedBy" value="craftingNecroNecromancy" />
<property name="DescriptionKey" value="necroGeoOssuaryDesc" />
@@ -208,9 +209,13 @@
<property name="ItemIconBackdrop" value="ui_game_symbol_mining" />
<property name="ItemBackgroundColor" value="140,20,20,255" />
<property name="FuelTypes" value="{blood,resourceBloodSphere,resourceBloodStone}" /><!-- камень - вечное топливо (GeoOssuaryPatch.removeFuel) -->
<property name="OutputTypes" value="{os1,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}{os2,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}{os3,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}" />
<property name="Outputs" value="os1,os2,os3" />
<property name="OutputGridHeight" value="1" />
<!-- #66 (22.10.2026): хранилище - 2 ряда (6 ячеек, «Хранилище добычи - увеличить»); столбец берёт ресурс из образца
над ним (GeoOssuary.ModSlotOf: «положить 3 разных образца, и тогда в ячейки под ними пойдут такие же ресурсы»);
время цикла вдвое дольше - общая добыча прежняя. Видно одну ячейку топлива; в данных их по-прежнему 6
(FuelGridHeight 2) - уменьшение обрезало бы сохранённое. -->
<property name="OutputTypes" value="{os1,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,12000,20000,collector_complete_item}{os2,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,12000,20000,collector_complete_item}{os3,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,12000,20000,collector_complete_item}{os4,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,12000,20000,collector_complete_item}{os5,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,12000,20000,collector_complete_item}{os6,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,12000,20000,collector_complete_item}" />
<property name="Outputs" value="os1,os2,os3,os4,os5,os6" />
<property name="OutputGridHeight" value="2" />
<property name="FuelGridHeight" value="2" />
<property name="RequiredMods" value="resourceRockSmall,resourceRockSmall,resourceRockSmall" />
<property name="RequiredModsOnly" value="true" />
@@ -223,7 +228,9 @@
<property name="FuelTitleSprite" value="ui_game_symbol_skull" />
<property name="FuelTitleSpriteAtlas" value="UIAtlas" />
<property name="FuelTypesSprites" value="BloodSphere,ItemIconAtlasGreyscale" />
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroossuary?necroOssuaryPrefab.prefab" />
<!-- 22.10.2026 (#67): новая модель - клёпаный котёл на костяной треноге (necroOssuaryCauldronPrefab: body, lid, sphere,
glow; Standard-шейдер, тег коллайдеру - DecorColliderTagPatch). Старый necroOssuaryPrefab убран из бандла 23.10. -->
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroossuary?necroOssuaryCauldronPrefab.prefab" />
<property name="CustomIcon" value="necroGeoOssuary" />
<property name="EconomicValue" value="0" />
<property name="SellableToTrader" value="false" />
@@ -331,6 +338,7 @@
<property name="CanPickup" value="false"/>
</block>
<block name="necroChalkboardPlayer">
<property name="Group" value="Decor/Miscellaneous,Necromancy"/>
<!-- Меловая доска игрока (указание 10.10: «мне нравится использовать её во время игры, делая записи»).
Та же модель и надпись, что у сюжетной necroChalkboard, но крафтится и пишется в обычной игре. Отдельный
блок, чтобы сюжетная доска чертогов (на ней пишет мод, MindPalace.UpdateBoards) осталась неизменяемой.
@@ -391,7 +399,7 @@
<drop event="Destroy" name="plantedNecroBloodRose1" count="1"/>
<property name="SortOrder1" value="B953"/>
<property name="SortOrder2" value="0100"/>
<property name="Group" value="Resources"/>
<property name="Group" value="Resources,Necromancy"/>
<property name="SoundPickup" value="seeds_generic1_grab"/>
<property name="SoundPlace" value="seeds_generic1_place"/>
</block>
@@ -401,6 +409,9 @@
<property name="CustomIcon" value="BloodSeeds"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrorose?necroBloodRose2Prefab.prefab"/>
<!-- 16.10: ModelEntity по умолчанию поднимает модель на полклетки (0,0.5,0) - бутоны висели в воздухе
(«бутоны почему-то подняты над землёй»), как ступка 15.10. Меш стоит основанием на y=0. -->
<property name="ModelOffset" value="0,0,0"/>
<property class="PlantGrowing">
<property name="Next" value="plantedNecroBloodRose3HarvestPlayer"/>
</property>
@@ -415,6 +426,9 @@
<property name="DescriptionKey" value="plantedNecroBloodRose3HarvestPlayerDesc"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrorose?necroBloodRose3Prefab.prefab"/>
<!-- 16.10: ModelEntity по умолчанию поднимает модель на полклетки (0,0.5,0) - бутоны висели в воздухе
(«бутоны почему-то подняты над землёй»), как ступка 15.10. Меш стоит основанием на y=0. -->
<property name="ModelOffset" value="0,0,0"/>
<drop event="Harvest" name="resourceNecroBlackRose" count="3" tag="cropHarvest"/>
<drop event="Harvest" name="resourceNecroBlackRose" count="1" tag="farmerBonusHarvest"/>
<drop event="Harvest" name="resourceNecroBlackRose" prob="0.5" count="1" tag="bonusCropHarvest"/>
@@ -444,6 +458,9 @@
<property name="Material" value="Mstone"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necromortar?necroMortarPrefab.prefab"/>
<!-- 15.10: без этого ModelEntity поднимает модель на полклетки (по умолчанию 0,0.5,0) - ступка висела
в воздухе (стрим 15.10). Меш уже стоит основанием на y=0, сдвиг не нужен. -->
<property name="ModelOffset" value="0,0,0"/>
<property name="ImposterDontBlock" value="true"/>
<property name="WaterFlow" value="permitted"/>
<property name="Place" value="TowardsPlacer"/>
@@ -455,7 +472,7 @@
<property name="OpenSound" value="open_workbench"/>
<property name="CloseSound" value="close_workbench"/>
<property name="TakeDelay" value="5"/>
<property name="Group" value="Building,advBuilding"/>
<property name="Group" value="Building,advBuilding,Necromancy"/>
<property name="FilterTags" value="MC_playerBlocks,SC_decor"/>
<drop event="Destroy" name="resourceRockSmall" count="10"/>
</block>
@@ -466,7 +483,9 @@
necroSkullDecorCenter - череп лежит на дне клетки по центру;
necroSkullDecorBack - на дне, затылком в заднюю грань (сторона стены, Place как у плакатов).
Модели - бандл necroskull (Unity NecroSkullDecorSetup.cs, тот же меш, что у предмета). Из черепа и обратно -
рецептами (recipes.xml); снятый блок возвращает помощника, разрушенный - череп-предмет (редкий, 2%). -->
рецептами (recipes.xml); снятый блок возвращает помощника, разрушенный - череп-предмет (редкий, 2%).
15.10, вторая версия: декор получается из черепа в руке - R, «Череп: по центру» / «у стены» превращает стопку
в блок (превью, повороты блока); обратно - R, «Просто череп» (SkullDecorPlace.cs). Поднятый - снова череп. -->
<append xpath="/blocks">
<block name="necroSkullDecorCenter">
<property name="CreativeMode" value="Player"/>
@@ -480,13 +499,14 @@
<property name="WaterFlow" value="permitted"/>
<property name="Collide" value="movement,melee,bullet,arrow,rocket"/>
<property name="Place" value="TowardsPlacerInverted"/>
<property name="CanPickup" value="true" param1="necroSkullDecor"/>
<property name="CanPickup" value="true" param1="necroSkull"/> <!-- поднятый - снова просто череп (15.10) -->
<property name="TakeDelay" value="1"/>
<property name="StabilitySupport" value="false"/>
<property name="MaxDamage" value="100"/>
<property class="RepairItems">
<property name="resourceBone" value="2"/>
</property>
<property name="Stacknumber" value="50"/> <!-- как у черепа: стопка превращается целиком (SkullDecorPlace.Convert) -->
<property name="EconomicValue" value="500"/>
<property name="SellableToTrader" value="false"/>
<property name="Group" value="Building,advBuilding"/>
@@ -498,10 +518,154 @@
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroskull?necroSkullDecorBackPrefab.prefab"/>
</block>
<block name="necroSkullDecor">
<!-- 15.10: помощник больше не крафтится - декор делается из черепа в руке через R (SkullDecorPlace.cs). Блок
оставлен, чтобы уже сделанные помощники не пропали: рецепт necroSkull из него остался. -->
<property name="Extends" value="necroSkullDecorCenter"/>
<property name="CreativeMode" value="None"/>
<property name="SelectAlternates" value="true"/>
<property name="PlaceAltBlockValue" value="necroSkullDecorCenter,necroSkullDecorBack"/>
<property name="ItemTypeIcon" value="all_blocks"/>
</block>
</append>
<!-- Урна-цинерарий (Harvest Cinerarium), 1.7.0, 16.10.2026: этап 2 некротехнолога (BACKLOG «Некротехнолог v2»).
Составной блок, как земельный камень: TEFeatureStorage - сумка духа (окно хранилища 6x2, «Открыть»),
TEFeatureCinerarium (HarmonySrc/Cinerarium.cs) - череп (положить/забрать), показ зоны 5x5, -1..+4,
лимит ceil(убийств / 500) и запрет пересечения зон при постановке. Всё - в радиальном меню на E (решение 16.10).
Модель (16.10) - своя: бандл necrocinerarium (BlenderWork/necroCinerarium, make_urn.py): чёрная глазурь, черепа по
четырём сторонам, руны футарка кровью «SERVU ĜIS LA LASTA CINDRO», меандр, бусины; свечение рун и глазниц
включает TEFeatureCinerarium по черепу. Иконка - рендер той же модели (make_icon.py). -->
<append xpath="/blocks">
<block name="necroCinerarium">
<property name="Group" value="Decor/Miscellaneous,Necromancy"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumDesc"/>
<property name="UnlockedBy" value="craftingNecroNecromancy"/>
<property name="Class" value="CompositeTileEntity"/>
<property class="CompositeFeatures">
<property class="TEFeatureStorage">
<property name="LootList" value="necroCinerariumBag"/>
</property>
<property class="TEFeatureCinerarium"/>
<property class="TEFeaturePickup">
<property name="TakeDelay" value="2"/>
</property>
</property>
<property name="Material" value="Mstone"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumPrefab.prefab"/>
<property name="ModelOffset" value="0,0,0"/> <!-- меш стоит основанием на y=0; без этого ModelEntity поднимает на полклетки -->
<property name="CustomIcon" value="necroCinerarium"/>
<property name="WaterFlow" value="permitted"/>
<property name="OnlySimpleRotations" value="true"/>
<property name="Collide" value="movement,melee,bullet,arrow,rocket"/>
<property name="StabilitySupport" value="false"/>
<property name="MaxDamage" value="500"/>
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
<property name="FilterTags" value="MC_playerBlocks,SC_decor"/>
<property class="RepairItems">
<property name="resourceBone" value="5"/>
</property>
</block>
<!-- Урна, поднятая с черепом внутри (как транспорт держит модификации): тот же блок, при постановке череп
уже в багажнике. Получается только подъёмом (Patch_Block_OnBlockPickedUp_Cinerarium). -->
<block name="necroCinerariumSkull">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumDesc"/>
</block>
<!-- Урна-логист (16.10.2026): «Я не могу ставить урны рядом. Может нам для логиста вообще сделать отдельные урны?»
Без зоны (ставится вплотную к любым урнам), только нити и перевозка (CinerariumThreads.cs), лимит урн общий.
18.10 - своя модель: латунное кадило с чеканкой (BlenderWork/necroCinerariumLogist, бандл necrocinerarium), два вида -
на подставке по центру клетки (necroCinerariumLogist - блок уже в сейвах, остаётся видом по умолчанию) и на кронштейне
у стены (necroCinerariumLogistWall, стена - задняя грань, как у черепа-декора). Вид - R с урной в руке, «Форма»
(CinerariumLogistForm.cs). Дух в покое сидит в кадиле, вылетает из окон крышки только на перевозку, втрое меньше. -->
<block name="necroCinerariumLogist">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumLogistDesc"/>
<property name="CustomIcon" value="necroCinerariumLogist"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumLogistPrefab.prefab"/>
<property name="Material" value="Mmetal"/>
</block>
<block name="necroCinerariumLogistSkull">
<property name="Extends" value="necroCinerariumLogist"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumLogistDesc"/>
</block>
<block name="necroCinerariumLogistWall">
<property name="Extends" value="necroCinerariumLogist"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumLogistDesc"/>
<property name="CustomIcon" value="necroCinerariumLogistWall"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumLogistWallPrefab.prefab"/>
<property name="Place" value="TowardsPlacerInverted"/>
</block>
<block name="necroCinerariumLogistWallSkull">
<property name="Extends" value="necroCinerariumLogistWall"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumLogistDesc"/>
</block>
<!-- Урна рабочего (17-18.10.2026, BACKLOG «Урна рабочего»): чаша на трёх костях (BlenderWork/necroCinerariumWorker,
necroCinerariumWorkerPrefab в бандле necrocinerarium). Изготавливает предмет из второй ячейки «Содержимого» (образец)
на станциях по нитям, пока запас меньше стопки образца (CinerariumCraft.cs). Без зоны и вне лимита, как логист.
Рецепт (18.10) - урна и молоток, открывается вместе с урнами; иконка - своя (18.10, рендер модели). -->
<block name="necroCinerariumWorker">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumWorkerDesc"/>
<property name="CustomIcon" value="necroCinerariumWorker"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumWorkerPrefab.prefab"/>
</block>
<block name="necroCinerariumWorkerSkull">
<property name="Extends" value="necroCinerariumWorker"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumWorkerDesc"/>
</block>
<!-- Урна ремонтника и урна лутера (18.10.2026, BACKLOG «Урна ремонтника», «Урна лутера»; CinerariumZone.cs): большая зона -
сторона притязания или меньше, весь столб мира; работа - по кнопке «Запустить» (один проход), кнопки-звонки с черепом
на модели показывают состояние. Ремонтник чинит повреждённые блоки материалами из ящиков по нитям; лутер собирает
предметы на земле, мешки с зомби и красные трупы в ящик по нити. Без зоны 5x5 и вне лимита, как логист. -->
<block name="necroCinerariumRepair">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumRepairDesc"/>
<property name="CustomIcon" value="necroCinerariumRepair"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumRepairPrefab.prefab"/>
</block>
<block name="necroCinerariumRepairSkull">
<property name="Extends" value="necroCinerariumRepair"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumRepairDesc"/>
</block>
<block name="necroCinerariumLoot">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumLootDesc"/>
<property name="CustomIcon" value="necroCinerariumLoot"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumLootPrefab.prefab"/>
</block>
<block name="necroCinerariumLootSkull">
<property name="Extends" value="necroCinerariumLoot"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumLootDesc"/>
</block>
<!-- Урна старателя (22.10.2026, #64; CinerariumProspector.cs): бронзовая катушка с золотой чеканкой на основе банки лутера
(BlenderWork/necroCinerariumProspector, necroCinerariumProspectorPrefab в бандле necrocinerarium). В «Содержимом» череп
и образец - что добывать; дух уходит под землю и раз в игровой час приносит добычу в ящик по нити. Без зоны и вне
лимита, как логист. -->
<block name="necroCinerariumProspector">
<property name="Extends" value="necroCinerarium"/>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroCinerariumProspectorDesc"/>
<property name="CustomIcon" value="necroCinerariumProspector"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrocinerarium?necroCinerariumProspectorPrefab.prefab"/>
</block>
<block name="necroCinerariumProspectorSkull">
<property name="Extends" value="necroCinerariumProspector"/>
<property name="CreativeMode" value="None"/>
<property name="DescriptionKey" value="necroCinerariumProspectorDesc"/>
</block>
</append>
</config>
+5
View File
@@ -469,4 +469,9 @@
<property name="Faction" value="none"/>
</entity_class>
</append>
<!-- Живой кролик в мешке (22.10.2026, RabbitGrab.cs): «Я бы хватал кролика, но в отличие от курицы пусть он сразу
оказывается в сумке». PickupItem - ванильный механизм захвата курицы (голой рукой, E); мешок кладёт в рюкзак код. -->
<append xpath="/entity_classes/entity_class[@name='animalRabbit']">
<property name="PickupItem" value="necroRabbitSack"/>
</append>
</config>
+8
View File
@@ -70,6 +70,7 @@
Gated to zombies via EntityTagCompare on "other" - killing a bear or a bird gives
nothing, matching how every other on-hit effect in this mod is gated. -->
<item_modifier name="necroModKnifeTearsOfTheDead" installable_tags="necroKnife" modifier_tags="necroKnifeWater" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="CustomIcon" value="TearsOfTheDead"/>
<property name="EconomicValue" value="0"/>
@@ -92,6 +93,7 @@
checks water and food independently and hands out whichever buffs apply, so stacking
the two mods costs nothing extra and neither one cancels the other. -->
<item_modifier name="necroModKnifeScavengersFeast" installable_tags="necroKnife" modifier_tags="necroKnifeFood" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="CustomIcon" value="ScavengersFeast"/>
<property name="EconomicValue" value="0"/>
@@ -113,6 +115,7 @@
onSelfAttackedOther, not onSelfKilledOther: the point is to slow a zombie that is
still coming at you, so it has to land on the hit, not on the kill. -->
<item_modifier name="necroModKnifeDeadMansGrip" installable_tags="necroKnife" modifier_tags="necroKnifeSlow" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="CustomIcon" value="DeadMansGrip"/>
<property name="EconomicValue" value="0"/>
@@ -177,6 +180,7 @@
CustomIcon спрайт называется как он сам), то есть по смыслу ровно та картинка. Тинт
тот же фиолетовый, что у остальных трёх, до появления собственной графики. -->
<item_modifier name="necroModKnifeGravesRepose" installable_tags="necroKnife" modifier_tags="necroKnifeThermal" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="CustomIcon" value="GravesRepose"/>
<property name="EconomicValue" value="0"/>
@@ -265,6 +269,7 @@
Иконка-заглушка - спрайт ванильного мода-репульсора электродубинки, тематически
ближайшее, что есть готового. -->
<item_modifier name="necroModKnifeDeadStorm" installable_tags="necroKnife" modifier_tags="necroKnifePower" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="CustomIcon" value="DeadStorm"/>
<property name="EconomicValue" value="0"/>
@@ -321,6 +326,7 @@
Бафф снимается и при смерти игрока: remove_on_death у него не выставлен, а по
умолчанию это true - то есть залипнуть после респавна он не может. -->
<item_modifier name="necroModKnifeDarkSense" installable_tags="necroKnife" modifier_tags="necroKnifeSense" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="CustomIcon" value="DarkSense"/>
<property name="EconomicValue" value="0"/>
@@ -360,6 +366,7 @@
type="attachment" - сферу можно вынуть обратно. -->
<append xpath="/item_modifiers">
<item_modifier name="resourceBloodSphere" installable_tags="necroBracelet" modifier_tags="necroBraceletSphere" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<!-- Extends на modGeneralMaster - та же база, что у шести модов ножа: даёт Group
"Mods", звуки mod_grab/mod_place, Stacknumber 1 и CreativeMode None.
param1="CustomIcon" исключает наследование родительского missingIcon.
@@ -471,6 +478,7 @@
-->
<append xpath="/item_modifiers">
<item_modifier name="resourceBloodStone" installable_tags="necroBracelet" modifier_tags="necroBraceletStone" type="attachment">
<property name="Group" value="Mods,Necromancy"/>
<!-- Та же база, что у сферы и у шести модов ножа: Group "Mods", звуки
mod_grab/mod_place, Stacknumber 1, CreativeMode None. param1="CustomIcon"
исключает наследование родительского missingIcon.
+99 -138
View File
@@ -9,131 +9,6 @@
dropped entirely. See progression.xml/entityclasses.xml: the skill now levels from
zombie kills instead, which already worked. -->
<!-- Reusable tin cans: eating canned food leaves you an empty can, which works like the
vanilla mason jar (drinkJarEmpty/drinkJarRiverWater/drinkJarBoiledWater - fill from
water, drink, get the empty can back). Ported from AC-EnergyBow (__NoMods reference
library), fixing one bug in it along the way: it extends the jar items but never
overrides Meshfile, so its "tin can" is visually a mason jar in hand. We use the mesh
from vanilla's own disabled-but-fully-built "drinkCanEmpty" item (items.xml, commented
out under "*** Drinks") instead - a real can model, just never wired up by TFP.
BUG FIXED 2026-09-01 (canned water didn't quench thirst): the initial port only copied
each item's own property/property-class nodes and dropped the effect_group blocks,
on the wrong assumption that Extends would pull those in from drinkJarRiverWater/
drinkJarBoiledWater - it doesn't (see the comment above: effect_group is parsed from
each item's own XML node only, never inherited through Extends). So tinCanRiverWater/
tinCanBoiledWater ran their Eat action (sound, delay, empty-can-back) but never applied
$waterAmountAdd - the can "drank" and did nothing. Restored the effect_group from
AC-EnergyBow verbatim (river water still carries dysentery risk/HP hit without
buffWaterPurifier, same as a jar; boiled water is clean), fixing one more small bug in
the source along the way: its dDysenteryRisk display_value said .16 while the actual
roll it sets is 8 (i.e. .08) - tooltip now matches what the roll does. -->
<append xpath="/items">
<item name="tinCanEmpty">
<property name="Extends" value="drinkJarEmpty"/>
<property name="CustomIcon" value="tinCanEmpty"/>
<property name="DescriptionKey" value="tinCanEmptyDesc"/>
<!-- Своя модель (01.10.2026): ванильная консервная банка без отогнутой крышки - пустая
открытая банка. Бандл necrocan; текстура пока ванильная cannedfood01 - до релиза
перерисовать (гигиена ассетов). Банки с водой - свои модели с водой внутри. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanPrefab.prefab"/>
<!-- 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"/>
<property name="Delay" value="3.4"/>
<property name="Change_item_to" value="tinCanRiverWater"/>
<property name="Sound_start" value="bucketfill_water"/>
</property>
</item>
<item name="tinCanRiverWater">
<property name="Extends" value="drinkJarRiverWater"/>
<property name="CustomIcon" value="tinCanRiverWater"/>
<property name="DescriptionKey" value="tinCanRiverWaterDesc"/>
<!-- Своя модель (01.10.2026): банка без крышки, внутри мутная бурая вода (бандл necrocan). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanMurkyPrefab.prefab"/>
<!-- HoldType 3 = хват стеклянной банки (drinkJar*): сразу питьё, без фазы вскрытия, которая есть у консервного хвата 14 (05.10.2026). -->
<property name="HoldType" value="3"/>
<property name="Material" value="Mmetal"/>
<property class="Action0">
<property name="Class" value="Eat"/>
<property name="Delay" value="4"/>
<property name="Sound_start" value="player_drinking"/>
<property name="Create_item" value="tinCanEmpty"/>
<property name="Use_jar_refund" value="false"/>
</property>
<effect_group tiered="false" name="Drink Tier 0">
<requirement name="!HasBuff" buff="buffIsOnFire"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="ModifyCVar" cvar="$waterAmountAdd" operation="add" value="10"/>
<display_value name="dStaminaRegen" value=".15"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="AddBuff" buff="buffProcessConsumables"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="ModifyStats" stat="Health" operation="add" value="-5">
<requirement name="!HasBuff" buff="buffWaterPurifier"/>
</triggered_effect>
<display_value name="foodHealthAmount" value="-5"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="ModifyCVar" cvar=".DiseaseRoll" operation="set" value="8"/>
<display_value name="dDysenteryRisk" value=".08"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="ModifyCVar" cvar=".DiseaseRoll" operation="add" value="@$MetabolismResist"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="AddBuff" buff="buffDysenteryCatchDrink">
<requirement name="RandomRoll" seed_type="Random" min_max="1,100" operation="LTE" value="@.DiseaseRoll"/>
</triggered_effect>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="PlaySound" sound="player#vomit">
<requirement name="!HasBuff" buff="buffWaterPurifier"/>
</triggered_effect>
</effect_group>
<effect_group tiered="false">
<requirement name="HasBuff" buff="buffIsOnFire"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="AddBuff" buff="buffExtinguishFire"/>
</effect_group>
</item>
<item name="tinCanBoiledWater">
<property name="Extends" value="drinkJarBoiledWater"/>
<property name="CustomIcon" value="tinCanBoiledWater"/>
<property name="DescriptionKey" value="tinCanBoiledWaterDesc"/>
<!-- Своя модель (01.10.2026): банка без крышки, внутри чистая прозрачная голубоватая вода (бандл necrocan). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanCleanPrefab.prefab"/>
<property name="HoldType" value="3"/> <!-- хват стеклянной банки, без вскрытия - см. tinCanRiverWater -->
<property name="Material" value="Mmetal"/>
<property class="Action0">
<property name="Class" value="Eat"/>
<property name="Delay" value="4"/>
<property name="Sound_start" value="player_drinking"/>
<property name="Create_item" value="tinCanEmpty"/>
<property name="Use_jar_refund" value="false"/>
</property>
<effect_group tiered="false" name="Drink Tier 0/1">
<requirement name="!HasBuff" buff="buffIsOnFire"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="ModifyCVar" cvar="$waterAmountAdd" operation="add" value="16"/>
<display_value name="dStaminaRegen" value=".15"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="AddBuff" buff="buffProcessConsumables"/>
</effect_group>
<effect_group tiered="false">
<requirement name="HasBuff" buff="buffIsOnFire"/>
<triggered_effect trigger="onSelfPrimaryActionEnd" action="AddBuff" buff="buffExtinguishFire"/>
</effect_group>
</item>
</append>
<!-- Only foodCanBeef needs patching, even though Chicken/Lamb/Catfood should also leave a
can behind: those three Extends="foodCanBeef" without their own Action0 element, so
there's nothing for a foodCanChicken/foodCanLamb/foodCanCatfood xpath to match - but for
the same reason, they'll all inherit foodCanBeef's patched Action0 (Create_item included)
at load time anyway, same as how our zombie kill counter above only had to patch
zombieTemplateMale once. -->
<append xpath="/items/item[@name='foodCanBeef']/property[@class='Action0']">
<property name="Create_item" value="tinCanEmpty"/>
</append>
<!-- Письмо архимага (ванильная записка noteDuke01) прочитано -> открыта глава «Письмо архимага»
в книге с оглавлением (HarmonySrc/NecroTome.cs). Ваниль сама ставит .dukeNoteRead, но cvar
с точкой в сейв не пишется (EntityBuffs.Write пропускает key[0]=='.', декомпиляция 3.3) -
@@ -203,7 +78,7 @@
<property name="RadiusEntities" value="0"/>
</property>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="UsableUnderwater" value="false"/>
<property name="SoundPickup" value="stone_grab"/>
<property name="SoundPlace" value="stone_place"/>
@@ -341,7 +216,7 @@
<property name="RadiusEntities" value="0"/>
</property>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="UsableUnderwater" value="false"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
@@ -441,7 +316,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="stone_grab"/>
<property name="SoundPlace" value="stone_place"/>
<!-- Delay="1" is just a re-click debounce now, not a channel length - see the big
@@ -476,7 +351,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="stone_grab"/>
<property name="SoundPlace" value="stone_place"/>
<property class="Action0">
@@ -557,7 +432,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
<property class="Action0">
@@ -794,7 +669,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
<property class="Action0">
@@ -853,7 +728,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
<property class="Action0">
@@ -914,7 +789,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
<property class="Action0">
@@ -971,7 +846,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
<property class="Action0">
@@ -1092,7 +967,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
<property class="Action0">
@@ -1170,7 +1045,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property name="SoundPickup" value="schematics_grab"/>
<property name="SoundPlace" value="schematics_place"/>
<property class="Action0">
@@ -1218,6 +1093,7 @@
the necromancer build over time, not an expensive unlock. Say if that should change. -->
<append xpath="/items">
<item name="necroWpnBladeNecroKnife">
<property name="Group" value="Limited,Ammo/Weapons,Melee Weapons,Necromancy"/>
<property name="Extends" value="meleeWpnBladeT0BoneKnife"/>
<!-- Not for sale, per user request 2026-08-29 ("убери у всех предметов некроманта
цену продажи") - overridden explicitly since this item never had its own
@@ -1677,6 +1553,7 @@
HarmonySrc/NecromancerBloodPatch.cs instead. -->
<append xpath="/items">
<item name="resourceNecromancerBlood">
<property name="Group" value="Science,Medical,TCMedical,Necromancy"/>
<property name="Extends" value="medicalBloodBag"/>
<property name="DescriptionKey" value="resourceNecromancerBloodDesc"/>
<!-- Своя рисованная иконка, 2026-08-30. CustomIcon задаётся явно даже при Extends. -->
@@ -1883,7 +1760,7 @@
<property name="Weight" value="0"/>
<property name="Stacknumber" value="1"/>
<property name="EconomicValue" value="0"/>
<property name="Group" value="Ammo/Weapons,Ammo"/>
<property name="Group" value="Ammo/Weapons,Ammo,Necromancy"/>
<property class="Action0">
<property name="Class" value="Eat"/>
<property name="Delay" value="0.3"/>
@@ -1944,6 +1821,7 @@
01.10: «не книга призыва, а та же система, что у ванильного транспорта». -->
<append xpath="/items">
<item name="necroBoneSteedPlaceable">
<property name="Group" value="Science,TCScience,Necromancy"/>
<property name="Extends" value="vehicleMotorcyclePlaceable"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobonesteed?necroBoneSteedP.prefab"/>
<property name="CustomIcon" value="necroBoneSteedPlaceable"/>
@@ -2003,7 +1881,7 @@
<property name="Stacknumber" value="50"/>
<property name="EconomicValue" value="500"/>
<property name="SellableToTrader" value="false"/>
<property name="Group" value="Resources"/>
<property name="Group" value="Resources,Necromancy"/>
</item>
</append>
<!-- Сок чёрной розы (05.10.2026): банка, на ступке из 4 роз. Заменяет кровь некроманта в копиях
@@ -2018,4 +1896,87 @@
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroJuicePrefab.prefab"/>
</item>
</append>
<!-- Нож некроманта «хватом для нитей» (16.10.2026, HarmonySrc/CinerariumThreads.cs): «нож некроманта, но взятый особым
хватом... как кисточка или как тот же стриппер». Отдельный предмет - класс ножа в сейве менять нельзя; переключение -
меню по R (качество, износ и моды переносятся). Не бьёт: ПКМ - тянуть нить урна-ящик, ЛКМ - снять нити. Удары
перехвачены в ItemClass.ExecuteAction, поэтому действия ножа (Extends) остаются, но не срабатывают. Держится пока как
обычный нож (хват стриппера HoldType 52 не подошёл - 16.10). effect_group через Extends не наследуется -
у этого предмета нет урона, лечения и слотов модов (моды в ItemValue сохраняются и вернутся с обычным хватом). -->
<append xpath="/items">
<item name="necroWpnBladeNecroKnifeThread">
<property name="Extends" value="necroWpnBladeNecroKnife"/>
<property name="CreativeMode" value="None"/>
<!-- Хват для нитей (16.10, подбор в игре): основа - 68 (деревянный лук), поверх - поза рук мода
(HandPose.PresetThread): нож в левой руке, правая раскрытой ладонью. 52 (стриппер) не подошёл. -->
<property name="HoldType" value="68"/>
<property name="DescriptionKey" value="necroWpnBladeNecroKnifeThreadDesc"/>
<property name="CustomIconTint" value="150, 205, 255"/>
</item>
</append>
<!-- Магические ингредиенты (18.10.2026): магическая пыль, хранилище магии (колба-«батарейка» в латунных клеммах с
маркировкой MAGIA KONSERVUJO / KAPACITO ✶ 30) и повреждённое хранилище. Пыль - из ступки (4 из повреждённого,
30 из целого); пыль - будущий ингредиент камня портала, целое хранилище - деталь батарей/портала, сами хранилища
позже появятся в префабе. Модели в руке пока ванильный мешок; свои модели - _private/.../necroMagic/necroMagic.blend. -->
<append xpath="/items">
<item name="necroMagicDust">
<property name="DescriptionKey" value="necroMagicDustDesc"/>
<property name="HoldType" value="45"/>
<property name="Meshfile" value="@:Other/Items/Misc/sackPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
<property name="Material" value="MresourceCrop"/>
<property name="Stacknumber" value="500"/>
<property name="EconomicValue" value="20"/>
<property name="Group" value="Resources,Necromancy"/>
</item>
<item name="necroManaVessel">
<property name="DescriptionKey" value="necroManaVesselDesc"/>
<property name="HoldType" value="45"/>
<property name="Meshfile" value="@:Other/Items/Misc/sackPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
<property name="Material" value="MresourceCrop"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="300"/>
<property name="Group" value="Resources"/>
</item>
<item name="necroManaVesselBroken">
<property name="DescriptionKey" value="necroManaVesselBrokenDesc"/>
<property name="HoldType" value="45"/>
<property name="Meshfile" value="@:Other/Items/Misc/sackPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
<property name="Material" value="MresourceCrop"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="40"/>
<property name="Group" value="Resources"/>
</item>
</append>
<!-- Живой кролик в мешке (22.10.2026, RabbitGrab.cs). Пойманный игроком кролик сразу в рюкзаке. ЛКМ с мешком в руке -
выпустить (ванильный SpawnEntity, мешок тратит код). «Разобрать» в рюкзаке - мясо 10, кожа 2, кости 5 на кролика,
как ножом (указание 22.10: «Кролика можно будет разобрать на кожу, кости и мясо», «по команде сломать»). Пункт
«Разобрать» игра даёт по материалу Mleather (кожа) и весу не меньше кожи (5). Мешок и поза в руке - как у кожи.
Иконка - ванильная блока «спящий кролик» (sleeperRabbitAnimal), по ссылке; имя предмета - просто «Кролик» (22.10). -->
<append xpath="/items">
<item name="necroRabbitSack">
<property name="Tags" value="noMods"/>
<property name="HoldType" value="45"/>
<property name="Meshfile" value="@:Other/Items/Misc/sackPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
<property name="Material" value="Mleather"/>
<property name="CustomIcon" value="sleeperRabbitAnimal"/>
<property name="DescriptionKey" value="necroRabbitSackDesc"/>
<property name="Stacknumber" value="10"/>
<property name="Weight" value="5"/>
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
<property name="Group" value="Resources"/>
<property name="SoundPickup" value="rabbitpain"/>
<property class="Action0">
<property name="Class" value="SpawnEntity"/>
<property name="AnimType" value="4"/>
<property name="AnimWait" value="0.3"/>
<property name="SoundWarn" value="rabbitpain"/>
<property name="Entity" value="animalRabbit"/>
<property name="EntityOffset" value="0, -1, 1.2"/>
</property>
</item>
</append>
</config>
+7
View File
@@ -45,4 +45,11 @@
<item group="groupZpackVictim"/>
</lootcontainer>
</append>
<!-- Сумка духа урны-цинерария (1.7.0): пустое хранилище игрока, 6x2. -->
<append xpath="/lootcontainers">
<lootcontainer name="necroCinerariumBag" count="0" size="6,2" sound_open="UseActions/open_backpack" sound_close="UseActions/close_backpack" open_time="0" loot_quality_template="qualBaseTemplate">
<item name="cobweb" count="0"/>
</lootcontainer>
</append>
</config>
+2
View File
@@ -181,7 +181,9 @@
пользуется, вода сидит на necroNecromancyLvl20. -->
<unlock_entry item="necroModKnifeScavengersFeast,bookSummonRatSpirit" unlock_tier="3"/>
<unlock_entry item="necroModKnifeGravesRepose" unlock_tier="4"/>
<!-- 22.10.2026: урны - одной иконкой с recipes= (как комплекты брони в ванили): в окне навыка у строки только 14 иконок, в группе было 17 - последние урны не показывались («Не все рецепты цинерария доступны»). Клик по иконке - все урны списком. -->
<unlock_entry item="necroModKnifeDarkSense" unlock_tier="5"/>
<unlock_entry item="necroCinerarium" unlock_tier="5" recipes="necroCinerarium,necroCinerariumLogist,necroCinerariumWorker,necroCinerariumRepair,necroCinerariumLoot,necroCinerariumProspector"/>
</display_entry>
<!-- Ступенчатая разблокировка внутри группы. ПЕРЕРАСПРЕДЕЛЕНО 2026-09-09: четыре из
шести модов ножа (вода/еда/покой/чутьё) уехали отсюда в группу 1 - см. большой
+189 -39
View File
@@ -1,31 +1,4 @@
<config>
<!-- Tin can water boiling - user request 2026-08-30 ("Вода в консервных банках должна
кипятиться на костре даже если там нету кастрюли... так было сделано в другом моём моде
'Энерголук'" = AC-EnergyBow, the same __NoMods reference the tin can items themselves were
ported from - see items.xml's own comment on tinCanEmpty/tinCanRiverWater/
tinCanBoiledWater).
Root cause this was missing at all: `Extends` copies an item's PROPERTIES, never its
RECIPES - recipes are separate top-level <recipe> nodes matched by name, with no
inheritance mechanism of their own (confirmed by grepping this mod's own recipes.xml -
zero "tinCan" hits before this entry). tinCanRiverWater/tinCanBoiledWater extending
drinkJarRiverWater/drinkJarBoiledWater therefore got NONE of the vanilla mason-jar-boiling
recipe (`<recipe name="drinkJarBoiledWater" craft_area="campfire"
craft_tool="toolCookingPot">`, Data/Config/recipes.xml) - boiling a tin can was never
possible at all until this recipe existed, not merely pot-gated.
No `craft_tool="toolCookingPot"` here BY DESIGN, per the direct request - a metal can can
sit right in a campfire's coals on its own, unlike a mason jar. Confirmed real vanilla
precedent for a pot-less campfire recipe existing at all: `foodBakedPotato`/
`foodCharredMeat` both use craft_area="campfire" with no craft_tool attribute at all - a
campfire recipe is not implicitly pot-gated, craft_tool is an opt-in extra requirement,
not something `craft_area="campfire"` implies on its own. -->
<append xpath="/recipes">
<recipe name="tinCanBoiledWater" count="1" craft_area="campfire">
<ingredient name="tinCanRiverWater" count="1"/>
</recipe>
</append>
<!-- "Камень духов" (Spirit Stone): craftable from the start, no perk/skill gate -
packMuleCrafting is a weight-while-crafting tag, not a gate (see vanilla
campfire/candle recipes for the same pattern). Per BACKLOG.md item 1: 1 rock +
@@ -480,12 +453,19 @@
именно тогда (правило «порог по нужде, а не по силе»). Тег и unlock_level группы 2 в
progression.xml обязаны совпадать. -->
<append xpath="/recipes">
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<!-- 22.10.2026 (#66): котёл кованый - в кузню («Он весь из себя кованный, так что будет логично»): железо вместо дерева
и юкки, кости и прах - ноги и содержимое; две версии кузни (плавильня / лом). -->
<recipe name="necroGeoOssuary" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyJourneyman,use_smelter">
<ingredient name="unit_iron" count="60"/>
<ingredient name="resourceBone" count="400"/>
<ingredient name="resourceZombieAsh" count="200"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
<recipe name="necroGeoOssuary" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyJourneyman,replace_smelter">
<ingredient name="resourceScrapIron" count="60"/>
<ingredient name="resourceBone" count="400"/>
<ingredient name="resourceZombieAsh" count="200"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceWood" count="100"/>
<ingredient name="resourceYuccaFibers" count="50"/>
</recipe>
</append>
@@ -493,7 +473,7 @@
гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по
указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. -->
<append xpath="/recipes">
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<recipe name="necroBoneSteedPlaceable" count="1" craft_time="120" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/>
@@ -580,14 +560,21 @@
<ingredient name="medicalBloodBag" count="4"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<!-- 22.10.2026 (#66): котёл кованый - в кузню («Он весь из себя кованный, так что будет логично»): железо вместо дерева
и юкки, кости и прах - ноги и содержимое; две версии кузни (плавильня / лом). -->
<recipe name="necroGeoOssuary" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyJourneyman,use_smelter">
<ingredient name="unit_iron" count="60"/>
<ingredient name="resourceBone" count="400"/>
<ingredient name="resourceZombieAsh" count="200"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceWood" count="100"/>
<ingredient name="resourceYuccaFibers" count="50"/>
</recipe>
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<recipe name="necroGeoOssuary" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyJourneyman,replace_smelter">
<ingredient name="resourceScrapIron" count="60"/>
<ingredient name="resourceBone" count="400"/>
<ingredient name="resourceZombieAsh" count="200"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
<recipe name="necroBoneSteedPlaceable" count="1" craft_time="120" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/>
@@ -599,13 +586,176 @@
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
</append>
<!-- Череп-декорация (15.10.2026): из черепа-предмета в блок и обратно, мгновенно, из рюкзака. -->
<!-- Череп-декорация (15.10.2026): декор делается из черепа в руке через R; здесь только обратный рецепт - для уже сделанных блоков-помощников. -->
<append xpath="/recipes">
<recipe name="necroSkullDecor" count="1" craft_time="1">
<ingredient name="necroSkull" count="1"/>
</recipe>
<recipe name="necroSkull" count="1" craft_time="1">
<ingredient name="necroSkullDecor" count="1"/>
</recipe>
</append>
<!-- Урна-цинерарий (1.7.0, 16.10): конец Адепта - уровень 15, 300 убийств (решение 06.10). Кровь - 16.10: «Да, кровью» (руны на урне расписаны кровью некроманта). -->
<append xpath="/recipes">
<recipe name="necroCinerarium" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
<!-- Копия на соке чёрной розы (правило 05.10: у каждого рецепта на крови - копия; одна банка крови = одна банка сока). -->
<recipe name="necroCinerarium" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
<!-- Урна-логист (16.10: «Рецепт с набором для шитья») - набор для шитья; копия на соке чёрной розы.
18.10: своя модель - латунное кадило («Сейчас кадило выглядит бронзовым. Учти это в рецепте, используя латунь и кузню»):
вместо глины латунь, плюс немного песка и угля («добавь в рецепт немного песка и угля») и воск - покрытие, чтобы латунь не тускнела («Это кстати будет шикарным поводом ставить пасеку»), делается в кузне. Как у кузнечных рецептов игры - две версии: с плавильней (use_smelter, unit_brass)
и без неё (replace_smelter, латунный лом). -->
<recipe name="necroCinerariumLogist" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyLvl300,use_smelter">
<ingredient name="unit_brass" count="50"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceSewingKit" count="1"/>
<ingredient name="resourceCrushedSand" count="10"/>
<ingredient name="resourceCoal" count="5"/>
<ingredient name="resourceBeeswax" count="2"/>
</recipe>
<recipe name="necroCinerariumLogist" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyLvl300,use_smelter">
<ingredient name="unit_brass" count="50"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceSewingKit" count="1"/>
<ingredient name="resourceCrushedSand" count="10"/>
<ingredient name="resourceCoal" count="5"/>
<ingredient name="resourceBeeswax" count="2"/>
</recipe>
<recipe name="necroCinerariumLogist" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyLvl300,replace_smelter">
<ingredient name="resourceScrapBrass" count="50"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceSewingKit" count="1"/>
<ingredient name="resourceCrushedSand" count="10"/>
<ingredient name="resourceCoal" count="5"/>
<ingredient name="resourceBeeswax" count="2"/>
</recipe>
<recipe name="necroCinerariumLogist" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyLvl300,replace_smelter">
<ingredient name="resourceScrapBrass" count="50"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceSewingKit" count="1"/>
<ingredient name="resourceCrushedSand" count="10"/>
<ingredient name="resourceCoal" count="5"/>
<ingredient name="resourceBeeswax" count="2"/>
</recipe>
<!-- Урна рабочего (18.10: «Молоток годится») - рецепт урны и молоток; копия на соке чёрной розы. -->
<recipe name="necroCinerariumWorker" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="meleeToolRepairT1ClawHammer" count="1"/>
</recipe>
<recipe name="necroCinerariumWorker" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="meleeToolRepairT1ClawHammer" count="1"/>
</recipe>
<!-- Урна ремонтника (18.10) - рецепт урны (глина - фляга), ремкомплект и латунь 5 (розетки звонков); копия на соке чёрной розы. -->
<recipe name="necroCinerariumRepair" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceRepairKit" count="1"/>
<ingredient name="resourceScrapBrass" count="5"/>
</recipe>
<recipe name="necroCinerariumRepair" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceRepairKit" count="1"/>
<ingredient name="resourceScrapBrass" count="5"/>
</recipe>
<!-- Урна лутера (18.10) - железная банка с крышкой-кадилом, шов пропаян свинцом («в рецепте железо и немного свинца»):
в кузне, железо 40 и свинец 5 вместо глины, отмычка; две версии кузни (плавильня / лом), на крови и на соке. -->
<recipe name="necroCinerariumLoot" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyLvl300,use_smelter">
<ingredient name="unit_iron" count="40"/>
<ingredient name="unit_lead" count="5"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceLockPick" count="1"/>
</recipe>
<recipe name="necroCinerariumLoot" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyLvl300,use_smelter">
<ingredient name="unit_iron" count="40"/>
<ingredient name="unit_lead" count="5"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceLockPick" count="1"/>
</recipe>
<recipe name="necroCinerariumLoot" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyLvl300,replace_smelter">
<ingredient name="resourceScrapIron" count="40"/>
<ingredient name="resourceScrapLead" count="5"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceLockPick" count="1"/>
</recipe>
<recipe name="necroCinerariumLoot" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyLvl300,replace_smelter">
<ingredient name="resourceScrapIron" count="40"/>
<ingredient name="resourceScrapLead" count="5"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceLockPick" count="1"/>
</recipe>
<!-- Урна старателя (22.10, #64) - бронзовая катушка: в кузне латунь 40 вместо железа и свинца, железная кирка вместо отмычки;
две версии кузни (плавильня / лом), на крови и на соке. Открывается вместе с остальными урнами. -->
<recipe name="necroCinerariumProspector" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyLvl300,use_smelter">
<ingredient name="unit_brass" count="40"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="meleeToolPickT1IronPickaxe" count="1"/>
</recipe>
<recipe name="necroCinerariumProspector" count="1" craft_area="forge" craft_time="60" material_based="true" tags="learnable,necroNecromancyLvl300,use_smelter">
<ingredient name="unit_brass" count="40"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="meleeToolPickT1IronPickaxe" count="1"/>
</recipe>
<recipe name="necroCinerariumProspector" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyLvl300,replace_smelter">
<ingredient name="resourceScrapBrass" count="40"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="meleeToolPickT1IronPickaxe" count="1"/>
</recipe>
<recipe name="necroCinerariumProspector" count="1" craft_area="forge" craft_time="60" tags="learnable,necroNecromancyLvl300,replace_smelter">
<ingredient name="resourceScrapBrass" count="40"/>
<ingredient name="resourceZombieAsh" count="100"/>
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="meleeToolPickT1IronPickaxe" count="1"/>
</recipe>
</append>
<!-- Магическая пыль в ступке (18.10.2026): повреждённое хранилище - 4, целое - 10 (22.10: было 30; «Из неповреждённого пусть будет 10»). -->
<append xpath="/recipes">
<recipe name="necroMagicDust" count="4" craft_time="10" craft_area="necroMortar">
<ingredient name="necroManaVesselBroken" count="1"/>
</recipe>
<recipe name="necroMagicDust" count="10" craft_time="20" craft_area="necroMortar">
<ingredient name="necroManaVessel" count="1"/>
</recipe>
</append>
</config>
+40
View File
@@ -42,6 +42,46 @@
<MaxRepeatRate value="0.1"/>
</SoundDataNode>
<!-- Некротехнолог - дух-работник цинерария (15.10.2026). Указание: «негромкие шорохи и скрипы, вроде шелеста
листьев под порывом ветра. В общем, звуки духов. А то опасаюсь, что некротехнолог унаследует звуки зомби».
Синтез с нуля (_private/tools/Sounds/make_technologist_sounds.py), вариант 2 - K 0.6 («Второй вариант лучше»).
Источник - ванильный AudioSource_Creatures (объёмное затухание, как у существ). Узла <Noise> НЕТ: дух не
привлекает зомби. Пока нигде не подключены - назначаются сущности вместе с цинерарием (1.7.0). -->
<!-- шорох в покое и за работой -->
<SoundDataNode name="necroTechRustle">
<AudioSource name="@:Sounds/Prefabs/AudioSource_Creatures.prefab"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechRustle1.wav"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechRustle2.wav"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechRustle3.wav"/>
<LowestPitch name="0.92"/>
<HighestPitch name="1.05"/>
<MaxVoices value="3"/>
<maxVoicesPerEntity value="1"/>
<MaxRepeatRate value="0.5"/>
</SoundDataNode>
<!-- порыв - дух перемещается -->
<SoundDataNode name="necroTechGust">
<AudioSource name="@:Sounds/Prefabs/AudioSource_Creatures.prefab"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechGust1.wav"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechGust2.wav"/>
<LowestPitch name="0.92"/>
<HighestPitch name="1.05"/>
<MaxVoices value="3"/>
<maxVoicesPerEntity value="1"/>
<MaxRepeatRate value="0.5"/>
</SoundDataNode>
<!-- скрип - собирает урожай, сажает -->
<SoundDataNode name="necroTechCreak">
<AudioSource name="@:Sounds/Prefabs/AudioSource_Creatures.prefab"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechCreak1.wav"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechCreak2.wav"/>
<AudioClip ClipName="#@modfolder(NecromancerTome):Resources/necrosounds?necroTechCreak3.wav"/>
<LowestPitch name="0.92"/>
<HighestPitch name="1.05"/>
<MaxVoices value="3"/>
<maxVoicesPerEntity value="1"/>
<MaxRepeatRate value="0.5"/>
</SoundDataNode>
</append>
</config>
+17
View File
@@ -0,0 +1,17 @@
<config>
<!-- Вкладка «Некромантия» в окне крафта (22.10.2026: «Иначе рецепты теряются. Их не видно ни так, ни в навыках»).
Отбор - по Group предмета/блока: у всех предметов мода в Group есть Necromancy (правило: новый предмет - сразу туда же).
В рюкзаке и на верстаке вкладок становится 10 - шаг значков сужен в XUi_InGame/windows.xml. -->
<append xpath="/ui_display_info/crafting_category_display/crafting_category_list[@display_type='']">
<crafting_category name="Necromancy" icon="ui_game_symbol_skull" display_name="lblCategoryNecromancy" />
</append>
<append xpath="/ui_display_info/crafting_category_display/crafting_category_list[@display_type='workbench']">
<crafting_category name="Necromancy" icon="ui_game_symbol_skull" display_name="lblCategoryNecromancy" />
</append>
<append xpath="/ui_display_info/crafting_category_display/crafting_category_list[@display_type='forge']">
<crafting_category name="Necromancy" icon="ui_game_symbol_skull" display_name="lblCategoryNecromancy" />
</append>
<append xpath="/ui_display_info/crafting_category_display/crafting_category_list[@display_type='chemistryStation']">
<crafting_category name="Necromancy" icon="ui_game_symbol_skull" display_name="lblCategoryNecromancy" />
</append>
</config>
File diff suppressed because it is too large Load Diff
+605
View File
@@ -0,0 +1,605 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Работа духа урны рабочего (18.10.2026, BACKLOG «Урна рабочего»): изготовить предмет из ячейки образца, пока его в запасе
/// меньше, чем в стопке образца (23.10 - без счёта: пока хватает ресурсов и есть куда класть). Решения 17.10: нити ящик -> урна - откуда ингредиенты и топливо, урна -> станция - где
/// изготавливать, урна -> ящик - куда готовое (нет такой нити - готовое остаётся в выходе станции, выход полон - станция
/// гаснет); только прямые рецепты (цепочки - несколько урн); качество 1; топливо - костру, химстанции, кузне, и станция
/// гасится, когда делать нечего (верстаку, ступке, бетономешалке топливо не нужно). Изготавливает сама станция игры: дух
/// только кладёт рецепт в её очередь, забрав ингредиенты, - время, огонь и выход считает игра.
/// Запас = такие же предметы в ящиках «куда», в выходах станций и в их очередях.
/// Одно дело за шаг, как у прочих духов (CinerariumWork.Step), _dry - пробный шаг без изменений.
/// </summary>
public static class CinerariumCraft
{
const int FuelModule = 3, MaterialModule = 4;
sealed class Station
{
public Vector3i Pos;
public TileEntityWorkstation Te;
public string[] Areas;
}
public static Vector3i? Step(World _world, TEFeatureCinerarium _urn, bool _dry, out bool _stuck, out Vector3i? _from)
{
_stuck = false;
_from = null;
ItemStack sample = _urn.Sample;
List<(Vector3i p, TEFeatureStorage box)> src = new List<(Vector3i, TEFeatureStorage)>();
foreach (Vector3i p in _urn.ThreadFrom)
{
TEFeatureStorage b = CinerariumWork.BoxAt(_world, p);
if (b != null)
{
src.Add((p, b));
}
}
List<Station> stations = new List<Station>();
List<(Vector3i p, TEFeatureStorage box)> outs = new List<(Vector3i, TEFeatureStorage)>();
foreach (Vector3i p in _urn.ThreadOut)
{
TileEntityWorkstation te = CinerariumThreads.StationAt(_world, p);
if (te != null)
{
if (!te.IsUserAccessing())
{
stations.Add(new Station { Pos = p, Te = te, Areas = AreasOf(_world.GetBlock(p).Block) });
}
continue;
}
TEFeatureStorage b = CinerariumWork.BoxAt(_world, p);
if (b != null)
{
outs.Add((p, b));
}
}
bool hasSample = sample != null && !sample.IsEmpty();
// 1. Готовое (только предмет образца - чужое изготовленное игроком не трогаем) - из выхода станции в ящики «куда».
if (hasSample && outs.Count > 0)
{
foreach (Station st in stations)
{
ItemStack[] output = st.Te.Output;
for (int i = 0; i < output.Length; i++)
{
ItemStack s = output[i];
if (s == null || s.IsEmpty() || s.itemValue.type != sample.itemValue.type)
{
continue;
}
Vector3i? dst = null;
foreach ((Vector3i p, TEFeatureStorage box) o in outs)
{
if (CinerariumWork.Fits(o.box, s))
{
dst = o.p;
break;
}
}
if (!dst.HasValue)
{
_stuck = true; // ящики «куда» полны
continue;
}
_from = st.Pos;
if (_dry)
{
return dst;
}
ItemStack moving = s.Clone();
List<TEFeatureStorage> boxes = new List<TEFeatureStorage>();
foreach ((Vector3i p, TEFeatureStorage box) o in outs)
{
boxes.Add(o.box);
}
CinerariumWork.Place(moving, boxes);
output[i] = moving.count > 0 ? moving : ItemStack.Empty.Clone();
st.Te.Output = output;
Debug.Log("[NecromancerTome] Cinerarium: рабочий перенёс " + (s.count - moving.count) + " " + s.itemValue.ItemClass?.GetItemName() + " из станции " + st.Pos);
return dst;
}
}
}
// 2. Изготовить. 23.10, пользователь: «Рабочий должен делать бесконечное количество по образцу. Я положил 1 цемент,
// а его задача наделать цемента столько, сколько возможно с учётом текущего количества ресурсов» - образец задаёт что,
// не сколько (было: пока запас меньше стопки образца). Стоп - нет ингредиентов или готовое класть некуда.
EntityPlayer owner = OwnerPlayer(_world, _urn);
bool outRoom = false;
foreach ((Vector3i p, TEFeatureStorage box) o in outs)
{
if (CinerariumWork.Fits(o.box, sample))
{
outRoom = true;
break;
}
}
if (hasSample)
{
string name = sample.itemValue.ItemClass?.GetItemName();
foreach (Station st in stations)
{
int slot = FreeQueueSlot(st.Te);
if (slot < 0)
{
continue;
}
if (!OutputHasRoom(st.Te, sample) && !outRoom)
{
continue; // выход полон и ящики «куда» полны (или их нет) - ниже станция погаснет
}
foreach (Recipe r in CraftingManager.GetRecipes(name))
{
if (r.IsScrap || !AreaFits(r, st.Areas) || !ToolFits(r, st.Te) || owner != null && !r.IsUnlocked(owner))
{
continue;
}
if (!Ingredients(r, st.Te, src, out Vector3i? srcAt, out string shortUnit))
{
// Кузня: не хватает переплавленного материала - положить сырьё во вход (плавит сама, с огнём).
if (shortUnit != null && FeedMaterial(st, shortUnit, src, _dry, out Vector3i? rawAt))
{
_from = rawAt;
if (!_dry)
{
Ignite(st.Te, src, false);
}
return st.Pos;
}
continue;
}
_from = srcAt;
if (_dry)
{
return st.Pos;
}
TakeIngredients(r, st.Te, src);
Enqueue(st.Te, slot, r);
Ignite(st.Te, src, false);
Debug.Log("[NecromancerTome] Cinerarium: рабочий поставил " + name + " на станцию " + st.Pos);
return st.Pos;
}
}
}
// 3. Огонь: работа есть - топливо и огонь; нет - погасить (экономия топлива).
foreach (Station st in stations)
{
if (!st.Te.isModuleUsed[FuelModule])
{
continue;
}
bool busy = st.Te.hasRecipeInQueue() || RawInInput(st.Te);
bool full = hasSample && !OutputHasRoom(st.Te, sample) && !outRoom;
if (busy && !full)
{
if (st.Te.BurnTotalTimeLeft <= 0f)
{
Vector3i? fuelAt = FuelSource(src);
if (fuelAt.HasValue)
{
_from = fuelAt;
if (_dry)
{
return st.Pos;
}
Ignite(st.Te, src, true);
return st.Pos;
}
}
else if (!_dry && !st.Te.IsBurning)
{
st.Te.IsBurning = true;
}
}
else if (st.Te.IsBurning)
{
// Погасить - тоже дело: дух летит к станции. Пробный шаг должен его найти - иначе настоящий шаг не
// запускается и огонь горел до конца топлива (проверка 18.10: костёр, химстанция, кузня).
if (_dry)
{
return st.Pos;
}
st.Te.IsBurning = false;
Debug.Log("[NecromancerTome] Cinerarium: рабочий погасил станцию " + st.Pos + (full ? " - выход полон" : " - делать нечего"));
if (full)
{
_stuck = true;
}
return st.Pos;
}
if (full)
{
_stuck = true;
}
}
return null;
}
/// <summary>Чьи рецепты изучены: хозяин урны, если он в игре; в одиночной - игрок. Нет никого - не проверяем.</summary>
static EntityPlayer OwnerPlayer(World _world, TEFeatureCinerarium _urn)
{
PlatformUserIdentifierAbs id = _urn.Parent?.Owner;
if (id != null)
{
PersistentPlayerData ppd = GameManager.Instance.persistentPlayers?.GetPlayerData(id);
if (ppd != null && _world.GetEntity(ppd.EntityId) is EntityPlayer p)
{
return p;
}
}
return _world.GetPrimaryPlayer();
}
/// <summary>Какие рецепты станция делает: Workstation.CraftingAreaRecipes или имя блока («player» - рецепты без станции).</summary>
static string[] AreasOf(Block _b)
{
if (_b == null)
{
return new string[0];
}
string a = _b.Properties.Contains("Workstation", "CraftingAreaRecipes") ? _b.Properties.GetString("Workstation", "CraftingAreaRecipes") : _b.GetBlockName();
return a.Replace(" ", "").Split(',');
}
static bool AreaFits(Recipe _r, string[] _areas)
{
string area = string.IsNullOrEmpty(_r.craftingArea) ? "player" : _r.craftingArea;
foreach (string a in _areas)
{
if (a.EqualsCaseInsensitive(area))
{
return true;
}
}
return false;
}
static bool ToolFits(Recipe _r, TileEntityWorkstation _te)
{
if (_r.craftingToolType == 0)
{
return true;
}
foreach (ItemStack t in _te.Tools)
{
if (t != null && !t.IsEmpty() && t.itemValue.type == _r.craftingToolType)
{
return true;
}
}
return false;
}
/// <summary>Свободная ячейка очереди: самая близкая к работающей (последней).</summary>
static int FreeQueueSlot(TileEntityWorkstation _te)
{
RecipeQueueItem[] q = _te.Queue;
for (int i = q.Length - 1; i >= 0; i--)
{
if (q[i] == null || q[i].Recipe == null || q[i].Multiplier <= 0)
{
return i;
}
}
return -1;
}
static bool OutputHasRoom(TileEntityWorkstation _te, ItemStack _sample)
{
foreach (ItemStack s in _te.Output)
{
if (s == null || s.IsEmpty())
{
return true;
}
int n = 1;
if (s.itemValue.type == _sample.itemValue.type && s.CanStackPartly(ref n))
{
return true;
}
}
return false;
}
/// <summary>Индекс ячейки переплавленного материала кузни для unit_X (вход 3 + номер материала), или -1.</summary>
static int UnitSlot(TileEntityWorkstation _te, string _itemName)
{
if (!_te.isModuleUsed[MaterialModule] || !_itemName.StartsWith("unit_"))
{
return -1;
}
string mat = _itemName.Substring(5);
for (int j = 0; j < _te.materialNames.Length; j++)
{
if (_te.materialNames[j].EqualsCaseInsensitive(mat) && 3 + j < _te.Input.Length)
{
return 3 + j;
}
}
return -1;
}
/// <summary>Хватает ли всего: переплавленное - в кузне, прочее - в ящиках «откуда». _srcAt - ящик, откуда первое;
/// _shortUnit - какого переплавленного не хватило (кузне можно подложить сырья).</summary>
static bool Ingredients(Recipe _r, TileEntityWorkstation _te, List<(Vector3i p, TEFeatureStorage box)> _src, out Vector3i? _srcAt, out string _shortUnit)
{
_srcAt = null;
_shortUnit = null;
foreach (ItemStack ing in _r.ingredients)
{
if (ing == null || ing.IsEmpty() || ing.count <= 0)
{
continue;
}
string iname = ing.itemValue.ItemClass?.GetItemName() ?? "";
int us = UnitSlot(_te, iname);
if (us >= 0)
{
if (_te.Input[us].count < ing.count)
{
_shortUnit = iname;
return false;
}
continue;
}
int have = 0;
foreach ((Vector3i p, TEFeatureStorage box) s in _src)
{
ItemStack[] grid = s.box.ItemGrid.items;
for (int k = 0; k < grid.Length; k++)
{
if (grid[k] != null && !grid[k].IsEmpty() && grid[k].itemValue.type == ing.itemValue.type && !CinerariumWork.Locked(s.box, k))
{
have += grid[k].count;
if (!_srcAt.HasValue)
{
_srcAt = s.p;
}
}
}
}
if (have < ing.count)
{
return false;
}
}
return true;
}
static void TakeIngredients(Recipe _r, TileEntityWorkstation _te, List<(Vector3i p, TEFeatureStorage box)> _src)
{
ItemStack[] input = _te.Input;
bool inputChanged = false;
foreach (ItemStack ing in _r.ingredients)
{
if (ing == null || ing.IsEmpty() || ing.count <= 0)
{
continue;
}
int us = UnitSlot(_te, ing.itemValue.ItemClass?.GetItemName() ?? "");
if (us >= 0)
{
input[us].count -= ing.count;
inputChanged = true;
continue;
}
int need = ing.count;
foreach ((Vector3i p, TEFeatureStorage box) s in _src)
{
ItemStack[] grid = s.box.ItemGrid.items;
for (int k = 0; k < grid.Length && need > 0; k++)
{
if (grid[k] != null && !grid[k].IsEmpty() && grid[k].itemValue.type == ing.itemValue.type && !CinerariumWork.Locked(s.box, k))
{
int take = Mathf.Min(need, grid[k].count);
CinerariumWork.Take(s.box, k, take);
need -= take;
}
}
if (need <= 0)
{
break;
}
}
}
if (inputChanged)
{
_te.Input = input;
}
}
static void Enqueue(TileEntityWorkstation _te, int _slot, Recipe _r)
{
RecipeQueueItem[] q = _te.Queue;
float t = _r.craftingTime > 0f ? _r.craftingTime : 1f;
ItemClass outClass = ItemClass.GetForId(_r.itemValueType);
q[_slot] = new RecipeQueueItem
{
Recipe = _r,
Multiplier = 1,
CraftingTimeLeft = t,
OneItemCraftTime = t,
IsCrafting = _slot == q.Length - 1,
Quality = (byte)(outClass != null && outClass.HasQuality ? 1 : 0),
StartingEntityId = -1
};
_te.Queue = q;
}
static bool RawInInput(TileEntityWorkstation _te)
{
if (!_te.isModuleUsed[MaterialModule])
{
return false;
}
for (int i = 0; i < 3 && i < _te.Input.Length; i++)
{
if (_te.Input[i] != null && !_te.Input[i].IsEmpty())
{
return true;
}
}
return false;
}
/// <summary>Сырьё для переплавки в unit_X: предмет из ящика «откуда», сделанный из этого материала, - во вход кузни.</summary>
static bool FeedMaterial(Station _st, string _unit, List<(Vector3i p, TEFeatureStorage box)> _src, bool _dry, out Vector3i? _at)
{
_at = null;
string mat = _unit.Substring(5);
ItemStack[] input = _st.Te.Input;
// 23.10 («вместо камней вплавляет булыжник»): сначала сырьё, у которого нет рецепта (камни, лом), сделанное руками
// (булыжник) - только если сырья нет. Раньше - первое подходящее по порядку ячеек.
foreach ((Vector3i p, TEFeatureStorage box, int k) s in Ordered(_src, g =>
{
ItemClass c = g.itemValue.ItemClass;
if (c?.MadeOfMaterial?.ForgeCategory == null || !c.MadeOfMaterial.ForgeCategory.EqualsCaseInsensitive(mat) ||
c.GetItemName().StartsWith("unit_"))
{
return -1;
}
return CraftingManager.GetRecipe(c.GetItemName()) == null ? 0 : 1;
}))
{
{
int k = s.k;
ItemStack g = s.box.ItemGrid.items[k];
ItemClass ic = g.itemValue.ItemClass;
for (int i = 0; i < 3 && i < input.Length; i++)
{
bool empty = input[i] == null || input[i].IsEmpty();
int room = empty ? ic.MaxCount : input[i].itemValue.type == g.itemValue.type ? ic.MaxCount - input[i].count : 0;
if (room <= 0)
{
continue;
}
_at = s.p;
if (_dry)
{
return true;
}
int take = Mathf.Min(room, g.count);
if (empty)
{
input[i] = new ItemStack(g.itemValue.Clone(), take);
}
else
{
input[i].count += take;
}
CinerariumWork.Take(s.box, k, take);
_st.Te.Input = input;
Debug.Log("[NecromancerTome] Cinerarium: рабочий положил в кузню " + _st.Pos + " " + take + " " + ic.GetItemName());
return true;
}
}
}
return false;
}
static Vector3i? FuelSource(List<(Vector3i p, TEFeatureStorage box)> _src)
{
foreach ((Vector3i p, TEFeatureStorage box, int k) s in Ordered(_src, FuelRank))
{
return s.p;
}
return null;
}
/// <summary>Порядок топлива (23.10: «вместо камней жжёт в печке сланец»): древесина, потом уголь, потом прочее горючее
/// (сланец, бензин - ценное сырьё, только если больше нечего). -1 - не горит.</summary>
static int FuelRank(ItemStack _g)
{
if (ItemClass.GetFuelValue(_g.itemValue) <= 0)
{
return -1;
}
string n = _g.itemValue.ItemClass?.GetItemName() ?? "";
return n.StartsWith("resourceWood") ? 0 : n.StartsWith("resourceCoal") ? 1 : 2;
}
/// <summary>Незакреплённые непустые ячейки ящиков «откуда», у которых _rank >= 0, по возрастанию ранга (при равном -
/// в прежнем порядке ящиков и ячеек).</summary>
static List<(Vector3i p, TEFeatureStorage box, int k)> Ordered(List<(Vector3i p, TEFeatureStorage box)> _src, System.Func<ItemStack, int> _rank)
{
List<(Vector3i p, TEFeatureStorage box, int k, int r, int i)> all = new List<(Vector3i, TEFeatureStorage, int, int, int)>();
foreach ((Vector3i p, TEFeatureStorage box) s in _src)
{
ItemStack[] grid = s.box.ItemGrid.items;
for (int k = 0; k < grid.Length; k++)
{
if (grid[k] == null || grid[k].IsEmpty() || CinerariumWork.Locked(s.box, k))
{
continue;
}
int r = _rank(grid[k]);
if (r >= 0)
{
all.Add((s.p, s.box, k, r, all.Count));
}
}
}
all.Sort((a, b) => a.r != b.r ? a.r.CompareTo(b.r) : a.i.CompareTo(b.i));
List<(Vector3i p, TEFeatureStorage box, int k)> res = new List<(Vector3i, TEFeatureStorage, int)>(all.Count);
foreach (var a in all)
{
res.Add((a.p, a.box, a.k));
}
return res;
}
/// <summary>Огонь станции с топливом: нет топлива - стопку горючего из ящиков «откуда» (_addFuel), затем зажечь.</summary>
static void Ignite(TileEntityWorkstation _te, List<(Vector3i p, TEFeatureStorage box)> _src, bool _addFuel)
{
if (!_te.isModuleUsed[FuelModule])
{
return;
}
if (_addFuel || _te.BurnTotalTimeLeft <= 0f)
{
ItemStack[] fuel = _te.Fuel;
foreach ((Vector3i p, TEFeatureStorage box, int k) s in Ordered(_src, FuelRank))
{
{
int k = s.k;
ItemStack g = s.box.ItemGrid.items[k];
for (int i = 0; i < fuel.Length; i++)
{
bool empty = fuel[i] == null || fuel[i].IsEmpty();
int max = g.itemValue.ItemClass.MaxCount;
int room = empty ? max : fuel[i].itemValue.type == g.itemValue.type ? max - fuel[i].count : 0;
if (room <= 0)
{
continue;
}
int take = Mathf.Min(room, g.count);
if (empty)
{
fuel[i] = new ItemStack(g.itemValue.Clone(), take);
}
else
{
fuel[i].count += take;
}
string fuelName = g.itemValue.ItemClass?.GetItemName(); // до Take: опустевшая ячейка очищает стопку (было NRE, 18.10)
CinerariumWork.Take(s.box, k, take);
_te.Fuel = fuel;
Debug.Log("[NecromancerTome] Cinerarium: рабочий подбросил топливо " + take + " " + fuelName);
goto lit;
}
}
}
}
lit:
if (_te.BurnTotalTimeLeft > 0f && !_te.IsBurning)
{
_te.IsBurning = true;
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Фильтр урны-логиста (16.10.2026): панель значков групп (CinerariumGroups) в окне «Содержимое» - окно necroUrnFilter
/// добавлено в группу окон игры bagStorage рядом с ячейкой черепа (Config/XUi_InGame). Видна только у урны с нитями;
/// у сумок транспорта и волка (та же группа окон) скрыта. Щелчок по значку включает/выключает группу; по умолчанию все
/// включены. Выключенный значок - тёмный.
/// </summary>
[Preserve]
public class XUiC_NecroUrnFilter : XUiController
{
readonly XUiController[] _icons = new XUiController[CinerariumGroups.Count];
readonly XUiController[] _frames = new XUiController[CinerariumGroups.Count];
static readonly Color On = Color.white;
static readonly Color Off = new Color(0.25f, 0.25f, 0.25f, 0.6f);
static readonly Color FrameOn = new Color(0.45f, 0.85f, 0.55f, 1f);
static readonly Color FrameOff = new Color(0.2f, 0.2f, 0.2f, 1f);
public override void Init()
{
base.Init();
for (int i = 0; i < CinerariumGroups.Count; i++)
{
int g = i;
_icons[i] = GetChildById("g" + i);
_frames[i] = GetChildById("f" + i);
if (_icons[i] == null)
{
continue;
}
_icons[i].OnPress += (_s, _m) => Toggle(g);
if (_icons[i].ViewComponent is XUiV_Sprite sp)
{
sp.UIAtlas = "ItemIconAtlas";
sp.SpriteName = CinerariumGroups.Icons[g];
}
}
}
public override void OnOpen()
{
base.OnOpen();
Refresh();
}
void Toggle(int _g)
{
TEFeatureCinerarium urn = CinerariumContents.Urn;
if (urn == null)
{
return;
}
urn.ToggleGroup(_g);
Refresh();
}
void Refresh()
{
TEFeatureCinerarium urn = CinerariumContents.Urn;
bool show = urn != null && urn.IsLogist;
ViewComponent.IsVisible = show;
if (!show)
{
return;
}
for (int i = 0; i < CinerariumGroups.Count; i++)
{
bool on = urn.GroupOn(i);
if (_icons[i]?.ViewComponent is XUiV_Sprite sp)
{
sp.Color = on ? On : Off;
sp.ToolTip = Localization.Get(CinerariumGroups.NameKey(i)) + " - " + Localization.Get(on ? "necroGroupOn" : "necroGroupOff");
}
if (_frames[i]?.ViewComponent is XUiV_Sprite fr)
{
fr.Color = on ? FrameOn : FrameOff;
}
}
}
}
}
+809
View File
@@ -0,0 +1,809 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Дух-работник урны - «джинн из урны» (решение 16.10): кончик хвоста уходит в горлышко, дух вырастает над урной,
/// без работы висит, чуть покачиваясь и медленно поворачиваясь. Этап 3 некротехнолога.
///
/// Не сущность игры, а видимая модель - дочерний объект модели блока урны, как каркас зоны (решение 16.10 вместо
/// плана 15.10 «сущность на префабе медсестры»): хитбокса, ИИ и сохранения у неё нет, поэтому «убить нельзя»,
/// «зомби не замечают», «сквозь стены» выходят сами, а сейвы дух не трогает - он появляется по черепу в урне
/// (TEFeatureCinerarium.HasSkull) и пропадает без него. Сдвиги Origin и выгрузку чанка переживает с моделью урны.
///
/// Модель - бандл necrotechnologist (NecroTechnologistSetup.cs): своя сетка на своём скелете NT_rig в позе покоя,
/// глаза - отдельные рендереры (в имени «eye»). Тело - светящаяся дымка (16.10: Fade давал пятна; сложение One/One,
/// холодный оттенок Tint, своё слабое свечение Glow); текстура уже серая (сделана под обесцвечивание). Глаза - свой
/// непрозрачный светящийся материал из бандла, очередь отрисовки 3100 - после тела.
/// </summary>
public static class CinerariumGenie
{
public const string ModelPath = "#@modfolder(NecromancerTome):Resources/necrotechnologist?necroTechnologistPrefab.prefab";
/// <summary>Высота корня модели над низом блока урны, м. 16.10 в Blender по модели урны: -0.15 («духа чуть приподними»).
/// Подгоняется командой necrogenie.</summary>
public static float OffsetY = -0.15f;
/// <summary>У урны рабочего горлышко ниже (земля в чаше на 0.45 м против 0.53 у урны сборщика) - дух ниже на столько же.</summary>
public static float WorkerDrop = -0.08f;
/// <summary>Сдвиг вперёд в системе самого духа, м (16.10: «чуть вперёд, чтобы хвост не просвечивал сквозь урну сзади»
/// - загнутый назад кончик хвоста; в Blender 0.06 - ни одной вершины за стенкой, взято 0.07). Поворачивается вместе
/// с духом, поэтому хвост всегда в горлышке. Знак проверить в игре: necrogenie fwd.</summary>
public static float Forward = 0.07f;
/// <summary>Поворот без работы, градусов в секунду.</summary>
public static float TurnSpeed = 6f;
/// <summary>Покачивание: размах (м) и период (с).</summary>
public static float BobHeight = 0.04f;
public static float BobPeriod = 4f;
public static float Alpha = 0.3f;
/// <summary>Засыпание (16.10): игрок дальше SleepDistance - дух замирает дома, огоньки нитей стоят. Работа идёт.</summary>
public static float SleepDistance = 40f;
public static bool Hidden;
/// <summary>Далеко ли игрок от точки сцены (без Origin). Для засыпания духа и нитей.</summary>
public static bool PlayerFar(Vector3 _scenePos)
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pl == null)
{
return true;
}
return (pl.GetPosition() - Origin.position - _scenePos).sqrMagnitude > SleepDistance * SleepDistance;
}
/// <summary>Руки в жесте: плечо от вертикали вбок, локоть согнут вперёд, градусы. Покой - «смирно» (18.10: «Всё Смирно и
/// будет позой для бездельничающих некротехов») запечён в позу покоя модели (pose_attention.json при выгрузке FBX),
/// аниматора нет - без жеста кости не трогаются.</summary>
public static float ArmOut = 3f;
public static float Elbow = 0f;
static GameObject _prefab;
static bool _tried;
static readonly Dictionary<Material, Material> _ghostMats = new Dictionary<Material, Material>();
/// <summary>Все джинны на сцене - команда necrogenie правит их сразу.</summary>
public static readonly List<CinerariumGenieMotion> Live = new List<CinerariumGenieMotion>();
static bool Load()
{
if (!_tried)
{
_tried = true;
_prefab = DataLoader.LoadAsset<GameObject>(ModelPath);
Debug.Log("[NecromancerTome] CinerariumGenie: модель " + (_prefab != null ? "загружена" : "НЕ загружена: " + ModelPath));
}
return _prefab != null;
}
/// <summary>Дух логиста (18.10: «Пусть дух вылетает когда нужно что-то нести. Но уменьшим его в три раза»): размер.</summary>
public static float LogistScale = 1f / 3f;
/// <summary>Дом духа логиста в осях модели урны - корень модели духа внутри кадила, середина тела на высоте окон крышки
/// (по центру - окна на 0.63 м, у стены - 0.73 м, кадило в 0.22 м от стены, стена - -z).</summary>
public static Vector3 LogistHome = new Vector3(0f, 0.25f, 0f);
public static Vector3 LogistWallHome = new Vector3(0f, 0.35f, -0.22f);
/// <summary>Окна крышки - на расстоянии LogistExit от оси кадила (радиус крышки 0.21 м), по осям модели.</summary>
public static float LogistExit = 0.3f;
/// <summary>Лутер: сдвиг духа вперёд (подобран в Blender 18.10).</summary>
public static float LootForward = 0.08f;
/// <summary>_logist: 0 - обычный дух над горлышком, 1 - дух логиста в кадиле по центру, 2 - в кадиле у стены.</summary>
public static GameObject Spawn(Transform _urnModel, float _drop, int _logist = 0)
{
if (_urnModel == null || !Load())
{
return null;
}
GameObject g = Object.Instantiate(_prefab, _urnModel, false);
g.name = "NecroCinerariumGenie";
foreach (Renderer r in g.GetComponentsInChildren<Renderer>(true))
{
r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
r.receiveShadows = false;
if (r is SkinnedMeshRenderer smr)
{
smr.updateWhenOffscreen = false; // 16.10: вне экрана скелет не считать (экономия); границы - от сетки
}
if (r.name.Contains("eye"))
{
continue;
}
Material[] mats = r.sharedMaterials;
for (int i = 0; i < mats.Length; i++)
{
mats[i] = Ghost(mats[i]);
}
r.sharedMaterials = mats;
}
foreach (Collider c in g.GetComponentsInChildren<Collider>(true))
{
Object.Destroy(c);
}
CinerariumGenieMotion m = g.AddComponent<CinerariumGenieMotion>();
m.Drop = _drop;
if (_logist > 0)
{
m.Small = true;
m.Wall = _logist == 2;
g.transform.localScale = Vector3.one * LogistScale;
}
m.Pose();
return g;
}
/// <summary>Полупрозрачная копия материала тела - одна на исходный материал на всю игру.</summary>
static Material Ghost(Material _src)
{
if (_src == null)
{
return null;
}
if (_ghostMats.TryGetValue(_src, out Material m) && m != null)
{
return m;
}
m = new Material(_src) { name = _src.name + "_ghost" };
Shader std = Shader.Find("Standard");
if (std != null && m.shader != std)
{
m.shader = std;
}
// 16.10: Fade (SrcAlpha/OneMinusSrcAlpha) давал «прозрачный, но какими-то пятнами» и «не выглядит бесплотным
// духом»: без записи глубины треугольники одной сетки ложатся в случайном порядке. Свечение (One/One) от порядка
// не зависит: дух только добавляет свет к тому, что за ним, - дымка. Альфа здесь не нужна, яркость - цветом.
m.SetFloat("_Mode", 2f);
m.SetOverrideTag("RenderType", "Transparent");
m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.One);
m.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.One);
m.SetInt("_ZWrite", 0);
m.DisableKeyword("_ALPHATEST_ON");
m.DisableKeyword("_ALPHABLEND_ON");
m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
if (m.HasProperty("_Glossiness"))
{
m.SetFloat("_Glossiness", 0f); // без бликов: блик при сложении - белое пятно
}
if (m.HasProperty("_Metallic"))
{
m.SetFloat("_Metallic", 0f);
}
if (m.HasProperty("_EmissionMap") && m.HasProperty("_MainTex"))
{
m.SetTexture("_EmissionMap", m.GetTexture("_MainTex")); // своё свечение - чтобы ночью не пропадал
m.EnableKeyword("_EMISSION");
m.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None;
}
SetLook(m); // смешивание, глубина и очередь - тоже там (Solid)
_ghostMats[_src] = m;
return m;
}
/// <summary>Холодный бледный оттенок духа (умножается на серую текстуру).</summary>
public static Color Tint = new Color(0.55f, 0.75f, 1f, 1f);
/// <summary>Своё свечение тела, доля от Tint.</summary>
public static float Glow = 0.35f;
/// <summary>ВРЕМЕННО (17.10: «Временно убери им прозрачность. А то плохо видно» - пока подбирается поза рук): дух
/// непрозрачный, обычная заливка с записью глубины. Перед релизом вернуть false (BACKLOG). Консоль: necrogenie solid 0|1.</summary>
public static bool Solid = false; // 18.10: прозрачность вернули («Прозрачность верни сразу»)
static void SetLook(Material _m)
{
if (_m == null)
{
return;
}
_m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.One);
_m.SetInt("_DstBlend", (int)(Solid ? UnityEngine.Rendering.BlendMode.Zero : UnityEngine.Rendering.BlendMode.One));
_m.SetInt("_ZWrite", Solid ? 1 : 0);
_m.SetOverrideTag("RenderType", Solid ? "Opaque" : "Transparent");
_m.renderQueue = (int)(Solid ? UnityEngine.Rendering.RenderQueue.Geometry : UnityEngine.Rendering.RenderQueue.Transparent);
if (Solid)
{
if (_m.HasProperty("_Color"))
{
_m.color = new Color(Tint.r, Tint.g, Tint.b, 1f);
}
if (_m.HasProperty("_EmissionColor"))
{
_m.SetColor("_EmissionColor", new Color(Tint.r * Glow * 0.5f, Tint.g * Glow * 0.5f, Tint.b * Glow * 0.5f, 1f));
}
return;
}
if (_m.HasProperty("_Color"))
{
_m.color = new Color(Tint.r * Alpha, Tint.g * Alpha, Tint.b * Alpha, 1f);
}
if (_m.HasProperty("_EmissionColor"))
{
_m.SetColor("_EmissionColor", new Color(Tint.r * Glow, Tint.g * Glow, Tint.b * Glow, 1f));
}
}
public static void ApplyAlpha()
{
foreach (Material m in _ghostMats.Values)
{
SetLook(m);
}
}
static readonly Dictionary<Material, Material> _stuckMats = new Dictionary<Material, Material>();
/// <summary>Почерневший дух (класть некуда): свечением чёрное не нарисовать, поэтому обычная Fade-копия, тёмная.</summary>
public static Material Stuck(Material _src)
{
if (_src == null)
{
return null;
}
if (_stuckMats.TryGetValue(_src, out Material m) && m != null)
{
return m;
}
m = new Material(_src) { name = _src.name + "_stuck" };
Shader std = Shader.Find("Standard");
if (std != null && m.shader != std)
{
m.shader = std;
}
m.SetFloat("_Mode", 2f);
m.SetOverrideTag("RenderType", "Transparent");
m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
m.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
m.SetInt("_ZWrite", 1); // с глубиной: пятен нет, тёмный и почти плотный
m.DisableKeyword("_ALPHATEST_ON");
m.EnableKeyword("_ALPHABLEND_ON");
m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
if (m.HasProperty("_Color"))
{
m.color = new Color(0.04f, 0.035f, 0.045f, 0.8f);
}
_stuckMats[_src] = m;
return m;
}
/// <summary>Исходный материал тела по его светящейся копии.</summary>
public static Material Source(Material _ghost)
{
foreach (KeyValuePair<Material, Material> p in _ghostMats)
{
if (p.Value == _ghost)
{
return p.Key;
}
}
foreach (KeyValuePair<Material, Material> p in _stuckMats)
{
if (p.Value == _ghost)
{
return p.Key;
}
}
return _ghost;
}
/// <summary>Шейдер материала пропал - дух рисуется пурпурным (23.10.2026, скриншот пользователя: после выхода из
/// питомца в 218 блоках от базы все духи некротехнологов пурпурные; раньше - #70, дух закопанной урны).</summary>
public static bool Broken(Material _m)
{
return _m != null && (_m.shader == null || !_m.shader.isSupported || _m.shader.name == "Hidden/InternalErrorShader");
}
/// <summary>Сломанную копию - из кэша вон и собрать заново из исходного материала модели.</summary>
public static Material Repair(Material _broken, bool _stuck)
{
Material src = Source(_broken);
Debug.Log("[NecromancerTome] CinerariumGenie: шейдер духа сломан (" + (_broken.shader != null ? _broken.shader.name : "null") +
", исходный материал '" + (src != null ? src.name : "null") + "' - шейдер " +
(src != null && src.shader != null ? src.shader.name + (src.shader.isSupported ? "" : " НЕ поддерживается") : "null") +
", Standard " + (Shader.Find("Standard") != null ? "есть" : "НЕ найден") + ") - материал пересоздан");
if (src == null || src == _broken)
{
return _broken;
}
_ghostMats.Remove(src);
_stuckMats.Remove(src);
return _stuck ? Stuck(src) : Ghost(src);
}
}
/// <summary>Покачивание и поворот джинна над урной. Только вид - никакой логики.</summary>
[Preserve]
public partial class CinerariumGenieMotion : MonoBehaviour
{
float _yaw;
float _phase;
/// <summary>Своя поправка высоты урны (CinerariumGenie.WorkerDrop у рабочего).</summary>
public float Drop;
/// <summary>Свой сдвиг вперёд (NaN - общий CinerariumGenie.Forward). Лутер (18.10, банка с крышкой-кадилом): 0.08 - хвост
/// по центру отверстия на макушке («Вот сейчас было идеально»).</summary>
public float ForwardOverride = float.NaN;
/// <summary>Полёт к месту работы и обратно (16.10: «возвращается задом», «делал действие не сразу, а хоть руками
/// пошевелив»): туда - лицом к месту, над местом - жест руками (дело делается в его середине, см. Visit),
/// обратно - развернувшись лицом к урне. Точка - в координатах модели урны; _t - время с начала полёта, -1 - дома.</summary>
struct Stop
{
public Vector3 Hover, Look; // в осях модели урны
public float Fly;
public bool Pass; // точка пути (окно кадила) - без жеста
}
/// <summary>Дух логиста (18.10): втрое меньше, дома (в кадиле) не виден, вылетает и влетает через окно крышки.</summary>
public bool Small;
/// <summary>Кадило у стены: дом сдвинут к кронштейну, окно в стену (-z) не используется.</summary>
public bool Wall;
readonly List<Stop> _stops = new List<Stop>();
float _backFly;
float _t = -1f;
/// <summary>Скорость полёта, м/с: время перелёта - не меньше OutSeconds/BackSeconds, дальше - по расстоянию (нити до 15 м).</summary>
public static float FlySpeed = 4f;
public static float OutSeconds = 1.3f;
public static float WorkSeconds = 1.6f;
public static float BackSeconds = 1.3f;
/// <summary>Жест: плечи вперёд до GestureFwd градусов, локти ходят на ±GestureWave вразнобой.</summary>
public static float GestureFwd = 55f;
public static float GestureWave = 30f;
bool _gesturing;
/// <summary>Почернел и замер (готовое класть некуда, решение 06.10).</summary>
bool _stuck;
/// <summary>Кости рук (плечо, предплечье, кисть по сторонам) и их поворот в позе покоя.</summary>
Transform[] _arm;
Quaternion[] _armRest;
static bool _armLogged;
/// <summary>Покой - руки в позе модели («смирно»).</summary>
public void Pose()
{
Pose(0f, CinerariumGenie.Elbow, CinerariumGenie.Elbow);
}
/// <summary>Жест долей _w от покоя: плечо вперёд на GestureFwd, направление плеча и предплечья задаётся в осях модели
/// (вперёд - +Z), поворот - от текущего направления кости к нужному, так что оси самих костей значения не имеют.</summary>
void Pose(float _w, float _elbowL, float _elbowR)
{
if (_arm == null)
{
string[] names = { "LeftArm", "LeftForeArm", "LeftHand", "RightArm", "RightForeArm", "RightHand" };
_arm = new Transform[names.Length];
_armRest = new Quaternion[names.Length];
foreach (Transform t in GetComponentsInChildren<Transform>(true))
{
for (int i = 0; i < names.Length; i++)
{
if (_arm[i] == null && t.name.EndsWith(names[i]))
{
_arm[i] = t;
_armRest[i] = t.localRotation;
}
}
}
if (!_armLogged)
{
_armLogged = true;
System.Text.StringBuilder sb = new System.Text.StringBuilder();
for (int i = 0; i < names.Length; i++)
{
sb.Append(names[i]).Append('=').Append(_arm[i] != null ? "ok" : "НЕТ").Append(' ');
}
Debug.Log("[NecromancerTome] CinerariumGenie: кости рук " + sb);
}
}
for (int i = 0; i < _arm.Length; i++)
{
if (_arm[i] != null)
{
_arm[i].localRotation = _armRest[i];
}
}
if (_w <= 0f)
{
return;
}
for (int side = 0; side < 2; side++)
{
Transform up = _arm[side * 3], fore = _arm[side * 3 + 1], hand = _arm[side * 3 + 2];
if (up == null || fore == null || hand == null)
{
continue;
}
float sign = Mathf.Sign(transform.InverseTransformPoint(up.position).x);
float o = CinerariumGenie.ArmOut * Mathf.Deg2Rad, f = GestureFwd * Mathf.Deg2Rad;
Vector3 upDir = new Vector3(sign * Mathf.Sin(o), -Mathf.Cos(o) * Mathf.Cos(f), Mathf.Sin(f)).normalized;
Vector3 foreDir = Vector3.RotateTowards(upDir, Vector3.forward, (side == 0 ? _elbowL : _elbowR) * Mathf.Deg2Rad, 0f);
up.rotation = Quaternion.Slerp(Quaternion.identity, Quaternion.FromToRotation(fore.position - up.position, transform.TransformDirection(upDir)), _w) * up.rotation;
fore.rotation = Quaternion.Slerp(Quaternion.identity, Quaternion.FromToRotation(hand.position - fore.position, transform.TransformDirection(foreDir)), _w) * fore.rotation;
}
}
/// <summary>Светящиеся материалы рендереров - вернуть после почернения.</summary>
readonly Dictionary<Renderer, Material[]> _glowing = new Dictionary<Renderer, Material[]>();
/// <summary>Облёт мест работы по порядку (у перевозки по нити - ящик «откуда», потом «куда»), у каждого - жест, затем
/// назад к урне. Точки - в координатах сцены (без Origin): stand - где встать (низ клетки), look - низ клетки места,
/// beside - встал сбоку, а не над местом. Возвращает, через сколько секунд дело (середина последнего жеста).</summary>
public float Visit(List<(Vector3 stand, Vector3 look, bool beside)> _list)
{
if (transform.parent == null || _list == null || _list.Count == 0)
{
return 0f;
}
_stops.Clear();
Vector3 home = Home();
Vector3 prev = home;
float delay = 0f;
Vector3 exit = home;
if (Small)
{
// Сначала - к окну крышки в сторону первого места (у стены окно в стену не годится), оттуда - к месту.
exit = Window(home, transform.parent.InverseTransformPoint(_list[0].stand) - home);
_stops.Add(new Stop { Hover = exit, Look = exit + (exit - home), Fly = 0.35f, Pass = true });
delay += 0.35f;
prev = exit;
}
foreach ((Vector3 stand, Vector3 look, bool beside) in _list)
{
// Высота корня духа над низом клетки места: обычный дух - над станцией 0.8 (сбоку 0.15) с поправкой урны;
// маленький - над верхом клетки (1.05) или у её середины сбоку (0.4).
float up = Small ? (beside ? 0.4f : 1.05f) : CinerariumGenie.OffsetY + Drop + (beside ? 0.15f : 0.8f);
Stop st = new Stop
{
Hover = transform.parent.InverseTransformPoint(stand) + new Vector3(0f, up, 0f),
Look = transform.parent.InverseTransformPoint(look)
};
st.Fly = Mathf.Max(OutSeconds, Vector3.Distance(prev, st.Hover) / FlySpeed);
delay += st.Fly + WorkSeconds;
prev = st.Hover;
_stops.Add(st);
}
if (Small)
{
// Назад - тем же окном.
_stops.Add(new Stop { Hover = exit, Look = home, Fly = Mathf.Max(BackSeconds, Vector3.Distance(prev, exit) / FlySpeed), Pass = true });
prev = exit;
}
_backFly = Small ? 0.35f : Mathf.Max(BackSeconds, Vector3.Distance(prev, home) / FlySpeed);
_t = 0f;
return delay - WorkSeconds * 0.5f;
}
/// <summary>Окно крышки кадила по направлению _dir (оси модели): одно из четырёх по осям x/z, у стены - не в стену.</summary>
Vector3 Window(Vector3 _home, Vector3 _dir)
{
Vector3 best = Vector3.forward;
float bestDot = float.MinValue;
foreach (Vector3 w in new[] { Vector3.forward, Vector3.back, Vector3.left, Vector3.right })
{
if (Wall && w == Vector3.back)
{
continue;
}
float d = Vector3.Dot(w, new Vector3(_dir.x, 0f, _dir.z));
if (d > bestDot)
{
bestDot = d;
best = w;
}
}
return _home + best * CinerariumGenie.LogistExit;
}
Vector3 Home()
{
if (Small)
{
return Wall ? CinerariumGenie.LogistWallHome : CinerariumGenie.LogistHome;
}
float fwd = float.IsNaN(ForwardOverride) ? CinerariumGenie.Forward : ForwardOverride;
return new Vector3(0f, CinerariumGenie.OffsetY + Drop, 0f) + Quaternion.Euler(0f, _yaw, 0f) * new Vector3(0f, 0f, fwd);
}
/// <summary>Повернуться лицом по направлению _dir (горизонтально), не быстрее 300°/с.</summary>
void Face(Vector3 _dir)
{
if (_dir.x * _dir.x + _dir.z * _dir.z > 0.01f)
{
_yaw = Mathf.MoveTowardsAngle(_yaw, Mathf.Atan2(_dir.x, _dir.z) * Mathf.Rad2Deg, 300f * Time.deltaTime) % 360f;
}
}
void Gesture(float _w)
{
if (_w > 0f)
{
float wave = Mathf.Sin(_t * Mathf.PI * 2f * 1.5f) * GestureWave;
Pose(_w, CinerariumGenie.Elbow + 15f + wave, CinerariumGenie.Elbow + 15f - wave);
_gesturing = true;
}
else if (_gesturing)
{
Pose();
_gesturing = false;
}
}
public void SetStuck(bool _on)
{
if (_stuck == _on)
{
return;
}
_stuck = _on;
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
if (r.name.Contains("eye"))
{
continue;
}
if (!_glowing.ContainsKey(r))
{
_glowing[r] = r.sharedMaterials;
}
Material[] glow = _glowing[r];
if (_on)
{
Material[] dark = new Material[glow.Length];
for (int i = 0; i < glow.Length; i++)
{
dark[i] = CinerariumGenie.Stuck(CinerariumGenie.Source(glow[i]));
}
r.sharedMaterials = dark;
}
else
{
r.sharedMaterials = glow;
}
}
}
void OnEnable()
{
CinerariumGenie.Live.Add(this);
_phase = Random.value * 10f;
_yaw = Random.value * 360f;
}
void OnDisable()
{
CinerariumGenie.Live.Remove(this);
}
float _nextSleepCheck;
bool _asleep;
bool _hiddenApplied;
float _nextShaderCheck;
Renderer[] _renderers;
/// <summary>Раз в 5 с: не сломан ли шейдер у материалов духа (CinerariumGenie.Broken) - починить на месте.</summary>
void CheckShaders()
{
if (_renderers == null)
{
_renderers = GetComponentsInChildren<Renderer>(true);
}
foreach (Renderer r in _renderers)
{
if (r == null || r.name.Contains("eye"))
{
continue;
}
Material[] mats = r.sharedMaterials;
bool fixedAny = false;
for (int i = 0; i < mats.Length; i++)
{
if (CinerariumGenie.Broken(mats[i]))
{
mats[i] = CinerariumGenie.Repair(mats[i], _stuck);
fixedAny = true;
}
}
if (fixedAny)
{
r.sharedMaterials = mats;
_glowing.Clear(); // сохранённые светлые наборы могли быть со сломанными копиями
}
}
}
void Update()
{
if (Time.time >= _nextShaderCheck)
{
_nextShaderCheck = Time.time + 5f;
CheckShaders();
}
if (Time.time >= _nextSleepCheck)
{
_nextSleepCheck = Time.time + 0.5f;
// Уснул - игрок далеко или дух не среди ближайших (CinerariumLoad.AnimateNearest): замирает дома, работа идёт.
bool asleep = CinerariumGenie.PlayerFar(transform.position) || !CinerariumLoad.Animated(this);
if (asleep && !_asleep && !Digging) // старатель под землёй - не возвращать домой
{
_t = -1f; // полёт прерван - дело всё равно выполнится
Gesture(0f);
transform.localPosition = Home();
}
_asleep = asleep;
}
// Виден ли: не скрыт командой; дух логиста - только в полёте (дома сидит в кадиле). Рендереры - только при смене
// (16.10, проверка на утечки: раньше список рендереров собирался каждые 0.5 с - мусор для GC).
bool show = !CinerariumGenie.Hidden && (!Small || _t >= 0f) && !DigHidden;
if (_hiddenApplied != !show)
{
_hiddenApplied = !show;
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
r.enabled = show;
}
}
if (DigUpdate(_asleep))
{
return; // старатель: спуск, под землёй, подъём (CinerariumProspector.cs)
}
if (_stuck || _asleep)
{
return; // замер как был
}
float yawTarget = _yaw + CinerariumGenie.TurnSpeed * Time.deltaTime;
Vector3 home = Home();
if (_t >= 0f && _stops.Count > 0)
{
_t += Time.deltaTime;
float acc = 0f;
Vector3 from = home;
foreach (Stop st in _stops)
{
if (_t < acc + st.Fly)
{
transform.localPosition = Vector3.Lerp(from, st.Hover, Mathf.SmoothStep(0f, 1f, (_t - acc) / st.Fly));
Face(st.Hover - from);
Gesture(0f);
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return;
}
acc += st.Fly;
if (!st.Pass && _t < acc + WorkSeconds)
{
transform.localPosition = st.Hover;
Face(st.Look - st.Hover); // за работой - лицом к месту
Gesture(Mathf.Sin((_t - acc) / WorkSeconds * Mathf.PI));
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return;
}
if (!st.Pass)
{
acc += WorkSeconds;
}
from = st.Hover;
}
if (_t < acc + _backFly)
{
transform.localPosition = Vector3.Lerp(from, home, Mathf.SmoothStep(0f, 1f, (_t - acc) / _backFly));
Face(home - from); // назад - лицом к урне
Gesture(0f);
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return;
}
_t = -1f;
Gesture(0f);
}
if (Small)
{
transform.localPosition = home; // в кадиле, не виден
return;
}
_yaw = yawTarget % 360f;
float bob = CinerariumGenie.BobPeriod > 0.01f
? Mathf.Sin((Time.time + _phase) * Mathf.PI * 2f / CinerariumGenie.BobPeriod) * CinerariumGenie.BobHeight
: 0f;
transform.localPosition = home + new Vector3(0f, bob, 0f);
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
}
}
/// <summary>Подгонка джинна в игре: высота над урной, скорость поворота, покачивание, прозрачность. Не сохраняется -
/// найденные значения переносятся в константы CinerariumGenie.</summary>
[Preserve]
public class ConsoleCmdNecroGenie : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necrogenie" };
}
public override string getDescription()
{
return "Necromancer's Tome: tune the cinerarium spirit (height over the urn, turning, bobbing, transparency).";
}
public override string getHelp()
{
return "necrogenie - show the current values and how many spirits are on screen\n" +
"necrogenie y <m> - model root height above the urn block's bottom (tail tip is 0.47 m above the root)\n" +
"necrogenie fwd <m> - shift along the spirit's own forward (keeps the curled tail inside the neck)\n" +
"necrogenie turn <deg/s> - idle turning speed\n" +
"necrogenie bob <m> <s> - bobbing height and period\n" +
"necrogenie alpha <0-1> - body brightness (the body adds light, it is not alpha-blended)\n" +
"necrogenie glow <0-1> - body's own glow (visible at night)\n" +
"necrogenie tint <r> <g> <b> - body colour\n" +
"necrogenie arms <out> [elbow] - gesture arms, degrees: upper arm from vertical sideways, elbow bend (rest pose 'attention' is the model's own)\n" +
"necrogenie solid <0|1> - TEMPORARY: opaque body (1) to see the pose, or the ghostly glow (0)\n" +
"necrogenie sleep <m> - spirits and threads freeze when the player is farther (work goes on)\n" +
"necrogenie hide|show - hide all spirits (weak PCs, video), work goes on";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
if (_params.Count == 1 && (_params[0] == "hide" || _params[0] == "show"))
{
CinerariumGenie.Hidden = _params[0] == "hide";
}
if (_params.Count >= 2 && float.TryParse(_params[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v))
{
switch (_params[0].ToLowerInvariant())
{
case "y":
CinerariumGenie.OffsetY = v;
break;
case "fwd":
CinerariumGenie.Forward = v;
break;
case "turn":
CinerariumGenie.TurnSpeed = v;
break;
case "bob":
CinerariumGenie.BobHeight = v;
if (_params.Count >= 3 && float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float per))
{
CinerariumGenie.BobPeriod = per;
}
break;
case "alpha":
CinerariumGenie.Alpha = Mathf.Clamp01(v);
CinerariumGenie.ApplyAlpha();
break;
case "glow":
CinerariumGenie.Glow = Mathf.Clamp01(v);
CinerariumGenie.ApplyAlpha();
break;
case "sleep":
CinerariumGenie.SleepDistance = Mathf.Max(5f, v);
break;
case "arms":
CinerariumGenie.ArmOut = v;
if (_params.Count >= 3 && float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float ae))
{
CinerariumGenie.Elbow = ae;
}
break;
case "solid":
CinerariumGenie.Solid = v != 0f;
CinerariumGenie.ApplyAlpha();
break;
case "tint":
if (_params.Count >= 4 &&
float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float g) &&
float.TryParse(_params[3], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float b))
{
CinerariumGenie.Tint = new Color(v, g, b, 1f);
CinerariumGenie.ApplyAlpha();
}
break;
default:
con.Output("necrogenie: unknown '" + _params[0] + "' - see 'help necrogenie'");
return;
}
}
con.Output("necrogenie: y " + CinerariumGenie.OffsetY.ToString("F2") + ", fwd " + CinerariumGenie.Forward.ToString("F2") + ", turn " + CinerariumGenie.TurnSpeed.ToString("F1") +
", bob " + CinerariumGenie.BobHeight.ToString("F2") + " / " + CinerariumGenie.BobPeriod.ToString("F1") + " s, alpha " +
CinerariumGenie.Alpha.ToString("F2") + ", glow " + CinerariumGenie.Glow.ToString("F2") + ", tint " +
CinerariumGenie.Tint.r.ToString("F2") + " " + CinerariumGenie.Tint.g.ToString("F2") + " " + CinerariumGenie.Tint.b.ToString("F2") + ", arms " + CinerariumGenie.ArmOut.ToString("F0") + " " +
CinerariumGenie.Elbow.ToString("F0") + ", sleep " + CinerariumGenie.SleepDistance.ToString("F0") + " m" + (CinerariumGenie.Hidden ? ", HIDDEN" : "") + "; spirits on screen: " + CinerariumGenie.Live.Count);
}
}
}
+260
View File
@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Xml;
namespace NecromancerTome
{
/// <summary>
/// Группы предметов для фильтра урны-логиста (16.10.2026): «пиктограммы, которые по умолчанию выбраны и активны...
/// изображения предмета, символизирующего группу предметов... Дух-логист будет переносить только те предметы, которые
/// активированы в его урне. Это же прямой путь к автоматизации сортировки». Деление игры (свойство Group) грубое -
/// «Ресурсы» смешивают руду, слитки, детали и кур, - поэтому группы свои, а предмет относится к группе по классу, тегам и
/// имени. Предметы других модов разойдутся по тем же правилам; что не подошло никуда - «Прочее».
/// Номер группы = номер бита в TEFeatureCinerarium.GroupMask - порядок НЕ менять (сейвы), новые - только в конец.
/// </summary>
public static class CinerariumGroups
{
public const int Cooked = 0, Canned = 1, Raw = 2, Harvest = 3, Flowers = 4, Seeds = 5, Drinks = 6, Medicine = 7, Ore = 8,
Ingots = 9, Parts = 10, Materials = 11, Chemicals = 12, Tools = 13, Weapons = 14, Ammo = 15, Armor = 16, Mods = 17,
Books = 18, Blocks = 19, Necro = 20, Other = 21,
// 17.10: «Корм для кур вынеси в отдельную группу» (был в «Сыром»).
ChickenFeed = 22,
// 23.10 (проверка всех предметов: куры и сложенный транспорт падали в «Прочее»; «Можно и добавить группы»).
Livestock = 23, Vehicles = 24,
// 23.10: «Заведи группу стройматериалов. Руда и камень отдельно. Сортировка должна быть удобной и с учётом
// производства» - то, чем игра улучшает блоки и что для этого производят: булыжник, цемент, бетонная смесь.
Building = 25;
public const int Count = 26;
public const uint All = (1u << Count) - 1;
/// <summary>Значок группы - иконка типичного предмета (ItemIconAtlas).</summary>
public static readonly string[] Icons =
{
"foodMeatStew", "foodCanChili", "foodRawMeat", "foodCropCorn", "plantedChrysanthemum3Harvest", "plantedCorn1",
"drinkJarBoiledWater", "medicalFirstAidKit", "resourceRockSmall", "resourceForgedSteel", "resourceMechanicalParts",
"resourceCloth", "resourceGunPowder", "meleeToolPickT1IronPickaxe", "gunHandgunT1Pistol", "ammo9mmBulletBall",
"armorMinerHelmet", "modGunLaserSight", "bookArtOfMiningLuckyStrike", "cobblestoneMaster", "necroSkull", "oldCash",
"resourceChickenFeed", "domesticatedChicken", "vehicleMinibikePlaceable", "resourceConcreteMix"
};
/// <summary>Ключ названия группы в Localization.csv: necroGroup0..necroGroup25.</summary>
public static string NameKey(int _g) => "necroGroup" + _g;
static readonly HashSet<string> OreNames = new HashSet<string>
{
"resourceRockSmall", "resourceRockSmallBundle", "resourceScrapIron", "resourceScrapIronBundle", "resourceScrapLead",
"resourceLeadBundle", "resourceCoal", "resourceCoalBundle", "resourcePotassiumNitratePowder",
"resourcePotassiumNitratePowderBundle", "resourceOilShale", "resourceOilShaleBundle", "resourceClayLump",
"resourceCrushedSand", "resourceGoldNugget", "resourceSilverNugget", "resourceRawDiamond",
"resourceSnowBall"
};
/// <summary>Стройматериалы (23.10): древесина, булыжник, цемент, бетонная смесь - чем строят и улучшают блоки; сталь - в «Слитках».</summary>
static readonly HashSet<string> BuildingNames = new HashSet<string>
{
"resourceCobblestones", "resourceCement", "resourceConcreteMix", "resourceWood", "resourceWoodBundle" // древесина - 23.10, пользователь
};
static readonly HashSet<string> IngotNames = new HashSet<string> { "resourceForgedIron", "resourceForgedSteel" };
static readonly HashSet<string> ChemNames = new HashSet<string>
{
"resourceGunPowder", "resourceGunPowderBundle", "resourceOil", "ammoGasCan", "ammoGasCanBundle", "resourceAcid",
"resourceGlue", "resourcePaint", "resourceTestosteroneExtract"
};
static readonly HashSet<string> MaterialNames = new HashSet<string>
{
"resourceCloth", "resourceLeather", "resourceBone", "resourceFeather",
"resourceYuccaFibers", "resourcePaper", "resourceBeeswax", "resourceCandleStick",
"resourceAnimalFat"
};
/// <summary>Детали, которые теги отнесли бы не туда (гвозди - патроны гвоздомёта).</summary>
static readonly HashSet<string> PartNames = new HashSet<string> { "resourceNail" };
static readonly HashSet<string> AmmoParts = new HashSet<string>
{
"resourceArrowHeadIron", "resourceArrowHeadSteelAP", "resourceBuckshot", "resourceBulletCasing", "resourceBulletTip",
"resourceRocketCasing", "resourceRocketTip"
};
static readonly HashSet<string> RawNames = new HashSet<string>
{
"foodRawMeat", "foodRawMeatBundle", "foodEgg", "foodHoney", "foodRottingFlesh"
};
/// <summary>Предметы и блоки самого мода - по его Config/items.xml, blocks.xml и item_modifiers.xml (у многих нет «necro»
/// в имени: книги призыва, браслет, камни портала, прах зомби, кожа жертвы; 23.10 - кровавая сфера и кровавый камень,
/// они модификаторы, и без item_modifiers.xml уходили в «Модификаторы»). Читается один раз.</summary>
static HashSet<string> _modNames;
static HashSet<string> ModNames()
{
if (_modNames != null)
{
return _modNames;
}
_modNames = new HashSet<string>();
foreach (string f in new[] { "items.xml", "blocks.xml", "item_modifiers.xml" })
{
try
{
XmlDocument doc = new XmlDocument();
doc.Load(Path.Combine(ModEntry.Instance.Path, "Config", f));
foreach (XmlNode n in doc.SelectNodes("//item[@name] | //block[@name] | //item_modifier[@name]"))
{
_modNames.Add(n.Attributes["name"].Value);
}
}
catch (Exception e)
{
UnityEngine.Debug.LogWarning("[NecromancerTome] CinerariumGroups: " + f + " мода не прочитан (" + e.Message + ")");
}
}
return _modNames;
}
static readonly Dictionary<int, int> _cache = new Dictionary<int, int>();
public static int Of(ItemValue _iv)
{
ItemClass ic = _iv?.ItemClass;
if (ic == null)
{
return Other;
}
if (!_cache.TryGetValue(ic.Id, out int g))
{
g = Classify(ic, _iv);
_cache[ic.Id] = g;
}
return g;
}
static bool Tag(ItemClass _ic, string _t)
{
return _ic.ItemTags.Test_AnySet(FastTags<TagGroup.Global>.Parse(_t));
}
static int Classify(ItemClass _ic, ItemValue _iv)
{
string n = _ic.GetItemName() ?? "";
// 23.10: семена - раньше «Некромантии»: кровавые семена (plantedNecroBloodRose1) уходили с грядок к логисту
// «только некромантия», и сборщику нечего было сеять. Всё, что сажается на грядку, - «Семена».
if (_ic.IsBlock() && new ItemValue(_ic.Id).ToBlockValue().Block is BlockPlantGrowing)
{
return Seeds;
}
// 23.10: живность (куры для курятника, кролик в мешке) и сложенный транспорт - свои группы.
if (n.StartsWith("domesticated") || n == RabbitGrab.SackItem)
{
return Livestock;
}
if (n.StartsWith("vehicle") && n.EndsWith("Placeable"))
{
return Vehicles;
}
// 23.10: жестяные банки - предметы мода, но по смыслу это стеклянные банки игры (кипятить воду) - к ним, не в «Некромантию».
if (n.StartsWith("tinCan"))
{
return Drinks;
}
if (n.IndexOf("necro", StringComparison.OrdinalIgnoreCase) >= 0 || ModNames().Contains(n))
{
return Necro;
}
if (n == "resourceChickenFeed")
{
return ChickenFeed;
}
if (_ic is ItemClassModifier)
{
return Mods;
}
if (_ic.IsBlock())
{
return new ItemValue(_ic.Id).ToBlockValue().Block is BlockPlantGrowing ? Seeds : Blocks;
}
if (_ic is ItemClassArmor || n.StartsWith("armor") || n.StartsWith("clothing"))
{
return Armor;
}
if (n.StartsWith("book") || n.Contains("Schematic") || n.Contains("Magazine") || Tag(_ic, "books"))
{
return Books;
}
if (BuildingNames.Contains(n))
{
return Building;
}
if (OreNames.Contains(n))
{
return Ore;
}
if (IngotNames.Contains(n) || n.StartsWith("unit_"))
{
return Ingots;
}
if (ChemNames.Contains(n))
{
return Chemicals;
}
if (PartNames.Contains(n))
{
return Parts;
}
if (MaterialNames.Contains(n))
{
return Materials;
}
if (AmmoParts.Contains(n) || n.StartsWith("ammo") || n.StartsWith("thrown") || Tag(_ic, "ammo"))
{
return Ammo;
}
if (n.EndsWith("Parts") || n == "partsMaster" || n.StartsWith("vehicle") && !n.EndsWith("Placeable"))
{
return Parts;
}
if (n.StartsWith("meleeTool"))
{
return Tools; // раньше оружия: у факела тег weapon
}
if (n.StartsWith("gun") || n.StartsWith("meleeWpn") || Tag(_ic, "weapon"))
{
return Weapons;
}
if (n.StartsWith("meleeTool") || n.StartsWith("tool") || n.StartsWith("bucket") || Tag(_ic, "tool"))
{
return Tools;
}
if (n.StartsWith("foodCan"))
{
return Canned;
}
if (n.StartsWith("foodCrop"))
{
return Harvest;
}
if (n.StartsWith("resourceCrop"))
{
return Flowers;
}
if (RawNames.Contains(n) || n.StartsWith("foodRaw"))
{
return Raw;
}
if (n.StartsWith("drink") || n.StartsWith("tinCan"))
{
return Drinks;
}
if (n.StartsWith("medical") || n.StartsWith("drug") || Tag(_ic, "medical"))
{
return Medicine;
}
if (n.StartsWith("food") || Tag(_ic, "food"))
{
return Cooked;
}
if (n.StartsWith("resource") || n == "carBattery" || n == "smallEngine" || n == "solarCell")
{
return Parts;
}
return Other;
}
}
}
+147
View File
@@ -0,0 +1,147 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Урны работают, пока игрок далеко (решение 23.10.2026, #78: «Харвестры работают только когда рядом» - «если игрок далеко
/// можно выключить анимацию духов... и пусть духи продолжат работать. С остальными тоже. Если ящики переполнятся, то
/// харвестры встанут»; досчёт по времени не годится - сборщик следит за ростом и сеет). Игра обновляет участки, за
/// которыми кто-то наблюдает (ChunkManager: активный набор = все участки наблюдателей), - поэтому у каждой группы урн
/// свой наблюдатель без видимой геометрии: участки не выгружаются, урны и грядки идут. Анимация вдали и так выключена
/// (CinerariumLoad.AnimateNearest). Группа - клетка CellSize×CellSize блоков по реестру урн сейва (CinerariumRegistry),
/// наблюдатель в её центре держит (ViewDim+2) участков во все стороны - клетку и рабочие зоны у её краёв.
/// Нагрузку мерить по логу (Heap/RSS, Chunks); если тяжело - сначала оптимизация, запасной путь - урны стоят без игрока.
/// Консоль: necrournkeep on|off|dim N.
/// </summary>
public static class CinerariumKeepAlive
{
public static bool Enabled = true;
public const int CellSize = 64;
/// <summary>Радиус наблюдателя в участках минус 2 (так считает ChunkManager): 1 - 7×7 участков.</summary>
public static int ViewDim = 1;
const float SyncSeconds = 10f;
static readonly Dictionary<Vector2i, ChunkManager.ChunkObserver> _observers = new Dictionary<Vector2i, ChunkManager.ChunkObserver>();
static World _world;
static float _nextSync;
public static int Count => _observers.Count;
public static void Tick()
{
if (Time.time < _nextSync)
{
return;
}
_nextSync = Time.time + SyncSeconds;
GameManager gm = GameManager.Instance;
World w = gm?.World;
if (w != _world)
{
_observers.Clear(); // мир сменился - прежние наблюдатели ушли вместе с ним
_world = w;
}
if (w == null || !ConnectionManager.Instance.IsServer)
{
return;
}
// Клетки с урнами и средняя высота урн в каждой.
Dictionary<Vector2i, (int sum, int n)> cells = new Dictionary<Vector2i, (int, int)>();
if (Enabled)
{
foreach (Vector3i p in CinerariumRegistry.All())
{
Vector2i c = new Vector2i(Mathf.FloorToInt(p.x / (float)CellSize), Mathf.FloorToInt(p.z / (float)CellSize));
cells.TryGetValue(c, out (int sum, int n) v);
cells[c] = (v.sum + p.y, v.n + 1);
}
}
List<Vector2i> gone = null;
foreach (KeyValuePair<Vector2i, ChunkManager.ChunkObserver> kv in _observers)
{
if (!cells.ContainsKey(kv.Key) || kv.Value.viewDim != ViewDim)
{
(gone ??= new List<Vector2i>()).Add(kv.Key);
}
}
if (gone != null)
{
foreach (Vector2i c in gone)
{
gm.RemoveChunkObserver(_observers[c]);
_observers.Remove(c);
Debug.Log("[NecromancerTome] CinerariumKeepAlive: группа урн " + c + " - наблюдатель снят, всего " + _observers.Count);
}
}
foreach (KeyValuePair<Vector2i, (int sum, int n)> kv in cells)
{
if (_observers.ContainsKey(kv.Key))
{
continue;
}
Vector3 center = new Vector3((kv.Key.x + 0.5f) * CellSize, kv.Value.sum / (float)kv.Value.n, (kv.Key.y + 0.5f) * CellSize);
_observers[kv.Key] = gm.AddChunkObserver(center, false, ViewDim, -1);
Debug.Log("[NecromancerTome] CinerariumKeepAlive: группа урн " + kv.Key + " (" + kv.Value.n + " шт.) - наблюдатель в " + center +
", всего " + _observers.Count);
}
}
/// <summary>Сразу пересобрать (команда).</summary>
public static void SyncNow()
{
_nextSync = 0f;
Tick();
}
}
[HarmonyPatch(typeof(World), nameof(World.OnUpdateTick))]
public static class Patch_World_OnUpdateTick_CinerariumKeepAlive
{
public static void Postfix()
{
CinerariumKeepAlive.Tick();
}
}
public class ConsoleCmdNecroUrnKeep : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => false;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necrournkeep" };
}
public override string getDescription()
{
return "Necromancer's Tome: keep urn areas loaded so urns work while you are away - switch.";
}
public override string getHelp()
{
return "necrournkeep - status\n" +
"necrournkeep on|off - off: urns stop when you are far (areas unload), as before\n" +
"necrournkeep dim <n> - observer size: (n+2) chunks each way around a 64-block group (default 1)";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
if (_params.Count >= 1 && (_params[0] == "on" || _params[0] == "off"))
{
CinerariumKeepAlive.Enabled = _params[0] == "on";
}
else if (_params.Count >= 2 && _params[0] == "dim" && int.TryParse(_params[1], out int d) && d >= 0 && d <= 6)
{
CinerariumKeepAlive.ViewDim = d;
}
CinerariumKeepAlive.SyncNow();
con.Output("necrournkeep: " + (CinerariumKeepAlive.Enabled ? "on" : "off") + ", dim " + CinerariumKeepAlive.ViewDim +
", observers " + CinerariumKeepAlive.Count);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Лимит урн в «Содержимом» (#65; 20.10: «индикатор, сколько урн уже поставлено и сколько ещё можно»; 23.10, по скриншоту
/// с цифрами в заголовке: «У нас есть куча пространства под черепом или справа от него... Всего цинерариев. Доступно
/// цинерариев. И до разблокировки ещё одного нужно упокоить зомби столько-то»). Блок дописан в окно игры windowBagStorage
/// (Config/XUi_InGame/windows.xml), справа от ячеек урны; в сумках волка и транспорта (то же окно) скрыт.
/// </summary>
[Preserve]
public class XUiC_NecroUrnLimit : XUiController
{
XUiV_Label _placed, _available, _next;
float _nextRefresh;
public override void Init()
{
base.Init();
_placed = GetChildById("placed")?.ViewComponent as XUiV_Label;
_available = GetChildById("available")?.ViewComponent as XUiV_Label;
_next = GetChildById("next")?.ViewComponent as XUiV_Label;
}
public override void OnOpen()
{
base.OnOpen();
_nextRefresh = 0f;
Refresh();
}
public override void Update(float _dt)
{
base.Update(_dt);
if (Time.time >= _nextRefresh)
{
Refresh();
}
}
void Refresh()
{
_nextRefresh = Time.time + 1f;
TEFeatureCinerarium urn = CinerariumContents.Urn;
int have = 0, limit = 0, next = 0;
bool show = urn != null && TEFeatureCinerarium.LimitNumbers(out have, out limit, out int _, out next);
ViewComponent.IsVisible = show;
if (!show)
{
return;
}
// Справа от ячеек урны: у рабочего и старателя их две (череп и образец).
int slots = urn.IsWorker || urn.IsProspector ? 2 : 1;
ViewComponent.Position = new Vector2i(slots * 75 + 12, -49);
if (_placed != null)
{
_placed.Text = string.Format(Localization.Get("necroCinerariumPlaced"), have);
}
if (_available != null)
{
_available.Text = string.Format(Localization.Get("necroCinerariumAvailable"), Mathf.Max(0, limit - have));
}
if (_next != null)
{
_next.Text = string.Format(Localization.Get("necroCinerariumNext"), next);
}
}
}
}
+239
View File
@@ -0,0 +1,239 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Меры нагрузки урн (решение 17.10: «общий бюджет шагов работы духов за кадр (очередь по кругу - нагрузка постоянна при
/// любом числе урн); полная анимация только ближайших ~15-20 духов; замер 100 урн»).
/// Бюджет: дел (пробный шаг + настоящий) в секунду - не больше StepsPerSecond на все урны сейва (23.10, было за кадр); урна, которой не досталось,
/// пробует в следующем кадре. Урна делает дело раз в CinerariumWork.StepSeconds, поэтому очередь сама идёт по кругу.
/// Анимация: двигаются (полёт, жесты, покачивание) только AnimateNearest ближайших к игроку духов, остальные замирают дома,
/// как уснувшие (CinerariumGenieMotion); работа у всех идёт.
/// Прилив (решение 17.10, «Прилив прячет некротехнологов»): ночью кровавой луны духи всех урн уходят в урны и работа стоит -
/// признак берётся раз за кадр.
/// </summary>
public static class CinerariumLoad
{
/// <summary>Дел в секунду на все урны (23.10, было «3 за кадр»). Замер: урна получает тик раз в ~1.4 с, и тики всех урн
/// приходят пачкой в одном кадре, в порядке участков от ближних к игроку. Лимит «3 за кадр» доставался трём ближним урнам,
/// дальние не получали шага никогда («стою в 19 м - не работают, подхожу - работают»). Теперь бюджет копится между
/// тиками (не больше StepsPerSecond за раз), и пачка обслуживает всех, у кого подошёл срок. Консоль: necrourn budget N.</summary>
public static float StepsPerSecond = 30f;
/// <summary>Запас бюджета, секунд (пачка тиков раз в ~1.4 с должна уместиться целиком).</summary>
public const float BurstSeconds = 2f;
static float _tokens;
static float _lastRefill;
/// <summary>Очередь (23.10, пользователь: «нужно иметь очередь урн»): урна, которой отказали, ждёт; пока очередь не пуста,
/// бюджет сначала ей - новая урна получит шаг, только если после всех ждущих ещё останется. Иначе ближние урны (их участки
/// идут первыми) снова забирали бы всё. Запись старше QueueForget секунд выбрасывается (урну сняли или участок выгружен).</summary>
static readonly Dictionary<Vector3i, float> _queue = new Dictionary<Vector3i, float>();
const float QueueForget = 10f;
static readonly List<Vector3i> _stale = new List<Vector3i>();
public static int AnimateNearest = 16;
static int _frame = -1;
static bool _tide;
static void NewFrame()
{
if (Time.frameCount != _frame)
{
_frame = Time.frameCount;
// По реальному времени с прошлого пополнения: NewFrame зовут только кадры с тиками урн (раз в ~1.4 с), и
// Time.deltaTime (один кадр) давал полшага на пачку (замер 23.10: 279 шагов за 600 с при лимите 30/с).
float now = Time.time;
_tokens = Mathf.Min(StepsPerSecond * BurstSeconds, _tokens + StepsPerSecond * Mathf.Max(0f, now - _lastRefill));
_lastRefill = now;
_stale.Clear();
foreach (KeyValuePair<Vector3i, float> kv in _queue)
{
if (now - kv.Value > QueueForget)
{
_stale.Add(kv.Key);
}
}
foreach (Vector3i p in _stale)
{
_queue.Remove(p);
}
_tide = SnakeMode.IsBloodTide();
}
}
/// <summary>Взять одно дело из бюджета кадра. _who - урна (учёт для necrourn budget).</summary>
public static bool TryTakeStep(Vector3i _who)
{
NewFrame();
bool waiting = _queue.ContainsKey(_who);
// Ждущая урна - если есть хоть один шаг; новая - только если хватит и всем ждущим.
if (_tokens < 1f || (!waiting && _tokens < 1f + _queue.Count))
{
if (!waiting)
{
_queue[_who] = Time.time;
}
Count(_denied, _who);
return false;
}
_tokens -= 1f;
_queue.Remove(_who);
Count(_granted, _who);
return true;
}
// Учёт бюджета (23.10: сборщики 600 с не получили ни одного шага, логист рядом - получал): кому дали, кому отказали.
static readonly Dictionary<Vector3i, int> _granted = new Dictionary<Vector3i, int>();
static readonly Dictionary<Vector3i, int> _denied = new Dictionary<Vector3i, int>();
static float _countSince;
static int _frames0;
static void Count(Dictionary<Vector3i, int> _d, Vector3i _who)
{
_d.TryGetValue(_who, out int n);
_d[_who] = n + 1;
}
/// <summary>Сводка с прошлого вызова и сброс (команда necrourn budget).</summary>
public static string Budget()
{
float sec = Time.time - _countSince;
int frames = Time.frameCount - _frames0;
string s = "за " + sec.ToString("0") + " с, кадров " + frames + ", лимит " + StepsPerSecond + "/с, в очереди " + _queue.Count + "; дано:";
foreach (KeyValuePair<Vector3i, int> kv in _granted)
{
s += " " + kv.Key + "=" + kv.Value;
}
s += "; отказано:";
foreach (KeyValuePair<Vector3i, int> kv in _denied)
{
s += " " + kv.Key + "=" + kv.Value;
}
_granted.Clear();
_denied.Clear();
_countSince = Time.time;
_frames0 = Time.frameCount;
return s;
}
/// <summary>Идёт ли кровавый прилив (раз за кадр).</summary>
public static bool Tide
{
get
{
NewFrame();
return _tide;
}
}
// ------------------------------------------------------------------
// Ближайшие духи.
// ------------------------------------------------------------------
static float _nextRank;
static readonly HashSet<CinerariumGenieMotion> _animated = new HashSet<CinerariumGenieMotion>();
static readonly List<CinerariumGenieMotion> _sorted = new List<CinerariumGenieMotion>();
/// <summary>Двигается ли дух: среди AnimateNearest ближайших к игроку (пересчёт раз в 0.5 с, без выделений памяти).</summary>
public static bool Animated(CinerariumGenieMotion _m)
{
if (Time.time >= _nextRank)
{
_nextRank = Time.time + 0.5f;
Rank();
}
return _animated.Contains(_m);
}
static void Rank()
{
_animated.Clear();
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pl == null)
{
return;
}
Vector3 me = pl.GetPosition() - Origin.position;
_sorted.Clear();
_sorted.AddRange(CinerariumGenie.Live);
if (_sorted.Count > AnimateNearest)
{
_sorted.Sort((a, b) => (a.transform.position - me).sqrMagnitude.CompareTo((b.transform.position - me).sqrMagnitude));
}
for (int i = 0; i < _sorted.Count && i < AnimateNearest; i++)
{
_animated.Add(_sorted[i]);
}
}
}
/// <summary>necrofps [секунд] - средний и худший кадр за время замера (по умолчанию 5 с), число духов и урн; ответ - строкой
/// почтового ящика в лог. Для замера нагрузки урн (17.10: «замер 100 урн»).</summary>
[Preserve]
public class ConsoleCmdNecroFps : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrofps" };
public override string getDescription() => "NecromancerTome (разработка): средний и худший кадр за N секунд";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
float sec = _params.Count > 0 ? float.Parse(_params[0], System.Globalization.CultureInfo.InvariantCulture) : 5f;
bool uncap = _params.Count > 1 && _params[1] == "uncap";
SdtdConsole.Instance.Output("necrofps: замер " + sec + " с" + (uncap ? " без ограничения кадров" : ""));
ThreadManager.StartCoroutine(Measure(sec, uncap));
}
/// <summary>_uncap - на время замера снять вертикальную синхронизацию и предел кадров: при 60 fps по синхронизации
/// запас не виден (18.10, замер 100 урн).</summary>
static IEnumerator Measure(float _sec, bool _uncap)
{
int vs = QualitySettings.vSyncCount, fr = Application.targetFrameRate;
if (_uncap)
{
QualitySettings.vSyncCount = 0;
Application.targetFrameRate = -1;
yield return new WaitForSecondsRealtime(0.5f);
}
float end = Time.realtimeSinceStartup + _sec;
int frames = 0;
float worst = 0f, sum = 0f;
long gc0 = System.GC.CollectionCount(0);
while (Time.realtimeSinceStartup < end)
{
yield return null;
frames++;
sum += Time.unscaledDeltaTime;
worst = Mathf.Max(worst, Time.unscaledDeltaTime);
}
if (_uncap)
{
QualitySettings.vSyncCount = vs;
Application.targetFrameRate = fr;
}
float avg = frames > 0 ? sum / frames * 1000f : 0f;
Debug.Log("[NecromancerTome] mailbox: necrofps: кадров " + frames + ", средний " + avg.ToString("F1") + " мс (" + (avg > 0 ? 1000f / avg : 0f).ToString("F0") +
" fps), худший " + (worst * 1000f).ToString("F1") + " мс, сборок мусора " + (System.GC.CollectionCount(0) - gc0) +
", духов " + CinerariumGenie.Live.Count + ", урн в сейве " + CinerariumRegistry.All().Count);
}
}
/// <summary>necrotide on|off|auto - прилив для проверок без ожидания ночи кровавой луны (SnakeMode.ForceTide).</summary>
[Preserve]
public class ConsoleCmdNecroTide : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrotide" };
public override string getDescription() => "NecromancerTome (разработка): прилив on|off|auto";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string v = _params.Count > 0 ? _params[0] : "";
SnakeMode.ForceTide = v == "on" ? true : v == "off" ? (bool?)false : null;
SdtdConsole.Instance.Output("necrotide: " + (SnakeMode.ForceTide.HasValue ? (SnakeMode.ForceTide.Value ? "прилив" : "нет прилива") : "по часам") +
", сейчас " + (SnakeMode.IsBloodTide() ? "прилив" : "не прилив"));
}
}
}
+146
View File
@@ -0,0 +1,146 @@
using System.Collections.Generic;
using GUI_2;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Урна логиста - латунное кадило (18.10.2026, BACKLOG «Урна логиста - своя модель: кадило»): два вида - на подставке по
/// центру клетки (necroCinerariumLogist) и на кронштейне у стены (necroCinerariumLogistWall). Пользователь: «Должна быть
/// возможность крепить её и к стене, и по центру какого-нибудь столба... как мы делали с черепом. Только череп у нас изначально
/// не блок, а тут должно получиться попроще». Вид выбирается, как у черепа-декора: урна в руке, R - меню блока игры, к нему
/// добавлен второй вид; выбор превращает стопку в ячейке пояса в другой блок (тот же счёт). Пустая урна и урна с черепом -
/// каждая в свой вид того же рода.
/// </summary>
public static class CinerariumLogistForm
{
/// <summary>Номера наших пунктов в меню блока - вне занятых игрой (-2..10) и черепом (40..42).</summary>
public const int MenuCenter = 43, MenuWall = 44;
static readonly Dictionary<string, (string center, string wall)> Pairs = new Dictionary<string, (string, string)>
{
{ TEFeatureCinerarium.LogistBlockName, (TEFeatureCinerarium.LogistBlockName, TEFeatureCinerarium.LogistWallBlockName) },
{ TEFeatureCinerarium.LogistWallBlockName, (TEFeatureCinerarium.LogistBlockName, TEFeatureCinerarium.LogistWallBlockName) },
{ TEFeatureCinerarium.LogistSkullBlockName, (TEFeatureCinerarium.LogistSkullBlockName, TEFeatureCinerarium.LogistWallSkullBlockName) },
{ TEFeatureCinerarium.LogistWallSkullBlockName, (TEFeatureCinerarium.LogistSkullBlockName, TEFeatureCinerarium.LogistWallSkullBlockName) },
};
public static bool IsLogistItem(ItemClass _item) => _item != null && Pairs.ContainsKey(_item.Name);
static void Convert(EntityPlayerLocal _pl, bool _wall)
{
ItemStack cur = _pl?.inventory?.holdingItemStack;
if (cur == null || cur.IsEmpty() || !Pairs.TryGetValue(cur.itemValue.ItemClass.Name, out var pair))
{
return;
}
string name = _wall ? pair.wall : pair.center;
if (cur.itemValue.ItemClass.Name == name)
{
return;
}
ItemValue iv = ItemClass.GetItem(name);
if (iv == null || iv.IsEmpty())
{
return;
}
_pl.inventory.SetItem(_pl.inventory.holdingItemIdx, new ItemStack(iv, cur.count));
}
public static void OnBlockMenu(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
EntityPlayerLocal pl = _sender?.xui?.playerUI?.entityPlayer;
switch (_commandIndex)
{
case MenuCenter: Convert(pl, false); return;
case MenuWall: Convert(pl, true); return;
}
_sender.handleBlockShapeCommand(_sender, _commandIndex, _context);
}
}
/// <summary>Меню блока с урной логиста в руке: к пунктам игры - второй вид (подставка / у стены).
/// 23.10 (#77, «зажимаю R - только вращает»): у блока с одним простым поворотом игра в конце SetupBlockShapeData зовёт
/// SetCommonData с одним пунктом - и сразу выполняет его (поворот) и закрывает меню; пункт, добавленный после (постфикс),
/// попадал в уже закрытое меню. Поэтому наш пункт и наш обработчик ставятся в самом SetCommonData этого вызова - до
/// подсчёта пунктов. Короткое нажатие R по-прежнему поворачивает (пункт «-1» уходит игре).</summary>
[HarmonyPatch(typeof(XUiC_Radial), nameof(XUiC_Radial.SetupBlockShapeData))]
public static class Patch_XUiC_Radial_SetupBlockShapeData_LogistForm
{
/// <summary>Идёт сборка меню блока с урной логиста в руке.</summary>
public static bool Building;
public static void Prefix(XUiC_Radial __instance)
{
Building = CinerariumLogistForm.IsLogistItem(__instance.xui?.playerUI?.entityPlayer?.inventory?.holdingItem);
}
public static void Postfix()
{
Building = false;
}
}
[HarmonyPatch(typeof(XUiC_Radial), nameof(XUiC_Radial.SetCommonData))]
public static class Patch_XUiC_Radial_SetCommonData_LogistForm
{
public static void Prefix(XUiC_Radial __instance, ref XUiC_Radial.CommandHandlerDelegate _commandHandlerFunc)
{
if (!Patch_XUiC_Radial_SetupBlockShapeData_LogistForm.Building)
{
return;
}
Patch_XUiC_Radial_SetupBlockShapeData_LogistForm.Building = false;
ItemClass held = __instance.xui.playerUI.entityPlayer.inventory.holdingItem;
bool wall = held.Name == TEFeatureCinerarium.LogistWallBlockName || held.Name == TEFeatureCinerarium.LogistWallSkullBlockName;
if (wall)
{
__instance.CreateRadialEntry(CinerariumLogistForm.MenuCenter, TEFeatureCinerarium.LogistBlockName, "ItemIconAtlas", "", Localization.Get("necroCinerariumLogistFormCenter"));
}
else
{
__instance.CreateRadialEntry(CinerariumLogistForm.MenuWall, TEFeatureCinerarium.LogistWallBlockName, "ItemIconAtlas", "", Localization.Get("necroCinerariumLogistFormWall"));
}
_commandHandlerFunc = CinerariumLogistForm.OnBlockMenu;
}
}
/// <summary>Мигание урны логиста, когда класть некуда (18.10: «При застревании должно мигать»): свечение модели
/// (MaterialPropertyBlock, как у TEFeatureCinerarium.SyncGlow) пульсирует от тёмного до обычного. Выключено - обычное свечение.</summary>
[Preserve]
public class CinerariumBlink : MonoBehaviour
{
public List<Renderer> Renderers = new List<Renderer>();
public static float Period = 0.8f;
static readonly MaterialPropertyBlock _block = new MaterialPropertyBlock();
static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor");
void Update()
{
// Резкий переход - мигание, а не дыхание: половину периода горит, половину почти погасла.
float k = Mathf.Repeat(Time.time, Period) < Period * 0.5f ? 1f : 0.08f;
Set(TEFeatureCinerarium.GlowColor * k);
}
void OnDisable()
{
Set(TEFeatureCinerarium.GlowColor);
}
void Set(Color _c)
{
foreach (Renderer r in Renderers)
{
if (r == null)
{
continue;
}
r.GetPropertyBlock(_block);
_block.SetColor(EmissionColorId, _c);
r.SetPropertyBlock(_block);
}
}
}
}
+650
View File
@@ -0,0 +1,650 @@
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Цинерарий-старатель (#64, решения 20.10 и 22.10.2026): дух добывает ископаемые. В «Содержимом» две ячейки, как у рабочего:
/// череп и образец - что добывать (один из Ores). Нить к ящику (любая, как у лутера) - куда класть. Есть всё - дух отлетает на
/// ближайшую свободную клетку рядом с урной и уходит на ProspectorDepth блоков вниз, растворяясь; раз в игровой час поднимается
/// так же, кладёт добычу в ящик и уходит снова. Без зоны и вне лимита урн, как остальные некротехнологи.
///
/// Добыча (20.10: «цена - скоростью, а не ресурсом»): за игровые сутки камень, глина, песок, древесина - 5 стопок по 6000,
/// железо, свинец, уголь, селитра, сланец - 3 стопки; ходка - сутки / 24, разброс ±20%. Находки (20.10: «шанс добыть алмаз,
/// серебро и золото, как и в игре»; 22.10 - всегда, без книги «Удачный удар», латуни нет): ходка руды = столько блоков, сколько
/// даёт её выход с блока в игре (железо 105, прочие 50), на каждый - шансы игры (алмаз 0.5%, серебро 1%, золото 0.7%).
///
/// Время - игровое (worldTime), чтобы добыча за сутки не зависела от их длины. Ходка засчитывается, только пока чанк урны
/// загружен; время перескочило больше чем на час (сон, прилив, settime) - ходка не засчитана, отсчёт заново. Ящик полон или
/// открыт игроком - дух ждёт дома с добычей (почернел, если полон) и докладывает, как появится место; новой ходки нет.
/// Добыча в руках духа не сохраняется: урну подняли или чанк выгрузился - ходка пропала.
/// </summary>
public partial class TEFeatureCinerarium
{
public const string ProspectorBlockName = "necroCinerariumProspector";
public const string ProspectorSkullBlockName = "necroCinerariumProspectorSkull";
public static bool IsProspectorBlock(Block _b)
{
string n = _b?.GetBlockName();
return n == ProspectorBlockName || n == ProspectorSkullBlockName;
}
bool _prospector;
public bool IsProspector
{
get
{
_ = IsLogist; // узнаёт тип блока
return _prospector;
}
}
/// <summary>Что добывает старатель: предмет, добыча за игровые сутки, выход с блока руды в игре (0 - находок нет).</summary>
public static readonly (string item, int perDay, int perBlock)[] Ores =
{
("resourceRockSmall", 30000, 0),
("resourceClayLump", 30000, 0),
("resourceCrushedSand", 30000, 0),
("resourceWood", 30000, 0),
("resourceScrapIron", 18000, 105),
("resourceScrapLead", 18000, 50),
("resourceCoal", 18000, 50),
("resourcePotassiumNitratePowder", 18000, 50),
("resourceOilShale", 18000, 50),
};
/// <summary>Находки с блока руды - как в blocks.xml игры (тег artOfMiningLuckyStrikeHarvest).</summary>
public static readonly (string item, float prob)[] Finds =
{
("resourceRawDiamond", 0.005f),
("resourceSilverNugget", 0.01f),
("resourceGoldNugget", 0.007f),
};
/// <summary>Выключатель (консоль necroprospector off): духи остаются дома, ходок нет.</summary>
public static bool ProspectorOn = true;
/// <summary>Множитель добычи за ходку (консоль, для проверки баланса).</summary>
public static float ProspectorYield = 1f;
public static float ProspectorSpread = 0.2f;
/// <summary>На сколько блоков дух уходит вниз.</summary>
public static int ProspectorDepth = 5;
/// <summary>Ходка - игровой час (1000 единиц worldTime).</summary>
public const ulong TripTicks = 1000UL;
/// <summary>necroprospector trip - номер «внеочередной ходки»: урна, ещё не видевшая его, ходит сразу.</summary>
public static int ForceTrip;
public enum ProspectorStatus
{
NoSkull,
Tide,
Off,
NoSample,
NoChest,
Full,
Digging
}
bool _under;
ulong _tripAt;
float _nextProspect;
float _deliverAt;
int _forceSeen;
readonly List<ItemStack> _carry = new List<ItemStack>();
public static int OreIndex(ItemValue _iv)
{
string n = _iv?.ItemClass?.GetItemName();
if (n == null)
{
return -1;
}
for (int i = 0; i < Ores.Length; i++)
{
if (Ores[i].item == n)
{
return i;
}
}
return -1;
}
public static bool IsOreSample(ItemStack _s) => _s != null && !_s.IsEmpty() && OreIndex(_s.itemValue) >= 0;
/// <summary>Сколько игровых минут до конца ходки (для консоли), -1 - дух не под землёй.</summary>
public int TripMinutesLeft(World _w)
{
if (!_under || _w == null)
{
return -1;
}
return _tripAt > _w.worldTime ? (int)((_tripAt - _w.worldTime) * 60UL / TripTicks) : 0;
}
public int CarryCount
{
get
{
int n = 0;
foreach (ItemStack s in _carry)
{
n += s.count;
}
return n;
}
}
public ProspectorStatus ProspectorNow(World _world)
{
if (!HasSkull)
{
return ProspectorStatus.NoSkull;
}
if (CinerariumLoad.Tide)
{
return ProspectorStatus.Tide;
}
if (!ProspectorOn)
{
return ProspectorStatus.Off;
}
if (!IsOreSample(Sample))
{
return ProspectorStatus.NoSample;
}
if (!HasThreads)
{
return ProspectorStatus.NoChest;
}
return Stuck ? ProspectorStatus.Full : ProspectorStatus.Digging;
}
string ProspectorStateText(World _world)
{
string key = ProspectorNow(_world) switch
{
ProspectorStatus.Tide => "necroZoneUrnTide",
ProspectorStatus.NoSample => "necroProspectorNoSample",
ProspectorStatus.NoChest => "necroProspectorNoChest",
ProspectorStatus.Full => "necroZoneUrnFull",
ProspectorStatus.Digging => "necroProspectorDigging",
_ => null
};
return key != null ? ", " + Localization.Get(key) : "";
}
/// <summary>Работа старателя (сервер, раз в секунду): донести добычу, уйти под землю, ходка по часам игры.</summary>
void ProspectorWork(World _world)
{
if (Time.time < _nextProspect)
{
return;
}
_nextProspect = Time.time + 1f;
CinerariumGenieMotion motion = _genie != null ? _genie.GetComponent<CinerariumGenieMotion>() : null;
ulong now = _world.worldTime;
if (_carry.Count > 0)
{
if (Time.time < _deliverAt)
{
return; // дух ещё поднимается
}
Deliver(_world);
bool full = _carry.Count > 0;
if (full != Stuck)
{
Stuck = full;
Debug.Log("[NecromancerTome] Cinerarium: старатель " + ToWorldPos() + (full ? " - ящик полон, дух ждёт с добычей" : " - добыча в ящике"));
}
motion?.SetStuck(full);
if (full)
{
return;
}
}
ProspectorStatus st = ProspectorNow(_world);
if (st != ProspectorStatus.Digging)
{
if (_under)
{
_under = false;
motion?.Rise();
Debug.Log("[NecromancerTome] Cinerarium: дух старателя " + ToWorldPos() + " вернулся без добычи - " + st);
}
return;
}
if (!_under)
{
_under = true;
if (_tripAt <= now)
{
_tripAt = now + TripTicks;
}
_forceSeen = ForceTrip;
motion?.Dive(DigStand(_world) - Origin.position, ProspectorDepth);
Debug.Log("[NecromancerTome] Cinerarium: дух старателя " + ToWorldPos() + " ушёл под землю за " + Sample.itemValue.ItemClass?.GetItemName());
return;
}
if (motion != null && !motion.Digging)
{
motion.Bury(DigStand(_world) - Origin.position, ProspectorDepth); // дух появился заново (чанк, прилив) - сразу под землёй
}
bool forced = _forceSeen != ForceTrip;
if (now < _tripAt && !forced)
{
return;
}
_forceSeen = ForceTrip;
if (!forced && now > _tripAt + TripTicks)
{
_tripAt = now + TripTicks; // время перескочило - ходка не засчитана
Debug.Log("[NecromancerTome] Cinerarium: у старателя " + ToWorldPos() + " время перескочило - ходка заново");
return;
}
DigTrip(OreIndex(Sample.itemValue));
_tripAt = forced ? now + TripTicks : _tripAt + TripTicks;
_under = false; // поднимается; донесёт - уйдёт снова
_deliverAt = Time.time + (motion != null ? motion.Rise() : 0f);
}
/// <summary>Добыча одной ходки - в руки духа (_carry), стопками не больше игровых.</summary>
void DigTrip(int _ore)
{
if (_ore < 0)
{
return;
}
(string item, int perDay, int perBlock) o = Ores[_ore];
float mean = o.perDay / 24f * ProspectorYield;
int n = Mathf.Max(1, Mathf.RoundToInt(mean * Random.Range(1f - ProspectorSpread, 1f + ProspectorSpread)));
Carry(o.item, n);
string log = o.item + " x" + n;
if (o.perBlock > 0)
{
int blocks = Mathf.Max(1, Mathf.RoundToInt(n / (float)o.perBlock));
foreach ((string item, float prob) f in Finds)
{
int k = 0;
for (int b = 0; b < blocks; b++)
{
if (Random.value < f.prob)
{
k++;
}
}
if (k > 0)
{
Carry(f.item, k);
log += ", " + f.item + " x" + k;
}
}
log += " (блоков руды " + blocks + ")";
}
Debug.Log("[NecromancerTome] Cinerarium: старатель " + ToWorldPos() + " - ходка: " + log);
}
void Carry(string _item, int _n)
{
ItemValue iv = ItemClass.GetItem(_item);
if (iv == null || iv.type <= 0)
{
Debug.LogWarning("[NecromancerTome] Cinerarium: старатель - нет предмета " + _item);
return;
}
int max = Mathf.Max(1, iv.ItemClass.Stacknumber.Value);
while (_n > 0)
{
int k = Mathf.Min(_n, max);
_carry.Add(new ItemStack(iv.Clone(), k));
_n -= k;
}
}
/// <summary>Добычу - в ящики по нитям; что не влезло, остаётся у духа.</summary>
void Deliver(World _world)
{
List<TEFeatureStorage> boxes = new List<TEFeatureStorage>();
void Add(Vector3i _p)
{
TEFeatureStorage s = CinerariumWork.BoxAt(_world, _p);
if (s != null && !boxes.Contains(s))
{
boxes.Add(s);
}
}
if (HasThreadTo)
{
Add(ThreadTo);
}
foreach (Vector3i p in ThreadOut)
{
Add(p);
}
foreach (Vector3i p in ThreadFrom)
{
Add(p);
}
if (boxes.Count == 0)
{
return; // ящик открыт игроком или нитей нет - подождать
}
for (int i = _carry.Count - 1; i >= 0; i--)
{
ItemStack s = _carry[i];
CinerariumWork.Place(s, boxes);
if (s.count <= 0)
{
_carry.RemoveAt(i);
}
}
}
/// <summary>Куда дух спускается (20.10: «отлетает от урны на ближайший свободный блок, опускается на 5 блоков вниз»): из
/// восьми соседних клеток - первая свободная (воздух или растение), сначала по сторонам; нет - над урной. Низ клетки, мир.</summary>
Vector3 DigStand(World _world)
{
Vector3i urn = ToWorldPos();
Vector3i[] dirs =
{
Vector3i.forward, Vector3i.right, Vector3i.back, Vector3i.left,
new Vector3i(1, 0, 1), new Vector3i(1, 0, -1), new Vector3i(-1, 0, -1), new Vector3i(-1, 0, 1)
};
foreach (Vector3i d in dirs)
{
BlockValue bv = _world.GetBlock(urn + d);
if (bv.isair || bv.Block is BlockPlant)
{
return World.blockToTransformPos(urn + d);
}
}
return World.blockToTransformPos(urn + Vector3i.up);
}
/// <summary>Урну убрали или выгрузили - добыча в руках духа пропадает.</summary>
void ProspectorReset()
{
if (_carry.Count > 0)
{
Debug.Log("[NecromancerTome] Cinerarium: старатель " + ToWorldPos() + " - недонесённая добыча пропала (" + CarryCount + ")");
}
_carry.Clear();
_under = false;
}
}
/// <summary>Спуск духа старателя под землю и подъём (20.10: «опускается на 5 блоков вниз, постепенно становясь полностью
/// прозрачным; обратно - так же»). Яркость - MaterialPropertyBlock на рендерерах духа (материалы общие не трогаются): дух
/// светится сложением, поэтому «прозрачнее» = тусклее цвет и свечение; глаза гаснут на половине.</summary>
public partial class CinerariumGenieMotion
{
/// <summary>0 - нет; 1 - летит к месту и спускается; 2 - под землёй (не виден); 3 - поднимается и летит домой.</summary>
int _digMode;
float _digT;
Vector3 _digFrom, _digHover;
float _digDepth;
public static float SinkSeconds = 2.5f;
List<Renderer> _fadeRenderers;
static readonly MaterialPropertyBlock _fadeBlock = new MaterialPropertyBlock();
static readonly int ColorId = Shader.PropertyToID("_Color");
static readonly int EmissionId = Shader.PropertyToID("_EmissionColor");
float _fade = 1f;
/// <summary>Под землёй или на пути туда/обратно.</summary>
public bool Digging => _digMode != 0;
bool DigHidden => _digMode == 2;
/// <summary>Лететь к клетке _stand (низ клетки, координаты сцены) и уйти на _depth блоков вниз.</summary>
public void Dive(Vector3 _stand, int _depth)
{
if (transform.parent == null)
{
return;
}
_t = -1f;
Gesture(0f);
_digFrom = transform.localPosition;
_digHover = DigHover(_stand);
_digDepth = _depth;
_digMode = 1;
_digT = 0f;
}
/// <summary>Сразу под землёй (дух появился заново, а работа идёт).</summary>
public void Bury(Vector3 _stand, int _depth)
{
if (transform.parent == null)
{
return;
}
_digHover = DigHover(_stand);
_digDepth = _depth;
_digMode = 2;
transform.localPosition = _digHover + Vector3.down * _digDepth;
}
/// <summary>Подняться и вернуться к урне; через сколько секунд будет дома.</summary>
public float Rise()
{
if (_digMode == 0)
{
return 0f;
}
_digMode = 3;
_digT = 0f;
return SinkSeconds + Mathf.Max(BackSeconds, Vector3.Distance(_digHover, Home()) / FlySpeed);
}
Vector3 DigHover(Vector3 _stand)
{
return transform.parent.InverseTransformPoint(_stand) + new Vector3(0f, CinerariumGenie.OffsetY + Drop + 0.15f, 0f);
}
/// <summary>Шаг спуска/подъёма; false - дух не копает, обычное поведение.</summary>
bool DigUpdate(bool _asleep)
{
if (_digMode == 0)
{
return false;
}
if (_asleep)
{
// Уснул - без полёта: спуск сразу закончен, подъём - сразу дома.
if (_digMode == 1)
{
_digMode = 2;
}
else if (_digMode == 3)
{
_digMode = 0;
SetFade(1f);
transform.localPosition = Home();
return false;
}
return true;
}
_digT += Time.deltaTime;
Vector3 bottom = _digHover + Vector3.down * _digDepth;
float fly = Mathf.Max(OutSeconds, Vector3.Distance(_digFrom, _digHover) / FlySpeed);
if (_digMode == 1)
{
if (_digT < fly)
{
transform.localPosition = Vector3.Lerp(_digFrom, _digHover, Mathf.SmoothStep(0f, 1f, _digT / fly));
Face(_digHover - _digFrom);
SetFade(1f);
}
else if (_digT < fly + SinkSeconds)
{
float s = (_digT - fly) / SinkSeconds;
transform.localPosition = Vector3.Lerp(_digHover, bottom, Mathf.SmoothStep(0f, 1f, s));
SetFade(1f - s);
}
else
{
_digMode = 2;
transform.localPosition = bottom;
}
}
else if (_digMode == 2)
{
transform.localPosition = bottom;
}
else
{
Vector3 home = Home();
float back = Mathf.Max(BackSeconds, Vector3.Distance(_digHover, home) / FlySpeed);
if (_digT < SinkSeconds)
{
float s = _digT / SinkSeconds;
transform.localPosition = Vector3.Lerp(bottom, _digHover, Mathf.SmoothStep(0f, 1f, s));
SetFade(s);
}
else if (_digT < SinkSeconds + back)
{
SetFade(1f);
transform.localPosition = Vector3.Lerp(_digHover, home, Mathf.SmoothStep(0f, 1f, (_digT - SinkSeconds) / back));
Face(home - _digHover);
}
else
{
_digMode = 0;
SetFade(1f);
return false;
}
}
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return true;
}
/// <summary>Яркость духа долей _k (1 - как обычно, свойства сняты).</summary>
void SetFade(float _k)
{
_k = Mathf.Clamp01(_k);
if (Mathf.Abs(_k - _fade) < 0.01f && (_k < 1f || _fade >= 1f))
{
return;
}
_fade = _k;
if (_fadeRenderers == null)
{
_fadeRenderers = new List<Renderer>(GetComponentsInChildren<Renderer>(true));
}
foreach (Renderer r in _fadeRenderers)
{
if (r == null)
{
continue;
}
if (r.name.Contains("eye"))
{
r.forceRenderingOff = _k < 0.5f;
continue;
}
if (_k >= 1f)
{
r.SetPropertyBlock(null);
continue;
}
Material m = r.sharedMaterial;
if (m == null)
{
continue;
}
r.GetPropertyBlock(_fadeBlock);
if (m.HasProperty(ColorId))
{
_fadeBlock.SetColor(ColorId, m.GetColor(ColorId) * _k);
}
if (m.HasProperty(EmissionId))
{
_fadeBlock.SetColor(EmissionId, m.GetColor(EmissionId) * _k);
}
r.SetPropertyBlock(_fadeBlock);
}
}
}
/// <summary>Консоль старателя: выключатель, внеочередная ходка, множитель добычи, состояние урн.</summary>
[Preserve]
public class ConsoleCmdNecroProspector : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necroprospector" };
}
public override string getDescription()
{
return "Necromancer's Tome: Prospector Cinerarium - switch, extra trip, yield multiplier, status.";
}
public override string getHelp()
{
return "necroprospector - status of every loaded prospector urn\n" +
"necroprospector on|off - off: spirits stay home, no trips\n" +
"necroprospector trip - every digging spirit comes back with a trip right now\n" +
"necroprospector yield <x> - trip size multiplier (1 = as designed)\n" +
"necroprospector spread <x> - random spread of a trip (0.2 = ±20%)\n" +
"necroprospector depth <n> - how many blocks the spirit sinks";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
World w = GameManager.Instance?.World;
if (_params.Count >= 1)
{
string cmd = _params[0].ToLowerInvariant();
float v = 0f;
bool num = _params.Count >= 2 && float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out v);
switch (cmd)
{
case "on":
case "off":
TEFeatureCinerarium.ProspectorOn = cmd == "on";
break;
case "trip":
TEFeatureCinerarium.ForceTrip++;
break;
case "yield" when num:
TEFeatureCinerarium.ProspectorYield = Mathf.Max(0f, v);
break;
case "spread" when num:
TEFeatureCinerarium.ProspectorSpread = Mathf.Clamp01(v);
break;
case "depth" when num:
TEFeatureCinerarium.ProspectorDepth = Mathf.Clamp((int)v, 1, 30);
break;
default:
con.Output("necroprospector: unknown '" + _params[0] + "' - see 'help necroprospector'");
return;
}
}
con.Output("necroprospector: " + (TEFeatureCinerarium.ProspectorOn ? "on" : "OFF") + ", yield x" + TEFeatureCinerarium.ProspectorYield.ToString("F2", CultureInfo.InvariantCulture) +
", spread " + TEFeatureCinerarium.ProspectorSpread.ToString("F2", CultureInfo.InvariantCulture) + ", depth " + TEFeatureCinerarium.ProspectorDepth);
if (w == null)
{
return;
}
foreach (Vector3i p in CinerariumRegistry.All())
{
TEFeatureCinerarium u = CinerariumThreads.UrnAt(w, p);
if (u == null || !u.IsProspector)
{
continue;
}
int left = u.TripMinutesLeft(w);
con.Output(" " + p + ": " + u.ProspectorNow(w) + ", sample " + (u.Sample == null || u.Sample.IsEmpty() ? "-" : u.Sample.itemValue.ItemClass?.GetItemName()) +
(left >= 0 ? ", trip in " + left + " game min" : "") + (u.CarryCount > 0 ? ", carrying " + u.CarryCount : ""));
}
}
}
}
+537
View File
@@ -0,0 +1,537 @@
using System.Collections.Generic;
using GUI_2;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Нити цинерария (16.10.2026, BACKLOG «Нити цинерария»): перевозка между ящиками, как провода электрики. «Ящик на
/// территории одного цинерария и присоединяю к нему другой цинерарий проводом. А затем от цинерария тяну провод до другого
/// ящика. Дух в цинерарии будет забирать всё что лежит в ящике из которого в него тянется провод, и тащить в ящик, в
/// который тянется провод.» Решения: у урны несколько нитей «откуда» и одна «куда»; везётся по образцу (только то, что уже
/// лежит в ящике «куда»; пустой - всё подряд); «куда» полон - дух ждёт; нить призрачная; длина - как у провода (15 м);
/// ящики - любые. Нити - только у отдельного блока «урна-логист» (necroCinerariumLogist, 16.10): без зоны, дух только перевозит,
/// глазницы всегда зелёные.
///
/// Инструмент - нож некроманта «хватом для нитей»: отдельный предмет necroWpnBladeNecroKnifeThread (класс ножа в сейве
/// менять нельзя), переключение - пункт в меню ножа по R (то же меню, что «человек/змея/чертоги», SnakeMode.cs; 16.10:
/// своё меню по R перекрывало его - «ты убрал хваты чертогов памяти и духа змеи»); качество и моды переносятся
/// (ItemValue.Clone со сменой типа). Как у стриппера: ПКМ - начать нить и закончить её (порядок = направление: ящик, потом
/// урна - «откуда»; урна, потом ящик - «куда»), ЛКМ по урне или ящику - снять его нити.
/// Первая версия - одиночная игра и хост (связи меняются на месте, клиентам сети - позже).
///
/// Урна рабочего (18.10): нити ящик -> урна - откуда брать ингредиенты и топливо, урна -> станция - где изготавливать,
/// урна -> ящик - куда класть готовое; станций и ящиков сколько угодно (до MaxFrom в каждую сторону). Нить между станцией
/// и урной рабочего - всегда «от урны», в каком бы порядке её ни тянули.
///
/// Череп-ретранслятор (18.10): череп-декор (блок necroSkullDecorCenter/Back) в середине нити. Начал нить у ящика или урны,
/// ПКМ по черепу - нить проходит через него, дальше - следующий череп или конец; каждый пролёт - до MaxLength. Черепа
/// хранятся у нити в урне (TEFeatureCinerarium.FromVia/ToVia/OutVia), ЛКМ по черепу - снять все нити через него.
/// </summary>
public static class CinerariumThreads
{
public const string KnifeName = Patch_ItemClass_ExecuteAction_KnifePowerCost.KnifeName;
public const string ThreadKnifeName = "necroWpnBladeNecroKnifeThread";
/// <summary>Как у провода электрики: ItemActionConnectPower.maxWireLength = 15.</summary>
public const float MaxLength = 15f;
public const int MaxFrom = 8;
public const int GripNormal = 0, GripThread = 1;
/// <summary>Цвет нити - в цвет духа (CinerariumGenie.Tint).</summary>
public static readonly Color ThreadColor = new Color(0.55f, 0.8f, 1f, 0.45f);
public static readonly Color SparkColor = new Color(0.8f, 0.95f, 1f, 1f);
public static bool IsKnife(ItemClass _i) => _i != null && (_i.Name == KnifeName || _i.Name == ThreadKnifeName);
/// <summary>Череп-ретранслятор - череп-декор в мире (оба вида и старый блок-помощник).</summary>
public static bool IsRelay(Block _b)
{
string n = _b?.GetBlockName();
return n == SkullDecorPlace.CenterBlock || n == SkullDecorPlace.BackBlock || n == "necroSkullDecor";
}
public static bool IsThreadKnife(ItemClass _i) => _i != null && _i.Name == ThreadKnifeName;
// ------------------------------------------------------------------
// Хват.
// ------------------------------------------------------------------
/// <summary>Нож в руке меняется на _name: тот же ItemValue (качество, износ, моды), другой тип.</summary>
public static void Convert(EntityPlayerLocal _pl, string _name)
{
ItemStack cur = _pl?.inventory?.holdingItemStack;
if (cur == null || cur.IsEmpty() || !IsKnife(cur.itemValue.ItemClass) || cur.itemValue.ItemClass.Name == _name)
{
return;
}
ItemValue target = ItemClass.GetItem(_name);
if (target == null || target.IsEmpty())
{
return;
}
ItemValue iv = cur.itemValue.Clone();
iv.type = target.type;
_pl.inventory.SetItem(_pl.inventory.holdingItemIdx, new ItemStack(iv, cur.count));
Cancel();
}
// ------------------------------------------------------------------
// Соединение.
// ------------------------------------------------------------------
static Vector3i? _start;
static bool _startUrn;
static bool _startStation;
static GameObject _preview;
/// <summary>Черепа, через которые уже прошла тянущаяся нить, по порядку от начала.</summary>
static readonly List<Vector3i> _via = new List<Vector3i>();
public static Vector3i? Start => _start;
public static bool StartIsUrn => _startUrn;
/// <summary>Откуда сейчас тянется нить: последний череп или начало.</summary>
public static Vector3i? LastPoint => _via.Count > 0 ? _via[_via.Count - 1] : _start;
public static bool LastIsRelay => _via.Count > 0;
public static void Cancel()
{
_start = null;
_via.Clear();
if (_preview != null)
{
Object.Destroy(_preview);
_preview = null;
}
}
/// <summary>Урна или ящик (составной блок игрока с хранилищем) в клетке _p; _owner - главная клетка блока.</summary>
public static bool Resolve(World _w, Vector3i _p, out Vector3i _owner, out TEFeatureCinerarium _urn)
{
BlockValue bv = _w.GetBlock(_p);
_owner = bv.ischild ? _p + bv.parent : _p;
_urn = null;
if (!(_w.GetTileEntity(_owner) is TileEntityComposite comp))
{
return false;
}
if (comp.TryGetSelfOrFeature<TEFeatureCinerarium>(out _urn))
{
return true;
}
return comp.PlayerPlaced && comp.TryGetSelfOrFeature<TEFeatureStorage>(out TEFeatureStorage _);
}
/// <summary>Станция игры (верстак, костёр, кузня, химстанция, бетономешалка, ступка) в клетке _p; _owner - главная клетка.</summary>
public static TileEntityWorkstation StationAt(World _w, Vector3i _p, out Vector3i _owner)
{
BlockValue bv = _w.GetBlock(_p);
_owner = bv.ischild ? _p + bv.parent : _p;
return _w.GetTileEntity(_owner) as TileEntityWorkstation;
}
public static TileEntityWorkstation StationAt(World _w, Vector3i _p) => StationAt(_w, _p, out Vector3i _);
public static TEFeatureCinerarium UrnAt(World _w, Vector3i _p)
{
return _w.GetTileEntity(_p) is TileEntityComposite c && c.TryGetSelfOrFeature<TEFeatureCinerarium>(out TEFeatureCinerarium u) ? u : null;
}
/// <summary>Отпущена кнопка с ножом-для-нитей: _connect - ПКМ (тянуть нить), иначе ЛКМ (снять нити).</summary>
public static void Click(EntityPlayerLocal _pl, bool _connect)
{
World w = GameManager.Instance?.World;
WorldRayHitInfo hi = _pl?.HitInfo;
if (w == null || hi == null || !hi.bHitValid || !GameUtils.IsBlockOrTerrain(hi.tag) || hi.hit.distanceSq > 36f)
{
return;
}
ClickAt(_pl, w, hi.hit.blockPos, _connect);
}
/// <summary>Щелчок ножом для нитей по клетке _pos (луч уже разобран; команда necroclick - напрямую).</summary>
public static void ClickAt(EntityPlayerLocal _pl, World w, Vector3i _pos, bool _connect)
{
if (IsRelay(w.GetBlock(_pos).Block))
{
return; // 23.10: черепа-ретрансляторы отменены - череп-декор нитью не задевается
}
bool ok = Resolve(w, _pos, out Vector3i owner, out TEFeatureCinerarium urn);
bool station = false;
if (!ok && StationAt(w, _pos, out Vector3i stOwner) != null)
{
ok = true;
station = true;
owner = stOwner;
}
if (!_connect)
{
if (_start.HasValue)
{
Cancel();
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadCancel"));
return;
}
if (ok && RemoveAll(w, owner, urn) > 0)
{
_pl.PlayOneShot("wiretool_grab");
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadRemoved"));
}
return;
}
if (!ok)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedPair"));
return;
}
if (!_start.HasValue)
{
if (urn != null && !urn.IsZoneless)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedLogist"));
return;
}
_start = owner;
_startUrn = urn != null;
_startStation = station;
_preview = new GameObject("NecroThreadPreview");
_preview.AddComponent<CinerariumThreadPreview>();
_pl.PlayOneShot("wiretool_grab");
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadStart"));
return;
}
Vector3i s = _start.Value;
bool sUrn = _startUrn;
bool sStation = _startStation;
Vector3i last = LastPoint.Value;
// Черепа - по порядку от урны: начали у урны - как шли, начали у ящика или станции - в обратном порядке.
List<Vector3i> via = new List<Vector3i>(_via);
Cancel();
if (s == owner)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadCancel"));
return;
}
if (!sUrn)
{
via.Reverse();
}
// Станция - только с урной рабочего, нить всегда «от урны».
if (sStation || station)
{
TEFeatureCinerarium wu = sStation ? urn : UrnAt(w, s);
Vector3i st = sStation ? s : owner;
if (wu == null || !wu.IsWorker)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedWorker"));
return;
}
if (Vector3.Distance(last.ToVector3(), owner.ToVector3()) > MaxLength)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTooFar"));
return;
}
if (!wu.AddThreadOut(st, via))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadLimit"));
return;
}
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadStation"));
_pl.PlayOneShot("wiretool_place");
return;
}
if (sUrn == (urn != null))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedPair"));
return;
}
TEFeatureCinerarium end = sUrn ? UrnAt(w, s) : urn;
if (end == null || !end.IsZoneless)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedLogist"));
return;
}
if (Vector3.Distance(last.ToVector3(), owner.ToVector3()) > MaxLength)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTooFar"));
return;
}
if (sUrn)
{
TEFeatureCinerarium u = UrnAt(w, s);
if (u == null)
{
return;
}
if (u.IsWorker)
{
// У рабочего ящиков «куда» сколько угодно - туда готовое.
if (!u.AddThreadOut(owner, via))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadLimit"));
return;
}
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadOut"));
}
else
{
u.SetThreadTo(owner, via);
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTo"));
}
}
else
{
if (!urn.AddThreadFrom(s, via))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadLimit"));
return;
}
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadFrom"));
}
_pl.PlayOneShot("wiretool_place");
}
/// <summary>Нить урны _urn к _end через черепа _via (по порядку от урны) - пролётами в _pairs, огоньки бегут по направлению
/// нити: _toUrn - от конца к урне (нить «откуда»), иначе от урны к концу.</summary>
public static void AddPath(List<(Vector3 a, Vector3 b)> _pairs, Vector3i _urn, List<Vector3i> _via, Vector3i _end, bool _toUrn)
{
List<Vector3> pts = new List<Vector3> { UrnPoint(_urn) };
if (_via != null)
{
foreach (Vector3i v in _via)
{
pts.Add(RelayPoint(v));
}
}
Vector3i before = _via != null && _via.Count > 0 ? _via[_via.Count - 1] : _urn;
pts.Add(BoxPoint(_end, before));
for (int i = 0; i + 1 < pts.Count; i++)
{
_pairs.Add(_toUrn ? (pts[i + 1], pts[i]) : (pts[i], pts[i + 1]));
}
}
/// <summary>Точка нити у черепа - над черепом-декором (он лежит на дне клетки). Сцена (без Origin).</summary>
public static Vector3 RelayPoint(Vector3i _skull) => World.blockToTransformPos(_skull) - Origin.position + Vector3.up * 0.3f;
/// <summary>Снять все нити урны или все нити, идущие к ящику (у всех урн сейва). Сколько снято.</summary>
static int RemoveAll(World _w, Vector3i _owner, TEFeatureCinerarium _urn)
{
if (_urn != null)
{
return _urn.ClearThreads();
}
int n = 0;
foreach (Vector3i p in CinerariumRegistry.All())
{
TEFeatureCinerarium u = UrnAt(_w, p);
if (u != null)
{
n += u.RemoveThreadsTo(_owner);
}
}
return n;
}
// ------------------------------------------------------------------
// Вид нитей.
// ------------------------------------------------------------------
static Material _mat;
public static Material Mat()
{
if (_mat == null)
{
_mat = new Material(Shader.Find("Sprites/Default")) { name = "NecroThread" };
}
return _mat;
}
/// <summary>Где нить крепится: у урны - горлышко, у ящика - край со стороны урны, чуть выше середины. Сцена (без Origin).</summary>
public static Vector3 UrnPoint(Vector3i _urn) => World.blockToTransformPos(_urn) - Origin.position + Vector3.up * 0.5f;
public static Vector3 BoxPoint(Vector3i _box, Vector3i _urn)
{
Vector3 c = World.blockToTransformPos(_box) - Origin.position + Vector3.up * 0.6f;
Vector3 d = (World.blockToTransformPos(_urn) - World.blockToTransformPos(_box));
d.y = 0f;
if (d.sqrMagnitude > 0.01f)
{
d.Normalize();
c += d * 0.5f;
}
return c;
}
/// <summary>Провисающая нить: точка по доле пути _u.</summary>
public static Vector3 Curve(Vector3 _a, Vector3 _b, float _u)
{
float sag = Mathf.Min(0.6f, Vector3.Distance(_a, _b) * 0.05f);
return Vector3.Lerp(_a, _b, _u) + Vector3.down * (sag * 4f * _u * (1f - _u));
}
public static LineRenderer NewLine(Transform _parent, string _name, float _width, Color _c)
{
GameObject g = new GameObject(_name);
g.transform.SetParent(_parent, false);
LineRenderer lr = g.AddComponent<LineRenderer>();
lr.useWorldSpace = false;
lr.widthMultiplier = _width;
lr.sharedMaterial = Mat();
lr.startColor = _c;
lr.endColor = _c;
lr.numCapVertices = 2;
lr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
lr.receiveShadows = false;
return lr;
}
}
/// <summary>Нити одной урны: провисающие линии и бегущие по ним огоньки (направление). Видны, только пока в руках нож
/// хватом для нитей. Дочерний объект модели урны -
/// сдвиги Origin и выгрузка чанка его не теряют; точки - в осях модели.</summary>
[Preserve]
public class CinerariumThreadView : MonoBehaviour
{
struct Thread
{
public Vector3 A, B; // в осях модели урны, огоньки бегут от A к B
public LineRenderer[] Sparks;
}
readonly List<Thread> _threads = new List<Thread>();
const int Segments = 16;
const int SparksPerThread = 3;
public static float SparkSpeed = 1.2f; // м/с
/// <summary>_pairs - концы в координатах сцены (без Origin). Видимость - заново по ножу в руке (Update).</summary>
public void Build(List<(Vector3 a, Vector3 b)> _pairs)
{
foreach (Transform c in transform)
{
Destroy(c.gameObject);
}
_threads.Clear();
Transform t = transform;
foreach ((Vector3 a, Vector3 b) in _pairs)
{
Thread th = new Thread { A = t.InverseTransformPoint(a), B = t.InverseTransformPoint(b), Sparks = new LineRenderer[SparksPerThread] };
LineRenderer lr = CinerariumThreads.NewLine(t, "thread", 0.025f, CinerariumThreads.ThreadColor);
lr.positionCount = Segments + 1;
for (int i = 0; i <= Segments; i++)
{
lr.SetPosition(i, CinerariumThreads.Curve(th.A, th.B, i / (float)Segments));
}
for (int k = 0; k < SparksPerThread; k++)
{
th.Sparks[k] = CinerariumThreads.NewLine(t, "spark", 0.06f, CinerariumThreads.SparkColor);
th.Sparks[k].positionCount = 2;
}
_threads.Add(th);
}
_shown = -1;
}
float _nextSleepCheck;
bool _asleep;
int _shown = -1;
/// <summary>16.10: «Нити должны быть видны, только когда нож в режиме нитей и в руках».</summary>
public static bool ToolInHand()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
return pl != null && CinerariumThreads.IsThreadKnife(pl.inventory?.holdingItem);
}
void Update()
{
if (Time.time >= _nextSleepCheck)
{
_nextSleepCheck = Time.time + 0.25f;
_asleep = CinerariumGenie.PlayerFar(transform.position); // засыпание (16.10): огоньки стоят
int show = ToolInHand() ? 1 : 0;
if (show != _shown)
{
_shown = show;
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
r.enabled = show == 1;
}
}
}
if (_asleep || _shown != 1)
{
return;
}
foreach (Thread th in _threads)
{
float len = Mathf.Max(0.5f, Vector3.Distance(th.A, th.B));
float step = 0.2f / len;
for (int k = 0; k < th.Sparks.Length; k++)
{
float u = Mathf.Repeat(Time.time * SparkSpeed / len + k / (float)th.Sparks.Length, 1f);
th.Sparks[k].SetPosition(0, CinerariumThreads.Curve(th.A, th.B, Mathf.Max(0f, u - step)));
th.Sparks[k].SetPosition(1, CinerariumThreads.Curve(th.A, th.B, u));
}
}
}
}
/// <summary>Нить, которую тянут: от начала до руки игрока; дальше 15 м - красная. Нож убрали - нить брошена.</summary>
[Preserve]
public class CinerariumThreadPreview : MonoBehaviour
{
LineRenderer _lr;
void Start()
{
_lr = CinerariumThreads.NewLine(transform, "line", 0.025f, CinerariumThreads.ThreadColor);
_lr.useWorldSpace = true;
_lr.positionCount = 17;
}
void Update()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pl == null || !CinerariumThreads.Start.HasValue || !CinerariumThreads.IsThreadKnife(pl.inventory?.holdingItem))
{
CinerariumThreads.Cancel();
return;
}
if (_lr == null)
{
return;
}
Vector3i s = CinerariumThreads.LastPoint.Value;
Vector3 a = CinerariumThreads.LastIsRelay ? CinerariumThreads.RelayPoint(s)
: World.blockToTransformPos(s) - Origin.position + Vector3.up * (CinerariumThreads.StartIsUrn ? 0.5f : 0.6f);
Transform cam = pl.playerCamera != null ? pl.playerCamera.transform : null;
Vector3 b = cam != null ? cam.position + cam.forward * 0.6f + cam.right * 0.2f - cam.up * 0.25f : pl.GetPosition() - Origin.position + Vector3.up * 1.2f;
bool far = Vector3.Distance(s.ToVector3(), pl.GetPosition()) > CinerariumThreads.MaxLength;
Color c = far ? new Color(1f, 0.3f, 0.3f, 0.7f) : CinerariumThreads.ThreadColor;
_lr.startColor = c;
_lr.endColor = c;
for (int i = 0; i <= 16; i++)
{
_lr.SetPosition(i, CinerariumThreads.Curve(a, b, i / 16f));
}
}
}
/// <summary>Нож для нитей не бьёт: ЛКМ и ПКМ - нити (на отпускание кнопки).</summary>
[HarmonyPatch(typeof(ItemClass), nameof(ItemClass.ExecuteAction))]
public static class Patch_ItemClass_ExecuteAction_ThreadKnife
{
public static bool Prefix(ItemClass __instance, int _actionIdx, ItemInventoryData _data, bool _bReleased)
{
if (!CinerariumThreads.IsThreadKnife(__instance))
{
return true;
}
if (_bReleased && _data?.holdingEntity is EntityPlayerLocal pl)
{
CinerariumThreads.Click(pl, _actionIdx == 1);
}
return false;
}
}
}
+163
View File
@@ -0,0 +1,163 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// «Содержимое» урны-цинерария - ячейка черепа (решение 16.10: «урна как транспорт»; переименовано из «Багажника»).
/// Окно - родное окно хранилища игры (XUiC_BagStorageWindowGroup, как сумка транспорта и сумка волка) с сумкой на одну
/// ячейку: перетаскивание, Shift+клик и правый клик - кодом игры (16.10: «Сумки, ящики в игре есть» - своё окно с
/// ячейкой коллектора не принимало череп ни перетаскиванием, ни Shift+кликом). Своё здесь одно: после каждого изменения
/// в ячейке остаётся один череп, всё остальное (лишние черепа, чужие предметы) - назад в рюкзак, и урна узнаёт, есть ли
/// в ней череп. Череп - обычный предмет (ItemClass): он уже в сейвах, менять ему класс на модификацию нельзя.
/// У урны рабочего (18.10) ячеек две: вторая - образец (любой предмет, стопка целиком - сколько держать в запасе).
/// У старателя (22.10) вторая ячейка - что добывать: один предмет из TEFeatureCinerarium.Ores, лишнее и чужое - назад.
/// </summary>
public static class CinerariumContents
{
public const string LootTemplate = "roboticDrone"; // звуки и время открытия, как у сумки волка
static TEFeatureCinerarium _urn;
/// <summary>Урна, чьё «Содержимое» сейчас открыто (окно фильтра логиста), или null.</summary>
public static TEFeatureCinerarium Urn => _urn;
static Bag _bag;
static EntityPlayerLocal _player;
public static void Open(EntityPlayerLocal _p, TEFeatureCinerarium _u)
{
LocalPlayerUI ui = _p != null ? LocalPlayerUI.GetUIForPlayer(_p) : null;
ItemValue skull = ItemClass.GetItem(TEFeatureCinerarium.SkullItemName);
if (ui == null || _u == null)
{
return;
}
_urn = _u;
_player = _p;
_bag = new Bag(new Vector2i(_u.IsWorker || _u.IsProspector ? 2 : 1, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
if (_u.HasSkull && skull != null && skull.type > 0)
{
_bag.SetSlot(0, new ItemStack(skull, 1));
}
if ((_u.IsWorker || _u.IsProspector) && _u.Sample != null && !_u.Sample.IsEmpty())
{
_bag.SetSlot(1, _u.Sample.Clone());
}
XUiC_BagStorageWindowGroup.Open(ui.xui, _p, _bag, LootContainer.GetLootContainer(LootTemplate),
Localization.Get("necroCinerariumTrunk"), Check, () =>
{
Check();
_urn = null;
_bag = null;
_player = null;
});
Debug.Log("[NecromancerTome] Cinerarium: «Содержимое» урны " + _u.ToWorldPos() + " открыто, череп " + (_u.HasSkull ? "есть" : "нет"));
}
/// <summary>Оставить в ячейке один череп, остальное - игроку; отметить в урне. У рабочего - запомнить образец; череп во
/// второй ячейке - не образец, переложить в первую (или вернуть).</summary>
static void Check()
{
if (_bag == null || _urn == null)
{
return;
}
if ((_urn.IsWorker || _urn.IsProspector) && _bag.ItemGrid.items.Length > 1)
{
ItemStack smp = _bag.ItemGrid.items[1];
ItemStack first = _bag.ItemGrid.items[0];
if (smp != null && !smp.IsEmpty() && smp.itemValue.ItemClass?.GetItemName() == TEFeatureCinerarium.SkullItemName &&
(first == null || first.IsEmpty()))
{
_bag.SetSlot(0, smp.Clone());
_bag.SetSlot(1, ItemStack.Empty.Clone());
smp = _bag.ItemGrid.items[1];
}
if (_urn.IsProspector && smp != null && !smp.IsEmpty())
{
// Старатель: образец - один предмет из списка добычи; чужое целиком, лишние штуки - назад.
if (!TEFeatureCinerarium.IsOreSample(smp))
{
GiveBack(smp.Clone());
_bag.SetSlot(1, ItemStack.Empty.Clone());
if (_player != null)
{
GameManager.ShowTooltip(_player, Localization.Get("necroProspectorBadSample"));
}
}
else if (smp.count > 1)
{
ItemStack back = smp.Clone();
back.count = smp.count - 1;
GiveBack(back);
ItemStack one = smp.Clone();
one.count = 1;
_bag.SetSlot(1, one);
}
smp = _bag.ItemGrid.items[1];
}
_urn.SetSample(smp != null && !smp.IsEmpty() ? smp.Clone() : ItemStack.Empty.Clone());
}
ItemStack s = _bag.ItemGrid.items[0];
bool has = false;
if (s != null && !s.IsEmpty())
{
ItemStack back = null;
if (s.itemValue.ItemClass?.GetItemName() != TEFeatureCinerarium.SkullItemName)
{
back = s.Clone();
_bag.SetSlot(0, ItemStack.Empty.Clone());
}
else
{
has = true;
if (s.count > 1)
{
back = s.Clone();
back.count = s.count - 1;
ItemStack one = s.Clone();
one.count = 1;
_bag.SetSlot(0, one);
}
}
if (back != null)
{
GiveBack(back);
}
}
_urn.SetSkull(has);
}
static void GiveBack(ItemStack _s)
{
if (_player == null)
{
return;
}
if (_player.bag != null && _player.bag.AddItem(_s))
{
return;
}
if (_player.inventory != null && _player.inventory.AddItem(_s))
{
return;
}
_player.world.gameManager.ItemDropServer(_s, _player.GetPosition(), Vector3.zero);
}
/// <summary>Урна убрана или выгружена, пока открыто её «Содержимое», - закрыть окно (череп из ячейки не потеряется:
/// Check успеет отметить его в урне, а урна при разрушении роняет череп).</summary>
public static void CloseIfShowing(TEFeatureCinerarium _u)
{
if (_urn == null || _urn != _u)
{
return;
}
Check();
LocalPlayerUI ui = _player != null ? LocalPlayerUI.GetUIForPlayer(_player) : null;
_urn = null;
if (ui != null && ui.windowManager.IsWindowOpen(XUiC_BagStorageWindowGroup.ID))
{
ui.windowManager.Close(XUiC_BagStorageWindowGroup.ID);
}
}
}
}
+603
View File
@@ -0,0 +1,603 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Работа духа урны - этап 4 некротехнолога (указание 16.10: дух - УНИВЕРСАЛЬНЫЙ работник зоны; BACKLOG «Некротехнолог v2»).
/// Раз в StepSeconds - одно дело, по порядку:
/// 1. пополнить расходники коллекторов из ящиков зоны: ячейки топлива (банки, цветы, корм, сферы - что коллектор
/// принимает, GetFuelTypes) и катализатора (куры - GetCatalystTypes; по одной в пустую ячейку). Модификации
/// коллекторов не трогаются;
/// 2. забрать готовое из коллекторов (Items) и станций (TileEntityWorkstation.Output - верстак, кузня, химстанция,
/// костёр, бетономешалка) и сложить в ящики зоны: сначала к таким же предметам, потом в свободное место; ящики полны -
/// в сумку урны; некуда - дух чернеет и замирает над урной, оживает сам, когда место появится;
/// 3. грядки - последними: собрать созревшее (блок с меткой crops и добычей Harvest) с «идеальными бонусами»
/// (06.10: все бонусные доли урожая и семя - всегда), семя сразу посадить на то же место; затем засадить пустые
/// грядки случайными семенами из ящиков и сумки (семя - предмет-блок стадии 1, BlockPlantGrowing; можно ли сажать,
/// решает сам блок семени - CanPlaceBlockAt проверяет плодородие под ним, то есть грядку).
/// Коллектор или станция подходят любые - будущий крольчатник обслуживается тем же кодом. Ящик - составной блок игрока
/// с хранилищем (TEFeatureStorage, PlayerPlaced), кроме урн. Открытое у игрока (IsUserAccessing) не трогается.
/// Работает на сервере: зовётся из TEFeatureCinerarium.UpdateTick.
/// </summary>
public static class CinerariumWork
{
public static float StepSeconds = 3f;
/// <summary>Что в зоне: собирается заново на каждом шаге (150 клеток - дёшево, а ящики могут переставить).</summary>
sealed class Zone
{
public readonly List<TEFeatureStorage> Boxes = new List<TEFeatureStorage>();
public readonly List<TileEntityCollector> Collectors = new List<TileEntityCollector>();
public readonly List<TileEntityWorkstation> Stations = new List<TileEntityWorkstation>();
public readonly Dictionary<TileEntity, Vector3i> Where = new Dictionary<TileEntity, Vector3i>();
public TEFeatureStorage Bag;
public readonly List<Vector3i> Ripe = new List<Vector3i>();
public readonly List<Vector3i> Empty = new List<Vector3i>();
}
static readonly FastTags<TagGroup.Global> CropTag = FastTags<TagGroup.Global>.Parse("crops");
static readonly GameRandom _rnd = GameRandomManager.Instance.CreateGameRandom();
static readonly HashSet<TileEntity> _seen = new HashSet<TileEntity>();
/// <summary>Пробный шаг: найти дело и место, ничего не меняя.</summary>
static bool _dry;
/// <summary>Одно дело. Возвращает, где дух работал (для полёта модели), или null; _stuck - есть что забрать, но некуда.
/// _dryRun - только узнать, где будет дело (дух летит туда заранее), ничего не трогая.</summary>
public static Vector3i? Step(World _world, TEFeatureCinerarium _urn, out bool _stuck, bool _dryRun)
{
_dry = _dryRun;
try
{
return DoStep(_world, _urn, out _stuck);
}
finally
{
_dry = false;
}
}
/// <summary>У перевозки по нити - ящик «откуда» последнего шага (дух летит сначала к нему), иначе null.</summary>
public static Vector3i? LastFrom;
/// <summary>Что выбрал последний шаг (для necrourn probe, 23.10: «стою в 19 м - не работают»).</summary>
public static string LastWhat;
/// <summary>Пробный шаг урны без изменений: что в зоне и что дух выбрал бы сейчас (команда necrourn x y z probe).</summary>
public static string Probe(World _world, TEFeatureCinerarium _urn)
{
string zone = "";
if (!_urn.IsLogist && !_urn.IsWorker)
{
Zone z = Scan(_world, _urn);
zone = "коллекторов " + z.Collectors.Count + ", станций " + z.Stations.Count + ", ящиков " + z.Boxes.Count +
", сумка " + (z.Bag != null) + ", спелых " + z.Ripe.Count + (z.Ripe.Count > 0 ? " (" + z.Ripe[0] + ")" : "") +
", пустых клеток " + z.Empty.Count + "; ";
}
Vector3i? at = Step(_world, _urn, out bool stuck, true);
return zone + "шаг: " + (at.HasValue ? LastWhat + " в " + at.Value : "нечего делать") + (stuck ? ", класть некуда" : "");
}
static Vector3i? DoStep(World _world, TEFeatureCinerarium _urn, out bool _stuck)
{
_stuck = false;
LastFrom = null;
LastWhat = "?";
// Урна с нитями - логист (16.10: «занимается только перетаскиванием из ящиков в ящики»).
if (_urn.IsLogist)
{
return Ferry(_world, _urn, out _stuck);
}
// Урна рабочего - изготовление по образцу на станциях по нитям (CinerariumCraft.cs).
if (_urn.IsWorker)
{
Vector3i? at = CinerariumCraft.Step(_world, _urn, _dry, out _stuck, out Vector3i? from);
LastFrom = from;
return at;
}
Zone z = Scan(_world, _urn);
// 1. Расходники.
foreach (TileEntityCollector c in z.Collectors)
{
if (Refill(c, c.FuelSlots, c.GetFuelTypes(), false, z) || Refill(c, c.CatalystSlots, c.GetCatalystTypes(), true, z))
{
if (!_dry)
{
c.SetModified();
}
LastWhat = "расходники коллектору";
return z.Where[c];
}
}
// 2. Готовое.
bool blocked = false;
foreach (TileEntityCollector c in z.Collectors)
{
ItemStack[] items = c.Items;
if (Unload(items, z, ref blocked))
{
if (!_dry)
{
c.Items = items;
c.SetModified();
}
LastWhat = "готовое из коллектора";
return z.Where[c];
}
}
foreach (TileEntityWorkstation w in z.Stations)
{
ItemStack[] output = w.Output;
if (Unload(output, z, ref blocked))
{
if (!_dry)
{
w.Output = output;
}
LastWhat = "готовое со станции";
return z.Where[w];
}
}
// 3. Грядки.
foreach (Vector3i p in z.Ripe)
{
if (Harvest(_world, p, z, ref blocked))
{
LastWhat = "сбор урожая";
return p;
}
}
if (z.Empty.Count > 0)
{
Vector3i? planted = PlantRandom(_world, z);
if (planted.HasValue)
{
LastWhat = "посадка";
return planted;
}
}
_stuck = blocked;
return null;
}
/// <summary>Перевозка по нитям: одна стопка из ящика «откуда» в ящик «куда». Только предметы включённых в урне групп
/// (фильтр логиста, CinerariumGroups; 16.10 заменил прежнее правило «по образцу»); «куда» полон - ждём, _stuck - урна
/// мигает (18.10). Открытые у игрока ящики и закреплённые ячейки не трогаются. Возвращает ящик «куда», LastFrom - откуда взято.</summary>
static Vector3i? Ferry(World _world, TEFeatureCinerarium _urn, out bool _stuck)
{
_stuck = false;
if (!_urn.HasThreadTo)
{
return null;
}
TEFeatureStorage dst = BoxAt(_world, _urn.ThreadTo);
if (dst == null)
{
return null;
}
foreach (Vector3i f in _urn.ThreadFrom)
{
if (f == _urn.ThreadTo)
{
continue;
}
TEFeatureStorage src = BoxAt(_world, f);
if (src == null)
{
continue;
}
for (int k = 0; k < src.ItemGrid.Length; k++)
{
ItemStack s = src.ItemGrid[k];
if (s == null || s.IsEmpty() || Locked(src, k) || !_urn.GroupOn(CinerariumGroups.Of(s.itemValue)))
{
continue;
}
if (!Fits(dst, s))
{
_stuck = true; // есть что везти, но некуда
continue;
}
LastFrom = f;
_stuck = false; // везти есть куда - не застрял
if (_dry)
{
return _urn.ThreadTo;
}
ItemStack moving = s.Clone();
string what = s.itemValue.ItemClass?.GetItemName(); // до Take: забранная целиком стопка становится пустой
Place(moving, new[] { dst });
int moved = s.count - moving.count;
if (moved <= 0)
{
continue;
}
Take(src, k, moved);
dst.SetModified();
Debug.Log("[NecromancerTome] Cinerarium: дух перенёс " + moved + " " + what + " из " + f + " в " + _urn.ThreadTo);
return _urn.ThreadTo;
}
}
return null;
}
internal static TEFeatureStorage BoxAt(World _world, Vector3i _p)
{
return _world.GetTileEntity(_p) is TileEntityComposite c && !c.IsUserAccessing() &&
!c.TryGetSelfOrFeature<TEFeatureCinerarium>(out TEFeatureCinerarium _) &&
c.TryGetSelfOrFeature<TEFeatureStorage>(out TEFeatureStorage s) ? s : null;
}
static bool IsRipe(Block _b)
{
return _b != null && !(_b is BlockPlantGrowing) && _b.HasAnyFastTags(CropTag) &&
_b.itemsToDrop.TryGetValue(EnumDropEvent.Harvest, out List<Block.SItemDropProb> l) && l != null && l.Count > 0;
}
static bool IsSeed(ItemValue _iv, out BlockValue _bv)
{
_bv = BlockValue.Air;
if (_iv == null || _iv.type == 0 || !_iv.ItemClass.IsBlock())
{
return false;
}
_bv = _iv.ToBlockValue();
return _bv.Block is BlockPlantGrowing;
}
/// <summary>Урожай: все доли добычи Harvest (бонусы - тоже, вероятность только у обычных долей), множители сервера на
/// урожай и семена. Одно семя - сразу в землю на то же место, остальное - в ящики/сумку. Нет места ни для чего -
/// грядку не трогаем, дух чернеет.</summary>
static bool Harvest(World _world, Vector3i _p, Zone _z, ref bool _blocked)
{
Block crop = _world.GetBlock(_p).Block;
List<ItemStack> got = new List<ItemStack>();
BlockValue replant = BlockValue.Air;
foreach (Block.SItemDropProb d in crop.itemsToDrop[EnumDropEvent.Harvest])
{
ItemValue iv = ItemClass.GetItem(d.name);
if (iv == null || iv.type == 0)
{
continue;
}
bool seed = IsSeed(iv, out BlockValue seedBv);
bool bonus = d.tag != null && (d.tag.Contains("Bonus") || d.tag.Contains("bonus"));
if (!bonus && d.prob < 1f && _rnd.RandomFloat > d.prob)
{
continue;
}
int n = _rnd.RandomRange(d.minCount, d.maxCount + 1);
n = Mathf.RoundToInt(n * (seed ? XUiM_Recipes.SeedDropOutputModifier : XUiM_Recipes.CropOutputModifier));
if (seed && replant.isair && n > 0)
{
replant = seedBv;
n--;
}
if (n > 0)
{
got.Add(new ItemStack(iv, n));
}
}
if (got.Count > 0 && !HasRoom(got[0], _z))
{
_blocked = true;
return false;
}
if (_dry)
{
return true;
}
_world.SetBlockRPC(_p, BlockValue.Air);
if (!replant.isair)
{
replant.rotation = (byte)_rnd.RandomRange(0, 4);
_world.SetBlockRPC(_p, replant);
}
foreach (ItemStack s in got)
{
Place(s, _z.Boxes);
if (s.count > 0 && _z.Bag != null)
{
Place(s, new[] { _z.Bag });
}
if (s.count > 0)
{
// Влезло не всё (проверяли только первую долю) - остаток падает у урны, не пропадает.
GameManager.Instance.ItemDropServer(s.Clone(), World.blockToTransformPos(_p) + Vector3.up * 0.5f, Vector3.zero);
}
}
Debug.Log("[NecromancerTome] Cinerarium: дух собрал " + crop.GetBlockName() + " в " + _p + (replant.isair ? "" : ", посадил снова"));
return true;
}
static bool HasRoom(ItemStack _s, Zone _z)
{
foreach (TEFeatureStorage t in _z.Boxes)
{
if (Fits(t, _s))
{
return true;
}
}
return _z.Bag != null && Fits(_z.Bag, _s);
}
internal static bool Fits(TEFeatureStorage _t, ItemStack _s)
{
for (int i = 0; i < _t.ItemGrid.Length; i++)
{
ItemStack x = _t.ItemGrid[i];
if (x == null || x.IsEmpty())
{
return true;
}
int n = _s.count;
if (x.itemValue.type == _s.itemValue.type && x.CanStackPartly(ref n))
{
return true;
}
}
return false;
}
/// <summary>Засадить одну пустую грядку случайным семенем из сумки и ящиков (06.10: «случайно сажает имеющиеся семена»).</summary>
static Vector3i? PlantRandom(World _world, Zone _z)
{
List<(TEFeatureStorage src, int slot, BlockValue bv)> seeds = new List<(TEFeatureStorage, int, BlockValue)>();
foreach (TEFeatureStorage src in Sources(_z))
{
for (int k = 0; k < src.ItemGrid.Length; k++)
{
ItemStack st = src.ItemGrid[k];
if (st != null && !st.IsEmpty() && !Locked(src, k) && IsSeed(st.itemValue, out BlockValue bv))
{
seeds.Add((src, k, bv));
}
}
}
if (seeds.Count == 0)
{
return null;
}
foreach (Vector3i p in _z.Empty)
{
var pick = seeds[_rnd.RandomRange(0, seeds.Count)];
BlockValue bv = pick.bv;
bv.rotation = (byte)_rnd.RandomRange(0, 4);
if (!bv.Block.CanPlaceBlockAt(_world, p, bv))
{
continue;
}
if (_dry)
{
return p;
}
Take(pick.src, pick.slot, 1);
_world.SetBlockRPC(p, bv);
Debug.Log("[NecromancerTome] Cinerarium: дух посадил " + bv.Block.GetBlockName() + " в " + p);
return p;
}
return null;
}
static Zone Scan(World _world, TEFeatureCinerarium _urn)
{
Zone z = new Zone();
_seen.Clear();
Vector3i c = _urn.ToWorldPos();
if (_urn.Parent != null)
{
_urn.Parent.TryGetSelfOrFeature<TEFeatureStorage>(out z.Bag);
_seen.Add(_urn.Parent);
}
int w = TEFeatureCinerarium.HalfWidth;
for (int dy = -TEFeatureCinerarium.Below; dy <= TEFeatureCinerarium.Above; dy++)
{
for (int dx = -w; dx <= w; dx++)
{
for (int dz = -w; dz <= w; dz++)
{
Vector3i p = new Vector3i(c.x + dx, c.y + dy, c.z + dz);
BlockValue bv = _world.GetBlock(p);
if (bv.isair)
{
// Пустая клетка над плодородным блоком - кандидат в грядку (окончательно решит блок семени).
if (_world.GetBlock(p - Vector3i.up).Block?.blockMaterial?.FertileLevel > 0)
{
z.Empty.Add(p);
}
continue;
}
if (!bv.ischild && IsRipe(bv.Block))
{
z.Ripe.Add(p);
continue;
}
Vector3i owner = bv.ischild ? p + bv.parent : p; // часть составного блока (верстак 2x1) - к главной клетке
TileEntity te = _world.GetTileEntity(owner);
if (te == null || !_seen.Add(te) || te.IsUserAccessing())
{
continue;
}
if (te is TileEntityCollector col)
{
z.Collectors.Add(col);
z.Where[col] = owner;
}
else if (te is TileEntityWorkstation ws)
{
z.Stations.Add(ws);
z.Where[ws] = owner;
}
else if (te is TileEntityComposite comp && comp.PlayerPlaced &&
!comp.TryGetSelfOrFeature<TEFeatureCinerarium>(out TEFeatureCinerarium _) &&
comp.TryGetSelfOrFeature<TEFeatureStorage>(out TEFeatureStorage box))
{
z.Boxes.Add(box);
z.Where[comp] = owner;
}
}
}
}
return z;
}
/// <summary>Дополнить ячейки коллектора тем, что он принимает, из ящиков и сумки. _single - по одному предмету в
/// пустую ячейку (куры), иначе до полного стака. true - что-то положено.</summary>
static bool Refill(TileEntityCollector _c, ItemStack[] _slots, string[] _allowed, bool _single, Zone _z)
{
if (_slots == null || _allowed == null || _allowed.Length == 0)
{
return false;
}
for (int i = 0; i < _slots.Length; i++)
{
ItemStack slot = _slots[i];
ItemClass want = null;
int room;
if (slot == null || slot.IsEmpty())
{
room = -1; // любой допустимый
}
else
{
if (_single)
{
continue;
}
want = slot.itemValue.ItemClass;
room = want != null ? want.MaxCount - slot.count : 0;
if (room <= 0)
{
continue;
}
}
foreach (TEFeatureStorage src in Sources(_z))
{
for (int k = 0; k < src.ItemGrid.Length; k++)
{
ItemStack s = src.ItemGrid[k];
if (s == null || s.IsEmpty() || Locked(src, k))
{
continue;
}
ItemClass ic = s.itemValue.ItemClass;
if (ic == null || (want != null ? ic != want : System.Array.IndexOf(_allowed, ic.GetItemName()) < 0))
{
continue;
}
int take = _single ? 1 : (room < 0 ? Mathf.Min(s.count, ic.MaxCount) : Mathf.Min(s.count, room));
if (take <= 0)
{
continue;
}
if (_dry)
{
return true;
}
if (want == null)
{
_slots[i] = new ItemStack(s.itemValue.Clone(), take);
}
else
{
_slots[i].count += take;
}
Take(src, k, take);
return true;
}
}
}
return false;
}
/// <summary>Одна стопка готового - в ящики (к таким же, потом в пустое), затем в сумку урны. true - что-то перенесено;
/// _blocked - была стопка, которой не нашлось места.</summary>
static bool Unload(ItemStack[] _items, Zone _z, ref bool _blocked)
{
if (_items == null)
{
return false;
}
for (int i = 0; i < _items.Length; i++)
{
ItemStack s = _items[i];
if (s == null || s.IsEmpty())
{
continue;
}
if (_dry)
{
if (HasRoom(s, _z))
{
return true;
}
_blocked = true;
continue;
}
int before = s.count;
// Сначала ящики зоны (к таким же, потом в пустое), они полны - тогда сумка урны (решение 06.10).
Place(s, _z.Boxes);
if (s.count > 0 && _z.Bag != null)
{
Place(s, new[] { _z.Bag });
}
if (s.count == 0)
{
_items[i] = ItemStack.Empty.Clone();
return true;
}
if (s.count != before)
{
return true;
}
_blocked = true;
}
return false;
}
internal static void Place(ItemStack _s, IList<TEFeatureStorage> _dst)
{
for (int i = 0; i < _dst.Count && _s.count > 0; i++)
{
_dst[i].TryStackItem(0, _s);
}
for (int i = 0; i < _dst.Count && _s.count > 0; i++)
{
if (_dst[i].AddItem(_s.Clone()))
{
_s.count = 0;
}
}
}
/// <summary>Откуда брать расходники: сумка урны (там то, что дух накопил), потом ящики зоны.</summary>
static IEnumerable<TEFeatureStorage> Sources(Zone _z)
{
if (_z.Bag != null)
{
yield return _z.Bag;
}
foreach (TEFeatureStorage b in _z.Boxes)
{
yield return b;
}
}
/// <summary>Ячейка, закреплённая игроком (замок в окне ящика), - не трогать.</summary>
internal static bool Locked(TEFeatureStorage _src, int _slot)
{
PackedBoolArray locks = _src.ItemGrid.SlotLocks;
return locks != null && _slot < locks.Length && locks[_slot];
}
internal static void Take(TEFeatureStorage _src, int _slot, int _count)
{
ItemStack s = _src.ItemGrid[_slot].Clone();
s.count -= _count;
_src.UpdateSlot(_slot, s.count > 0 ? s : ItemStack.Empty.Clone());
_src.SetModified();
}
}
}
+716
View File
@@ -0,0 +1,716 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Урны с большой зоной - ремонтник и лутер (решения 17.10 и 18.10, BACKLOG «Урна ремонтника», «Урна лутера»).
///
/// Общее. Зона - квадрат со стороной блока притязания (LandClaimSize сервера, по умолчанию 41) или меньше (31/21/11, пункт
/// меню «Размер зоны»), по высоте - весь столб мира; зоны таких урн могут пересекаться. Работа - только по кнопке (17.10: «А
/// если сделать рубильник или кнопку. Включил, ремонтник начал работу»): «Запустить» - один проход по зоне, конец прохода -
/// снова ожидание; до нажатия урна ничего не считает. Кнопки - на самой урне с четырёх сторон («как у дверного звонка, но с
/// черепом»), их цвет - состояние (CinerariumButtons): не горят - нет черепа; жёлтая ровно - ожидание; жёлтая мигает - не
/// хватает материалов (ремонтник) или ящик полон (лутер); зелёная - работа; красная - прилив (работа стоит, дух в урне).
///
/// Ремонтник. Обход - по столбам зоны, порциями из общего бюджета клеток на кадр (CellsPerFrame на все урны); в столбе - от
/// низа до высоты чанка, земля мира (форма terrain) и воздух пропускаются сразу. Найденный повреждённый блок с RepairItems
/// встаёт в очередь (не длиннее QueueMax - обход ждёт), блок берёт тот, кто нашёл первым (Claimed). Ремонт - неспешно: одно
/// действие в RepairSeconds, дух летит к блоку и машет руками; материалы - RepairItems блока, доля по повреждению (как молотком),
/// из ящиков с нитями к урне. Не хватает - кнопка мигает, урна ждёт (положил в ящик - продолжит сама). Разрушенное не
/// восстанавливает - только повреждённое (17.10: «Восстанавливаю порушенное, затем включаю ремонтника и ухожу по делам»).
///
/// Лутер. За действие - одна цель в зоне: предмет на земле, затем мешок с зомби, затем красный труп (тем же путём, что игрок:
/// LootBagOpened + NecroSkull.Roll, стадия добычи - того, кто нажал кнопку); добыча - в ящик с любой нитью к урне. Ящики
/// зданий, аэродроп и рюкзаки погибших игроков не трогает (18.10 «Принимаю всё»). Целей нет - проход окончен.
/// </summary>
public partial class TEFeatureCinerarium
{
public const string RepairBlockName = "necroCinerariumRepair";
public const string RepairSkullBlockName = "necroCinerariumRepairSkull";
public const string LootBlockName = "necroCinerariumLoot";
public const string LootSkullBlockName = "necroCinerariumLootSkull";
public static bool IsRepairBlock(Block _b)
{
string n = _b?.GetBlockName();
return n == RepairBlockName || n == RepairSkullBlockName;
}
public static bool IsLootBlock(Block _b)
{
string n = _b?.GetBlockName();
return n == LootBlockName || n == LootSkullBlockName;
}
bool _repair, _loot;
public bool IsRepairer
{
get
{
_ = IsLogist;
return _repair;
}
}
public bool IsLooter
{
get
{
_ = IsLogist;
return _loot;
}
}
public bool IsBigZone => IsRepairer || IsLooter;
/// <summary>Сторона зоны, выбранная в меню (0 - по притязанию). Сохраняется (v6).</summary>
public int ZoneSide;
public bool ZoneRunning;
/// <summary>Обход ремонтника: номер следующего столба зоны (строка за строкой).</summary>
public int ZoneCursor;
/// <summary>Кто нажал «Запустить» - стадия добычи лутера.</summary>
public int ZonePressedBy = -1;
/// <summary>Не хватает материалов / ящик полон - кнопка мигает жёлтым.</summary>
public bool ZoneLacking;
public static float RepairSeconds = 2.5f;
public static float LootSeconds = 1.5f;
public static int CellsPerFrame = 4000;
public static int QueueMax = 32;
static int _cellsFrame = -1, _cellsUsed;
/// <summary>Блоки, взятые ремонтниками в очередь (зоны пересекаются - чинит взявший первым).</summary>
static readonly HashSet<Vector3i> Claimed = new HashSet<Vector3i>();
readonly List<Vector3i> _queue = new List<Vector3i>();
float _zoneNext;
bool _zonePending;
float _zoneActAt;
Vector3i _zoneTarget;
int _zoneTargetEntity = -1;
/// <summary>Стороны зоны: притязание сервера и меньшие нечётные (31, 21, 11), не больше притязания.</summary>
public static List<int> ZoneSides()
{
int claim = Mathf.Max(11, GamePrefs.GetInt(EnumGamePrefs.LandClaimSize));
List<int> l = new List<int> { claim };
foreach (int s in new[] { 31, 21, 11 })
{
if (s < claim)
{
l.Add(s);
}
}
return l;
}
public int ZoneSideNow
{
get
{
List<int> l = ZoneSides();
return ZoneSide > 0 && l.Contains(ZoneSide) ? ZoneSide : l[0];
}
}
public enum ButtonState
{
Off,
Wait,
Lacking,
Busy,
Tide
}
public ButtonState Buttons
{
get
{
if (!HasSkull)
{
return ButtonState.Off;
}
if (CinerariumLoad.Tide)
{
return ButtonState.Tide;
}
if (!ZoneRunning)
{
return ButtonState.Wait;
}
return ZoneLacking ? ButtonState.Lacking : ButtonState.Busy;
}
}
string ZoneStateText()
{
string key = Buttons switch
{
ButtonState.Tide => "necroZoneUrnTide",
ButtonState.Lacking => IsLooter ? "necroZoneUrnFull" : "necroZoneUrnNoMaterials",
ButtonState.Busy => "necroZoneUrnBusy",
ButtonState.Wait => "necroZoneUrnWait",
_ => null
};
string side = ZoneSideNow.ToString();
return (key != null ? ", " + Localization.Get(key) : "") + ", " + string.Format(Localization.Get("necroZoneUrnSize"), side);
}
/// <summary>Пункты меню на E: запустить, остановить, размер зоны (по кругу).</summary>
public void ZoneCommand(string _cmd, EntityPlayerLocal _player)
{
if (_cmd.EndsWith("zurn_run"))
{
if (!HasSkull)
{
return;
}
ZoneRunning = true;
ZoneCursor = 0;
ZoneLacking = false;
ZonePressedBy = _player != null ? _player.entityId : -1;
_queue.Clear();
_lootDone.Clear();
Audio.Manager.BroadcastPlay(World.blockToTransformPos(ToWorldPos()), "button4");
Debug.Log("[NecromancerTome] Cinerarium: " + (IsLooter ? "лутер" : "ремонтник") + " " + ToWorldPos() + " запущен, зона " + ZoneSideNow);
}
else if (_cmd.EndsWith("zurn_stop"))
{
ZoneStop(true);
Audio.Manager.BroadcastPlay(World.blockToTransformPos(ToWorldPos()), "button4");
}
else if (_cmd.EndsWith("zurn_size"))
{
List<int> l = ZoneSides();
int i = l.IndexOf(ZoneSideNow);
ZoneSide = l[(i + 1) % l.Count];
if (_player != null)
{
GameManager.ShowTooltip(_player, string.Format(Localization.Get("necroZoneUrnSize"), ZoneSide.ToString()));
}
if (ZoneShown)
{
RemoveBounds();
SpawnBounds();
}
}
SetModified();
}
/// <summary>Остановить проход (кнопка, урна убрана): очередь и взятые блоки отпускаются.</summary>
public void ZoneStop(bool _log)
{
foreach (Vector3i p in _queue)
{
Claimed.Remove(p);
}
_queue.Clear();
_zonePending = false;
if (ZoneRunning && _log)
{
Debug.Log("[NecromancerTome] Cinerarium: " + (IsLooter ? "лутер" : "ремонтник") + " " + ToWorldPos() + " остановлен");
}
ZoneRunning = false;
ZoneLacking = false;
}
void ZoneWork(World _world)
{
SyncButtons();
if (!ZoneRunning)
{
return;
}
CinerariumGenieMotion motion = _genie != null ? _genie.GetComponent<CinerariumGenieMotion>() : null;
if (_zonePending)
{
if (Time.time < _zoneActAt)
{
return;
}
_zonePending = false;
if (IsRepairer)
{
RepairNow(_world, _zoneTarget);
}
else
{
LootNow(_world, _zoneTargetEntity);
}
return;
}
if (IsRepairer)
{
ScanColumns(_world);
}
if (Time.time < _zoneNext || !CinerariumLoad.TryTakeStep(ToWorldPos()))
{
return;
}
_zoneNext = Time.time + (IsRepairer ? RepairSeconds : LootSeconds);
if (IsRepairer)
{
RepairPick(_world, motion);
}
else
{
LootPick(_world, motion);
}
}
/// <summary>Слетать к месту и сделать дело по прилёте (как дух сборщика) или сразу, если духа нет.</summary>
void FlyThenAct(World _world, CinerariumGenieMotion _motion, Vector3i _at)
{
_zonePending = true;
_zoneActAt = Time.time;
if (_motion != null)
{
List<(Vector3, Vector3, bool)> stops = new List<(Vector3, Vector3, bool)>
{
(Stand(_world, _at, out bool beside) - Origin.position, World.blockToTransformPos(_at) - Origin.position, beside)
};
_zoneActAt = Time.time + _motion.Visit(stops);
}
}
// ------------------------------------------------------------------
// Ремонтник.
// ------------------------------------------------------------------
void ScanColumns(World _world)
{
if (_queue.Count >= QueueMax)
{
return;
}
if (Time.frameCount != _cellsFrame)
{
_cellsFrame = Time.frameCount;
_cellsUsed = 0;
}
int side = ZoneSideNow, half = side / 2, total = side * side;
Vector3i c = ToWorldPos();
while (ZoneCursor < total && _cellsUsed < CellsPerFrame && _queue.Count < QueueMax)
{
int x = c.x - half + ZoneCursor % side, z = c.z - half + ZoneCursor / side;
ZoneCursor++;
if (!(_world.GetChunkFromWorldPos(x, 0, z) is Chunk ch))
{
continue; // не загружен - пропустить столб
}
int lx = World.toBlockXZ(x), lz = World.toBlockXZ(z);
int top = Mathf.Min(254, ch.GetHeight(lx, lz) + 1);
_cellsUsed += top;
for (int y = 1; y <= top; y++)
{
BlockValue bv = ch.GetBlock(lx, y, lz);
if (bv.isair || bv.ischild || bv.damage <= 0)
{
continue;
}
Block b = bv.Block;
if (b == null || b.shape.IsTerrain() || b.RepairItems == null || b.RepairItems.Count == 0)
{
continue;
}
Vector3i p = new Vector3i(x, y, z);
if (Claimed.Add(p))
{
_queue.Add(p);
}
}
}
if (ZoneCursor >= total && _queue.Count == 0 && !_zonePending)
{
Debug.Log("[NecromancerTome] Cinerarium: ремонтник " + c + " обошёл зону - ожидание");
ZoneStop(false);
SetModified();
}
}
void RepairPick(World _world, CinerariumGenieMotion _motion)
{
while (_queue.Count > 0)
{
Vector3i p = _queue[0];
BlockValue bv = _world.GetBlock(p);
if (bv.isair || bv.damage <= 0 || bv.Block?.RepairItems == null)
{
_queue.RemoveAt(0);
Claimed.Remove(p);
continue; // починили до нас или блок убран
}
if (!HasMaterials(_world, bv, false))
{
if (!ZoneLacking)
{
Debug.Log("[NecromancerTome] Cinerarium: ремонтнику " + ToWorldPos() + " не хватает материалов для " + bv.Block.GetBlockName() + " в " + p);
}
ZoneLacking = true;
return; // ждём: положат в ящик - продолжим с того же блока
}
ZoneLacking = false;
_zoneTarget = p;
FlyThenAct(_world, _motion, p);
return;
}
}
void RepairNow(World _world, Vector3i _p)
{
_queue.Remove(_p);
Claimed.Remove(_p);
BlockValue bv = _world.GetBlock(_p);
if (bv.isair || bv.damage <= 0 || bv.Block?.RepairItems == null)
{
return;
}
if (!HasMaterials(_world, bv, true))
{
ZoneLacking = true;
_queue.Insert(0, _p);
Claimed.Add(_p);
return;
}
int was = bv.damage;
bv.damage = 0;
_world.SetBlockRPC(_p, bv);
Debug.Log("[NecromancerTome] Cinerarium: ремонтник " + ToWorldPos() + " починил " + bv.Block.GetBlockName() + " в " + _p + " (было повреждение " + was + ")");
}
/// <summary>Хватает ли в ящиках с нитями к урне материалов на ремонт (RepairItems блока, доля по повреждению, не меньше
/// одного); _take - сразу и забрать.</summary>
bool HasMaterials(World _world, BlockValue _bv, bool _take)
{
Block b = _bv.Block;
float share = Mathf.Clamp01(_bv.damage / (float)Mathf.Max(1, b.MaxDamage));
List<TEFeatureStorage> boxes = ThreadBoxes(_world);
foreach (Block.SItemNameCount need in b.RepairItems)
{
ItemValue iv = ItemClass.GetItem(need.ItemName);
if (iv == null || iv.type == 0)
{
continue;
}
int n = Mathf.Max(1, Mathf.CeilToInt(need.Count * share));
int have = 0;
foreach (TEFeatureStorage s in boxes)
{
have += Count(s, iv);
}
if (have < n)
{
return false;
}
}
if (!_take)
{
return true;
}
foreach (Block.SItemNameCount need in b.RepairItems)
{
ItemValue iv = ItemClass.GetItem(need.ItemName);
if (iv == null || iv.type == 0)
{
continue;
}
int n = Mathf.Max(1, Mathf.CeilToInt(need.Count * share));
foreach (TEFeatureStorage s in boxes)
{
n -= TakeFrom(s, iv, n);
if (n <= 0)
{
break;
}
}
}
return true;
}
static int Count(TEFeatureStorage _s, ItemValue _iv)
{
int n = 0;
for (int i = 0; i < _s.ItemGrid.Length; i++)
{
ItemStack st = _s.ItemGrid[i];
if (st != null && !st.IsEmpty() && st.itemValue.type == _iv.type && !CinerariumWork.Locked(_s, i))
{
n += st.count;
}
}
return n;
}
static int TakeFrom(TEFeatureStorage _s, ItemValue _iv, int _n)
{
int took = 0;
for (int i = 0; i < _s.ItemGrid.Length && took < _n; i++)
{
ItemStack st = _s.ItemGrid[i];
if (st == null || st.IsEmpty() || st.itemValue.type != _iv.type || CinerariumWork.Locked(_s, i))
{
continue;
}
int k = Mathf.Min(st.count, _n - took);
CinerariumWork.Take(_s, i, k);
took += k;
}
return took;
}
/// <summary>Ящики урны по нитям: у ремонтника - «откуда» (материалы), у лутера - любая нить (куда класть).</summary>
List<TEFeatureStorage> ThreadBoxes(World _world)
{
List<TEFeatureStorage> l = new List<TEFeatureStorage>();
void Add(Vector3i _p)
{
TEFeatureStorage s = CinerariumWork.BoxAt(_world, _p);
if (s != null && !l.Contains(s))
{
l.Add(s);
}
}
foreach (Vector3i p in ThreadFrom)
{
Add(p);
}
if (IsLooter)
{
if (HasThreadTo)
{
Add(ThreadTo);
}
foreach (Vector3i p in ThreadOut)
{
Add(p);
}
}
return l;
}
// ------------------------------------------------------------------
// Лутер.
// ------------------------------------------------------------------
static readonly List<Entity> _ents = new List<Entity>();
/// <summary>Цели, уже обысканные за этот проход (пустой мешок, нечего взять) - второй раз не трогать.</summary>
readonly HashSet<int> _lootDone = new HashSet<int>();
/// <summary>Цель лутера в зоне: предмет на земле, потом мешок с зомби, потом красный труп. Ничего - проход окончен.</summary>
void LootPick(World _world, CinerariumGenieMotion _motion)
{
List<TEFeatureStorage> boxes = ThreadBoxes(_world);
if (boxes.Count == 0)
{
ZoneLacking = true; // некуда класть - как полный ящик
return;
}
int side = ZoneSideNow;
Vector3 c = World.blockToTransformPos(ToWorldPos());
Bounds bb = new Bounds(new Vector3(c.x, 128f, c.z), new Vector3(side, 256f, side));
Entity best = null;
int bestRank = int.MaxValue;
_ents.Clear();
_world.GetEntitiesInBounds(typeof(Entity), bb, _ents);
foreach (Entity e in _ents)
{
int rank = LootRank(e);
if (rank < bestRank && !_lootDone.Contains(e.entityId) && !LockManager.Instance.IsLockedServer(e, 0))
{
best = e;
bestRank = rank;
}
}
_ents.Clear();
if (best == null)
{
Debug.Log("[NecromancerTome] Cinerarium: лутер " + ToWorldPos() + " собрал всё в зоне - ожидание");
ZoneStop(false);
SetModified();
return;
}
ZoneLacking = false;
_zoneTargetEntity = best.entityId;
FlyThenAct(_world, _motion, World.worldToBlockPos(best.GetPosition()));
}
/// <summary>Что собирать и в каком порядке (меньше - раньше); int.MaxValue - не трогать. Рюкзаки игроков (EntityBackpack) и
/// аэродроп (EntitySupplyCrate) - не трогать.</summary>
static int LootRank(Entity _e)
{
if (_e == null || _e.IsDespawned)
{
return int.MaxValue;
}
if (_e is EntityBackpack || _e is EntitySupplyCrate)
{
return int.MaxValue;
}
if (_e is EntityLootContainer lc)
{
return lc.bag != null ? 1 : int.MaxValue;
}
if (_e is EntityItem it && !(_e is EntityContainerAbs))
{
return it.itemStack != null && !it.itemStack.IsEmpty() ? 0 : int.MaxValue;
}
if (_e is EntityAlive a && a.IsDead() && CorpseLoot.IsSearchable(a) && a.bag != null)
{
return 2;
}
return int.MaxValue;
}
void LootNow(World _world, int _id)
{
Entity e = _world.GetEntity(_id);
if (e == null || LootRank(e) == int.MaxValue || LockManager.Instance.IsLockedServer(e, 0))
{
return;
}
List<TEFeatureStorage> boxes = ThreadBoxes(_world);
if (e is EntityItem it && !(e is EntityContainerAbs))
{
ItemStack s = it.itemStack.Clone();
int before = s.count;
CinerariumWork.Place(s, boxes);
if (s.count == before)
{
ZoneLacking = true;
return;
}
if (s.count <= 0)
{
_world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
}
else
{
it.itemStack = s;
}
Debug.Log("[NecromancerTome] Cinerarium: лутер " + ToWorldPos() + " подобрал " + s.itemValue.ItemClass?.GetItemName() + " x" + (before - s.count));
return;
}
Bag bag = e is EntityAlive a ? a.bag : (e as EntityLootContainer)?.bag;
if (bag == null)
{
return;
}
if (!bag.Touched)
{
// Как игрок: первое открытие наполняет сумку по списку добычи (стадия - нажавшего кнопку); у красного трупа - и
// находка черепа (CorpseLootPatch: OnLockRequestServer).
int who = ZonePressedBy >= 0 ? ZonePressedBy : (_world.GetPrimaryPlayer()?.entityId ?? -1);
GameManager.Instance.lootManager.LootBagOpened(bag, e, who);
if (e is EntityAlive corpse)
{
NecroSkull.Roll(corpse, bag);
}
}
bool moved = false, left = false;
ItemStackGrid slots = bag.ItemGrid;
for (int i = 0; i < slots.Length; i++)
{
if (slots[i] == null || slots[i].IsEmpty())
{
continue;
}
ItemStack s = slots[i].Clone();
int before = s.count;
CinerariumWork.Place(s, boxes);
if (s.count != before)
{
moved = true;
}
if (s.count > 0)
{
left = true;
}
bag.SetSlot(i, s.count > 0 ? s : ItemStack.Empty.Clone());
}
ZoneLacking = left; // не всё влезло - ящик полон, кнопка мигает
if (!left)
{
_lootDone.Add(e.entityId); // опустошён (или был пуст) - больше не цель; пустой убирает игра
}
Debug.Log("[NecromancerTome] Cinerarium: лутер " + ToWorldPos() + " обыскал " + e.GetType().Name + " " + e.entityId + (left ? " - не всё влезло" : ""));
}
// ------------------------------------------------------------------
// Кнопки на модели.
// ------------------------------------------------------------------
void SyncButtons()
{
Transform t = ModelTransform();
if (t == null)
{
return;
}
CinerariumButtons b = t.GetComponent<CinerariumButtons>();
if (b == null)
{
b = t.gameObject.AddComponent<CinerariumButtons>();
}
b.State = Buttons;
}
}
/// <summary>Кнопки-звонки с черепом на урне ремонтника/лутера: рендереры модели с «button» в имени. Цвет свечения - по
/// состоянию урны (TEFeatureCinerarium.Buttons), жёлтая мигает - нехватка.</summary>
[Preserve]
public class CinerariumButtons : MonoBehaviour
{
public TEFeatureCinerarium.ButtonState State;
List<Renderer> _buttons;
static readonly MaterialPropertyBlock _block = new MaterialPropertyBlock();
static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor");
public static Color Yellow = new Color(2.2f, 1.6f, 0.15f);
public static Color Green = new Color(0.3f, 2.2f, 0.4f);
public static Color Red = new Color(2.4f, 0.15f, 0.1f);
Color _last = new Color(-1f, 0f, 0f);
public static bool IsButton(Renderer _r) => _r != null && _r.name.ToLowerInvariant().Contains("button");
void Update()
{
if (_buttons == null)
{
_buttons = new List<Renderer>();
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
if (IsButton(r))
{
_buttons.Add(r);
}
}
}
Color c;
switch (State)
{
case TEFeatureCinerarium.ButtonState.Wait: c = Yellow; break;
case TEFeatureCinerarium.ButtonState.Lacking: c = Mathf.Repeat(Time.time, 0.8f) < 0.4f ? Yellow : Color.black; break;
case TEFeatureCinerarium.ButtonState.Busy: c = Green; break;
case TEFeatureCinerarium.ButtonState.Tide: c = Red; break;
default: c = Color.black; break;
}
if (c == _last)
{
return;
}
_last = c;
foreach (Renderer r in _buttons)
{
if (r == null)
{
continue;
}
r.GetPropertyBlock(_block);
_block.SetColor(EmissionColorId, c);
r.SetPropertyBlock(_block);
}
}
}
}
+1 -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") || _name == "necroBookDoorPrefab" || _name.StartsWith("necroSkullDecor");
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster") || _name == "necroBookDoorPrefab" || _name.StartsWith("necroSkullDecor") || _name == "necroCinerariumPrefab" || _name == "necroCinerariumWorkerPrefab" || _name.StartsWith("necroCinerariumLogist") || _name == "necroCinerariumRepairPrefab" || _name == "necroCinerariumLootPrefab" || _name == "necroCinerariumProspectorPrefab" || _name == "necroOssuaryCauldronPrefab";
}
public static void Postfix(Transform __result)
+51
View File
@@ -0,0 +1,51 @@
using System;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Стрела: NullReferenceException в ItemAction.GetDismemberChance (23.10.2026). Вызывается из ProjectileMoveScript.checkCollision
/// аргументом к ItemActionAttack.Hit - поэтому в первых логах исключение выглядело как ошибка «внутри Hit» (метод был встроен).
/// Пустое звено цепочки actionData.invData.holdingEntity.inventory / invData.item у стрелы, запомнившей данные лука при выстреле.
/// Исключение вылетало раньше, чем стрела сменит состояние, и она «попадала» снова каждый шаг физики - пачки по 6-19 ошибок.
/// Здесь: при исключении шанс отстрела конечности 0 (стрела долетает как обычно), в лог - какое звено пустое (не больше
/// MaxLogs раз за запуск). Причина звена - выяснить по этим строкам.
/// </summary>
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.GetDismemberChance))]
public static class Patch_ItemAction_GetDismemberChance_Guard
{
const int MaxLogs = 5;
static int _logged;
public static Exception Finalizer(Exception __exception, ItemActionData _actionData, WorldRayHitInfo hitInfo, ref float __result)
{
if (__exception == null)
{
return null;
}
__result = 0f;
if (_logged < MaxLogs)
{
_logged++;
try
{
ItemInventoryData inv = _actionData?.invData;
EntityAlive holder = inv?.holdingEntity;
Debug.Log("[NecromancerTome] DismemberGuard: " + __exception.GetType().Name + " в GetDismemberChance - actionData " +
(_actionData == null ? "NULL" : _actionData.GetType().Name) +
", invData " + (inv == null ? "NULL" : "slot " + inv.slotIdx) +
", item " + (inv?.item == null ? "NULL" : inv.item.Name) +
", holdingEntity " + (holder == null ? "NULL" : holder.GetType().Name + " " + holder.entityId + (holder.IsDead() ? " dead" : "")) +
", inventory " + (holder == null ? "-" : holder.inventory == null ? "NULL" : "в руке " + holder.inventory.holdingItem?.Name) +
", tag " + (hitInfo?.tag ?? "NULL") + " - шанс отстрела 0");
}
catch (Exception e2)
{
Debug.Log("[NecromancerTome] DismemberGuard: не удалось описать - " + e2.Message);
}
}
return null; // проглотить: стрела долетает и попадает как обычно
}
}
}
+185 -4
View File
@@ -53,15 +53,25 @@ namespace NecromancerTome
public static bool Is(TileEntityCollector te) => te != null && Is(te.blockValue.Block);
/// <summary>os1 -> 0, os2 -> 1, os3 -> 2; иначе -1.</summary>
/// <summary>Ячейка образца для выхода: хранилище 2 ряда по 3, столбец - образец над ним (os1 и os4 - образец 1, os2 и
/// os5 - 2, os3 и os6 - 3). 22.10 сначала решили «ячейка под образец - одна», но спрятать две лишние не вышло с первого
/// раза, и пользователь: «Пусть можно будет положить 3 разных образца, и тогда в ячейки под ними пойдут такие же ресурсы.
/// Примерно так оссуарий и работал». Не наш выход - -1.</summary>
public static int ModSlotOf(BlockCollector.OutputType outputType)
{
string name = outputType?.Name;
if (name == null || name.Length != 3 || !name.StartsWith("os"))
return -1;
return name[2] - '1';
return (name[2] - '1') % 3;
}
/// <summary>Видимых в окне ячеек образца и топлива (#66, 22.10: «Ячейка под образец - одна; ячейка под кровавую
/// сферу (или камень) - одна»). В данных их по-прежнему 3 и 6: длины пишутся в сейв, а при загрузке игра обрезает
/// лишнее по конфигу - уменьшение молча выбросило бы содержимое. Лишние прячутся в окне, их старое содержимое
/// переносит в хранилище Patch_TileEntityCollector_UpdateTick_GeoOssuaryMigrate.</summary>
public const int VisibleModSlots = 3; // все три (см. ModSlotOf); прятать нечего, перенос образцов не нужен
public const int VisibleFuelSlots = 1;
/// <summary>Ресурс в ячейке модификации, если он из списка; иначе null.</summary>
public static ItemClass ResourceIn(TileEntityCollector te, int modSlot)
{
@@ -82,8 +92,11 @@ namespace NecromancerTome
public static void Postfix(XUiC_DewCollectorModGrid __instance, TileEntityCollector te)
{
bool ossuary = GeoOssuary.Is(te);
foreach (XUiC_ItemStack slot in __instance.itemControllers)
for (int i = 0; i < __instance.itemControllers.Length; i++)
{
XUiC_ItemStack slot = __instance.itemControllers[i];
// окно у всех коллекторов общее - чужим возвращаем все ячейки
slot.ViewComponent.IsVisible = !ossuary || i < GeoOssuary.VisibleModSlots;
if (!(slot is XUiC_RequiredItemStack required))
continue;
required.OverrideStackCount = ossuary ? 1 : -1;
@@ -96,6 +109,70 @@ namespace NecromancerTome
}
}
/// <summary>Лишние ячейки держать скрытыми каждый кадр, пока окно открыто (22.10: «почему-то ячеек под образец 3, а мы
/// планировали сделать 1»): сетка топлива сама выставляет видимость по высоте в своём Update (updateGridHeight), ячейки
/// образца тоже показывались снова после SetTileEntity. Сеттер IsVisible при том же значении ничего не делает.</summary>
[HarmonyPatch(typeof(XUiC_ItemStackGrid), nameof(XUiC_ItemStackGrid.Update))]
public static class Patch_XUiC_ItemStackGrid_Update_GeoOssuary
{
public static void Postfix(XUiC_ItemStackGrid __instance)
{
if (__instance is XUiC_DewCollectorModGrid mods)
{
if (!GeoOssuary.Is(mods.tileEntity))
return;
for (int i = GeoOssuary.VisibleModSlots; i < mods.itemControllers.Length; i++)
mods.itemControllers[i].ViewComponent.IsVisible = false;
}
else if (__instance is XUiC_CollectorFuelGrid fuel && fuel.fuelBehavior == XUiC_CollectorFuelGrid.FuelBehavior.Fuel &&
GeoOssuary.Is(fuel.tileEntity))
{
GeoOssuaryFuelGrid.Apply(fuel);
}
}
}
/// <summary>Сетка топлива меняет видимость после базового Update (updateGridHeight) - прятать и после него.</summary>
[HarmonyPatch(typeof(XUiC_VariableHeightGrid), nameof(XUiC_VariableHeightGrid.Update))]
public static class Patch_XUiC_VariableHeightGrid_Update_GeoOssuary
{
public static void Postfix(XUiC_VariableHeightGrid __instance)
{
if (__instance is XUiC_CollectorFuelGrid fuel && fuel.fuelBehavior == XUiC_CollectorFuelGrid.FuelBehavior.Fuel &&
GeoOssuary.Is(fuel.tileEntity))
GeoOssuaryFuelGrid.Apply(fuel);
}
}
/// <summary>Заголовок над ячейками образцов (22.10: «Переименуй»): у коллекторов игры - «Инструменты» (xuiTools), у котла -
/// «Образцы». Окно общее, поэтому чужим возвращаем родное.</summary>
[HarmonyPatch(typeof(XUiC_DewCollectorModGrid), nameof(XUiC_DewCollectorModGrid.OnOpen))]
public static class Patch_XUiC_DewCollectorModGrid_OnOpen_GeoOssuaryHeader
{
public static void Postfix(XUiC_DewCollectorModGrid __instance)
{
XUiController window = __instance;
while (window.Parent != null && !(window.ViewComponent is XUiV_Window))
window = window.Parent;
XUiV_Label header = FindHeader(window);
if (header != null)
header.Text = Localization.Get(GeoOssuary.Is(__instance.tileEntity) ? "necroGeoOssuarySamples" : "xuiTools");
}
static XUiV_Label FindHeader(XUiController _c)
{
if (_c.ViewComponent is XUiV_Label l && (l.Text == Localization.Get("xuiTools") || l.Text == Localization.Get("necroGeoOssuarySamples")))
return l;
foreach (XUiController child in _c.Children)
{
XUiV_Label found = FindHeader(child);
if (found != null)
return found;
}
return null;
}
}
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.isDisabled))]
public static class Patch_TileEntityCollector_isDisabled_GeoOssuary
{
@@ -143,7 +220,7 @@ namespace NecromancerTome
// Пирамида отрицания (01.10) собрана из копии префаба оссуария - те же имена объектов,
// тот же запасной шейдер, поэтому лечится тем же постфиксом.
static readonly System.Collections.Generic.HashSet<string> models =
new System.Collections.Generic.HashSet<string> { "necroOssuaryPrefab", "necroWardPrefab" };
new System.Collections.Generic.HashSet<string> { "necroWardPrefab" };
static readonly System.Collections.Generic.HashSet<string> done = new System.Collections.Generic.HashSet<string>();
public static void Postfix(Transform __result)
@@ -251,4 +328,108 @@ namespace NecromancerTome
__result = false;
}
}
/// <summary>Одна ячейка топлива в окне (GeoOssuary.VisibleFuelSlots): остальные ячейки сетки и их подсказки-картинки
/// спрятаны. Сетка общая для всех коллекторов (и для катализатора) - чужим всё показываем обратно.</summary>
public static class GeoOssuaryFuelGrid
{
public static void Apply(XUiC_CollectorFuelGrid _grid)
{
if (_grid?.tileEntity == null || _grid.fuelBehavior != XUiC_CollectorFuelGrid.FuelBehavior.Fuel)
return;
// 23.10: окно ещё слушает урну, а блока коллектора под ней уже нет (сняли / поставили заново) - FuelGridLength
// игры тогда падает NullReference.
if (!(_grid.tileEntity.blockValue.Block is BlockCollector))
return;
bool ossuary = GeoOssuary.Is(_grid.tileEntity);
int n = _grid.tileEntity.FuelGridLength;
for (int i = 0; i < _grid.itemControllers.Length; i++)
{
bool show = i < n && (!ossuary || i < GeoOssuary.VisibleFuelSlots);
if (_grid.itemControllers[i].ViewComponent.IsVisible != show && (ossuary || i < n))
_grid.itemControllers[i].ViewComponent.IsVisible = show;
if (_grid.previewSprites != null && i < _grid.previewSprites.Length && _grid.previewSprites[i] != null && ossuary && i >= GeoOssuary.VisibleFuelSlots)
_grid.previewSprites[i].IsVisible = false;
}
}
}
[HarmonyPatch(typeof(XUiC_CollectorFuelGrid), nameof(XUiC_CollectorFuelGrid.SetTileEntity))]
public static class Patch_XUiC_CollectorFuelGrid_SetTileEntity_GeoOssuary
{
public static void Postfix(XUiC_CollectorFuelGrid __instance) => GeoOssuaryFuelGrid.Apply(__instance);
}
[HarmonyPatch(typeof(XUiC_CollectorFuelGrid), nameof(XUiC_CollectorFuelGrid.OnTileEntityChanged))]
public static class Patch_XUiC_CollectorFuelGrid_OnTileEntityChanged_GeoOssuary
{
public static void Postfix(XUiC_CollectorFuelGrid __instance) => GeoOssuaryFuelGrid.Apply(__instance);
}
/// <summary>Shift-клик топливом: у оссуария - только в видимую ячейку (ваниль раскладывает по всей сетке).</summary>
[HarmonyPatch(typeof(XUiC_CollectorFuelGrid), nameof(XUiC_CollectorFuelGrid.TryAddFuel))]
public static class Patch_XUiC_CollectorFuelGrid_TryAddFuel_GeoOssuary
{
public static bool Prefix(XUiC_CollectorFuelGrid __instance, ItemClass newItemClass, ItemStack newItemStack, ref bool __result)
{
if (__instance.fuelBehavior != XUiC_CollectorFuelGrid.FuelBehavior.Fuel || !GeoOssuary.Is(__instance.tileEntity))
return true;
if (System.Array.IndexOf(__instance.tileEntity.GetFuelTypes(), newItemClass.Name) >= 0)
{
XUiC_ItemStack slot = __instance.itemControllers[0];
if (slot.ItemStack.IsEmpty())
{
slot.ItemStack = newItemStack.Clone();
newItemStack.count = 0;
}
else
{
int n = slot.ItemStack.StackTransferCount(newItemStack);
slot.ItemStack.count += n;
newItemStack.count -= n;
}
__instance.UpdateBackend(__instance.getUISlots());
}
__result = newItemStack.count == 0;
return false;
}
}
/// <summary>Переход на котёл (#66, 22.10: «у уже поставленных оссуариев при первой загрузке новой версии всё лишнее из
/// старых ячеек образцов и топлива переложить в новое хранилище - ничего не теряется»). Сервер на каждом тике смотрит
/// спрятанные ячейки (образцы 2-3, топливо 2-6) и перекладывает их содержимое в свободные ячейки хранилища; нет места -
/// ждёт, пока игрок заберёт добычу.</summary>
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.UpdateTick))]
public static class Patch_TileEntityCollector_UpdateTick_GeoOssuaryMigrate
{
public static void Postfix(TileEntityCollector __instance)
{
if (!GeoOssuary.Is(__instance) || !SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer)
return;
bool moved = Move(__instance, __instance.ModSlots, GeoOssuary.VisibleModSlots, "образца");
moved |= Move(__instance, __instance.FuelSlots, GeoOssuary.VisibleFuelSlots, "топлива");
if (moved)
__instance.SetModified();
}
static bool Move(TileEntityCollector _te, ItemStack[] _from, int _keep, string _what)
{
bool moved = false;
ItemStack[] store = _te.Items;
for (int i = _keep; i < _from.Length; i++)
{
if (_from[i] == null || _from[i].IsEmpty())
continue;
int free = System.Array.FindIndex(store, s => s == null || s.IsEmpty());
if (free < 0)
return moved;
store[free] = _from[i].Clone();
_from[i] = ItemStack.Empty.Clone();
moved = true;
Debug.Log("[NecromancerTome] GeoOssuary: " + store[free].itemValue.ItemClass?.Name + " x" + store[free].count +
" из ячейки " + _what + " " + (i + 1) + " -> хранилище " + (free + 1) + " в " + _te.ToWorldPos());
}
return moved;
}
}
}
+3 -1
View File
@@ -319,7 +319,9 @@ namespace NecromancerTome
{
if (_entity is EntityTrader)
{
return true;
// Только торговцы самой игры (класс EntityTrader). 16.10 (Nexus, Crozekanov): NPC мода NPCCore построены на
// наследнике EntityTrader и тоже становились призраками - «практически невидимыми».
return _entity.GetType() == typeof(EntityTrader);
}
EntityClass entityClass = EntityClass.list[_entity.entityClass];
if (entityClass == null || entityClass.entityClassName == null)
+22 -1
View File
@@ -23,7 +23,10 @@ namespace NecromancerTome
public static class HandHolds
{
public const string HandItem = "meleeHandPlayer";
public const int None = 0, Deny = 1, PifPaf = 2;
public const int None = 0, Deny = 1, PifPaf = 2, Thread = 3;
/// <summary>Хват для нитей - не сразу: снимок замороженных костей должен быть в покое хвата 68, после доставания.</summary>
const float ThreadDelay = 0.6f;
static float _threadSince = -1f;
/// <summary>Дальность и конус выбора зомби для «отрицания».</summary>
public const float DenyRange = 25f;
@@ -59,6 +62,19 @@ namespace NecromancerTome
public static int Current(EntityPlayerLocal _player)
{
// Только от первого лица (16.10: «В виде от третьего лица что-то страшное»): поза и перенос ножа - для рук первого
// лица; от третьего лица модель в руке - у тела, и нож улетал к невидимой руке первого лица.
if (_player != null && _player.inventory?.holdingItem?.Name == CinerariumThreads.ThreadKnifeName &&
(_player.emodel as EModelSDCS)?.IsFPV == true && !SnakeMode.Active && !MindPalace.Active && !Possession.Active && !_player.IsDead())
{
if (_threadSince < 0f)
{
_threadSince = Time.time;
}
bool ready = Time.time - _threadSince >= ThreadDelay && !(_player.Hand != null && _player.Hand.isSwitching);
return ready ? Thread : None;
}
_threadSince = -1f;
if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) ||
SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead())
{
@@ -104,9 +120,14 @@ namespace NecromancerTome
{
HandPose.PresetPifPaf();
}
else if (want == Thread)
{
HandPose.PresetThread();
}
else
{
HandPose.Enabled = false;
HandPose.KnifeToLeft = false;
}
}
+127 -1
View File
@@ -76,6 +76,8 @@ namespace NecromancerTome
/// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза.
/// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.</summary>
public static bool Freeze = true;
/// <summary>Замереть без своего хвата - на время осмотра со стороны (NecroView).</summary>
public static bool HoldStill;
static readonly List<Transform> _frozen = new List<Transform>();
static readonly List<Vector3> _frozenPos = new List<Vector3>();
static readonly List<Quaternion> _frozenRot = new List<Quaternion>();
@@ -86,6 +88,8 @@ namespace NecromancerTome
public static void ResetAll()
{
Enabled = false;
KnifeToLeft = false;
NecroView.Stop();
Overrides.Clear();
CamOverrides.Clear();
_jabStart = -1f;
@@ -142,10 +146,18 @@ namespace NecromancerTome
{
if (!Enabled || Overrides.Count == 0 && CamOverrides.Count == 0)
{
if (_frozen.Count > 0)
if (HoldStill)
{
FreezeBones(); // осмотр со стороны (necroview): поза замирает и без своего хвата
}
else if (_frozen.Count > 0)
{
Unfreeze();
}
if (KnifeToLeft && Enabled)
{
ApplyKnifeToLeft();
}
return;
}
if (Freeze)
@@ -160,6 +172,10 @@ namespace NecromancerTome
b.localRotation = Quaternion.Euler(kv.Value);
}
}
if (KnifeToLeft)
{
ApplyKnifeToLeft();
}
Transform cam = CamOverrides.Count > 0 || _jabStart >= 0f ? Camera() : null;
ApplyJab(cam);
if (cam != null)
@@ -265,6 +281,7 @@ namespace NecromancerTome
/// (сгиб - как у поджатых пальцев «пиф-пафа»), указательный и мизинец прямые.</summary>
public static void PresetDeny()
{
KnifeToLeft = false;
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(345.8f, 34.9f, 24.3f);
@@ -291,6 +308,7 @@ namespace NecromancerTome
/// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.</summary>
public static void PresetPifPaf()
{
KnifeToLeft = false;
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
@@ -313,6 +331,111 @@ namespace NecromancerTome
Enabled = true;
}
/// <summary>Нож в левой кисти (хват для нитей): корень модели в руке ставится каждый кадр от LeftHand. Смещение -
/// снимок хвата 68 (деревянный лук) 16.10, где нож лежал в левой руке («Нож в левой руке»).</summary>
public static bool KnifeToLeft;
static readonly Vector3 KnifeLeftPos = new Vector3(-0.0832f, -0.0354f, 0.0186f);
static readonly Quaternion KnifeLeftRot = new Quaternion(0.56471f, -0.62192f, -0.41297f, 0.35182f);
static void ApplyKnifeToLeft()
{
Transform lh = Bone("LeftHand");
Transform m = HeldTweak.Model();
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (lh != null && m != null && (pl?.emodel as EModelSDCS)?.IsFPV == true)
{
m.SetPositionAndRotation(lh.TransformPoint(KnifeLeftPos), lh.rotation * KnifeLeftRot);
}
}
/// <summary>Хват для нитей (16.10, подбор с пользователем: «Нож в левую руку, а правую сделай как в том хвате, где
/// ты говорил что она перебирает нити»): основа - HoldType 68 (деревянный лук, items.xml); левая рука поднята и
/// отведена влево, нож в ней; правая - раскрытая ладонь хвата 64 (бинт), пересчитанная под плечо хвата 68 так,
/// чтобы кисть стояла там же относительно камеры (scratchpad ik.py).</summary>
public static void PresetThread()
{
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightShoulder"] = new Vector3(0.0f, 180.0f, 180.0f);
Overrides["Right_sup_shoulder_01"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_shoulder_02"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_trap_01"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightArm"] = new Vector3(311.4f, 24.9f, 39.2f);
Overrides["Right_sup_arm_01"] = new Vector3(0.0f, 0.0f, 358.2f);
Overrides["RightArmRoll"] = new Vector3(357.9f, 0.0f, 0.0f);
Overrides["RightForeArm"] = new Vector3(0.0f, 79.9f, 0.0f);
Overrides["Right_sup_elbow_00"] = new Vector3(0.0f, 332.8f, 0.0f);
Overrides["RightForeArmRoll"] = new Vector3(8.7f, 0.0f, 0.0f);
Overrides["RightHand"] = new Vector3(7.2f, 346.5f, 5.4f);
Overrides["RightHandIndex0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_index_00"] = new Vector3(0.0f, 0.0f, 17.3f);
Overrides["Right_sup_index_00_palm"] = new Vector3(0.0f, 0.0f, 15.6f);
Overrides["RightHandIndex1"] = new Vector3(2.6f, 2.9f, 31.3f);
Overrides["Right_sup_index_01_under"] = new Vector3(0.0f, 0.0f, 3.5f);
Overrides["RightHandIndex2"] = new Vector3(0.0f, 0.0f, 52.8f);
Overrides["Right_sup_index_02"] = new Vector3(0.0f, 0.0f, 333.6f);
Overrides["Right_sup_index_02_under"] = new Vector3(0.0f, 0.0f, 5.1f);
Overrides["RightHandIndex3"] = new Vector3(0.0f, 0.0f, 77.0f);
Overrides["Right_sup_index_03"] = new Vector3(0.0f, 0.0f, 321.5f);
Overrides["RightHandIndex4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandMiddle0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_middle_00"] = new Vector3(0.0f, 0.0f, 32.1f);
Overrides["Right_sup_middle_00_palm"] = new Vector3(0.0f, 0.0f, 28.8f);
Overrides["RightHandMiddle1"] = new Vector3(0.0f, 0.0f, 57.7f);
Overrides["Right_sup_middle_01_under"] = new Vector3(0.0f, 0.0f, 5.7f);
Overrides["RightHandMiddle2"] = new Vector3(0.0f, 0.0f, 85.0f);
Overrides["Right_sup_middle_02"] = new Vector3(0.0f, 0.0f, 317.5f);
Overrides["Right_sup_middle_02_under"] = new Vector3(0.0f, 0.0f, 5.1f);
Overrides["RightHandMiddle3"] = new Vector3(0.0f, 0.0f, 77.0f);
Overrides["Right_sup_middle_03"] = new Vector3(0.0f, 0.0f, 321.5f);
Overrides["RightHandMiddle4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandPinky0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_pinky_00"] = new Vector3(0.0f, 0.0f, 45.0f);
Overrides["Right_sup_pinky_00_palm"] = new Vector3(0.0f, 0.0f, 45.0f);
Overrides["RightHandPinky1"] = new Vector3(0.2f, 353.1f, 92.0f);
Overrides["Right_sup_pinky_01_under"] = new Vector3(0.0f, 0.0f, 5.6f);
Overrides["RightHandPinky2"] = new Vector3(0.0f, 0.0f, 84.0f);
Overrides["Right_sup_pinky_02"] = new Vector3(0.0f, 0.0f, 318.0f);
Overrides["Right_sup_pinky_02_under"] = new Vector3(0.0f, 0.0f, 5.2f);
Overrides["RightHandPinky3"] = new Vector3(0.0f, 0.0f, 78.0f);
Overrides["Right_sup_pinky_03"] = new Vector3(0.0f, 0.0f, 321.0f);
Overrides["RightHandPinky4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandRing0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["Right_sup_ring_00"] = new Vector3(0.0f, 0.0f, 33.0f);
Overrides["Right_sup_ring_00_palm"] = new Vector3(0.0f, 0.0f, 33.0f);
Overrides["RightHandRing1"] = new Vector3(358.6f, 355.2f, 66.1f);
Overrides["Right_sup_ring_01_under"] = new Vector3(0.0f, 0.0f, 5.5f);
Overrides["RightHandRing2"] = new Vector3(0.0f, 0.0f, 83.0f);
Overrides["Right_sup_ring_02"] = new Vector3(0.0f, 0.0f, 318.5f);
Overrides["Right_sup_ring_02_under"] = new Vector3(0.0f, 0.0f, 5.1f);
Overrides["RightHandRing3"] = new Vector3(0.0f, 0.0f, 77.0f);
Overrides["Right_sup_ring_03"] = new Vector3(0.0f, 0.0f, 321.5f);
Overrides["RightHandRing4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightHandThumb1"] = new Vector3(27.9f, 75.4f, 33.2f);
Overrides["RightHandThumb2"] = new Vector3(0.0f, 0.0f, 15.8f);
Overrides["Right_sup_thumb_01"] = new Vector3(0.0f, 0.0f, 352.1f);
Overrides["Right_sup_thumb_01_under"] = new Vector3(0.0f, 0.0f, 1.2f);
Overrides["RightHandThumb3"] = new Vector3(0.0f, 0.0f, 17.9f);
Overrides["Right_sup_thumb_02"] = new Vector3(0.0f, 0.0f, 351.0f);
Overrides["RightHandThumb4"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["necroKnifePrefab(Clone)"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["boneShiv_LOD0"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["boneShiv_LOD1"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["boneShiv_LOD2"] = new Vector3(0.0f, 0.0f, 0.0f);
Overrides["RightForeArmRoll1"] = new Vector3(3.5f, 0.0f, 0.0f);
Overrides["RightForeArmRoll2"] = new Vector3(7.0f, 0.0f, 0.0f);
Overrides["RightForeArmRoll3"] = new Vector3(10.5f, 0.0f, 0.0f);
Overrides["RightForeArmRoll4"] = new Vector3(14.0f, 0.0f, 0.0f);
Overrides["Right_sup_wrist_00"] = new Vector3(0.0f, 0.0f, 357.5f);
Overrides["Right_sup_wrist_01"] = new Vector3(0.0f, 347.6f, 0.0f);
Overrides["Right_sup_wrist_02"] = new Vector3(0.0f, 0.0f, 356.8f);
Overrides["Right_sup_wrist_03"] = new Vector3(0.0f, 347.6f, 0.0f);
Overrides["LeftArm"] = new Vector3(329.6f, 42.5f, 8.5f);
KnifeToLeft = true;
Unfreeze();
Enabled = true;
}
public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture);
public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
}
@@ -324,6 +447,9 @@ namespace NecromancerTome
HandHolds.Update();
HandPose.Apply();
HeldTweak.Apply();
NecroView.Update();
NecroBody.LateUpdate();
ThreadKnifeBody.LateUpdate();
}
}
+37 -2
View File
@@ -72,19 +72,44 @@ namespace NecromancerTome
}
}
/// <summary>Сменить хват (HoldType) предмета в руке и пересобрать его в руке, как BloodTide (16.10: «давай попробуем в
/// режиме нитей разные хваты. Ты будешь перебирать их по моей просьбе»). Меняется класс предмета - до перезапуска
/// игры или следующей команды; итог переносится в items.xml.</summary>
public static string SetHold(int _hold)
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
Inventory inv = pl?.inventory;
ItemInventoryData held = inv?.holdingItemData;
if (held?.item == null)
{
return "в руке ничего";
}
if (pl.Hand != null && pl.Hand.isSwitching || inv.IsHoldingItemActionRunning())
{
return "рука занята - повторите";
}
int was = held.item.HoldType.Value;
held.item.HoldType.Value = _hold;
ItemValue pv = (held.physicalItemValue ?? held.itemValue).Clone();
pv.Seed = (ushort)(pv.Seed + 1); // «собранный» предмет устарел - соберётся заново (NeedsRebuild)
held.physicalItemValue = pv;
inv.SetHoldingItemIdx(inv.holdingItemIdx);
return held.item.Name + ": хват " + was + " -> " + _hold;
}
public static string F(float _v) => _v.ToString("0.###", CultureInfo.InvariantCulture);
public static string F(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
/// pos x y z (см) | add x y z (см) | rot x y z (градусы) | scale s | hold N (хват, HoldType) | 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";
public override string getDescription() => "NecromancerTome: посадка модели в руке - pos x y z (см)|add x y z (см)|rot x y z|scale s|hold N|off|show";
static bool Num(List<string> _p, int _i, out float _v)
{
@@ -117,6 +142,16 @@ namespace NecromancerTome
case "scale" when Num(_params, 1, out float s) && s > 0f:
HeldTweak.Scale = s;
break;
case "tphold" when Num(_params, 1, out float th):
ThreadKnifeBody.TpHold = (int)th;
Out("хват тела для ножа-для-нитей: " + ThreadKnifeBody.TpHold + (ThreadKnifeBody.TpHold > 0 ? "" : " (выкл - как у рук)") + "; место в руке - при следующем доставании");
return;
case "hold" when Num(_params, 1, out float h):
Out(HeldTweak.SetHold((int)h));
return;
case "hold":
Out("хват предмета в руке: " + (GameManager.Instance?.World?.GetPrimaryPlayer()?.inventory?.holdingItem?.HoldType.Value.ToString() ?? "нет"));
return;
case "off":
HeldTweak.Enabled = false;
HeldTweak.Pos = Vector3.zero;
+694
View File
@@ -0,0 +1,694 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Правка аниматора ТЕЛА игрока (вид от третьего лица, его же видят другие) прямо в игре (16.10: «Добавь все необходимые
/// команды для аниматора тела, чтобы можно было править прямо в игре»). Пара к necropose/necrobones, которые - для рук
/// первого лица. Всё держится до necrobody off или выхода из мира; итог переносится в код (necrobody save).
/// Параметры ставятся после Update аватара (иначе игра перезапишет их до расчёта аниматора), кости - в LateUpdate.
/// </summary>
public static class NecroBody
{
/// <summary>Параметры аниматора тела, которые держим своими (имя -> значение; bool - 0/1).</summary>
public static readonly Dictionary<string, float> Params = new Dictionary<string, float>();
/// <summary>Локальные повороты костей тела поверх анимации.</summary>
public static readonly Dictionary<string, Vector3> Pose = new Dictionary<string, Vector3>();
public static bool Freeze;
public static float Speed = 1f;
/// <summary>Место предмета в руке тела (поверх смещения игры по хвату), если задано.</summary>
public static Vector3? ItemPos, ItemRot;
static Animator _anim;
static readonly Dictionary<string, Transform> _bones = new Dictionary<string, Transform>();
static readonly List<Transform> _frozen = new List<Transform>();
static readonly List<Vector3> _frozenPos = new List<Vector3>();
static readonly List<Quaternion> _frozenRot = new List<Quaternion>();
static int _playerId = -1;
/// <summary>Любая загруженная анимация на теле (16.10: «Может и танцевать сможешь заставить?.. сильно бы помогло в
/// создании контента»): клип играется поверх контроллера через Playables, по кругу. Танцы есть у зомби (twitch_dance) -
/// их клипы в памяти, пока в мире есть зомби с таким аниматором.</summary>
static PlayableGraph _graph;
static AnimationClipPlayable _clip;
public static string Playing;
public static List<AnimationClip> FindClips(string _filter)
{
var list = new List<AnimationClip>();
var seen = new HashSet<string>();
foreach (AnimationClip c in Resources.FindObjectsOfTypeAll<AnimationClip>())
{
if (c != null && (_filter.Length == 0 || c.name.IndexOf(_filter, System.StringComparison.OrdinalIgnoreCase) >= 0) && seen.Add(c.name))
{
list.Add(c);
}
}
list.Sort((x, y) => string.CompareOrdinal(x.name, y.name));
return list;
}
public static string Play(string _name, float _speed)
{
Animator a = Anim();
if (a == null)
{
return "нет аниматора тела";
}
AnimationClip clip = null;
foreach (AnimationClip c in FindClips(_name))
{
if (c.name == _name)
{
clip = c;
break;
}
clip = clip ?? c;
}
if (clip == null)
{
return "нет загруженной анимации «" + _name + "» (necrobody anims слово)";
}
StopPlay();
_graph = PlayableGraph.Create("NecroBodyPlay");
_graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
var output = AnimationPlayableOutput.Create(_graph, "body", a);
_clip = AnimationClipPlayable.Create(_graph, clip);
_clip.SetSpeed(_speed);
output.SetSourcePlayable(_clip);
_graph.Play();
Playing = clip.name;
return "играет " + clip.name + " (" + NecroBody.F(clip.length) + " с" + (clip.isLooping ? ", петля" : "") + (clip.humanMotion ? ", гуманоид" : ", не гуманоид - может лечь криво") + "), скорость " + NecroBody.F(_speed);
}
/// <summary>Тело повторяет движения другого существа (16.10: танец зомби не лёг на тело игрока через Playables - клип
/// гуманоидный, а тело игрока - нет). Каждый кадр локальные повороты костей источника копируются на кости тела с теми же
/// именами (Hips, Spine, LeftArm...); у бёдер - ещё и сдвиг. Источник - зомби, которого necrobody zdance заставляет танцевать.</summary>
static EntityAlive _mimic;
static readonly List<Transform> _mimicSrc = new List<Transform>();
static readonly List<Transform> _mimicDst = new List<Transform>();
public static EntityAlive Nearest(float _radius, bool _zombiesOnly)
{
EntityPlayerLocal pl = Player();
if (pl == null)
{
return null;
}
EntityAlive best = null;
float bestD = _radius * _radius;
foreach (Entity e in GameManager.Instance.World.Entities.list)
{
if (e is EntityAlive a && a != pl && !a.IsDead() && (!_zombiesOnly || a is EntityZombie))
{
float d = (a.position - pl.position).sqrMagnitude;
if (d < bestD)
{
bestD = d;
best = a;
}
}
}
return best;
}
static Transform ModelRoot(EntityAlive _e)
{
if (_e == null || _e.emodel == null)
{
return null;
}
if (_e.emodel.avatarController is AvatarCharacterController ac && ac.CharacterBody?.Animator != null)
{
return ac.CharacterBody.Animator.transform;
}
return _e.emodel.GetModelTransform();
}
public static string Mimic(EntityAlive _src)
{
StopMimic();
Animator a = Anim();
Transform src = ModelRoot(_src);
if (a == null || src == null)
{
return "нет тела игрока или источника";
}
var byName = new Dictionary<string, Transform>();
foreach (Transform t in src.GetComponentsInChildren<Transform>(true))
{
if (!byName.ContainsKey(t.name))
{
byName[t.name] = t;
}
}
foreach (Transform t in a.GetComponentsInChildren<Transform>(true))
{
if (t != a.transform && byName.TryGetValue(t.name, out Transform s2) && s2 != src)
{
_mimicDst.Add(t);
_mimicSrc.Add(s2);
}
}
_mimic = _src;
return "тело повторяет за " + _src.EntityName + " (id " + _src.entityId + "): общих костей " + _mimicDst.Count;
}
public static void StopMimic()
{
_mimic = null;
_mimicSrc.Clear();
_mimicDst.Clear();
}
static void ApplyMimic()
{
if (_mimic == null)
{
return;
}
if (_mimic.IsDead() || _mimicSrc.Count == 0 || _mimicSrc[0] == null)
{
StopMimic();
return;
}
for (int i = 0; i < _mimicDst.Count; i++)
{
Transform s = _mimicSrc[i], d = _mimicDst[i];
if (s == null || d == null)
{
continue;
}
d.localRotation = s.localRotation;
if (d.name == "Hips")
{
d.localPosition = s.localPosition;
}
}
}
public static void StopPlay()
{
if (_graph.IsValid())
{
_graph.Destroy();
}
Playing = null;
}
/// <summary>Петля для клипов без своей петли.</summary>
static void LoopPlay()
{
if (!_graph.IsValid())
{
return;
}
AnimationClip c = _clip.GetAnimationClip();
if (c != null && !c.isLooping && _clip.GetTime() >= c.length)
{
_clip.SetTime(0);
}
}
public static EntityPlayerLocal Player() => GameManager.Instance?.World?.GetPrimaryPlayer();
public static BodyAnimator Body()
{
return (Player()?.emodel?.avatarController as AvatarCharacterController)?.CharacterBody;
}
public static Animator Anim() => Body()?.Animator;
public static bool Active => Params.Count > 0 || Pose.Count > 0 || Freeze || Speed != 1f || ItemPos.HasValue || ItemRot.HasValue;
public static Transform Bone(string _name)
{
Animator a = Anim();
if (a == null)
{
return null;
}
if (a != _anim)
{
_anim = a;
_bones.Clear();
_frozen.Clear();
foreach (Transform t in a.GetComponentsInChildren<Transform>(true))
{
if (!_bones.ContainsKey(t.name))
{
_bones[t.name] = t;
}
}
}
return _bones.TryGetValue(_name, out Transform b) ? b : null;
}
public static void Reset()
{
StopPlay();
StopMimic();
Params.Clear();
Pose.Clear();
Freeze = false;
_frozen.Clear();
ItemPos = null;
ItemRot = null;
if (Speed != 1f)
{
Speed = 1f;
Animator a = Anim();
if (a != null)
{
a.speed = 1f;
}
}
}
public static void Unfreeze()
{
_frozen.Clear();
_frozenPos.Clear();
_frozenRot.Clear();
}
/// <summary>Параметр по имени: тип берётся из аниматора. false - нет такого.</summary>
public static bool SetParam(Animator _a, string _name, float _v)
{
foreach (AnimatorControllerParameter p in _a.parameters)
{
if (p.name != _name)
{
continue;
}
switch (p.type)
{
case AnimatorControllerParameterType.Float: _a.SetFloat(p.nameHash, _v); break;
case AnimatorControllerParameterType.Int: _a.SetInteger(p.nameHash, (int)_v); break;
case AnimatorControllerParameterType.Bool: _a.SetBool(p.nameHash, _v != 0f); break;
case AnimatorControllerParameterType.Trigger: _a.SetTrigger(p.nameHash); break;
}
return true;
}
return false;
}
/// <summary>После Update аватара (заплатка ниже) - до расчёта аниматора в этом кадре.</summary>
public static void ApplyParams()
{
if (Params.Count == 0 && Speed == 1f)
{
return;
}
Animator a = Anim();
if (a == null)
{
return;
}
foreach (KeyValuePair<string, float> kv in Params)
{
SetParam(a, kv.Key, kv.Value);
}
a.speed = Speed;
}
/// <summary>Из HandPoseDriver.LateUpdate.</summary>
public static void LateUpdate()
{
EntityPlayerLocal pl = Player();
int id = pl != null ? pl.entityId : -1;
if (id != _playerId)
{
_playerId = id;
Reset();
_anim = null;
_bones.Clear();
return;
}
LoopPlay();
ApplyMimic();
if (!Active || Bone("") == null && _anim == null)
{
return;
}
if (Freeze)
{
if (_frozen.Count == 0 || _frozen[0] == null)
{
Unfreeze();
foreach (Transform t in _anim.GetComponentsInChildren<Transform>(true))
{
_frozen.Add(t);
_frozenPos.Add(t.localPosition);
_frozenRot.Add(t.localRotation);
}
Debug.Log("[NecromancerTome] necrobody: заморожено костей " + _frozen.Count);
}
else
{
for (int i = 0; i < _frozen.Count; i++)
{
if (_frozen[i] != null)
{
_frozen[i].localPosition = _frozenPos[i];
_frozen[i].localRotation = _frozenRot[i];
}
}
}
}
foreach (KeyValuePair<string, Vector3> kv in Pose)
{
Transform b = Bone(kv.Key);
if (b != null)
{
b.localRotation = Quaternion.Euler(kv.Value);
}
}
if ((ItemPos.HasValue || ItemRot.HasValue) && pl.emodel != null && !pl.emodel.IsFPV)
{
Transform item = (pl.emodel.avatarController as AvatarMultiBodyController)?.heldItemTransform;
if (item != null)
{
if (ItemPos.HasValue)
{
item.localPosition = ItemPos.Value;
}
if (ItemRot.HasValue)
{
item.localEulerAngles = ItemRot.Value;
}
}
}
}
public static string F(float _v) => _v.ToString("0.###", CultureInfo.InvariantCulture);
public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
}
[HarmonyPatch(typeof(AvatarMultiBodyController), "Update")]
public static class Patch_AvatarMultiBodyController_Update_NecroBody
{
public static void Postfix(AvatarMultiBodyController __instance)
{
if (NecroBody.Params.Count > 0 || NecroBody.Speed != 1f)
{
EntityPlayerLocal pl = NecroBody.Player();
if (pl != null && __instance.Entity == pl)
{
NecroBody.ApplyParams();
}
}
}
}
/// <summary>
/// necrobody - аниматор тела игрока (вид от третьего лица):
/// params [фильтр] - параметры аниматора и значения
/// clips - что играет в каждом слое (клип, вес слоя)
/// set &lt;параметр&gt; &lt;число&gt; - держать значение (bool 0/1); unset &lt;параметр&gt;; trigger &lt;параметр&gt;
/// speed &lt;k&gt; - скорость аниматора (0 - пауза)
/// freeze [off] - замереть в текущем кадре (все кости)
/// bones [фильтр] - кости тела с локальными поворотами в лог
/// show &lt;кость&gt;; pose &lt;кость&gt; x y z; add &lt;кость&gt; dx dy dz; unpose &lt;кость&gt;
/// item pos x y z (см) | rot x y z | off | show - место предмета в руке тела
/// holdoffset [N] - смещение игры для хвата N (по умолчанию - предмета в руке)
/// save - всё заданное строками в лог; off - всё снять
/// </summary>
[Preserve]
public class ConsoleCmdNecroBody : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrobody" };
public override string getDescription() => "NecromancerTome: аниматор тела - params [ф]|clips|set п v|unset п|trigger п|speed k|freeze [off]|bones [ф]|show к|pose к x y z|add к dx dy dz|unpose к|item pos|rot|off|show|holdoffset [N]|anims [слово]|play имя [скорость]|play off|zdance [off]|mimic [id]|mimic off|save|off";
static bool Num(List<string> _p, int _i, out float _v)
{
_v = 0f;
return _p.Count > _i && float.TryParse(_p[_i].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
}
static bool Vec(List<string> _p, int _i, out Vector3 _v)
{
_v = Vector3.zero;
if (Num(_p, _i, out float x) && Num(_p, _i + 1, out float y) && Num(_p, _i + 2, out float z))
{
_v = new Vector3(x, y, z);
return true;
}
return false;
}
static void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
foreach (string line in _s.Split('\n'))
{
Debug.Log("[NecromancerTome] necrobody: " + line);
}
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
Animator a = NecroBody.Anim();
if (a == null)
{
Out("нет аниматора тела (игрок не в мире?)");
return;
}
string cmd = _params.Count > 0 ? _params[0] : "";
string arg = _params.Count > 1 ? _params[1] : "";
switch (cmd)
{
case "params":
{
var sb = new StringBuilder("аниматор " + a.name + ", контроллер " + (a.runtimeAnimatorController != null ? a.runtimeAnimatorController.name : "нет"));
foreach (AnimatorControllerParameter p in a.parameters)
{
if (arg.Length > 0 && p.name.IndexOf(arg, System.StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
string v = p.type == AnimatorControllerParameterType.Float ? NecroBody.F(a.GetFloat(p.nameHash))
: p.type == AnimatorControllerParameterType.Int ? a.GetInteger(p.nameHash).ToString()
: p.type == AnimatorControllerParameterType.Bool ? (a.GetBool(p.nameHash) ? "1" : "0") : "trigger";
sb.Append("\n ").Append(p.name).Append(" (").Append(p.type).Append(") = ").Append(v)
.Append(NecroBody.Params.ContainsKey(p.name) ? " [держим]" : "");
}
Out(sb.ToString());
return;
}
case "clips":
{
var sb = new StringBuilder("слоёв " + a.layerCount);
for (int l = 0; l < a.layerCount; l++)
{
string names = "";
foreach (AnimatorClipInfo c in a.GetCurrentAnimatorClipInfo(l))
{
names += (names.Length > 0 ? ", " : "") + (c.clip != null ? c.clip.name : "?") + " x" + c.weight.ToString("0.##");
}
sb.Append("\n ").Append(l).Append(' ').Append(a.GetLayerName(l)).Append(" вес ").Append(a.GetLayerWeight(l).ToString("0.##")).Append(": ").Append(names);
}
Out(sb.ToString());
return;
}
case "set" when Num(_params, 2, out float v):
if (!NecroBody.SetParam(a, arg, v))
{
Out("нет параметра " + arg + " (necrobody params)");
return;
}
NecroBody.Params[arg] = v;
Out(arg + " = " + NecroBody.F(v) + " (держим; unset - отпустить)");
return;
case "unset":
Out(NecroBody.Params.Remove(arg) ? arg + " отпущен" : arg + " не был задан");
return;
case "trigger":
Out(NecroBody.SetParam(a, arg, 1f) ? "сработал " + arg : "нет параметра " + arg);
return;
case "speed" when Num(_params, 1, out float k):
NecroBody.Speed = k;
a.speed = k;
Out("скорость аниматора тела " + NecroBody.F(k));
return;
case "freeze":
NecroBody.Freeze = arg != "off";
NecroBody.Unfreeze();
Out("заморозка тела " + (NecroBody.Freeze ? "включена (снимок - в этом кадре)" : "выключена"));
return;
case "bones":
{
var sb = new StringBuilder();
int n = 0;
Dump(a.transform, 0, arg, sb, ref n);
Out("костей " + n + (arg.Length > 0 ? " (фильтр " + arg + ")" : "") + sb);
return;
}
case "show":
{
Transform b = NecroBody.Bone(arg);
Out(b == null ? "нет кости " + arg : arg + " = " + NecroBody.F(b.localEulerAngles) + (NecroBody.Pose.ContainsKey(arg) ? " (задана)" : " (от аниматора)") + ", поз. " + NecroBody.F(b.localPosition));
return;
}
case "pose" when Vec(_params, 2, out Vector3 r):
if (NecroBody.Bone(arg) == null)
{
Out("нет кости " + arg);
return;
}
NecroBody.Pose[arg] = r;
Out(arg + " = " + NecroBody.F(r));
return;
case "add" when Vec(_params, 2, out Vector3 d):
{
Transform b = NecroBody.Bone(arg);
if (b == null)
{
Out("нет кости " + arg);
return;
}
Vector3 cur = NecroBody.Pose.TryGetValue(arg, out Vector3 was) ? was : b.localEulerAngles;
NecroBody.Pose[arg] = cur + d;
Out(arg + " = " + NecroBody.F(NecroBody.Pose[arg]));
return;
}
case "unpose":
Out(NecroBody.Pose.Remove(arg) ? arg + " отпущена" : arg + " не была задана");
return;
case "item":
{
Transform it = (NecroBody.Player()?.emodel?.avatarController as AvatarMultiBodyController)?.heldItemTransform;
if (arg == "pos" && Vec(_params, 2, out Vector3 p))
{
NecroBody.ItemPos = p / 100f;
}
else if (arg == "rot" && Vec(_params, 2, out Vector3 r2))
{
NecroBody.ItemRot = r2;
}
else if (arg == "off")
{
NecroBody.ItemPos = null;
NecroBody.ItemRot = null;
Out("место предмета - как у игры (при следующем доставании)");
return;
}
Out("предмет в руке тела " + (it != null ? it.name + ": поз. (см) " + NecroBody.F(it.localPosition * 100f) + ", пов. " + NecroBody.F(it.localEulerAngles) : "нет") +
(NecroBody.ItemPos.HasValue || NecroBody.ItemRot.HasValue ? " [держим]" : ""));
return;
}
case "holdoffset":
{
int h = Num(_params, 1, out float hv) ? (int)hv : NecroBody.Player()?.inventory?.holdingItem?.HoldType.Value ?? 0;
var o = AnimationGunjointOffsetData.AnimationGunjointOffset[h];
Out("хват " + h + ": смещение игры поз. (см) " + NecroBody.F(o.position * 100f) + ", пов. " + NecroBody.F(o.rotation));
return;
}
case "save":
{
var sb = new StringBuilder("заданное:");
foreach (var kv in NecroBody.Params)
{
sb.Append("\n param ").Append(kv.Key).Append(" = ").Append(NecroBody.F(kv.Value));
}
foreach (var kv in NecroBody.Pose)
{
sb.Append("\n Pose[\"").Append(kv.Key).Append("\"] = new Vector3(").Append(NecroBody.F(kv.Value.x)).Append("f, ")
.Append(NecroBody.F(kv.Value.y)).Append("f, ").Append(NecroBody.F(kv.Value.z)).Append("f);");
}
if (NecroBody.ItemPos.HasValue)
{
sb.Append("\n item pos (см) ").Append(NecroBody.F(NecroBody.ItemPos.Value * 100f));
}
if (NecroBody.ItemRot.HasValue)
{
sb.Append("\n item rot ").Append(NecroBody.F(NecroBody.ItemRot.Value));
}
sb.Append("\n speed ").Append(NecroBody.F(NecroBody.Speed)).Append(", freeze ").Append(NecroBody.Freeze);
Out(sb.ToString());
return;
}
case "anims":
{
List<AnimationClip> l = NecroBody.FindClips(arg);
var sb = new StringBuilder("анимаций в памяти" + (arg.Length > 0 ? " со словом " + arg : "") + ": " + l.Count);
int shown = 0;
foreach (AnimationClip c in l)
{
if (shown++ >= 200)
{
sb.Append("\n ... (уточните слово)");
break;
}
sb.Append("\n ").Append(c.name).Append(" ").Append(NecroBody.F(c.length)).Append(" с").Append(c.humanMotion ? "" : " [не гуманоид]");
}
Out(sb.ToString());
return;
}
case "zdance":
{
EntityAlive z = NecroBody.Nearest(30f, true);
if (z == null)
{
Out("рядом (30 м) нет зомби");
return;
}
z.SetDancing(arg != "off");
Out(z.EntityName + " (id " + z.entityId + ") " + (arg != "off" ? "танцует" : "перестал танцевать"));
return;
}
case "mimic" when arg == "off":
NecroBody.StopMimic();
Out("тело больше не повторяет");
return;
case "mimic":
{
EntityAlive src = null;
if (Num(_params, 1, out float mid))
{
src = GameManager.Instance.World.GetEntity((int)mid) as EntityAlive;
}
else
{
src = NecroBody.Nearest(30f, false);
}
Out(src == null ? "нет источника (id или существо в 30 м)" : NecroBody.Mimic(src));
return;
}
case "play" when arg == "off":
NecroBody.StopPlay();
Out("анимация снята");
return;
case "play" when arg.Length > 0:
Out(NecroBody.Play(arg, Num(_params, 2, out float ps) ? ps : 1f));
return;
case "off":
NecroBody.Reset();
Out("тело - как у игры");
return;
}
Out(getDescription());
}
static void Dump(Transform _t, int _depth, string _filter, StringBuilder _sb, ref int _n)
{
if (_filter.Length == 0 || _t.name.IndexOf(_filter, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
_n++;
_sb.Append('\n').Append(new string(' ', _depth * 2)).Append(_t.name).Append(_t.gameObject.activeSelf ? "" : " [выкл]")
.Append(" пов. ").Append(NecroBody.F(_t.localEulerAngles)).Append(" поз. ").Append(NecroBody.F(_t.localPosition));
}
for (int i = 0; i < _t.childCount; i++)
{
Dump(_t.GetChild(i), _depth + 1, _filter, _sb, ref _n);
}
}
}
}
+684
View File
@@ -0,0 +1,684 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Команды разработки для проверок без мыши (18.10.2026: «И управление персонажем тоже, чтобы ты мог его повернуть как надо,
/// довести куда надо»). Набираются через почтовый ящик (NecroMailbox), ответы - в лог. Перемещение - teleport игры
/// (tp x y z), время - settime, предметы - giveself; здесь - то, чего у игры нет.
/// necrolook yaw [pitch] | necrolook at x y z - повернуть взгляд (градусы; север 0, восток 90; pitch вверх +, вниз -)
/// necroaim - позиция игрока и блок под прицелом
/// necroplace блок x y z [поворот 0-3] - поставить блок (как игрок: OnBlockAdded, TE)
/// necrohold предмет [число] - положить в текущую ячейку пояса и взять в руку
/// necroslot [N] - какая ячейка пояса в руке и что в ячейках; N - взять в руку ячейку N (23.10: проверка Archer's Eye без мыши)
/// necrofire press|release - ЛКМ предметом в руке: нажать / отпустить (лук: натянуть / выстрелить; 23.10, ошибка в Hit от стрелы)
/// necrobox x y z list|clear|add предмет число - содержимое хранилища (ящик, сумка урны)
/// necrourn x y z info|skull 0|1|from|to|out bx by bz|clear|sample предмет число|group все|нет - урна
/// necroclick 0|1 - отпущенная ЛКМ/ПКМ ножом для нитей по блоку под прицелом
/// </summary>
public static class NecroDev
{
static void Out(string _s) => SdtdConsole.Instance.Output(_s);
static float F(string _s) => float.Parse(_s, CultureInfo.InvariantCulture);
static Vector3i P(List<string> _p, int _i) => new Vector3i(int.Parse(_p[_i]), int.Parse(_p[_i + 1]), int.Parse(_p[_i + 2]));
/// <summary>Общий вход всех команд: игрок есть - выполнить, ошибка - строкой в ответ.</summary>
public static void Run(string _cmd, List<string> _params)
{
World w = GameManager.Instance.World;
EntityPlayerLocal pl = w?.GetPrimaryPlayer();
if (pl == null)
{
Out(_cmd + ": нет игрока");
return;
}
try
{
switch (_cmd)
{
case "necrolook": Look(pl, _params); break;
case "necroaim": Aim(w, pl); break;
case "necroplace": PlaceBlock(w, _params); break;
case "necrohold": Hold(pl, _params); break;
case "necrofire":
{
bool rel = _params.Count > 0 && _params[0] == "release";
pl.inventory.Execute(0, rel, pl.playerInput);
Out("necrofire: " + (rel ? "отпущено" : "нажато") + ", в руке " + pl.inventory.holdingItem?.Name + ", заряжено " + pl.inventory.holdingItemItemValue.Meta);
break;
}
case "necroslot":
{
if (_params.Count > 0)
{
pl.inventory.SetHoldingItemIdx(int.Parse(_params[0]));
}
string belt = "";
for (int i = 0; i < pl.inventory.SlotCount; i++)
{
ItemStack st = pl.inventory.GetItem(i);
belt += " " + i + ":" + (st == null || st.IsEmpty() ? "-" : st.itemValue.ItemClass.GetItemName());
}
Out("necroslot: в руке " + pl.inventory.holdingItemIdx + ";" + belt);
break;
}
case "necrobox": Box(w, _params); break;
case "necrourn": Urn(w, _params); break;
case "necrodamage":
{
// necrodamage x y z урон - повредить блок (проверка ремонтника)
Vector3i dp = P(_params, 0);
BlockValue dbv = w.GetBlock(dp);
dbv.damage = Mathf.Min(dbv.Block.MaxDamage - 1, dbv.damage + int.Parse(_params[3]));
w.SetBlockRPC(dp, dbv);
Out("necrodamage: " + dbv.Block.GetBlockName() + " в " + dp + " повреждение " + dbv.damage + "/" + dbv.Block.MaxDamage);
break;
}
case "necrodrop":
{
// necrodrop предмет число x y z - бросить на землю (проверка лутера)
ItemValue div = ItemClass.GetItem(_params[0], true);
GameManager.Instance.ItemDropServer(new ItemStack(div, int.Parse(_params[1])), P(_params, 2).ToVector3() + new Vector3(0.5f, 0.5f, 0.5f), Vector3.zero);
Out("necrodrop: " + _params[0] + " x" + _params[1]);
break;
}
case "necrobag":
{
// necrobag x y z - мешок с зомби (EntityLootContainer со списком cntDropBag), нетронутый
Entity bag = EntityFactory.CreateEntity(EntityClass.FromString("DroppedLootContainer"), P(_params, 0).ToVector3() + new Vector3(0.5f, 0.3f, 0.5f));
bag.lootList = "cntDropBag";
LootContainer lc = LootContainer.GetLootContainer(bag.lootList, false);
if (lc != null)
{
bag.bag = new Bag(lc.size, XUiC_ItemStack.StackLocationTypes.LootContainer, bag);
}
w.SpawnEntityInWorld(bag);
Out("necrobag: " + bag.entityId);
break;
}
case "necrocorpse":
{
// necrocorpse x y z [класс] - зомби, убитый и помеченный красным трупом (как VictimPatch)
string cls = _params.Count > 3 ? _params[3] : "zombieArlene";
EntityAlive z = EntityFactory.CreateEntity(EntityClass.FromString(cls), P(_params, 0).ToVector3() + new Vector3(0.5f, 0.1f, 0.5f)) as EntityAlive;
w.SpawnEntityInWorld(z);
ThreadManager.StartCoroutine(KillAndMark(z));
Out("necrocorpse: " + z.entityId + " " + cls);
break;
}
case "necroclear":
{
// necroclear x0 z0 x1 z1 [y0 y1] - убрать только растения (трава, кусты) в прямоугольнике; землю и постройки
// не трогает (18.10: «necroplace air» по ровной высоте срезал рельеф - яма в test8).
int x0 = int.Parse(_params[0]), z0 = int.Parse(_params[1]), x1 = int.Parse(_params[2]), z1 = int.Parse(_params[3]);
int y0 = _params.Count > 5 ? int.Parse(_params[4]) : 1, y1 = _params.Count > 5 ? int.Parse(_params[5]) : 250;
int n = 0;
for (int x = Mathf.Min(x0, x1); x <= Mathf.Max(x0, x1); x++)
{
for (int z = Mathf.Min(z0, z1); z <= Mathf.Max(z0, z1); z++)
{
for (int y = y0; y <= y1; y++)
{
BlockValue cb = w.GetBlock(x, y, z);
if (!cb.isair && cb.Block != null && cb.Block.IsPlant())
{
w.SetBlockRPC(new Vector3i(x, y, z), BlockValue.Air);
n++;
}
}
}
}
Out("necroclear: растений убрано " + n);
break;
}
case "necrostation":
{
// necrostation x y z - станция текстом: огонь, топливо, вход, очередь, выход (проверка урны рабочего)
TileEntityWorkstation ws = CinerariumThreads.StationAt(w, P(_params, 0));
if (ws == null)
{
Out("necrostation: нет станции");
break;
}
string Stacks(ItemStack[] _a)
{
StringBuilder s = new StringBuilder();
if (_a != null)
{
foreach (ItemStack st in _a)
{
if (st != null && !st.IsEmpty())
{
s.Append(' ').Append(st.itemValue.ItemClass?.GetItemName()).Append('x').Append(st.count);
}
}
}
return s.Length > 0 ? s.ToString() : " -";
}
int q = 0;
if (ws.Queue != null)
{
foreach (RecipeQueueItem r in ws.Queue)
{
if (r != null && r.Recipe != null && r.Multiplier > 0)
{
q += r.Multiplier;
}
}
}
Out("necrostation: огонь " + (ws.IsBurning ? "горит" : "погашен") + ", топливо" + Stacks(ws.Fuel) + ", вход" + Stacks(ws.Input) +
", очередь " + q + ", выход" + Stacks(ws.Output));
break;
}
case "necrofill":
{
// necrofill x0 z0 x1 z1 y [блок] [соседей] - засыпать ямы рельефа на уровне y: пустую клетку, у которой рядом по
// уровню не меньше двух клеток земли, - землёй с полной плотностью; повторять, пока засыпается.
// Ровную землю рядом не поднимает (у неё на этом уровне соседи - воздух). 18.10: ямы в test8.
int x0 = int.Parse(_params[0]), z0 = int.Parse(_params[1]), x1 = int.Parse(_params[2]), z1 = int.Parse(_params[3]);
int fy = int.Parse(_params[4]);
Block fb = Block.GetBlockByName(_params.Count > 5 ? _params[5] : "terrForestGround", true);
int total = 0;
for (int pass = 0; pass < 20; pass++)
{
List<Vector3i> fill = new List<Vector3i>();
for (int x = Mathf.Min(x0, x1); x <= Mathf.Max(x0, x1); x++)
{
for (int z = Mathf.Min(z0, z1); z <= Mathf.Max(z0, z1); z++)
{
Vector3i c = new Vector3i(x, fy, z);
if (!w.GetBlock(c).isair || w.GetBlock(c - Vector3i.up).isair)
{
continue;
}
int n = 0;
foreach (Vector3i d in new[] { Vector3i.forward, Vector3i.back, Vector3i.left, Vector3i.right })
{
BlockValue nb = w.GetBlock(c + d);
if (!nb.isair && nb.Block != null && nb.Block.shape.IsTerrain())
{
n++;
}
}
if (n >= (_params.Count > 6 ? int.Parse(_params[6]) : 2)) // седьмой параметр - сколько соседей-земли нужно (широкие ямы - 1)
{
fill.Add(c);
}
}
}
if (fill.Count == 0)
{
break;
}
foreach (Vector3i c in fill)
{
w.SetBlockRPC(c, fb.ToBlockValue(), MarchingCubes.DensityTerrain);
}
total += fill.Count;
}
Out("necrofill: засыпано клеток " + total);
break;
}
case "necrosmooth":
{
// necrosmooth x0 z0 x1 z1 y [плотность] - край насыпи скатом: клеткам земли на уровне y, у которых сбоку воздух,
// - неполная плотность (по умолчанию -30; полная земля -128), игра рисует их наклоном.
int x0 = int.Parse(_params[0]), z0 = int.Parse(_params[1]), x1 = int.Parse(_params[2]), z1 = int.Parse(_params[3]);
int sy = int.Parse(_params[4]);
sbyte dens = (sbyte)(_params.Count > 5 ? int.Parse(_params[5]) : -30);
List<(Vector3i, BlockValue)> edge = new List<(Vector3i, BlockValue)>();
for (int x = Mathf.Min(x0, x1); x <= Mathf.Max(x0, x1); x++)
{
for (int z = Mathf.Min(z0, z1); z <= Mathf.Max(z0, z1); z++)
{
Vector3i c = new Vector3i(x, sy, z);
BlockValue bv = w.GetBlock(c);
if (bv.isair || bv.Block == null || !bv.Block.shape.IsTerrain() || !w.GetBlock(c + Vector3i.up).isair)
{
continue;
}
foreach (Vector3i d in new[] { Vector3i.forward, Vector3i.back, Vector3i.left, Vector3i.right })
{
if (w.GetBlock(c + d).isair)
{
edge.Add((c, bv));
break;
}
}
}
}
foreach ((Vector3i c, BlockValue bv) in edge)
{
w.SetBlockRPC(c, bv, dens);
}
Out("necrosmooth: краевых клеток " + edge.Count);
break;
}
case "necrocanplace":
// necrocanplace блок x y z - подсказка, почему урну сюда нельзя (или «можно»)
Out("necrocanplace: " + (TEFeatureCinerarium.PlacementRefusal(P(_params, 1), Block.GetBlockByName(_params[0], true)) ?? "можно"));
break;
case "necrogod": God(pl, _params.Count == 0 || _params[0] != "off"); break;
case "necroclick":
// necroclick 0|1 [x y z] - по блоку под прицелом или по указанной клетке (луч не всегда попадает в низкий череп).
if (_params.Count >= 4)
{
CinerariumThreads.ClickAt(pl, w, P(_params, 1), _params[0] == "1");
}
else
{
CinerariumThreads.Click(pl, _params.Count > 0 && _params[0] == "1");
}
Out("necroclick: ok");
break;
}
}
catch (System.Exception e)
{
Out(_cmd + ": ошибка - " + e.Message);
}
}
static void Look(EntityPlayerLocal _pl, List<string> _p)
{
float yaw, pitch = 0f;
if (_p.Count >= 4 && _p[0] == "at")
{
Vector3 eye = _pl.getHeadPosition();
Vector3 t = new Vector3(F(_p[1]) + 0.5f, F(_p[2]) + 0.5f, F(_p[3]) + 0.5f);
Vector3 d = t - eye;
yaw = Mathf.Atan2(d.x, d.z) * Mathf.Rad2Deg;
pitch = Mathf.Atan2(d.y, new Vector2(d.x, d.z).magnitude) * Mathf.Rad2Deg; // в игре + вверх
}
else
{
yaw = F(_p[0]);
if (_p.Count > 1)
{
pitch = F(_p[1]);
}
}
_pl.TeleportToPosition(_pl.position, false, new Vector3(pitch, yaw, 0f));
Out("necrolook: yaw " + yaw.ToString("F0") + ", pitch " + pitch.ToString("F0"));
}
/// <summary>Режим бога и невидимость для ИИ: пока ассистент проверяет, персонаж стоит - зомби тестового мира его не трогают
/// (18.10: «пока персонаж просто стоит, его скушают»; «Я обычно ставлю режим Бога»).</summary>
static void God(EntityPlayerLocal _pl, bool _on)
{
_pl.IsGodMode.Value = _on;
_pl.SetIgnoredByAI(_on);
Out("necrogod: " + (_on ? "бог, ИИ не видит" : "обычный"));
}
static System.Collections.IEnumerator KillAndMark(EntityAlive _z)
{
yield return new WaitForSeconds(1f);
_z.DamageEntity(new DamageSource(EnumDamageSource.Internal, EnumDamageTypes.Suicide), 99999, false);
yield return new WaitForSeconds(1f);
if (_z.IsDead())
{
CorpseLoot.MarkCorpse(_z);
}
UnityEngine.Debug.Log("[NecromancerTome] mailbox: necrocorpse: " + _z.entityId + (_z.IsDead() ? " убит и помечен" : " НЕ умер"));
}
static void Aim(World _w, EntityPlayerLocal _pl)
{
StringBuilder sb = new StringBuilder("necroaim: игрок ").Append(_pl.position.ToString("F1")).Append(", взгляд ").Append(_pl.rotation.ToString("F0"));
WorldRayHitInfo hi = _pl.HitInfo;
if (hi != null && hi.bHitValid)
{
BlockValue bv = _w.GetBlock(hi.hit.blockPos);
sb.Append(", под прицелом ").Append(hi.hit.blockPos).Append(' ').Append(bv.Block?.GetBlockName()).Append(" rot ").Append(bv.rotation)
.Append(", дальность ").Append(Mathf.Sqrt(hi.hit.distanceSq).ToString("F1"));
}
else
{
sb.Append(", под прицелом ничего");
}
Out(sb.ToString());
}
static void PlaceBlock(World _w, List<string> _p)
{
Block b = Block.GetBlockByName(_p[0], true);
if (b == null)
{
Out("necroplace: нет блока " + _p[0]);
return;
}
BlockValue bv = b.ToBlockValue();
if (_p.Count > 4)
{
bv.rotation = (byte)int.Parse(_p[4]);
}
Vector3i pos = P(_p, 1);
_w.SetBlockRPC(pos, bv);
// Как поставленный игроком (хозяин - мы): ящики без хозяина нити и духи не считают ящиками игрока.
if (_w.GetTileEntity(pos) is TileEntityComposite comp && comp.Owner == null)
{
comp.SetOwner(Platform.PlatformManager.InternalLocalUserIdentifier);
}
Out("necroplace: " + b.GetBlockName() + " в " + pos + " rot " + bv.rotation);
}
static void Hold(EntityPlayerLocal _pl, List<string> _p)
{
ItemValue iv = ItemClass.GetItem(_p[0], true);
if (iv == null || iv.IsEmpty())
{
Out("necrohold: нет предмета " + _p[0]);
return;
}
int n = _p.Count > 1 ? int.Parse(_p[1]) : 1;
if (iv.ItemClass.HasQuality)
{
iv = new ItemValue(iv.type, 1, 1, true);
}
_pl.inventory.SetItem(_pl.inventory.holdingItemIdx, new ItemStack(iv, n));
Out("necrohold: " + iv.ItemClass.GetItemName() + " x" + n + " в ячейке " + _pl.inventory.holdingItemIdx);
}
static TEFeatureStorage StorageAt(World _w, Vector3i _p)
{
BlockValue bv = _w.GetBlock(_p);
Vector3i owner = bv.ischild ? _p + bv.parent : _p;
return _w.GetTileEntity(owner) is TileEntityComposite c && c.TryGetSelfOrFeature<TEFeatureStorage>(out TEFeatureStorage s) ? s : null;
}
static void Box(World _w, List<string> _p)
{
TEFeatureStorage s = StorageAt(_w, P(_p, 0));
if (s == null)
{
Out("necrobox: нет хранилища в " + P(_p, 0));
return;
}
string op = _p.Count > 3 ? _p[3] : "list";
if (op == "fill")
{
// Все пустые ячейки - полной стопкой предмета (ящик «полон» по-честному).
ItemValue iv = ItemClass.GetItem(_p[4], true);
for (int i = 0; i < s.ItemGrid.Length; i++)
{
if (s.ItemGrid[i] == null || s.ItemGrid[i].IsEmpty())
{
s.UpdateSlot(i, new ItemStack(iv.Clone(), iv.ItemClass.Stacknumber.Value));
}
}
s.SetModified();
}
else if (op == "clear")
{
for (int i = 0; i < s.ItemGrid.Length; i++)
{
s.UpdateSlot(i, ItemStack.Empty.Clone());
}
s.SetModified();
}
else if (op == "add")
{
ItemValue iv = ItemClass.GetItem(_p[4], true);
ItemStack st = new ItemStack(iv, int.Parse(_p[5]));
CinerariumWork.Place(st, new[] { s });
s.SetModified();
if (st.count > 0)
{
Out("necrobox: не влезло " + st.count);
}
}
StringBuilder sb = new StringBuilder("necrobox: ").Append(P(_p, 0)).Append(':');
for (int i = 0; i < s.ItemGrid.Length; i++)
{
ItemStack st = s.ItemGrid[i];
if (st != null && !st.IsEmpty())
{
sb.Append(' ').Append(st.itemValue.ItemClass?.GetItemName()).Append('x').Append(st.count);
}
}
Out(sb.ToString());
}
/// <summary>Черепа на пути нити: тройки координат после конца нити, по порядку от урны.</summary>
static List<Vector3i> Via(List<string> _p, int _i)
{
List<Vector3i> l = new List<Vector3i>();
for (int k = _i; k + 2 < _p.Count; k += 3)
{
l.Add(P(_p, k));
}
return l;
}
static void Urn(World _w, List<string> _p)
{
if (_p.Count > 0 && _p[0] == "budget")
{
// necrourn budget [N] - кому из урн дали шаг и кому отказали с прошлого вызова; N - дел в секунду на все урны (30)
if (_p.Count > 1)
{
CinerariumLoad.StepsPerSecond = float.Parse(_p[1], System.Globalization.CultureInfo.InvariantCulture);
}
Out("necrourn budget: " + CinerariumLoad.Budget());
return;
}
Vector3i pos = P(_p, 0);
TEFeatureCinerarium u = CinerariumThreads.UrnAt(_w, pos);
if (u == null)
{
Out("necrourn: нет урны в " + pos);
return;
}
string op = _p.Count > 3 ? _p[3] : "info";
switch (op)
{
case "skull": u.SetSkull(_p[4] == "1"); break;
case "from": u.AddThreadFrom(P(_p, 4), Via(_p, 7)); break;
case "to": u.SetThreadTo(P(_p, 4), Via(_p, 7)); break;
case "out": u.AddThreadOut(P(_p, 4), Via(_p, 7)); break;
case "clear": u.ClearThreads(); break;
case "sample":
u.SetSample(_p.Count > 5 ? new ItemStack(ItemClass.GetItem(_p[4], true), int.Parse(_p[5])) : ItemStack.Empty.Clone());
break;
case "contents": CinerariumContents.Open(GameManager.Instance.World.GetPrimaryPlayer(), u); break;
case "probe": Out("necrourn: " + pos + " " + CinerariumWork.Probe(GameManager.Instance.World, u)); return;
case "run": u.ZoneCommand("zurn_run", GameManager.Instance.World.GetPrimaryPlayer()); break;
case "stop": u.ZoneCommand("zurn_stop", null); break;
case "size": u.ZoneSide = int.Parse(_p[4]); u.SetModified(); break;
case "group":
u.GroupMask = _p[4] == "нет" || _p[4] == "none" ? 0u : CinerariumGroups.All;
u.SetModified();
break;
}
string blink = "нет";
Chunk ch = _w.GetChunkFromWorldPos(pos) as Chunk;
BlockEntityData be = ch?.GetBlockEntity(pos);
if (be != null && be.bHasTransform)
{
CinerariumBlink cb = be.transform.GetComponent<CinerariumBlink>();
blink = cb == null ? "нет" : (cb.enabled ? "мигает" : "выкл") + " (" + cb.Renderers.Count + " рендер.)";
}
Out("necrourn: " + pos + " " + _w.GetBlock(pos).Block?.GetBlockName() + ", череп " + u.HasSkull + ", застрял " + u.Stuck + ", мигание " + blink +
", откуда " + u.ThreadFrom.Count + ", куда " + (u.HasThreadTo ? u.ThreadTo.ToString() : "нет") + ", от урны " + u.ThreadOut.Count +
", зона " + (u.IsBigZone ? u.ZoneSideNow + (u.ZoneRunning ? " идёт" : " ждёт") + (u.ZoneLacking ? " нехватка" : "") + " курсор " + u.ZoneCursor : "-") + ", образец " + (u.Sample == null || u.Sample.IsEmpty() ? "нет" : u.Sample.itemValue.ItemClass?.GetItemName() + "x" + u.Sample.count) + "; " + u.WorkState());
}
}
[Preserve]
public class ConsoleCmdNecroLook : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrolook" };
public override string getDescription() => "NecromancerTome (разработка): повернуть взгляд: yaw [pitch] | at x y z";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrolook", _params);
}
[Preserve]
public class ConsoleCmdNecroAim : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroaim" };
public override string getDescription() => "NecromancerTome (разработка): позиция игрока и блок под прицелом";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necroaim", _params);
}
[Preserve]
public class ConsoleCmdNecroPlace : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroplace" };
public override string getDescription() => "NecromancerTome (разработка): поставить блок: имя x y z [поворот]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necroplace", _params);
}
[Preserve]
public class ConsoleCmdNecroHold : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrohold" };
public override string getDescription() => "NecromancerTome (разработка): предмет в руку: имя [число]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrohold", _params);
}
[Preserve]
public class ConsoleCmdNecroFire : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrofire" };
public override string getDescription() => "NecromancerTome (разработка): ЛКМ предметом в руке: press|release";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrofire", _params);
}
[Preserve]
public class ConsoleCmdNecroSlot : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroslot" };
public override string getDescription() => "NecromancerTome (разработка): ячейка пояса в руке: [N]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necroslot", _params);
}
[Preserve]
public class ConsoleCmdNecroBox : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrobox" };
public override string getDescription() => "NecromancerTome (разработка): хранилище: x y z list|clear|add предмет число";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrobox", _params);
}
[Preserve]
public class ConsoleCmdNecroUrn : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrourn" };
public override string getDescription() => "NecromancerTome (разработка): урна: x y z info|skull|from|to|out|clear|sample|group";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrourn", _params);
}
[Preserve]
public class ConsoleCmdNecroClick : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroclick" };
public override string getDescription() => "NecromancerTome (разработка): нож для нитей: 0 - ЛКМ, 1 - ПКМ по блоку под прицелом";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necroclick", _params);
}
[Preserve]
public class ConsoleCmdNecroGod : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrogod" };
public override string getDescription() => "NecromancerTome (разработка): режим бога и невидимость для ИИ [off]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrogod", _params);
}
[Preserve]
public class ConsoleCmdNecroDamage : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrodamage" };
public override string getDescription() => "NecromancerTome (разработка): повредить блок x y z урон";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrodamage", _params);
}
[Preserve]
public class ConsoleCmdNecroDrop : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrodrop" };
public override string getDescription() => "NecromancerTome (разработка): бросить предмет: имя число x y z";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrodrop", _params);
}
[Preserve]
public class ConsoleCmdNecroBag : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrobag" };
public override string getDescription() => "NecromancerTome (разработка): мешок с зомби x y z";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrobag", _params);
}
[Preserve]
public class ConsoleCmdNecroCorpse : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrocorpse" };
public override string getDescription() => "NecromancerTome (разработка): красный труп x y z [класс]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrocorpse", _params);
}
[Preserve]
public class ConsoleCmdNecroClear : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroclear" };
public override string getDescription() => "NecromancerTome (разработка): убрать растения x0 z0 x1 z1 [y0 y1]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necroclear", _params);
}
[Preserve]
public class ConsoleCmdNecroStation : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrostation" };
public override string getDescription() => "NecromancerTome (разработка): станция текстом x y z";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrostation", _params);
}
[Preserve]
public class ConsoleCmdNecroFill : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrofill" };
public override string getDescription() => "NecromancerTome (разработка): засыпать ямы x0 z0 x1 z1 y [блок]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrofill", _params);
}
[Preserve]
public class ConsoleCmdNecroSmooth : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrosmooth" };
public override string getDescription() => "NecromancerTome (разработка): край насыпи скатом x0 z0 x1 z1 y [плотность]";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrosmooth", _params);
}
[Preserve]
public class ConsoleCmdNecroCanPlace : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrocanplace" };
public override string getDescription() => "NecromancerTome (разработка): почему урну нельзя поставить: блок x y z";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necrocanplace", _params);
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Подрастить растения вокруг игрока (16.10, для проверки работы духа урны на грядках): каждое растущее растение
/// (BlockPlantGrowing) в радиусе переходит на следующие стадии родным BlockPlantGrowing.ForceNextGrowStage.
/// </summary>
[Preserve]
public class ConsoleCmdNecroGrow : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => false;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necrogrow" };
}
public override string getDescription()
{
return "Necromancer's Tome: grow the plants around you by one or more stages (testing).";
}
public override string getHelp()
{
return "necrogrow [radius] [stages] - advance every growing plant within radius (default 8) by stages (default 9 = ripe)";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
World world = GameManager.Instance.World;
EntityPlayer player = null;
if (_senderInfo.RemoteClientInfo != null)
{
world.Players.dict.TryGetValue(_senderInfo.RemoteClientInfo.entityId, out player);
}
else
{
player = world.GetPrimaryPlayer();
}
if (player == null)
{
con.Output("necrogrow: no player");
return;
}
int radius = 8, stages = 9;
if (_params.Count >= 1)
{
int.TryParse(_params[0], out radius);
}
if (_params.Count >= 2)
{
int.TryParse(_params[1], out stages);
}
radius = Mathf.Clamp(radius, 1, 32);
Vector3i c = World.worldToBlockPos(player.GetPosition());
int plants = 0, steps = 0;
for (int dx = -radius; dx <= radius; dx++)
{
for (int dz = -radius; dz <= radius; dz++)
{
for (int dy = -4; dy <= 4; dy++)
{
Vector3i p = new Vector3i(c.x + dx, c.y + dy, c.z + dz);
BlockValue bv = world.GetBlock(p);
if (bv.ischild || !(bv.Block is BlockPlantGrowing))
{
continue;
}
plants++;
for (int s = 0; s < stages && bv.Block is BlockPlantGrowing g && !g.nextPlant.isair; s++)
{
BlockValue next = g.ForceNextGrowStage(world, p, bv);
if (next.type == bv.type)
{
break;
}
world.SetBlockRPC(p, next);
bv = next;
steps++;
}
}
}
}
con.Output("necrogrow: " + plants + " growing plant(s) within " + radius + ", " + steps + " stage(s) advanced");
}
}
}
+79
View File
@@ -83,9 +83,88 @@ namespace NecromancerTome
string dir = Path.Combine(NecroMailbox.Dir, "shots");
Directory.CreateDirectory(dir);
string name = _params.Count > 0 ? _params[0] : System.DateTime.Now.ToString("HHmmss");
// necroshot имя N интервал - серия из N кадров (имя1..имяN) через интервал секунд (18.10: мигание, полёт духа).
if (_params.Count >= 3 && int.TryParse(_params[1], out int n) &&
float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float dt))
{
ThreadManager.StartCoroutine(Burst(dir, name, n, dt));
SdtdConsole.Instance.Output("серия: " + n + " кадров через " + dt + " с");
return;
}
string path = Path.Combine(dir, name + ".png");
ScreenCapture.CaptureScreenshot(path);
SdtdConsole.Instance.Output("снимок: " + path);
}
static System.Collections.IEnumerator Burst(string _dir, string _name, int _n, float _dt)
{
for (int i = 1; i <= _n; i++)
{
ScreenCapture.CaptureScreenshot(Path.Combine(_dir, _name + i + ".png"));
yield return new WaitForSecondsRealtime(_dt);
}
Debug.Log("[NecromancerTome] mailbox: серия " + _name + " готова");
}
}
/// <summary>necrospawn - нажать «Появиться» в окне появления (18.10: ассистент сам запускает игру с сейвом
/// -LoadSaveGame=true и входит в мир без мыши). То же, что кнопка: XUiC_SpawnSelectionWindow.SpawnButtonPressed.</summary>
[Preserve]
public class ConsoleCmdNecroSpawn : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrospawn" };
public override string getDescription() => "NecromancerTome: нажать «Появиться» (разработка, почтовый ящик)";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
foreach (LocalPlayerUI ui in LocalPlayerUI.PlayerUIs)
{
XUiC_SpawnSelectionWindow w = ui?.xui?.GetWindowByType<XUiC_SpawnSelectionWindow>();
if (w != null && w.IsOpen)
{
w.SpawnButtonPressed(SpawnMethod.Invalid);
SdtdConsole.Instance.Output("necrospawn: нажато «Появиться»");
return;
}
}
SdtdConsole.Instance.Output("necrospawn: окно появления не открыто");
}
}
/// <summary>necroexit [quit] - выйти из мира с сохранением, как «Выход» в меню паузы (GameManager.Disconnect); с quit -
/// затем закрыть игру. Нужно перед сборкой бандлов и заменой DLL (игра держит файлы).</summary>
[Preserve]
public class ConsoleCmdNecroExit : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroexit" };
public override string getDescription() => "NecromancerTome: выйти из мира с сохранением [quit - и закрыть игру] (разработка)";
public override bool AllowedInMainMenu => true;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
bool quit = _params.Count > 0 && _params[0] == "quit";
SdtdConsole.Instance.Output("necroexit: выход" + (quit ? " и закрытие игры" : " в меню"));
ThreadManager.StartCoroutine(ExitCo(quit));
}
static System.Collections.IEnumerator ExitCo(bool _quit)
{
if (GameManager.Instance.World != null)
{
GameManager.Instance.Disconnect();
while (GameManager.Instance.World != null)
{
yield return null;
}
Debug.Log("[NecromancerTome] necroexit: мир сохранён и закрыт");
}
if (_quit)
{
yield return new WaitForSecondsRealtime(2f);
Debug.Log("[NecromancerTome] necroexit: закрываю игру");
Application.Quit();
}
}
}
}
+26
View File
@@ -128,6 +128,17 @@ namespace NecromancerTome
ImageTop = true,
Unlocked = _p => GameUtils.WorldTimeToDays(_p.world.worldTime) >= TideChapterDay
},
// Урны некротехнологов (18.10, после всех урн - «нужно будет сделать иллюстрацию для книги некроманта и рисунки»):
// лист Fig. VIII - пять урн эскизом с ремёслами на эсперанто (BlenderWork/make_urn_sheet.py). Открывается вместе с
// рецептом урны.
new Chapter
{
TitleKey = "necroTomeUrnsTitle",
PageKeys = new[] { "necroTomeUrnsP1", "necroTomeUrnsP2", "necroTomeUrnsP3" },
Image = "@modfolder(NecromancerTome):Textures/necroTomeUrns.png",
ImageTop = true,
Unlocked = _p => RecipeUnlocked(_p, "necroCinerarium")
},
};
/// <summary>С какого дня видна глава о кровавом приливе. По замыслу - 5 (тогда же надпись на доске
@@ -265,6 +276,16 @@ namespace NecromancerTome
if (_next != null) _next.Enabled = _tocPage < pages - 1;
}
/// <summary>Открыть главу по ключу заголовка (команда necrotome <ключ> [страница] - проверка без мыши).</summary>
public void ShowChapterKey(string _titleKey, int _page)
{
int i = NecroTomeBook.Chapters.FindIndex(c => c.TitleKey == _titleKey);
if (i >= 0)
{
ShowChapter(i, _page);
}
}
void ShowChapter(int _c, int _p)
{
if (_c < 0 || _c >= NecroTomeBook.Chapters.Count)
@@ -414,6 +435,11 @@ namespace NecromancerTome
if (player != null)
{
NecroTomeBook.Open(player);
if (_params.Count > 0)
{
XUiC_NecroTome w = LocalPlayerUI.GetUIForPlayer(player)?.xui?.GetWindowByType<XUiC_NecroTome>();
w?.ShowChapterKey(_params[0], _params.Count > 1 ? int.Parse(_params[1]) : 0);
}
}
}
}
+271
View File
@@ -0,0 +1,271 @@
using System.Collections.Generic;
using System.Globalization;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Осмотр рук от первого лица со стороны (16.10: «зафиксировать командой позу игрока и осматривать его камерой... хват
/// от первого лица не позволяет видеть всех нюансов»). Руки первого лица (baseRigFP) - отдельная модель под камерой
/// игрока, в виде от третьего лица их нет, а у тела свои анимации. Поэтому вид остаётся от первого лица, а поверх
/// рисует своя камера, которая облетает руки. Её место задано в осях камеры игрока: повернёшь голову - вид на руки
/// не меняется. Поза замирает (снимок всех костей, как у хватов мода).
/// Управление - цифровой блок: 4/6 вокруг, 8/2 выше/ниже, +/- ближе/дальше, 5 - сначала; или командой necroview.
/// </summary>
public static class NecroView
{
public static bool On;
public static float Yaw, Pitch, Dist = 0.6f;
/// <summary>Точка, вокруг которой летает камера (между кистями), - в осях камеры игрока.</summary>
static Vector3 _target;
static Camera _cam;
const float TurnSpeed = 60f, ZoomSpeed = 0.4f;
/// <summary>Мышь облетает руки, персонаж стоит (16.10: «я поворачиваю мышь, а туловище проворачивается следом, и я
/// вижу тело только с одной позиции»). Поворот игрока каждый кадр возвращается к снимку при включении.</summary>
public static bool Mouse = true;
public static float MouseSpeed = 3f;
static Vector3 _lockRot;
static Quaternion _lockCam;
static bool _mouseBroken;
/// <summary>«Выйти из тела» (16.10: «временно мог выйти из тела, когда попрошу? Чтобы мог ходить вокруг тела и
/// осматривать»): камера летает свободно - WASD, мышь, пробел/Ctrl - выше/ниже, Shift - быстрее; тело стоит
/// (PlayerMoveController.Update пропускается, как при одержимости), поза рук замерла. Выход - End или necroview off.</summary>
public static bool Free;
static Vector3 _freePos;
static float _freeYaw, _freePitch;
const float FlySpeed = 1.5f;
public static string StartFree()
{
string r = Start(0f, 0f, 0.6f);
Transform pc = HandPose.Camera();
if (!On || pc == null)
{
return r;
}
Free = true;
// Руки первого лица (слой HoldingItem) вне тела не рисуем: 16.10 они висели над телом без головы.
_cam.cullingMask &= ~(1 << LayerMask.NameToLayer("HoldingItem"));
_freePos = pc.position;
Vector3 e = pc.eulerAngles;
_freeYaw = e.y;
_freePitch = e.x > 180f ? e.x - 360f : e.x;
return "вне тела: WASD, мышь, пробел/Ctrl - выше/ниже, Shift - быстрее, End - вернуться";
}
static void UpdateFree(float _dt)
{
PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
if (!_mouseBroken)
{
try
{
_freeYaw += Input.GetAxis("Mouse X") * sens.x;
_freePitch = Mathf.Clamp(_freePitch - Input.GetAxis("Mouse Y") * sens.y, -89f, 89f);
}
catch (System.Exception e)
{
_mouseBroken = true;
Debug.LogWarning("[NecromancerTome] necroview: мышь не читается (" + e.Message + ")");
}
}
Quaternion rot = Quaternion.Euler(_freePitch, _freeYaw, 0f);
Vector3 move = Vector3.zero;
if (Input.GetKey(KeyCode.W)) move += Vector3.forward;
if (Input.GetKey(KeyCode.S)) move += Vector3.back;
if (Input.GetKey(KeyCode.A)) move += Vector3.left;
if (Input.GetKey(KeyCode.D)) move += Vector3.right;
Vector3 world = rot * move;
if (Input.GetKey(KeyCode.Space)) world += Vector3.up;
if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.C)) world += Vector3.down;
float speed = FlySpeed * (Input.GetKey(KeyCode.LeftShift) ? 4f : 1f);
_freePos += world * speed * _dt;
_cam.transform.SetPositionAndRotation(_freePos, rot);
}
public static string Start(float _yaw, float _pitch, float _dist)
{
Transform pc = HandPose.Camera();
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pc == null || pl?.playerCamera == null)
{
return "нет камеры игрока";
}
Transform l = HandPose.Bone("LeftHand"), r = HandPose.Bone("RightHand");
Vector3 mid = l != null && r != null ? (l.position + r.position) * 0.5f : pc.position + pc.forward * 0.4f;
_target = pc.InverseTransformPoint(mid);
Yaw = _yaw;
Pitch = _pitch;
Dist = _dist;
if (_cam == null)
{
GameObject go = new GameObject("NecroViewCam");
Object.DontDestroyOnLoad(go);
_cam = go.AddComponent<Camera>();
}
_cam.CopyFrom(pl.playerCamera);
// Сразу над камерой игрока, но ПОД камерами интерфейса (16.10: с +50 рисовала поверх меню - «мне из игры не выйти даже»).
float above = float.MaxValue;
foreach (Camera c in Camera.allCameras)
{
if (c != _cam && c.depth > pl.playerCamera.depth && c.depth < above)
{
above = c.depth;
}
}
_cam.depth = above < float.MaxValue ? (pl.playerCamera.depth + above) * 0.5f : pl.playerCamera.depth + 1f;
_cam.nearClipPlane = 0.01f;
_cam.gameObject.SetActive(true);
HandPose.HoldStill = true;
_lockRot = pl.rotation;
_lockCam = pc.localRotation;
On = true;
return "осмотр включён: вокруг " + Yaw + ", высота " + Pitch + ", расстояние " + Dist;
}
public static void Stop()
{
On = false;
Free = false;
HandPose.HoldStill = false;
if (_cam != null)
{
Object.Destroy(_cam.gameObject);
_cam = null;
}
}
/// <summary>Из HandPoseDriver.LateUpdate - после позы.</summary>
public static void Update()
{
if (!On)
{
return;
}
Transform pc = HandPose.Camera();
if (pc == null || _cam == null)
{
Stop();
return;
}
float dt = Time.unscaledDeltaTime;
if (Free)
{
if (Input.GetKeyDown(KeyCode.End))
{
Stop();
return;
}
UpdateFree(dt);
return;
}
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (Mouse && pl != null && !_mouseBroken)
{
try
{
Yaw += Input.GetAxis("Mouse X") * MouseSpeed;
Pitch = Mathf.Clamp(Pitch - Input.GetAxis("Mouse Y") * MouseSpeed, -85f, 85f);
}
catch (System.Exception e)
{
_mouseBroken = true; // нет оси мыши в старом вводе - остаётся цифровой блок
Debug.LogWarning("[NecromancerTome] necroview: мышь не читается (" + e.Message + ")");
}
pl.rotation = _lockRot;
pc.localRotation = _lockCam;
}
if (Input.GetKey(KeyCode.Keypad4)) Yaw -= TurnSpeed * dt;
if (Input.GetKey(KeyCode.Keypad6)) Yaw += TurnSpeed * dt;
if (Input.GetKey(KeyCode.Keypad8)) Pitch = Mathf.Min(85f, Pitch + TurnSpeed * dt);
if (Input.GetKey(KeyCode.Keypad2)) Pitch = Mathf.Max(-85f, Pitch - TurnSpeed * dt);
if (Input.GetKey(KeyCode.KeypadPlus)) Dist = Mathf.Max(0.1f, Dist - ZoomSpeed * dt);
if (Input.GetKey(KeyCode.KeypadMinus)) Dist = Mathf.Min(3f, Dist + ZoomSpeed * dt);
if (Input.GetKeyDown(KeyCode.Keypad5))
{
Yaw = 0f;
Pitch = 0f;
Dist = 0.6f;
}
// Yaw 0, Pitch 0 - смотрим на руки со стороны глаз игрока (сзади), 180 - спереди.
Quaternion local = Quaternion.Euler(Pitch, Yaw, 0f);
Vector3 offset = local * new Vector3(0f, 0f, -Dist);
Vector3 t = pc.TransformPoint(_target);
_cam.transform.SetPositionAndRotation(t + pc.rotation * offset, pc.rotation * local);
}
public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture);
}
/// <summary>necroview on [вокруг высота расстояние] | off | set вокруг высота расстояние | show</summary>
[Preserve]
public class ConsoleCmdNecroView : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroview" };
public override string getDescription() => "NecromancerTome: осмотр рук со стороны - on [вокруг высота расстояние]|off|set вокруг высота расстояние|free (выйти из тела, End - назад)|mouse [off]|show; мышь или цифровой блок 4/6 8/2 +/- 5";
static bool Num(List<string> _p, int _i, out float _v)
{
_v = 0f;
return _p.Count > _i && float.TryParse(_p[_i].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
}
void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necroview: " + _s);
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "";
switch (cmd)
{
case "on":
Out(NecroView.Start(Num(_params, 1, out float y) ? y : 150f, Num(_params, 2, out float p) ? p : 10f, Num(_params, 3, out float d) ? d : 0.6f));
return;
case "off":
NecroView.Stop();
Out("осмотр выключен");
return;
case "set" when NecroView.On && Num(_params, 1, out float y2) && Num(_params, 2, out float p2) && Num(_params, 3, out float d2):
NecroView.Yaw = y2;
NecroView.Pitch = p2;
NecroView.Dist = d2;
break;
case "free":
Out(NecroView.StartFree());
return;
case "mouse":
NecroView.Mouse = !(_params.Count > 1 && _params[1] == "off");
Out("мышь " + (NecroView.Mouse ? "облетает руки" : "крутит персонажа, облёт - цифровой блок"));
return;
case "show":
break;
default:
Out(getDescription());
return;
}
Out(NecroView.On ? "вокруг " + NecroView.F(NecroView.Yaw) + ", высота " + NecroView.F(NecroView.Pitch) + ", расстояние " + NecroView.F(NecroView.Dist) : "осмотр выключен");
}
}
/// <summary>Вне тела (NecroView.Free) тело не ходит и не крутится - как при одержимости.</summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_NecroView
{
public static bool Prefix(PlayerMoveController __instance)
{
if (!NecroView.Free)
{
return true;
}
__instance.stopMoving();
return false;
}
}
}
+715
View File
@@ -0,0 +1,715 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Новый оссуарий-котёл (#67, 22.10.2026): свечение модели и отладочная команда necroossuary.
/// Свечение - руны на котле, знаки на крышке, прах и кости внутри (карта _e, цвет - EmissionColor через MaterialPropertyBlock,
/// общий материал не трогается) и свет glow внутри котла (дочерний объект префаба, выключен) - пока оссуарий исправно работает
/// (22.10: «Когда оссуарий исправно работает, он должен подсвечиваться изнутри. Чтобы сразу было видно если что-то не так»;
/// «Пусть руны тоже загораются»). Сфера в держателе (sphere) видна, пока в топливе есть сфера или камень. Следит компонент
/// OssuaryVisual на модели - поэтому работает и у клиентов сервера. Выключатель - necroossuary off (модель как в префабе).
/// </summary>
public static class OssuaryGlow
{
public static bool Enabled = true;
/// <summary>Дикий дух-огонёк (#69): летает, пока оссуарий работает. Выключатель - necroossuary wisp off.</summary>
public static bool WispEnabled = true;
/// <summary>necroossuary wisp log - писать в лог вылет и влёт (чтобы успеть снять).</summary>
public static bool WispLog;
/// <summary>necroossuary wisp trace - трассер: линии обоих путей огонька (вылет и влёт) у каждого котла (22.10: «А не проще
/// для отладки нарисовать трассер, по которому будет лететь огонёк?»).</summary>
public static bool WispTrace;
public const string WispPath = "#@modfolder(NecromancerTome):Resources/necrowisp?necroWispPrefab.prefab";
static GameObject _wispPrefab;
static bool _wispTried;
public static GameObject WispPrefab()
{
if (!_wispTried)
{
_wispTried = true;
_wispPrefab = DataLoader.LoadAsset<GameObject>(WispPath);
Debug.Log("[NecromancerTome] OssuaryGlow: огонёк " + (_wispPrefab != null ? "загружен" : "НЕ загружен: " + WispPath));
}
return _wispPrefab;
}
/// <summary>Цвет свечения (HDR). 22.10: было (2, 0.24, 0.12) - «Руны нарисованы красным. Это не выглядит как свечение»:
/// днём смотрелось краской. Теперь ярче и теплее, как раскалённый уголь, с пульсацией. Подбор - necroossuary color / pulse.</summary>
public static Color GlowColor = new Color(6f, 1.4f, 0.35f, 1f);
/// <summary>Размах пульсации свечения (доля яркости) и её скорость (радиан в секунду).</summary>
public static float GlowPulse = 0.3f, GlowPulseSpeed = 1.6f;
static readonly MaterialPropertyBlock _block = new MaterialPropertyBlock();
static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor");
public static Transform Model(World _w, Vector3i _pos)
{
Chunk chunk = _w.GetChunkFromWorldPos(_pos) as Chunk;
BlockEntityData be = chunk?.GetBlockEntity(_pos);
return be != null && be.bHasTransform ? be.transform : null;
}
/// <summary>true - модель найдена и переключена.</summary>
public static bool Set(Transform _model, bool _on, bool _sphere = true)
{
if (_model == null)
{
return false;
}
foreach (string part in new[] { "body", "lid" })
{
Transform t = _model.Find(part);
Renderer r = t != null ? t.GetComponent<Renderer>() : null;
if (r == null)
{
continue;
}
r.GetPropertyBlock(_block);
_block.SetColor(EmissionColorId, _on ? GlowColor : Color.black);
r.SetPropertyBlock(_block);
}
Transform glow = _model.Find("glow");
if (glow != null)
{
glow.gameObject.SetActive(_on);
}
Transform sphere = _model.Find("sphere");
if (sphere != null)
{
sphere.gameObject.SetActive(_sphere);
}
return true;
}
/// <summary>Пульсация: свечение рун и нутра - GlowColor с множителем _k (каждый кадр, пока светится).</summary>
public static void Pulse(Transform _model, float _k)
{
foreach (string part in new[] { "body", "lid" })
{
Transform t = _model.Find(part);
Renderer r = t != null ? t.GetComponent<Renderer>() : null;
if (r == null)
{
continue;
}
r.GetPropertyBlock(_block);
_block.SetColor(EmissionColorId, GlowColor * _k);
r.SetPropertyBlock(_block);
}
}
/// <summary>Оссуарии (главные блоки) в кубе вокруг точки.</summary>
public static List<Vector3i> Find(World _w, Vector3i _c, int _r, int _ry)
{
var res = new List<Vector3i>();
for (int x = _c.x - _r; x <= _c.x + _r; x++)
{
for (int z = _c.z - _r; z <= _c.z + _r; z++)
{
for (int y = Mathf.Max(1, _c.y - _ry); y <= Mathf.Min(254, _c.y + _ry); y++)
{
var p = new Vector3i(x, y, z);
BlockValue bv = _w.GetBlock(p);
if (!bv.ischild && GeoOssuary.Is(bv.Block))
{
res.Add(p);
}
}
}
}
return res;
}
}
/// <summary>На модели оссуария: два раза в секунду смотрит на коллектор и переключает сферу и свечение, только при смене.
/// anyEnabled - ванильная проверка «есть рабочий выход»: учитывает топливо, заполненный выход, воду и пустые ячейки
/// модификаций (GeoOssuaryPatch.isDisabled).</summary>
public class OssuaryVisual : MonoBehaviour
{
public Vector3i Pos;
float _next;
int _state = -1;
readonly float _phase = Random.Range(0f, 6.28f); // соседние котлы пульсируют не в такт
void Update()
{
bool lit = _state > 0 && (_state & 1) != 0 && OssuaryGlow.Enabled; // -1 - ещё не смотрели
_wisp.Tick(transform, lit && OssuaryGlow.WispEnabled);
if (lit && OssuaryGlow.GlowPulse > 0f)
{
OssuaryGlow.Pulse(transform, 1f + OssuaryGlow.GlowPulse * Mathf.Sin(Time.time * OssuaryGlow.GlowPulseSpeed + _phase));
}
if (Time.time < _next)
{
return;
}
_next = Time.time + 0.5f;
World w = GameManager.Instance?.World;
if (w == null)
{
return;
}
int st; // бит 1 - светится, бит 2 - сфера видна; выключатель - как в префабе (2)
if (OssuaryGlow.Enabled)
{
st = 0;
if (w.GetTileEntity(Pos) is TileEntityCollector te)
{
bool fuel = false;
foreach (ItemStack s in te.FuelSlots)
{
if (s != null && !s.IsEmpty())
{
fuel = true;
break;
}
}
st = (fuel ? 2 : 0) + (fuel && te.anyEnabled() ? 1 : 0);
}
}
else
{
st = 2;
}
if (st == _state)
{
return;
}
_state = st;
OssuaryGlow.Set(transform, (st & 1) != 0, (st & 2) != 0);
}
readonly OssuaryWisp _wisp = new OssuaryWisp();
void OnDestroy()
{
_wisp.Destroy();
}
}
/// <summary>Дикий дух оссуария (#69; 20.10: «Хватит одного огонька. Пусть светится сам. Производительность нам важна»):
/// чистая анимация, без сущностей (22.10: «периодически из окошка в котле вылетает дух и уходит вниз, увеличивая прозрачность
/// до полной, а через какое-то время с другой стороны снизу вылетает полностью прозрачный дух, плавно уменьшая прозрачность,
/// и влетает в противоположное окошко»). Один огонёк на котёл (бандл necrowisp), только пока оссуарий исправно работает
/// и игрок ближе WispViewRange. На добычу не влияет.</summary>
public class OssuaryWisp
{
public const float WispViewRange = 60f;
/// <summary>Окошки крышки (модель #67): шесть, через 60°, со сдвигом WindowAngle от оси X модели (рамки окошек в сетке -
/// на 30°, 90°...; на 0°, 60°... - металлические полосы: 22.10 «Светлячок заходит в полосы металла, и игнорирует окошки»).
/// По высоте - на WindowUp от низа крышки, от оси - на WindowR (доли размеров крышки; в модели окошко 0.46-0.56 м
/// при низе крышки 0.382, радиус 0.37 при полуширине 0.418).</summary>
const float WindowAngle = 30f;
const float WindowUp = 0.13f / 0.836f, WindowR = 0.37f / 0.418f;
/// <summary>Подъём и спуск - вертикально снаружи, на Clear дальше самого широкого места котла (22.10: «Огонёк летит сквозь
/// котёл. А надо чтобы поднимался почти на уровень окошка, и по дуге летел прямо через его центр»).</summary>
const float Clear = 0.25f;
const float BelowWindow = 0.1f; // вертикаль кончается на столько ниже окошка, м; дальше дуга
const float Under = 0.35f; // насколько под землю, м
const float FlyTime = 2.4f;
static readonly int TintId = Shader.PropertyToID("_TintColor");
static readonly int ColorId = Shader.PropertyToID("_Color");
static readonly MaterialPropertyBlock _mpb = new MaterialPropertyBlock();
GameObject _go;
Renderer[] _rs;
Color[] _base;
TrailRenderer _trail;
int _phase = -1; // -1 дома, 0 вылет и уход в землю, 1 под землёй, 2 подъём и влёт
float _t0, _until;
LineRenderer _traceOut, _traceIn;
Vector3[] _pathOut, _pathIn; // вылет: нутро -> окошко -> дуга -> вниз в землю; влёт - обратный порядок с другой стороны
static float Ease(float _k) => _k * _k * (3f - 2f * _k);
public void Tick(Transform _model, bool _work)
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
bool near = pl != null && (pl.position - Origin.position - _model.position).sqrMagnitude < WispViewRange * WispViewRange;
float now = Time.time;
if (!_work || !near)
{
if (_phase >= 0)
{
Hide();
_phase = -1;
}
_until = Mathf.Max(_until, now + Random.Range(1f, 4f));
return;
}
if (_go == null && !Spawn(_model))
{
return;
}
if (!OssuaryGlow.WispTrace && _traceOut != null && _traceOut.enabled)
{
_traceOut.enabled = _traceIn.enabled = false;
}
else if (OssuaryGlow.WispTrace && _pathOut == null)
{
Places(_model);
}
float k = Mathf.Clamp01((now - _t0) / Mathf.Max(0.01f, _until - _t0));
switch (_phase)
{
case -1:
if (now < _until || !Places(_model))
{
return;
}
Show(_pathOut[0]);
if (OssuaryGlow.WispLog)
{
Debug.Log("[NecromancerTome] OssuaryWisp: вылет " + _pathOut[1]);
}
Next(0, FlyTime);
return;
case 0: // из окошка наружу и вниз, в землю; тает
Fly(_pathOut, k);
Alpha(1f - Ease(Mathf.InverseLerp(0.45f, 1f, k)));
if (k >= 1f)
{
Hide();
Next(1, Random.Range(12f, 20f));
}
return;
case 1: // под землёй
if (now >= _until)
{
Alpha(0f);
Show(_pathIn[0]);
if (OssuaryGlow.WispLog)
{
Debug.Log("[NecromancerTome] OssuaryWisp: влёт " + _pathIn[_pathIn.Length - 2]);
}
Next(2, FlyTime);
}
return;
case 2: // с другой стороны снизу вверх, проявляется, влетает в противоположное окошко
Fly(_pathIn, k);
Alpha(Ease(Mathf.InverseLerp(0f, 0.55f, k)));
if (k >= 1f)
{
Hide();
_phase = -1;
_until = now + Random.Range(3f, 6f);
}
return;
}
}
void Next(int _p, float _dur)
{
_phase = _p;
_t0 = Time.time;
_until = _t0 + _dur;
}
/// <summary>Гладкая кривая (Catmull-Rom) точно через точки пути; скорость по пути ровная (время - по длинам отрезков).</summary>
void Fly(Vector3[] _path, float _k)
{
Vector3 p = At(_path, Ease(_k));
p.y += 0.015f * Mathf.Sin(Time.time * 7f); // лёгкое дрожание огонька
_go.transform.position = p;
}
/// <summary>Точка пути на доле длины _s (0..1).</summary>
static Vector3 At(Vector3[] _path, float _s01)
{
float total = 0f;
for (int i = 1; i < _path.Length; i++)
{
total += Vector3.Distance(_path[i - 1], _path[i]);
}
float s = _s01 * total;
int seg = 0;
float len = 0f;
for (; seg < _path.Length - 2; seg++)
{
len = Vector3.Distance(_path[seg], _path[seg + 1]);
if (s <= len)
{
break;
}
s -= len;
}
len = Vector3.Distance(_path[seg], _path[seg + 1]);
float u = len > 0.0001f ? Mathf.Clamp01(s / len) : 1f;
Vector3 p0 = _path[Mathf.Max(0, seg - 1)], p1 = _path[seg], p2 = _path[seg + 1], p3 = _path[Mathf.Min(_path.Length - 1, seg + 2)];
return 0.5f * (2f * p1 + (p2 - p0) * u + (2f * p0 - 5f * p1 + 4f * p2 - p3) * u * u + (3f * p1 - p0 - 3f * p2 + p3) * u * u * u);
}
/// <summary>Окошко вылета - случайное из шести, влёта - напротив. Путь: нутро, центр окошка, точка дуги снаружи на высоте
/// окошка, вертикаль снаружи котла чуть ниже окошка, та же вертикаль под землёй (высота земли - лучом).</summary>
bool Places(Transform _model)
{
Transform lid = _model.Find("lid");
Transform body = _model.Find("body");
Renderer lr = lid != null ? lid.GetComponent<Renderer>() : null;
Renderer br = body != null ? body.GetComponent<Renderer>() : null;
if (lr == null)
{
return false;
}
Bounds b = lr.bounds;
float half = (b.size.x + b.size.z) * 0.25f;
float bodyHalf = br != null ? (br.bounds.size.x + br.bounds.size.z) * 0.25f : half * 1.1f;
float winY = b.min.y + WindowUp * half * 2f;
Vector3 axis = new Vector3(b.center.x, winY, b.center.z);
int w = Random.Range(0, 6);
Vector3 dOut = lid.rotation * Quaternion.Euler(0f, WindowAngle + w * 60f, 0f) * Vector3.right;
dOut.y = 0f;
dOut.Normalize();
_pathOut = Path(axis, dOut, half, Mathf.Max(bodyHalf, half) + Clear);
_pathIn = Path(axis, -dOut, half, Mathf.Max(bodyHalf, half) + Clear);
System.Array.Reverse(_pathIn);
Trace(_model);
return true;
}
Vector3[] Path(Vector3 _axis, Vector3 _dir, float _half, float _rOut)
{
float rWin = _half * WindowR;
Vector3 low = _axis + _dir * _rOut + Vector3.down * BelowWindow;
Vector3 under = Ground(_axis + _dir * _rOut) + Vector3.down * Under;
return new[]
{
_axis + _dir * (rWin * 0.45f), // внутри котла
_axis + _dir * rWin, // центр окошка
_axis + _dir * (rWin + (_rOut - rWin) * 0.55f), // дуга: снаружи на высоте окошка
low, // вертикаль снаружи, чуть ниже окошка
new Vector3(low.x, Mathf.Lerp(low.y, under.y, 0.5f), low.z),
under // в земле
};
}
/// <summary>Трассер: путь вылета и путь влёта линиями, точками той же кривой, по которой летит огонёк.</summary>
void Trace(Transform _model)
{
if (!OssuaryGlow.WispTrace || _trail == null)
{
return;
}
if (_traceOut == null)
{
_traceOut = Line(_model, "NecroWispTraceOut", new Color(1f, 0.6f, 0.1f));
_traceIn = Line(_model, "NecroWispTraceIn", new Color(0.2f, 0.8f, 1f));
}
Fill(_traceOut, _pathOut);
Fill(_traceIn, _pathIn);
}
LineRenderer Line(Transform _model, string _name, Color _c)
{
var g = new GameObject(_name);
g.transform.SetParent(_model, false);
LineRenderer l = g.AddComponent<LineRenderer>();
l.sharedMaterial = _trail.sharedMaterial;
l.useWorldSpace = true;
l.widthMultiplier = 0.015f;
l.startColor = l.endColor = _c;
l.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
l.receiveShadows = false;
return l;
}
void Fill(LineRenderer _l, Vector3[] _path)
{
const int n = 60;
var pts = new Vector3[n + 1];
for (int i = 0; i <= n; i++)
{
pts[i] = At(_path, (float)i / n);
}
_l.positionCount = pts.Length;
_l.SetPositions(pts);
_l.enabled = true;
}
Vector3 Ground(Vector3 _p)
{
if (Physics.Raycast(_p + Vector3.up * 1.5f, Vector3.down, out RaycastHit hit, 8f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
{
_p.y = hit.point.y;
}
else
{
_p.y -= 1f;
}
return _p;
}
bool Spawn(Transform _model)
{
GameObject prefab = OssuaryGlow.WispPrefab();
if (prefab == null)
{
return false;
}
_go = Object.Instantiate(prefab, _model, false);
_go.name = "NecroOssuaryWisp";
_trail = _go.GetComponent<TrailRenderer>();
_rs = _go.GetComponentsInChildren<Renderer>(true);
_base = new Color[_rs.Length];
for (int i = 0; i < _rs.Length; i++)
{
Material m = _rs[i].sharedMaterial;
_base[i] = m == null ? Color.white : m.HasProperty(TintId) ? m.GetColor(TintId) : m.HasProperty(ColorId) ? m.GetColor(ColorId) : Color.white;
}
_go.SetActive(false);
return true;
}
/// <summary>Прозрачность - цветом материала через MaterialPropertyBlock (шейдер аддитивный: тусклее = прозрачнее),
/// общий материал не трогается.</summary>
void Alpha(float _a)
{
for (int i = 0; i < _rs.Length; i++)
{
Material m = _rs[i].sharedMaterial;
if (m == null)
{
continue;
}
Color c = _base[i] * _a;
_rs[i].GetPropertyBlock(_mpb);
if (m.HasProperty(TintId))
{
_mpb.SetColor(TintId, c);
}
if (m.HasProperty(ColorId))
{
_mpb.SetColor(ColorId, c);
}
_rs[i].SetPropertyBlock(_mpb);
}
}
void Show(Vector3 _at)
{
_go.transform.position = _at;
if (_trail != null)
{
_trail.Clear();
}
_go.SetActive(true);
if (_phase != 1)
{
Alpha(1f);
}
}
void Hide()
{
if (_go != null)
{
_go.SetActive(false);
}
}
public void Destroy()
{
if (_go != null)
{
Object.Destroy(_go);
_go = null;
}
foreach (LineRenderer l in new[] { _traceOut, _traceIn })
{
if (l != null)
{
Object.Destroy(l.gameObject);
}
}
_traceOut = _traceIn = null;
}
}
/// <summary>Модель оссуария появилась в мире - повесить на неё OssuaryVisual.</summary>
[HarmonyPatch(typeof(BlockCollector), nameof(BlockCollector.OnBlockEntityTransformBeforeActivated))]
public static class Patch_BlockCollector_OnBlockEntityTransformBeforeActivated_OssuaryVisual
{
public static void Postfix(Vector3i _blockPos, BlockValue _blockValue, BlockEntityData _ebcd)
{
if (!GeoOssuary.Is(_blockValue.Block) || _ebcd?.transform == null || _ebcd.transform.Find("body") == null)
{
return;
}
OssuaryVisual v = _ebcd.transform.GetComponent<OssuaryVisual>() ?? _ebcd.transform.gameObject.AddComponent<OssuaryVisual>();
v.Pos = _blockPos;
}
}
public class ConsoleCmdNecroOssuary : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necroossuary" };
}
public override string getDescription()
{
return "Necromancer's Tome: cauldron ossuary - find, remove, sphere/glow switch.";
}
public override string getHelp()
{
return "necroossuary find [r] - ossuaries around the player (r blocks, default 30)\n" +
"necroossuary remove x y z - remove the ossuary at x y z (only if it is an ossuary)\n" +
"necroossuary on|off - off: sphere and glow no longer follow the ossuary state\n" +
"necroossuary wisp on|off - wild spirit wisp flying while the ossuary works\n" +
"necroossuary wisp trace|log - dev: draw the wisp paths / log take-off and return\n" +
"necroossuary open x y z - dev: open the ossuary window as with E\n" +
"necroossuary info x y z - slots of the ossuary (samples, fuel, storage) and whether it works\n" +
"necroossuary put x y z mod|fuel <slot 1..> <item|none> [count] - dev: put an item into a slot (none - empty it)\n" +
"necroossuary color r g b - glow color (HDR, e.g. 6 1.4 0.35)\n" +
"necroossuary pulse a [speed] - glow pulse amplitude (0 - steady) and speed";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
World w = GameManager.Instance?.World;
EntityPlayerLocal pl = w?.GetPrimaryPlayer();
if (w == null || pl == null || _params.Count == 0)
{
con.Output(getHelp());
return;
}
Vector3i me = World.worldToBlockPos(pl.position);
switch (_params[0])
{
case "find":
{
int r = _params.Count > 1 ? int.Parse(_params[1]) : 30;
List<Vector3i> found = OssuaryGlow.Find(w, me, r, 20);
con.Output("necroossuary: найдено " + found.Count + (found.Count > 0 ? ": " + string.Join("; ", found) : ""));
break;
}
case "remove":
{
var p = new Vector3i(int.Parse(_params[1]), int.Parse(_params[2]), int.Parse(_params[3]));
BlockValue bv = w.GetBlock(p);
if (!GeoOssuary.Is(bv.Block))
{
con.Output("necroossuary: в " + p + " не оссуарий (" + bv.Block?.GetBlockName() + ") - не трогаю");
break;
}
if (bv.ischild)
{
p = p + bv.parent;
}
w.SetBlockRPC(p, BlockValue.Air);
con.Output("necroossuary: убран оссуарий в " + p);
break;
}
case "open":
{
var p = new Vector3i(int.Parse(_params[1]), int.Parse(_params[2]), int.Parse(_params[3]));
BlockValue bv = w.GetBlock(p);
if (bv.ischild)
{
p = p + bv.parent;
bv = w.GetBlock(p);
}
con.Output("necroossuary: открыть " + p + " - " + (GeoOssuary.Is(bv.Block) && bv.Block.OnBlockActivated(w, p, bv, pl)));
break;
}
case "info":
case "put":
{
var p = new Vector3i(int.Parse(_params[1]), int.Parse(_params[2]), int.Parse(_params[3]));
BlockValue bv = w.GetBlock(p);
if (bv.ischild)
{
p = p + bv.parent;
}
if (!(w.GetTileEntity(p) is TileEntityCollector te) || !GeoOssuary.Is(te))
{
con.Output("necroossuary: в " + p + " не оссуарий");
break;
}
if (_params[0] == "put")
{
ItemStack[] slots = _params[4] == "fuel" ? te.FuelSlots : te.ModSlots;
int i = int.Parse(_params[5]) - 1;
bool none = _params[6] == "none";
ItemValue iv = none ? ItemValue.None : ItemClass.GetItem(_params[6]);
if (i < 0 || i >= slots.Length || iv == null || (!none && iv.IsEmpty()))
{
con.Output("necroossuary: нет ячейки " + (i + 1) + " или предмета " + _params[6]);
break;
}
slots[i] = none ? ItemStack.Empty.Clone() : new ItemStack(iv, _params.Count > 7 ? int.Parse(_params[7]) : 1);
te.SetModified();
}
string S(ItemStack[] _a)
{
var parts = new List<string>();
for (int i = 0; i < _a.Length; i++)
{
parts.Add(_a[i] == null || _a[i].IsEmpty() ? "-" : _a[i].itemValue.ItemClass?.Name + " x" + _a[i].count);
}
return string.Join(", ", parts);
}
con.Output("necroossuary " + p + ": образцы [" + S(te.ModSlots) + "]; топливо [" + S(te.FuelSlots) + "]; хранилище [" +
S(te.Items) + "]; работает " + te.anyEnabled());
break;
}
case "color":
if (_params.Count > 3)
{
OssuaryGlow.GlowColor = new Color(float.Parse(_params[1], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(_params[2], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(_params[3], System.Globalization.CultureInfo.InvariantCulture), 1f);
}
con.Output("necroossuary: цвет свечения " + OssuaryGlow.GlowColor);
break;
case "pulse":
if (_params.Count > 1)
{
OssuaryGlow.GlowPulse = float.Parse(_params[1], System.Globalization.CultureInfo.InvariantCulture);
}
if (_params.Count > 2)
{
OssuaryGlow.GlowPulseSpeed = float.Parse(_params[2], System.Globalization.CultureInfo.InvariantCulture);
}
con.Output("necroossuary: пульсация " + OssuaryGlow.GlowPulse + ", скорость " + OssuaryGlow.GlowPulseSpeed);
break;
case "wisp":
if (_params.Count > 1 && _params[1] == "trace")
{
OssuaryGlow.WispTrace = !OssuaryGlow.WispTrace;
con.Output("necroossuary: трассер огонька " + (OssuaryGlow.WispTrace ? "включён (оранжевый - вылет, голубой - влёт)" : "выключен"));
break;
}
if (_params.Count > 1 && _params[1] == "log")
{
OssuaryGlow.WispLog = !OssuaryGlow.WispLog;
con.Output("necroossuary: лог огонька " + (OssuaryGlow.WispLog ? "включён" : "выключен"));
break;
}
if (_params.Count > 1)
{
OssuaryGlow.WispEnabled = _params[1] == "on";
}
con.Output("necroossuary: огонёк " + (OssuaryGlow.WispEnabled ? "летает" : "выключен"));
break;
case "on":
case "off":
OssuaryGlow.Enabled = _params[0] == "on";
con.Output("necroossuary: сфера и свечение по работе " + (OssuaryGlow.Enabled ? "включены" : "выключены (модель как в префабе)"));
break;
default:
con.Output(getHelp());
break;
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Дальний питомец после одержимости (указание 23.10.2026, #35: «Если питомец далеко от хозяина и хозяин вышел из режима
/// одержимости, то питомец должен сразу быть отозван. Скажем, если больше 100 блоков, то отзовём, если меньше, то
/// возвращается своим ходом»). Отзыв - тот же, что книгой (RecallPet: подсказка «… отозван», книга назад, если призыв её
/// тратит). Далеко ушедший питомец иначе остаётся в выгруженном участке, и PetFollowPatch снимает с него владение.
/// </summary>
public static class PetFarRecall
{
/// <summary>Порог в блоках; 0 - выключено (питомец всегда идёт своим ходом, как раньше).</summary>
public static float Distance = 100f;
/// <summary>true - питомец отозван (дальше ничего с ним не делать).</summary>
public static bool TryRecall(EntityPlayer _owner, EntityAlive _pet)
{
if (Distance <= 0f || _owner == null || _pet == null || _pet.IsDead())
{
return false;
}
float d = Vector3.Distance(_owner.position, _pet.position);
string cls = EntityClass.list.TryGetValue(_pet.entityClass, out EntityClass ec) ? ec.entityClassName : null;
if (d <= Distance || cls == null ||
!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(cls, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo info) ||
info.RecalledKey == null)
{
return false;
}
Debug.Log("[NecromancerTome] PetFarRecall: " + cls + " " + _pet.entityId + " в " + Mathf.RoundToInt(d) + " блоках от хозяина - отозван");
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(_owner, _pet.entityId, info.RecalledKey, info.ConsumesBook ? info.SummonItemName : null);
return true;
}
}
public class ConsoleCmdNecroPetFar : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necropetfar" };
}
public override string getDescription()
{
return "Necromancer's Tome: recall a far pet when leaving possession - distance.";
}
public override string getHelp()
{
return "necropetfar - status\n" +
"necropetfar <blocks> - recall the pet if it is farther than this from you\n" +
"necropetfar off - never recall, the pet always walks back";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
if (_params.Count >= 1)
{
if (_params[0] == "off")
{
PetFarRecall.Distance = 0f;
}
else if (float.TryParse(_params[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v) && v >= 0f)
{
PetFarRecall.Distance = v;
}
}
con.Output("necropetfar: " + (PetFarRecall.Distance > 0f ? "recall farther than " + PetFarRecall.Distance + " blocks" : "off - the pet always walks back"));
}
}
}
+111 -11
View File
@@ -106,8 +106,12 @@ namespace NecromancerTome
/// угол из PlaceCamera затирался).</summary>
public static float ZoomFov = -1f;
/// <summary>Зазор между когтями стервятника и спиной ноши, м; минус - когти впиваются в спину
/// (тест 08.10: «курица висит под стервятником, а не в его когтях»).</summary>
public const float CarryGap = -0.06f;
/// (тест 08.10: «курица висит под стервятником, а не в его когтях»). 22.10: -0.06 -> -0.15 («поднимай курицу и кролика
/// ещё выше. Иначе они не достают до лап»). Подбор без перезапуска - necrocarry gap.</summary>
public static float CarryGap = -0.15f;
/// <summary>С ношей Shift не разгоняет (22.10: «он не может разгоняться по шифту, ибо перегружен»). Выключатель -
/// necrocarry run on|off.</summary>
public static bool CarryNoRun = true;
/// <summary>Цель рывка под прицелом сейчас (каждый кадр) - обычный прицел игры краснеет (AimTint).</summary>
public static EntityAlive FlyAim;
static float _flyDashUntil;
@@ -445,10 +449,12 @@ namespace NecromancerTome
}
}
// Питомец просто отпущен: его ИИ (следование, охрана, поводок) сам вернёт его к хозяину.
// Далеко ушедший - сразу отозван (23.10, #35: PetFarRecall).
if (pet)
{
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
z.SetAttackTarget(null, 0);
PetFarRecall.TryRecall(player, z);
return;
}
// Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»).
@@ -636,7 +642,6 @@ namespace NecromancerTome
{
HideBody(null);
FpArms.Hide(_player);
ShowPlayerBelt(true);
}
Debug.Log("[NecromancerTome] Possession: вид от " + (FirstPerson ? "первого" : "третьего") + " лица");
}
@@ -826,6 +831,11 @@ namespace NecromancerTome
}
if (IsPet && RatModel.Is(z))
{
// E на зомби в прицеле - дух переходит в него, крыса развеивается (указание 16.10).
if (detonate && TryRatJump(world, z))
{
return;
}
// E - осмотреть контейнер без права взять (указание 08.10: крыса - разведчик).
RatLoot.Tick(world, _player, z, detonate);
// ПКМ (держать) - сквозь постройки, без полёта (BACKLOG, «Могильная крыса» п.4).
@@ -901,7 +911,7 @@ namespace NecromancerTome
{
PossessionPowers.BreakInvis(z, "удар");
z.SetLookPosition(MeleeAim(world, z));
if (z.Attack(false))
if (StartStrike(z))
{
z.Attack(true);
if (IsPet && BearControl.Is(z))
@@ -957,7 +967,8 @@ namespace NecromancerTome
dir += Vector3.down;
}
bool moving = dir.sqrMagnitude > 0.01f;
float accel = _flyRun ? FlyAccelRun * _v.moveSpeedAggro : FlyAccel * _v.moveSpeed;
bool run = _flyRun && !(CarryNoRun && _carry != null); // с ношей - перегружен, без разгона
float accel = run ? FlyAccelRun * _v.moveSpeedAggro : FlyAccel * _v.moveSpeed;
if (moving)
{
_v.motion = _v.motion * 0.9f + dir.normalized * (accel * 0.1f);
@@ -972,7 +983,7 @@ namespace NecromancerTome
_v.SetRotation(new Vector3(0f, _bodyYaw, 0f));
if (_v.emodel != null && _v.emodel.avatarController != null)
{
_v.glidingCurrentPercent = Mathf.MoveTowards(_v.glidingCurrentPercent, _flyRun && moving ? 1f : 0f, 0.06f);
_v.glidingCurrentPercent = Mathf.MoveTowards(_v.glidingCurrentPercent, run && moving ? 1f : 0f, 0.06f);
_v.emodel.avatarController.UpdateFloat("Gliding", _v.glidingCurrentPercent);
}
// «Вниз» на земле посадке не мешает: Ctrl - переключатель, и с ним стервятник не сел бы никогда.
@@ -1525,6 +1536,42 @@ namespace NecromancerTome
return best;
}
/// <summary>Перенос духа из крысы в зомби под прицелом (решения 16.10): E, зомби не дальше RatJumpRange от
/// крысы и в прямой видимости (GroundAimTarget не видит сквозь стены, во время прохода - не работает);
/// цена - как у «пиф-пафа» (никакой). Крыса отзывается и развеивается; цепочки нет - после зомби дух
/// вернётся в тело ГГ (Exit). True - E израсходована: перенос сделан или начат.</summary>
static bool TryRatJump(World _world, EntityAlive _rat)
{
if (RatPhase.On)
{
return false;
}
EntityAlive victim = GroundAimTarget(_world, _rat);
if (victim == null || !CanPossess(victim) ||
(victim.position - _rat.position).sqrMagnitude > RatJumpRange * RatJumpRange)
{
return false;
}
EntityPlayerLocal player = _player;
int ratId = _rat.entityId;
Exit("крыса: дух переходит в " + victim.EntityName + " (" + victim.entityId + ")");
if (!Enter(player, victim))
{
Debug.Log("[NecromancerTome] Possession: перенос из крысы не удался - дух в теле, крыса осталась");
return true;
}
if (NecroBurst.EnsureRegistered())
{
ParticleEffect pe = new ParticleEffect(NecroBurst.ParticleName, _rat.position,
_world.GetLightBrightness(_rat.GetBlockPosition()), Color.white, null, null, _OLDCreateColliders: false);
GameManager.Instance.SpawnParticleEffectServer(pe, ratId);
}
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(player, ratId, null, null); // без подсказки «отозвана» - её место у подсказки вселения
return true;
}
public const float RatJumpRange = 3f;
public const float GroundAimRange = 30f; // зрение зомби, как TauntRange
public const float GroundAimRadius = 0.6f;
@@ -1679,7 +1726,8 @@ namespace NecromancerTome
}
if (IsPet && RatModel.Is(_z))
{
text += ", " + Localization.Get("necroPossessHintLook") + ", " + Localization.Get("necroPossessHintPhase");
text += ", " + Localization.Get("necroPossessHintLook") + ", " + Localization.Get("necroPossessHintRatJump") + ", " +
Localization.Get("necroPossessHintPhase");
}
if (IsPet && DogSense.Is(_z))
{
@@ -2309,6 +2357,28 @@ namespace NecromancerTome
/// цель - его, взгляд - на прицел (MeleeAim).</summary>
public static bool ForceImpact;
/// <summary>Идёт замах по нажатию игрока (StartStrike) - патчи ниже снимают с одержимого запреты на удар.</summary>
public static bool Striking;
/// <summary>Начало удара одержимого по ЛКМ. Стрим 15.10: удар рук «иногда не проходил», когда противник подходил
/// вплотную (в логе - «зомби не может бить сейчас»). Причина в игре: EntityAlive.IsAttackValid запрещает удар, пока
/// идёт анимация попадания по самому зомби или аниматор ставит preventAttack, а UseHoldingItem - пока доигрывает
/// прошлый замах. Для ИИ это пауза после пропущенного удара, для игрока - съеденный клик. На время этого вызова
/// запреты сняты (Patch_EntityAlive_IsAttackValid_Possession, Patch_AvatarZombieController_IsAnimationAttackPlaying_Possession);
/// остаются смерть, оглушение «на колени»/«лёжа» и удар током. Частоту ударов держит _nextAttack.</summary>
static bool StartStrike(EntityAlive _z)
{
Striking = true;
try
{
return _z.Attack(false);
}
finally
{
Striking = false;
}
}
public static void FpImpact()
{
EntityAlive z = Zombie;
@@ -2318,7 +2388,7 @@ namespace NecromancerTome
}
z.SetLookPosition(MeleeAim(z.world, z));
_meleeLookUntil = Time.time + 0.6f;
if (!z.Attack(false))
if (!StartStrike(z))
{
Debug.Log("[NecromancerTome] Possession: удар рук - зомби не может бить сейчас");
return;
@@ -2332,8 +2402,8 @@ namespace NecromancerTome
}
}
/// <summary>Ячейки пояса персонажа (окно windowToolbelt) - скрыты в виде от первого лица (указание 13.10: «пояс игрока
/// нужно прятать», у зомби свой пояс). Только это окно: группа toolbelt держит и полоски здоровья и сытости персонажа
/// <summary>Ячейки пояса персонажа (окно windowToolbelt) - скрыты всю одержимость, в любом виде (указание 13.10: «пояс игрока
/// нужно прятать», у зомби свой пояс; 15.10 - и от третьего лица тоже). Только это окно: группа toolbelt держит и полоски здоровья и сытости персонажа
/// (тест 13.10 - пропали вместе с поясом). Ставится каждый кадр - игра может показать окно снова.</summary>
static void ShowPlayerBelt(bool _show)
{
@@ -2407,6 +2477,8 @@ namespace NecromancerTome
VultureNeck.Apply(z, _flySitting, !_eating);
}
Zoom(Flying && _flyZoomHeld);
// Пояс персонажа скрыт всю одержимость, в любом виде (стрим 15.10: от третьего лица был виден).
ShowPlayerBelt(false);
Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null;
Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f;
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
@@ -2420,7 +2492,6 @@ namespace NecromancerTome
HideBody(z);
_camera.SetPositionAndRotation(_fpEye, rot);
FpArms.Tick(_player?.playerCamera);
ShowPlayerBelt(false);
return;
}
Vector3 eye = anchor + Vector3.up * CamUp - rot * Vector3.forward * CamBack;
@@ -2464,6 +2535,35 @@ namespace NecromancerTome
public static bool Prefix() => !Possession.Active;
}
/// <summary>Удар одержимого по ЛКМ не съедается реакцией на попадание (стрим 15.10, см. Possession.StartStrike).</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.IsAttackValid))]
public static class Patch_EntityAlive_IsAttackValid_Possession
{
public static void Postfix(EntityAlive __instance, ref bool __result)
{
if (__result || !Possession.Striking || __instance != Possession.Zombie || __instance.IsDead() || __instance.Electrocuted)
{
return;
}
EnumEntityStunType stun = __instance.bodyDamage.CurrentStun;
__result = stun != EnumEntityStunType.Kneel && stun != EnumEntityStunType.Prone;
}
}
/// <summary>И не ждёт конца прошлого замаха (там же).</summary>
[HarmonyPatch(typeof(AvatarZombieController), nameof(AvatarZombieController.IsAnimationAttackPlaying))]
public static class Patch_AvatarZombieController_IsAnimationAttackPlaying_Possession
{
public static void Postfix(AvatarZombieController __instance, ref bool __result)
{
if (__result && Possession.Striking && Possession.Zombie != null && Possession.Zombie.emodel != null &&
Possession.Zombie.emodel.avatarController == __instance)
{
__result = false;
}
}
}
/// <summary>Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))]
public static class Patch_EntityAlive_updateTasks_Possession
+180
View File
@@ -0,0 +1,180 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Живой кролик в мешке (указание 22.10.2026: «Я бы хватал кролика, но в отличие от курицы пусть он сразу
/// оказывается в сумке»; предмет - «Живой кролик в мешке»). Ваниль даёт схватить любое существо, у которого в
/// описании есть PickupItem (EntityAlive: команда grab голой рукой, предмет кладётся в слот руки, существо
/// уходит из мира через Collect) - так ловят курицу. Кролику PickupItem дан в entityclasses.xml, а здесь его
/// мешок кладётся сразу в рюкзак (нет места - на пояс, иначе под ноги).
///
/// Выпустить - ЛКМ с мешком в руке (ItemActionSpawnEntity, Entity=animalRabbit): ваниль предмет не тратит, тратим
/// здесь. Разделать - «Разобрать» в рюкзаке. Стервятник кролика по-прежнему несёт под лапами (Possession.Carryable). Выключатель - necrorabbit off:
/// тогда захват идёт ванильно, мешок - в руку, как курица.
/// </summary>
public static class RabbitGrab
{
public const string SackItem = "necroRabbitSack";
public static bool Enabled = true;
public static bool IsSackAnimal(EntityAlive _e) => _e != null && _e.EntityClass != null && _e.EntityClass.PickupItem == SackItem;
public static bool IsSack(ItemValue _iv) => _iv != null && _iv.ItemClass != null && _iv.ItemClass.GetItemName() == SackItem;
}
/// <summary>Захват кролика - мешок сразу в рюкзак.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.OnEntityActivated))]
public static class Patch_EntityAlive_OnEntityActivated_RabbitGrab
{
public static bool Prefix(EntityAlive __instance, EntityActivationCommand _command, EntityPlayerLocal _playerFocusing)
{
if (!RabbitGrab.Enabled || _command.commandId != "grab" || _playerFocusing == null || !RabbitGrab.IsSackAnimal(__instance))
{
return true;
}
ItemClass ic = ItemClass.GetItemClass(RabbitGrab.SackItem);
if (ic == null)
{
Debug.LogWarning("[NecromancerTome] RabbitGrab: нет предмета " + RabbitGrab.SackItem + " - захват ванильный");
return true;
}
ItemStack sack = new ItemStack(new ItemValue(ic.Id), 1);
string where = "рюкзак";
if (_playerFocusing.bag == null || !_playerFocusing.bag.AddItem(sack))
{
where = "пояс";
if (_playerFocusing.inventory == null || !_playerFocusing.inventory.AddItem(sack))
{
where = "под ноги";
_playerFocusing.world.gameManager.ItemDropServer(sack, _playerFocusing.GetPosition() + Vector3.up * 0.5f, Vector3.zero);
}
}
_playerFocusing.MinEventContext.Other = __instance;
_playerFocusing.FireEvent(MinEventTypes.onSelfPickupOther);
__instance.MinEventContext.Other = _playerFocusing;
__instance.FireEvent(MinEventTypes.onSelfPickedUpByOther);
Audio.Manager.PlayInsidePlayerHead(ic.Properties.GetString("SoundPickup"));
__instance.Collect(_playerFocusing.entityId);
Debug.Log("[NecromancerTome] RabbitGrab: кролик " + __instance.entityId + " пойман, мешок - " + where);
return false;
}
}
/// <summary>Подсказка над кроликом - своя (ваниль пишет «схватить курицу» для любого существа с PickupItem).</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.GetActivationText))]
public static class Patch_EntityAlive_GetActivationText_RabbitGrab
{
public static void Postfix(EntityAlive __instance, ref string __result)
{
if (RabbitGrab.Enabled && RabbitGrab.IsSackAnimal(__instance))
{
__result = Localization.Get("necroRabbitGrab");
}
}
}
/// <summary>Выпуск из мешка тратит мешок (ванильный SpawnEntity предмет не трогает).</summary>
[HarmonyPatch(typeof(ItemActionSpawnEntity), "Spawn", new System.Type[] { typeof(ItemActionData) })]
public static class Patch_ItemActionSpawnEntity_Spawn_RabbitSack
{
public static void Postfix(ItemActionData _actionData)
{
ItemInventoryData inv = _actionData?.invData;
if (inv?.holdingEntity == null || !RabbitGrab.IsSack(inv.itemValue))
{
return;
}
Inventory pack = inv.holdingEntity.inventory;
ItemStack held = pack.GetItem(inv.slotIdx);
if (held == null || held.IsEmpty() || !RabbitGrab.IsSack(held.itemValue))
{
return;
}
held = held.Clone();
held.count--;
pack.SetItem(inv.slotIdx, held.count > 0 ? held : ItemStack.Empty.Clone());
Debug.Log("[NecromancerTome] RabbitGrab: кролик выпущен из мешка, осталось " + Mathf.Max(0, held.count));
}
}
/// <summary>Разделать кролика - «Разобрать» в рюкзаке (указание 22.10: «Кролика можно будет разобрать на кожу, кости и
/// мясо»; «Разделывать будем по команде сломать»). Пункт ваниль показывает сама: мешок из кожи (Mleather, forge_category
/// leather), вес не меньше кожи. Но ванильная разборка даёт один материал через очередь крафта - у мешка она наша:
/// вся стопка сразу, на каждого кролика добыча разделки ножом (entityclasses.xml animalRabbit, butcherHarvest).</summary>
[HarmonyPatch(typeof(ItemActionEntryScrap), nameof(ItemActionEntryScrap.OnActivated))]
public static class Patch_ItemActionEntryScrap_OnActivated_RabbitSack
{
static readonly string[] Yield = { "foodRawMeat", "resourceLeather", "resourceBone" };
static readonly int[] YieldCount = { 10, 2, 5 };
public static bool Prefix(ItemActionEntryScrap __instance)
{
if (!(__instance.ItemController is IItemStackController isc))
{
return true;
}
ItemStack st = isc.ItemStack;
if (st == null || st.IsEmpty() || !RabbitGrab.IsSack(st.itemValue))
{
return true;
}
EntityPlayerLocal player = __instance.ItemController.xui.playerUI.entityPlayer;
XUiM_PlayerInventory pack = __instance.ItemController.xui.PlayerInventory;
int n = st.count;
isc.ItemStack = ItemStack.Empty.Clone();
for (int i = 0; i < Yield.Length; i++)
{
ItemClass ic = ItemClass.GetItemClass(Yield[i]);
if (ic == null)
{
continue;
}
ItemStack s = new ItemStack(new ItemValue(ic.Id), YieldCount[i] * n);
if (!pack.AddItem(s))
{
GameManager.Instance.ItemDropServer(s, player.GetPosition(), new Vector3(0.5f, 0f, 0.5f));
}
}
isc.WindowGroup?.Controller?.SetAllChildrenDirty();
Audio.Manager.PlayInsidePlayerHead("rabbitdeath");
Debug.Log("[NecromancerTome] RabbitGrab: разобрано мешков с кроликами - " + n);
return false;
}
}
public class ConsoleCmdNecroRabbit : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necrorabbit" };
}
public override string getDescription()
{
return "Necromancer's Tome: grabbed rabbit goes straight to the backpack - switch.";
}
public override string getHelp()
{
return "necrorabbit - status\n" +
"necrorabbit on|off - off: vanilla grab, the sack goes to the hand like a chicken";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
if (_params.Count >= 1 && (_params[0] == "on" || _params[0] == "off"))
{
RabbitGrab.Enabled = _params[0] == "on";
}
con.Output("necrorabbit: " + (RabbitGrab.Enabled ? "on - rabbit sack goes to the backpack" : "off - vanilla grab"));
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using System.Collections.Generic;
using HarmonyLib;
namespace NecromancerTome
{
/// <summary>
/// Кузнечные рецепты в двух версиях (плавильня - use_smelter, лом - replace_smelter) игра разводит по настройке мира
/// только в списке верстака (FilterRecipesByWorkstation) и в поиске по имени (FilterRecipesByName). Список по предмету
/// (клик по иконке в окне навыка), по id и по ингредиенту показывал обе версии - 22.10.2026 на стриме рецепт оссуария
/// «задвоился» (4 строки вместо 2: кровь/сок × плавильня/лом), у кузнечных урн так же. Здесь тот же фильтр, что у игры.
/// Выключатель - necrosmelterfilter off.
/// </summary>
public static class RecipeSmelterFilter
{
public static bool Enabled = true;
public static void Apply(List<Recipe> _list)
{
if (!Enabled || _list == null)
return;
_list.RemoveAll(r => r != null && r.craftingArea == "forge" &&
((XUiM_Recipes.DisableSmelter && r.tags.Test_AnySet(XUiM_Recipes.useSmelterTag)) ||
(!XUiM_Recipes.DisableSmelter && r.tags.Test_AnySet(XUiM_Recipes.replaceSmelterTag))));
}
}
[HarmonyPatch(typeof(XUiM_Recipes), nameof(XUiM_Recipes.FilterRecipesByItem))]
public static class Patch_XUiM_Recipes_FilterRecipesByItem_Smelter
{
public static void Postfix(List<Recipe> __result) => RecipeSmelterFilter.Apply(__result);
}
[HarmonyPatch(typeof(XUiM_Recipes), nameof(XUiM_Recipes.FilterRecipesByID))]
public static class Patch_XUiM_Recipes_FilterRecipesByID_Smelter
{
public static void Postfix(List<Recipe> __result) => RecipeSmelterFilter.Apply(__result);
}
[HarmonyPatch(typeof(XUiM_Recipes), nameof(XUiM_Recipes.FilterRecipesByIngredient))]
public static class Patch_XUiM_Recipes_FilterRecipesByIngredient_Smelter
{
public static void Postfix(List<Recipe> __result) => RecipeSmelterFilter.Apply(__result);
}
public class ConsoleCmdNecroSmelterFilter : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => true;
public override string[] getCommands()
{
return new string[] { "necrosmelterfilter" };
}
public override string getDescription()
{
return "Necromancer's Tome: hide forge recipe versions that do not match the smelter setting in item/ingredient recipe lists.";
}
public override string getHelp()
{
return "necrosmelterfilter on|off - off: lists by item/ingredient show both forge versions, as in the base game";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.Instance;
if (_params.Count > 0 && (_params[0] == "on" || _params[0] == "off"))
RecipeSmelterFilter.Enabled = _params[0] == "on";
con.Output("necrosmelterfilter: " + (RecipeSmelterFilter.Enabled ? "on" : "off"));
}
}
}
+123
View File
@@ -0,0 +1,123 @@
using GUI_2;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Череп - предмет или декор (15.10.2026, вторая версия). Указание после теста: «Посмотри как ставится мебель. Например,
/// горшки с цветами... Три режима. Предмет, и два декора. Если выбран декор, хват меняется на тот который при установке
/// строительных блоков или мебели, и я начинаю видеть типа превью того как блок будет стоять»; поворот - как у блоков.
/// Череп-предмет в руке, удержание R - меню: «Просто череп» / «Череп: по центру» / «Череп: у стены». Выбор декора
/// превращает всю стопку в ячейке пояса в блок этого вида: в руке настоящий блок - превью, повороты блока.
/// Блок-декор в руке, R - обычное меню блока (короткое нажатие - поворот), к нему добавлены «Просто череп» (стопка
/// обратно в черепа) и второй вид декора.
/// Поднятый (E) и разрушенный декор - снова череп-предмет (blocks.xml: CanPickup param1 / drop).
/// Предмет necroSkull не меняется (ItemClass, без действий) - сейвы с черепом целы; блоки-декор - отдельные блоки.
/// R для предмета игра не обрабатывает (PlayerMoveController открывает меню только оружию, ближнему бою и блокам) -
/// нажатие ловится постфиксом к PlayerMoveController.Update.
/// </summary>
public static class SkullDecorPlace
{
public const int ItemIdx = 0, CenterIdx = 1, BackIdx = 2;
/// <summary>Номера наших пунктов в меню блока - вне занятых игрой (-2..10).</summary>
public const int BlockMenuItem = 40, BlockMenuCenter = 41, BlockMenuBack = 42;
public const string CenterBlock = "necroSkullDecorCenter";
public const string BackBlock = "necroSkullDecorBack";
public static bool IsSkull(ItemClass _item) => _item != null && _item.Name == NecroSkull.ItemName;
public static bool IsDecor(ItemClass _item) => _item != null && (_item.Name == CenterBlock || _item.Name == BackBlock);
/// <summary>Стопка в руке превращается в _name (тот же счёт).</summary>
public static void Convert(EntityPlayerLocal _pl, string _name)
{
if (_pl?.inventory == null) return;
ItemStack cur = _pl.inventory.holdingItemStack;
if (cur == null || cur.IsEmpty() || !(IsSkull(cur.itemValue.ItemClass) || IsDecor(cur.itemValue.ItemClass))) return;
if (cur.itemValue.ItemClass.Name == _name) return;
ItemValue iv = ItemClass.GetItem(_name);
if (iv == null || iv.IsEmpty()) return;
_pl.inventory.SetItem(_pl.inventory.holdingItemIdx, new ItemStack(iv, cur.count));
}
public static void OnItemMenu(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
EntityPlayerLocal pl = _sender?.xui?.playerUI?.entityPlayer;
if (_commandIndex == CenterIdx) Convert(pl, CenterBlock);
else if (_commandIndex == BackIdx) Convert(pl, BackBlock);
}
public static void OpenItemMenu(PlayerMoveController _pmc, EntityPlayerLocal _pl)
{
XUiC_Radial r = _pmc.playerUI?.xui?.RadialWindow;
if (r == null) return;
r.Open();
r.ResetRadialEntries();
r.CreateRadialEntry(ItemIdx, "necroSkull", "ItemIconAtlas", "", Localization.Get("necroSkullModeOff"), true);
r.CreateRadialEntry(CenterIdx, "ui_game_symbol_skull", "UIAtlas", "", Localization.Get("necroSkullDecorCenter"));
r.CreateRadialEntry(BackIdx, "ui_game_symbol_skull", "UIAtlas", "", Localization.Get("necroSkullDecorBack"));
r.SetCommonData(UIUtils.GetButtonIconForAction(_pmc.playerInput.Reload), OnItemMenu,
new XUiC_Radial.RadialContextHoldingSlotIndex(_pl.inventory.holdingItemIdx), ItemIdx, false,
XUiC_Radial.RadialValidSameHoldingSlotIndex);
}
/// <summary>Меню блока с декором в руке: наши пункты - сами, остальное - игре.</summary>
public static void OnBlockMenu(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
EntityPlayerLocal pl = _sender?.xui?.playerUI?.entityPlayer;
switch (_commandIndex)
{
case BlockMenuItem: Convert(pl, NecroSkull.ItemName); return;
case BlockMenuCenter: Convert(pl, CenterBlock); return;
case BlockMenuBack: Convert(pl, BackBlock); return;
}
_sender.handleBlockShapeCommand(_sender, _commandIndex, _context);
}
}
/// <summary>R с черепом-предметом в руке - меню «предмет / два декора».</summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_SkullDecor
{
public static void Postfix(PlayerMoveController __instance)
{
EntityPlayerLocal pl = __instance.entityPlayerLocal;
if (pl == null || pl.inventory == null || !SkullDecorPlace.IsSkull(pl.inventory.holdingItem) || pl.IsDead())
{
return;
}
PlayerActionsLocal inp = __instance.playerInput;
if (inp == null || !(inp.Reload.WasPressed || inp.PermanentActions.Reload.WasPressed) || pl.inventory.IsHandSwitching())
{
return;
}
if (__instance.playerUI == null || __instance.playerUI.windowManager.IsModalWindowOpen())
{
return;
}
SkullDecorPlace.OpenItemMenu(__instance, pl);
}
}
/// <summary>Меню блока с черепом-декором в руке: к пунктам игры - «Просто череп» и второй вид.</summary>
[HarmonyPatch(typeof(XUiC_Radial), nameof(XUiC_Radial.SetupBlockShapeData))]
public static class Patch_XUiC_Radial_SetupBlockShapeData_SkullDecor
{
public static void Postfix(XUiC_Radial __instance)
{
EntityPlayerLocal pl = __instance.xui?.playerUI?.entityPlayer;
ItemClass held = pl?.inventory?.holdingItem;
if (!SkullDecorPlace.IsDecor(held))
{
return;
}
__instance.CreateRadialEntry(SkullDecorPlace.BlockMenuItem, "necroSkull", "ItemIconAtlas", "", Localization.Get("necroSkullModeOff"));
if (held.Name == SkullDecorPlace.CenterBlock)
__instance.CreateRadialEntry(SkullDecorPlace.BlockMenuBack, "ui_game_symbol_skull", "UIAtlas", "", Localization.Get("necroSkullDecorBack"));
else
__instance.CreateRadialEntry(SkullDecorPlace.BlockMenuCenter, "ui_game_symbol_skull", "UIAtlas", "", Localization.Get("necroSkullDecorCenter"));
__instance.SetCommonData(UIUtils.GetButtonIconForAction(__instance.xui.playerUI.playerInput.Activate), SkullDecorPlace.OnBlockMenu,
new XUiC_Radial.RadialContextHoldingSlotIndex(pl.inventory.holdingItemIdx), -1, true, XUiC_Radial.RadialValidSameHoldingSlotIndex);
}
}
}
+182
View File
@@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Подбор посадки черепа-декорации прямо в игре (15.10.2026, по образцу necroheld): «чтобы если нужно будет
/// спозиционировать, не перезапускать игру». Правка ставится узлу skull во всех экземплярах вида (поставленные
/// блоки) и в загруженном префабе этого вида - новые блоки ставятся уже с ней. Коллайдер корня (хитбокс)
/// пересчитывается по черепу. Итог - строкой в лог, затем переносится в NecroSkullDecorSetup (Unity) и бандл
/// necroskull пересобирается при закрытой игре.
/// </summary>
public static class SkullDecorTweak
{
public const string CenterPrefab = "necroSkullDecorCenterPrefab";
public const string BackPrefab = "necroSkullDecorBackPrefab";
public class State
{
public Vector3 Pos; // метры, добавка к исходному положению, в осях блока
public Vector3 Rot; // градусы, добавка к исходному повороту
public float Scale = 1f;
}
public static readonly Dictionary<string, State> States = new Dictionary<string, State>
{
{ CenterPrefab, new State() }, { BackPrefab, new State() }
};
/// <summary>Исходные значения узла skull (по экземпляру узла).</summary>
static readonly Dictionary<int, (Vector3 p, Quaternion r, Vector3 s)> _base = new Dictionary<int, (Vector3, Quaternion, Vector3)>();
static string KindOf(Transform _root)
{
string n = _root.name;
if (n.StartsWith(CenterPrefab)) return CenterPrefab;
if (n.StartsWith(BackPrefab)) return BackPrefab;
return null;
}
/// <summary>Применяет правку вида _kind ко всем найденным черепам; возвращает их число.</summary>
public static int Apply(string _kind)
{
State st = States[_kind];
int n = 0;
foreach (Transform t in Resources.FindObjectsOfTypeAll<Transform>())
{
if (t == null || t.name != "skull" || t.parent == null || KindOf(t.parent) != _kind)
{
continue;
}
int id = t.GetInstanceID();
if (!_base.TryGetValue(id, out var b))
{
b = (t.localPosition, t.localRotation, t.localScale);
_base[id] = b;
}
t.localPosition = b.p + st.Pos;
t.localRotation = Quaternion.Euler(st.Rot) * b.r;
t.localScale = b.s * st.Scale;
FitCollider(t);
n++;
}
return n;
}
/// <summary>Хитбокс корня - по габариту черепа в осях корня.</summary>
static void FitCollider(Transform _skull)
{
BoxCollider bc = _skull.parent.GetComponent<BoxCollider>();
MeshFilter mf = _skull.GetComponent<MeshFilter>();
if (bc == null || mf == null || mf.sharedMesh == null)
{
return;
}
Bounds mb = mf.sharedMesh.bounds;
Matrix4x4 m = Matrix4x4.TRS(_skull.localPosition, _skull.localRotation, _skull.localScale);
Bounds r = new Bounds(m.MultiplyPoint3x4(mb.center), Vector3.zero);
for (int sx = -1; sx <= 1; sx += 2) for (int sy = -1; sy <= 1; sy += 2) for (int sz = -1; sz <= 1; sz += 2)
r.Encapsulate(m.MultiplyPoint3x4(mb.center + Vector3.Scale(mb.extents, new Vector3(sx, sy, sz))));
bc.center = r.center;
bc.size = r.size;
}
/// <summary>Габарит черепа вида в осях блока (по первому найденному экземпляру): «влезает ли в клетку».</summary>
public static string Box(string _kind)
{
foreach (Transform t in Resources.FindObjectsOfTypeAll<Transform>())
{
if (t != null && t.name == "skull" && t.parent != null && KindOf(t.parent) == _kind)
{
BoxCollider bc = t.parent.GetComponent<BoxCollider>();
if (bc != null)
{
Vector3 a = bc.center - bc.size / 2f, z = bc.center + bc.size / 2f;
bool inside = a.x >= -0.5001f && z.x <= 0.5001f && a.y >= -0.0001f && z.y <= 1.0001f && a.z >= -0.5001f && z.z <= 0.5001f;
return "габарит (м) от " + F(a) + " до " + F(z) + (inside ? " - в клетке" : " - ВЫЛЕЗАЕТ ИЗ КЛЕТКИ");
}
}
}
return "черепов этого вида не найдено";
}
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>
/// necroskulldecor center|back - посадка черепа-декорации:
/// pos x y z (см) | add x y z (см) | rot x y z (градусы) | scale s | off | show
/// </summary>
[Preserve]
public class ConsoleCmdNecroSkullDecor : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroskulldecor" };
public override string getDescription() =>
"NecromancerTome: посадка черепа-декорации - center|back, затем 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, 2, out float x) & Num(_p, 3, out float y) & Num(_p, 4, out float z);
_v = new Vector3(x, y, z);
return ok;
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string which = _params.Count > 0 ? _params[0] : "";
string kind = which == "center" ? SkullDecorTweak.CenterPrefab : which == "back" ? SkullDecorTweak.BackPrefab : null;
if (kind == null)
{
Out(getDescription());
return;
}
SkullDecorTweak.State st = SkullDecorTweak.States[kind];
string cmd = _params.Count > 1 ? _params[1] : "show";
Vector3 v;
switch (cmd)
{
case "pos" when Vec(_params, out v):
st.Pos = v / 100f;
break;
case "add" when Vec(_params, out v):
st.Pos += v / 100f;
break;
case "rot" when Vec(_params, out v):
st.Rot = v;
break;
case "scale" when Num(_params, 2, out float s) && s > 0f:
st.Scale = s;
break;
case "off":
st.Pos = Vector3.zero;
st.Rot = Vector3.zero;
st.Scale = 1f;
break;
case "show":
break;
default:
Out(getDescription());
return;
}
int n = SkullDecorTweak.Apply(kind);
Out(which + ": черепов " + n + ", сдвиг (см) " + SkullDecorTweak.F(st.Pos * 100f) + ", поворот " + SkullDecorTweak.F(st.Rot) +
", масштаб " + SkullDecorTweak.F(st.Scale) + "; " + SkullDecorTweak.Box(kind));
}
static void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necroskulldecor: " + _s);
}
}
}
+38 -1
View File
@@ -75,8 +75,15 @@ namespace NecromancerTome
/// Время - ванильное GameUtils.IsBloodMoonTime (от заката дня луны до рассвета), оно считается по часам
/// мира и одинаково у хоста и клиента; AIDirector с его BloodMoonActive есть только у сервера.
/// </summary>
/// <summary>Прилив для проверок (necrotide on|off|auto): null - по часам мира.</summary>
public static bool? ForceTide;
public static bool IsBloodTide()
{
if (ForceTide.HasValue)
{
return ForceTide.Value;
}
World world = GameManager.Instance?.World;
return world != null && GameUtils.IsBloodMoonTime(world.worldTime, (world.DuskHour, world.DawnHour),
GameStats.GetInt(EnumGameStats.BloodMoonDay));
@@ -356,8 +363,22 @@ namespace NecromancerTome
}
}
public const int GripThreadIdx = 3;
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
EntityPlayerLocal pl = _sender.xui.playerUI.entityPlayer;
// Хват ножа (16.10, нити цинерария): 3 - хват для нитей; 0 у ножа-для-нитей - обычный хват.
if (_commandIndex == GripThreadIdx)
{
CinerariumThreads.Convert(pl, CinerariumThreads.ThreadKnifeName);
return;
}
if (_commandIndex == 0 && CinerariumThreads.IsThreadKnife(pl?.inventory?.holdingItem))
{
CinerariumThreads.Convert(pl, CinerariumThreads.KnifeName);
return;
}
if (_commandIndex == 1)
{
Enter(_sender.xui.playerUI.entityPlayer);
@@ -374,7 +395,8 @@ namespace NecromancerTome
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
if (!__result && !SnakeMode.Active && !MindPalace.Active && SnakeMode.IsKnife(__instance.item))
if (!__result && !SnakeMode.Active && !MindPalace.Active &&
(SnakeMode.IsKnife(__instance.item) || CinerariumThreads.IsThreadKnife(__instance.item)))
{
__result = true;
}
@@ -389,6 +411,18 @@ namespace NecromancerTome
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (CinerariumThreads.IsThreadKnife(__instance.item))
{
// Нож хватом для нитей: вернуть обычный хват (0) или остаться (3).
_xuiRadialWindow.ResetRadialEntries();
_xuiRadialWindow.CreateRadialEntry(0, "NecromancersKnife", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripNormal"), _highlighted: false);
_xuiRadialWindow.CreateRadialEntry(SnakeMode.GripThreadIdx, "resourceSewingKit", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripThread"), _highlighted: true);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, SnakeMode.GripThreadIdx);
return false;
}
if (!SnakeMode.IsKnife(__instance.item))
{
return true;
@@ -417,6 +451,9 @@ namespace NecromancerTome
_xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false);
}
// Хват для нитей (16.10: «Есть в игре пиктограмма ниток, катушки?» - набор для шитья) - в любое время суток.
_xuiRadialWindow.CreateRadialEntry(SnakeMode.GripThreadIdx, "resourceSewingKit", "ItemIconAtlas", "",
Localization.Get("necroKnifeGripThread"), _highlighted: false);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0);
return false;
+1 -1
View File
@@ -562,7 +562,7 @@ namespace NecromancerTome
{
GiveBackSummonItem(owner, summonItemName);
}
if (world != null)
if (world != null && recalledTooltipKey != null) // null - без подсказки (перенос духа крысой, 16.10)
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), recalledTooltipKey);
}
+138
View File
@@ -0,0 +1,138 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Тело (вид от третьего лица и для других игроков) держит нож-для-нитей обычным хватом ножа, а не луком (16.10, скрин
/// от третьего лица: «у персонажа запястье вывихнуто» - анимация деревянного лука тянет правую кисть с ножом). Хват
/// предмета один на все модели (HoldType 68 нужен рукам первого лица - основа позы HandPose.PresetThread), поэтому
/// подменяется только параметр аниматора WeaponHoldType/WeaponCarry у тел, кроме рук первого лица, и место ножа в
/// руке тела. Подбор на лету: necroheld tphold N (0 - выключить подмену).
/// </summary>
public static class ThreadKnifeBody
{
/// <summary>0 - тело держит хватом предмета (68, лук). 16.10 в игре (necrobody) пользователь выбрал именно его: нож в левой
/// руке тела, «Нож был нормальным», нужна только поправка кисти и места ножа (ниже). Подмена на 36 - на пробу командой.</summary>
public static int TpHold = 0;
/// <summary>Поправка тела, подобранная 16.10 командами necrobody: левая кисть к ладони на 20° («лучезапястный сустав
/// выгнуло... словно перелом»: LeftHand Z 302 -> 322), нож в кулаке - item pos 3 -4 -2 см («Вот так идеально»).</summary>
public static float WristBend = 20f;
public static Vector3 ItemPos = new Vector3(0.03f, -0.04f, -0.02f);
static readonly Dictionary<Animator, Transform> _leftHand = new Dictionary<Animator, Transform>();
/// <summary>Из HandPoseDriver.LateUpdate: у всех игроков в мире с ножом-для-нитей, кроме своего вида от первого лица.</summary>
public static void LateUpdate()
{
World w = GameManager.Instance?.World;
if (w?.Players?.list == null || TpHold > 0)
{
return;
}
foreach (EntityPlayer p in w.Players.list)
{
if (p == null || p.inventory?.holdingItem?.Name != CinerariumThreads.ThreadKnifeName || p.emodel == null ||
p.emodel.IsFPV && !p.isEntityRemote)
{
continue;
}
AvatarCharacterController ac = p.emodel.avatarController as AvatarCharacterController;
Animator a = ac?.CharacterBody?.Animator;
if (a == null)
{
continue;
}
if (!_leftHand.TryGetValue(a, out Transform lh) || lh == null)
{
lh = null;
foreach (Transform t in a.GetComponentsInChildren<Transform>(true))
{
if (t.name == "LeftHand")
{
lh = t;
break;
}
}
if (_leftHand.Count > 16)
{
_leftHand.Clear();
}
_leftHand[a] = lh;
}
if (lh != null)
{
lh.localRotation *= Quaternion.Euler(0f, 0f, WristBend);
}
if (ac.heldItemTransform != null && !NecroBody.ItemPos.HasValue)
{
ac.heldItemTransform.localPosition = ItemPos;
}
}
}
public static bool Applies(EntityAlive _e)
{
return TpHold > 0 && _e != null && _e.inventory?.holdingItem?.Name == CinerariumThreads.ThreadKnifeName;
}
}
[HarmonyPatch(typeof(AvatarMultiBodyController), nameof(AvatarMultiBodyController._setInt), new[] { typeof(int), typeof(int), typeof(bool) })]
public static class Patch_AvatarMultiBodyController_setInt_ThreadKnife
{
public static bool Prefix(AvatarMultiBodyController __instance, int _propertyHash, int _value)
{
bool hold = _propertyHash == AvatarController.weaponHoldTypeHash;
if (!hold && _propertyHash != AvatarController.weaponCarryHash || !ThreadKnifeBody.Applies(__instance.Entity))
{
return true;
}
BodyAnimator fp = (__instance as AvatarLocalPlayerController)?.FPSArms;
int tp = hold ? ThreadKnifeBody.TpHold : AnimationDelayData.AnimationDelay[ThreadKnifeBody.TpHold].Carry;
foreach (BodyAnimator b in __instance.BodyAnimators)
{
Animator a = b.Animator;
int v = b == fp ? _value : tp;
if (a != null && a.GetInteger(_propertyHash) != v)
{
a.SetInteger(_propertyHash, v);
}
}
return false; // без рассылки по сети: у других игроков мод ставит то же сам
}
}
/// <summary>Место ножа в руке тела - по хвату ножа (игра берёт смещение по HoldType предмета).</summary>
[HarmonyPatch(typeof(AvatarMultiBodyController), nameof(AvatarMultiBodyController.SetInRightHand))]
public static class Patch_AvatarMultiBodyController_SetInRightHand_ThreadKnife
{
public static void Postfix(AvatarMultiBodyController __instance, Transform _transform)
{
EntityAlive e = __instance.Entity;
if (_transform == null || !ThreadKnifeBody.Applies(e) || e.emodel.IsFPV && !e.isEntityRemote)
{
return;
}
AnimationGunjointOffsetData.AnimationGunjointOffsets o = AnimationGunjointOffsetData.AnimationGunjointOffset[ThreadKnifeBody.TpHold];
_transform.localPosition = o.position;
_transform.localRotation = Quaternion.Euler(o.rotation);
}
}
[HarmonyPatch(typeof(AvatarMultiBodyController), nameof(AvatarMultiBodyController.SwitchModelAndView))]
public static class Patch_AvatarMultiBodyController_SwitchModelAndView_ThreadKnife
{
public static void Postfix(AvatarMultiBodyController __instance, bool _bFPV)
{
Transform t = __instance.heldItemTransform;
if (t == null || _bFPV || !ThreadKnifeBody.Applies(__instance.Entity))
{
return;
}
AnimationGunjointOffsetData.AnimationGunjointOffsets o = AnimationGunjointOffsetData.AnimationGunjointOffset[ThreadKnifeBody.TpHold];
t.localPosition = o.position;
t.localEulerAngles = o.rotation;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>Ноша одержимого стервятника (курица, кролик): высота под лапами и запрет разгона с грузом (22.10.2026).</summary>
public class ConsoleCmdNecroCarry : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrocarry" };
public override string getDescription() => "NecromancerTome: ноша стервятника - gap <м, минус - выше>|run on|off|show";
void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necrocarry: " + _s);
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "show";
switch (cmd)
{
case "gap":
if (_params.Count > 1 && float.TryParse(_params[1].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out float g))
{
Possession.CarryGap = Mathf.Clamp(g, -0.6f, 0.3f);
}
break;
case "run":
if (_params.Count > 1 && (_params[1] == "on" || _params[1] == "off"))
{
Possession.CarryNoRun = _params[1] == "on";
}
break;
}
Out("gap " + Possession.CarryGap.ToString("0.###", CultureInfo.InvariantCulture) + ", без разгона с ношей - " +
(Possession.CarryNoRun ? "да" : "нет"));
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
<xml>
<Name value="NecromancerTome" />
<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." />
<Description value="A dark necromancy progression for 7 Days to Die 3.3: a skill that grows from the dead you put to rest, a bone knife, undead servants, possession of zombies and beasts, the Snake Spirit, the Mind Palace, the Blood Tide and a story-ending Black Portal. Fully localized into 13 languages. Single-player; requires EAC off." />
<Author value="Alex Cube" />
<Version value="1.6.0" />
<Version value="1.7.0" />
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
</xml>
+14
View File
@@ -0,0 +1,14 @@
NEW: the Snake Spirit. At night the Necromancer's Knife lets you leave your body and hunt the undead as a snake - through buildings and through the air, while your body lies defenceless.
NEW: possession. With an empty hand you can slip into a zombie and fight, jump, open doors and use its own tricks - the screamer calls the dead, the cop detonates, and others have theirs. First-person view with the zombie's own arms.
NEW: possess your own servants. The Grave Vulture flies and scouts, the bear tanks and roars, the wolf hunts and carries a small bag, the dog smells everything around, the rat slips through buildings. All of them can feed on carrion.
NEW: the Mind Palace - a place inside your own head, entered from the knife's menu at night.
NEW: the Necromancer's Tome - a book with chapters that open as you play: the archmage's letter, the knife, every servant, possession and the Blood Tide.
NEW: the Blood Tide. On blood moon night the spirit cannot leave the body, but the knife becomes a sword.
NEW: the Grave Rat with its own model, an early servant. Servants now guard their master, and summoning a new one recalls the old - one at a time.
NEW: the Bone Steed - a motorcycle of bones that runs on blood. The Geo-Ossuary - a block that mines ore from blood.
NEW: the blood rose. Plant blood seeds, grow black roses and press them in the mortar into juice that replaces Necromancer's Blood in many recipes.
NEW: red Victim corpses decay into remains that keep their loot; rarely a red corpse yields a skull, which can also be placed as a decoration.
NEW: a chalkboard to write on, nine new Necromancy challenges, the mod's own models for the knife, cans, blood, the pyramid and the grimoires.
CHANGE: the Knife's power attack now costs health, more with Dead Storm. The Pyramid of Heresy is now called the Pyramid of Negation.
FIX: many smaller fixes found on stream tests, among them the mortar floating above its block, the character's toolbelt showing during third-person possession, and possessed zombies skipping punches when an enemy was close.
Drop-in over 1.5.x, saves carry over. For game 3.3; game 3.2 stays on 1.4.3. Single-player. The mod ships Harmony libraries, so EasyAntiCheat must be off.
+2
View File
@@ -0,0 +1,2 @@
FIX: blood rose buds and blooming roses floated half a block above the planter. The plant model was lifted by the game's default model offset; now it stands on the soil.
Nothing else changed. Drop-in over 1.6.0 or 1.5.x: saves carry over, roses already planted fix themselves.
+3
View File
@@ -0,0 +1,3 @@
COMPATIBILITY: the ghost look (grey and translucent) is now given only to the game's own traders. Before, it covered everything built on the game's trader type, so NPCs from other mods such as NPCCore turned nearly invisible. Thanks to Crozekanov for the report.
FIX (from 1.6.1): blood rose buds and blooming roses no longer float half a block above the planter.
Nothing else changed. Drop-in over 1.6.0, 1.6.1 or 1.5.x: saves carry over.
+31 -36
View File
@@ -1,8 +1,32 @@
[size=5][b]Description[/b][/size]
Every other survivor in this world ends the same way: one bad night, one horde, and you are part of it. [b]Necromancer's Tome[/b] gives you the other road. Necromancy here is an answer to a curse, not a side branch of crafting - you learn to command the dead instead of joining them. Infect zombies with madness so they tear into each other, cut with a blade that heals you as it kills, raise your own undead to walk beside you, and plant a ward that turns your attackers into your defenders.
You were left in this world to die, and your memory was wiped. Every survivor here ends the same way: one bad night, one horde, and you are part of it. [b]Necromancer's Tome[/b] gives you the other road. Necromancy is how you take back power over death - and find out who did this to you.
It starts with a note. Reading it, your character sees a blurred flashback - a fragment of whatever happened to them before they woke up in this world. It ends, five thousand kills later, with the Black Portal: a full-screen finale that finishes the story the note began, and closes on a choice that is yours to make.
[b]A skill that grows from the dead you put to rest, not from perk points.[/b] Every zombie you put down brings the next rank closer - from Adept to Master - and each rank opens another part of the arsenal. Its own challenges point the way.
[b]The Necromancer's Tome.[/b] Its chapters open as you play: recipes, the habits of your servants and pieces of someone else's story. It all starts with a note and a blurred flashback.
[b]A knife carved from a thighbone.[/b] It heals its owner, grows with the skill and turns the slain into Victims, whose red corpses are the necromancer's pantry. Now and then they hold something worth putting on a shelf.
[b]The Snake Spirit.[/b] At night you can leave your body lying defenceless and go hunting as a snake - through walls, through the air, for the undead only.
[b]Possession.[/b] Slip into a stranger's corpse and walk in its skin: the screamer, the cop and many others each have their own tricks. Or step into your own beast and see the world through its eyes.
[b]Servants.[/b] Grimoires raise a Grave Rat, a Zombie Dog, a Grave Vulture, a Zombie Wolf and a Zombie Bear - they follow you and defend their master. Scrolls turn whole crowds.
[b]The Mind Palace.[/b] A place inside your own head where, behind locked doors, lies what was taken from you.
[b]An economy of blood.[/b] Your blood is currency. It feeds the Bone Steed, the Geo-Ossuary that mines ore on its own, and the bracelet's pocket dimension. And a black rose can give you the same without opening your veins.
[b]The Blood Tide.[/b] On the night of the blood moon the spirit is chained to the body. The knife becomes a sword instead.
[b]Defence that converts.[/b] The Pyramid of Negation turns attackers against each other.
[b]A real ending.[/b] The Black Portal finishes the story the note began and leaves the choice to you.
[b]The traders are dead.[/b] Black and white, half-transparent: nobody alive is still trading out here.
[b]Fully localized into 13 languages[/b]: English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese.
[size=5][b]Installation instructions[/b][/size]
@@ -10,53 +34,24 @@ It starts with a note. Reading it, your character sees a blurred flashback - a f
[*]Turn [b]EasyAntiCheat off[/b]. The mod ships Harmony DLLs and will not load with EAC enabled.
[*]Download the archive and unpack it.
[*]Drop the [b]NecromancerTome[/b] folder into your [b]Mods[/b] folder, so that you end up with [i]Mods/NecromancerTome/ModInfo.xml[/i]. The Mods folder sits either next to the game executable or in [i]%APPDATA%/7DaysToDie/[/i] - either location works, create it if it is not there.
[*]Start the game. A new [b]Necromancy[/b] skill appears in the crafting skills panel right away, at level 0, and rises by one for every 20 zombies you put to rest.
[*]Start the game. The [b]Necromancy[/b] skill appears in the crafting skills panel right away.
[/list]
To uninstall, delete the folder. The mod adds items and one block, so a save that used them will lose them - start a fresh world if you want a clean state.
[size=5][b]Main features[/b][/size]
[b]A skill that levels from kills, not perk points.[/b] Necromancy counts every zombie you put down: twenty of them make one level, up to level 250 (5000 zombies). The skull in the status bar shows the level, the purple bar beside the experience bar shows the progress inside it. Five tiers, each threshold opening part of the arsenal (zombie counts, level in brackets):
[list]
[*][b]Adept[/b] (from the start, level 0) - Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits
[*][b]Adept[/b] (20 / 60 / 100 / 300, levels 1 / 3 / 5 / 15) - Spatial Bracelet and Blood Sphere, then four knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense
[*][b]Journeyman[/b] (500 / 1400 / 1700) - Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm
[*][b]Apprentice[/b] (2000) - Summon Zombie Dog, Pharaoh’s Swarm, Summon Grave Vulture, Blood Stone
[*][b]Necromancer[/b] (3000) - Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll
[*][b]Master[/b] (5000) - Black Portal Stone
[/list]
[b]Turn the horde on itself.[/b] The [b]Spirit Stone[/b] is thrown; the zombie it touches switches sides and starts tearing into the others instead of you. The [b]Scroll of Deviation[/b] does the same to everything in the blast radius at once.
[b]A blade that grows with you.[/b] The [b]Necromancer's Knife[/b] is carved from a thighbone - a straight amputation-knife edge, a bone guard, a rope-wrapped grip and runes that glow. It scales its damage with your Necromancy level - nearly useless in unskilled hands, lethal once you are levelled. It heals you for half the damage dealt and marks the wounded zombie as a Victim, even with a single killing blow: its corpse turns red and opens like a container full of necromantic ingredients. An untouched corpse stays for five minutes, an opened one for an hour, an emptied one vanishes at once. [b]Six mods fit this knife and nothing else[/b]: water and food drawn straight out of corpses, protection from heat and cold, slowed victims, an area power-attack storm bought with your own health, and every nearby zombie marked on your compass and map.
[b]Undead that fight for you.[/b] Summoning books raise a [b]zombie dog, bear, wolf or griffin[/b] - permanent companions, one of each kind at a time, recalled into the book with a power attack. [b]Pharaoh’s Swarm[/b] release a swarm that scatters wide and stings zombies onto your side. The [b]Banshee's Scroll[/b] screams up a small horde that is [i]not[/i] on your side at all - use it on purpose.
[b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius.
[b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level - and holding its regular attack on a block pulls that block straight into the rift, ten seconds up close and one more per block of distance, while the world drains to black and white around you. That pull runs on a charge in the bracelet's mod slot: the [b]Blood Sphere[/b] is spent a point per second and crumbles when empty, while the [b]Blood Stone[/b] from the third grade is never spent at all. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can.
[b]The dead keep shop.[/b] Every trader in the world is rendered in black and white, half-transparent and matte. Nothing about the trade changes - but the necromancer deals with the dead, and the only people still doing business out here are not quite alive.
[b]A real ending.[/b] The [b]Black Portal Stone[/b] is the last thing the skill tree gives you, and it is the end of the mod's story - a full-screen finale that stops the game and closes on a choice of two. One of them ends the run and returns you to the main menu; the other lets you come back and keep playing. What waits on the far side is better seen than described.
[b]Fully localized into 13 languages[/b]: English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. Every string, not just the item names.
Updating from 1.5.x: replace the folder, saves carry over. To uninstall, delete the folder; the mod's items and blocks will be gone from the save.
[size=5][b]Requirements[/b][/size]
[list]
[*][b]7 Days to Die 3.3 (experimental): version 1.5.0. 7 Days to Die 3.2: version 1.4.2.[/b] Both are on the Files tab. Each build tells you in the main menu if it is started on the wrong game version.
[*][b]7 Days to Die 3.3: version 1.6.2. 7 Days to Die 3.2: version 1.4.3.[/b] Both are on the Files tab. Each build tells you in the main menu if it is started on the wrong game version.
[*][b]EasyAntiCheat must be off[/b] - the mod uses Harmony patches.
[*][b]No other mods required.[/b] Harmony itself ships with the game.
[*][b]Single-player.[/b] The mod was made for single-player from the start. It reworks a number of the game's own mechanics - corpses, loot, zombie behaviour - and a server isn't necessarily built for that, so in multiplayer some things may not work (the Victim corpses, for one). The story scenes also play there without stopping time, since vanilla pauses only in single-player.
[*][b]Single-player.[/b] The mod was made for single-player from the start. It reworks a number of the game's own mechanics - corpses, loot, zombie behaviour - and a server isn't necessarily built for that, so in multiplayer some things may not work. The story scenes also play there without stopping time, since vanilla pauses only in single-player.
[*]Client-side install: every player who wants it installs it themselves.
[/list]
[size=5][b]Shout outs[/b][/size]
Thanks to [b]The Fun Pimps[/b] for a game that survives this much rewriting, and to [b]Andreas Pardeike[/b] for Harmony, without which none of the runtime side of this mod would exist. Thanks to the 7 Days to Die modding community, whose forum posts and open-source mods answered questions the XML documentation does not.
Thanks to [b]The Fun Pimps[/b] for a game that survives this much rewriting, and to [b]Andreas Pardeike[/b] for Harmony, without which none of the runtime side of this mod would exist. Thanks to the 7 Days to Die modding community, whose forum posts and open-source mods answered questions the XML documentation does not, and to everyone who reported bugs here in Posts.
[b]AI disclosure:[/b] the item icons and the finale artwork in this mod are AI-generated. Everything else - the code, the design, the mechanics and the writing - is my own.
+1
View File
@@ -0,0 +1 @@
The biggest update yet: Snake Spirit, possession of zombies and your own servants, the Mind Palace, the Necromancer's Tome with chapters, Blood Tide, Bone Steed, Geo-Ossuary, blood rose. For game 3.3, drop-in over 1.5.x. Single-player, EAC off.
+1
View File
@@ -0,0 +1 @@
Fix for 1.6.0: blood rose buds and flowers no longer float half a block above the planter. Nothing else changed - drop-in over 1.6.0 or 1.5.x, saves carry over. For game 3.3. Single-player, EAC off.
+1
View File
@@ -0,0 +1 @@
For 1.6.0: compatibility - only the game's own traders get the ghost look now, so NPCs from other mods (e.g. NPCCore) look normal; blood rose buds and flowers no longer float. Drop-in over 1.6.x or 1.5.x. Game 3.3, single-player, EAC off.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+129 -446
View File
@@ -2,514 +2,197 @@
*English version below — scroll past the Russian half.*
**Версия 1.5.0** — для 7 Days to Die 3.3 (experimental). Для игры 3.2 — версия 1.4.2. Каждая сборка на чужой версии игры сама скажет, что несовместима. Автор: Alex Cube.
**Версия 1.6.2** — для 7 Days to Die 3.3. Для игры 3.2 — версия 1.4.3. Каждая сборка на чужой версии игры сама скажет, что несовместима. Автор: Alex Cube.
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
- Репозиторий: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
- YouTube-канал автора: https://www.youtube.com/@alex_cube
- Новости в Telegram: https://t.me/necromancerstome
Мод для 7 Days to Die о пути от обычного выжившего до некроманта — с собственной веткой
прогрессии, тёмным оружием, призываемыми существами и сюжетной концовкой.
Вас бросили в этот мир умирать и стёрли вам память. Каждый выживший здесь кончает одинаково: одна
плохая ночь, одна орда — и ты уже часть её. Этот мод даёт другую дорогу. Некромантия — способ
вернуть себе власть над смертью и узнать, кто сделал это с вами.
## Завязка
## Что вас ждёт
Всё начинается с записки, читая которую персонаж видит неясный флэшбек.
**Навык, который растёт от упокоенных мёртвых, а не от очков.** Каждый добитый зомби приближает
следующий ранг — от Адепта до Мастера, и каждый ранг открывает новую часть арсенала. Свои
испытания подскажут, куда двигаться дальше.
Некромантия в этом моде — ответ на проклятие, а не побочная ветка крафта. Вместо того чтобы
однажды пасть перед ордой и присоединиться к ней, игрок учится подчинять мёртвых себе: заражать
зомби безумием, стравливать их друг с другом, поднимать против них собственных тварей. Не
выживание вопреки смерти, а власть.
**Книга некроманта.** Главы открываются по ходу игры: в них рецепты, повадки слуг и обрывки
чужой истории. Начинается всё с записки и неясного флэшбека.
## Прогрессия
**Нож из бедренной кости.** Лечит хозяина, растёт вместе с навыком и превращает убитых в
Жертв: их красные трупы — кладовая некроманта. Иногда в них находится кое-что, что стоит
поставить на полку.
Отдельный скилл **"Некромантия"** растёт не от опыта, а от счётчика упокоенных зомби — свой
счётчик, своя механика. Засчитывается любое убийство, за которое игра начисляет вам опыт: добитые
ловушкой, сгоревшие, умершие от наложенного вами кровотечения, и зомби-звери наравне с
человекоподобными.
**Дух змеи.** Ночью можно оставить тело лежать беззащитным и выйти на охоту змеёй — сквозь стены,
по воздуху, только по нежити.
**Двадцать упокоенных зомби — один уровень Некромантии**, максимум 250-й уровень (5000 зомби). В
HUD за этим следят два индикатора: череп в статус-баре показывает текущий уровень, а фиолетовая
шкала рядом с полосой опыта — сколько зомби набрано внутри уровня; заполнилась — уровень вырос, и
шкала обнулилась.
**Одержимость.** Вселитесь в чужого мертвеца и походите в его шкуре: у кричащей, у полицейского
и у многих других свои умения. Или войдите в своего зверя и посмотрите на мир его глазами.
Пять тиров, каждый открывает часть арсенала (в таблице и порог в зомби, и уровень скилла):
**Слуги.** Гримуары поднимают могильную крысу, зомбособаку, могильного стервятника, зомбоволка и
зомбомедведя — они идут за вами и защищают хозяина. Свитки обращают целые толпы.
| Тир | Порог | Уровень | Что открывается |
|---|---|---|---|
| Адепт | сразу | 0 | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов |
| Адепт (доп.) | 20 зомби | 1 | Пространственный браслет, Кровавая сфера, мод ножа «Слёзы мертвеца» |
| Адепт (доп.) | 60 / 100 / 300 зомби | 3 / 5 / 15 | Свиток духа крысы; моды ножа: Пир падальщика, Могильный покой, Тёмное чутьё |
| Подмастерье | 500 зомби | 25 | Свиток девиации, Призыв могильного стервятника |
| Подмастерье (доп.) | 1300 зомби | 65 | Призыв зомбособаки |
| Подмастерье (доп.) | 1400 / 1700 зомби | 70 / 85 | Моды ножа: Хватка мертвеца, Мёртвая буря |
| Ученик | 2000 зомби | 100 | Призыв зомбомедведя, Рой фараона, Кровавый камень |
| Некромант | 3000 зомби | 150 | Свиток банши |
| Некромант (доп.) | 4000 зомби | 200 | Призыв зомбоволка |
| Мастер | 5000 зомби | 250 | Чёрный портальный камень |
**Чертоги разума.** Место внутри собственной головы, где за запертыми дверями лежит то, что у вас
отняли.
## Арсенал
**Хозяйство на крови.** Ваша кровь — валюта. Ею питаются костяной скакун, гео-оссуарий, который
сам добывает руду, и карманное измерение браслета. А из чёрной розы можно получить то же самое,
не вскрывая себе вены.
- **Камень духов** — брошенный камень, светящийся некромантской энергией. Задевает одного зомби:
тот переходит на вашу сторону и начинает атаковать других зомби вместо вас.
- **Свиток девиации** — тот же эффект, но мощнее: при броске переманивает на вашу сторону сразу
всех зомби в области взрыва, а не одного. Расходуется при использовании.
- **Нож некроманта** — клинок, выточенный из бедренной кости: прямое лезвие, как у ампутационного
ножа, костяная гарда, рукоять в верёвочной обмотке и светящиеся руны. Урон растёт вместе с
навыком Некромантии: почти бесполезен в неопытных руках, смертоносен у прокачанного игрока.
Лечит владельца на половину нанесённого урона и метит раненого зомби как Жертву (даже убитого
с одного удара) — труп такого
зомби краснеет, и его можно обыскать, как ящик: внутри ингредиенты Жертвы. Нетронутый труп
лежит 5 минут, открытый, но не опустошённый, — час, опустошённый исчезает сразу.
- **Шесть модов только для Ножа некроманта** — обычные моды для ножей в это оружие не встают, а
эти не встают ни во что другое: **Слёзы мертвеца** (2 воды с каждого убитого клинком зомби),
**Пир падальщика** (2 еды за труп), **Могильный покой** (защита от перегрева и переохлаждения,
пока нож в руках), **Хватка мертвеца** (раненый клинком зомби замедляется), **Мёртвая буря**
(силовая атака бьёт по площади с кровотечением, за 10 здоровья вместо 5 и двойную выносливость),
**Тёмное чутьё** (все зомби поблизости отмечены на компасе и карте, пока нож в руках).
- **Кровь некроманта** — ритуальный ресурс: чтобы получить банку, нужны пустая банка, любой нож
в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов — Чёрного
портала и самого Ножа.
- **Кожа жертвы** и **Прах зомби** — остаются от зомби, помеченного Ножом некроманта как Жертва.
Ингредиенты для книг призыва и большинства некромантских рецептов соответственно.
- **Синий портальный камень** — держите кнопку использования 10 секунд, чтобы телепортироваться к
своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не
расходуется.
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер
растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок
прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, с тем же
круглым индикатором, что и у разбора верстака. Мир на это время обесцвечивается. Не поддаются
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира —
каждый отказ со своим сообщением. Силовая атака прерывает утаскивание и открывает хранилище.
Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака
отказывает.
- **Кровавая сфера** — заряд браслета. 500 прочности, каждая секунда утаскивания блока тратит
единицу; опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две
за раз, из крови некроманта и праха зомби.
- **Кровавый камень** — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на
2000 упокоенных (уровень 100) и варится на химической станции из праха зомби, костей, обычной крови и крови
некроманта.
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
кипячёная безопасна. Вмещают меньше воды, чем стеклянные банки.
**Кровавый прилив.** В ночь кровавой луны дух прикован к телу. Зато нож становится мечом.
## Питомцы
**Оборона, которая обращает.** Пирамида отрицания натравливает нападающих друг на друга.
Призывные книги поднимают союзных существ, которые дерутся с зомби, а не с игроком. Все призванные
существа выглядят выцветшими призраками — прозрачность настраивается консольной командой
`necroghost`, той же, что и у призрачных торговцев.
**Настоящий финал.** Чёрный портал завершает историю, начатую запиской, и оставляет выбор за вами.
- **Дух крысы** — первый доступный питомец (Некромантия 3). Он ведёт себя не так, как остальные:
сам не нападает никогда, держится справа-сзади от вас в двух блоках, а упёршись в препятствие,
проходит сквозь него — блоки он не грызёт вовсе. Пока крыса призвана, обычное применение свитка
становится приказом: она бросается на того, на кого наведён ваш прицел (кроме торговцев); если
цели под прицелом нет, появится надпись «Нет цели для атаки». Силовая атака отзывает её. Урон у
крысы почти никакой (5), но её укус делает три вещи: сильно замедляет, рвёт жилу (кровотечение,
которое не заживает, пока крыса грызёт) и оставляет **метку духа** — помеченный зомби полминуты
виден на вашем компасе и карте и получает на четверть больше урона от всего, чем бы вы его ни
били. Саму крысу видно на карте жёлтым. Обгрызая труп добитого зомби, она восстанавливает себе
здоровье. Если ударить саму крысу, она огрызнётся — но никогда на хозяина.
- **Зомбособака**, **Зомбомедведь**, **Зомбоволк**, **Могильный стервятник** — постоянные спутники, и ведут
себя они так же, как Дух крысы: идут за вами, сами ни на кого не нападают и ждут приказа. Разница
в силе: урон растёт от могильного стервятника к зомбоволку, и в отличие от крысы эти убивают быстро — пёс,
медведь и волк вдобавок могут отрывать зомби конечности. Их укус вызывает кровотечение, но метку
духа не ставит. Можно держать по одному экземпляру каждого вида
одновременно; отзываются обратно в книгу силовой атакой.
- **Рой фараона** — разовый свиток, выпускающий рой. Насекомые сами разлетаются по широкому
радиусу и жалят зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой нельзя
отозвать обратно, активен может быть только один. Расходуется при использовании.
- **Свиток банши** — одноразовый: при открытии вопит голосом банши и поднимает рядом с игроком
небольшую враждебную орду. Это не союзники — эти зомби так же опасны для вас, как любые другие.
**Торговцы — мёртвые.** Чёрно-белые и полупрозрачные: живых, кто ещё торгует, здесь не осталось.
## Оборона базы
- **Пирамида духов** — деплоябл-блок, не удержимый предмет. Пока вы стоите в её радиусе, она сама
заряжает девиацией любого незаряженного зомби поблизости и поджигает его холодным фиолетовым
пламенем, обращая против других зомби вместо вас или вашей базы. В меню блока можно
включать/выключать эффект и показывать границу радиуса действия.
## Чёрный портал — финал истории
**Чёрный портальный камень открывается на вершине прогрессии (5000 зомби) и является концовкой
мода.** Активация запускает диалог подтверждения, останавливает игру и разворачивает
полноэкранную финальную сцену — она досказывает историю, начатую запиской в первый день.
Заканчивается сцена выбором из двух вариантов: один закрывает историю и выходит в главное меню,
другой возвращает игрока в мир и позволяет играть дальше.
Сюжетные тексты — в `Config/Localization.csv`, ключи `necroFinal*`. Здесь они не пересказываются
намеренно: README читают до прохождения.
## По мелочи
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
- Некоторые блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием, как верстак.
Кровать, поставленная после этого своими руками, становится точкой возрождения, как спальник.
- Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с
мёртвыми, и торгуют с ним те, кто уже не совсем жив.
## Локализация
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
традиционный. Все 165 ключей `Config/Localization.csv` заполнены, пустых ячеек нет.
Полностью переведён на 13 языков: русский, английский, немецкий, испанский, французский,
итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский
упрощённый и традиционный.
## Установка
Распакуйте папку `NecromancerTome` в `<папка игры>/Mods/` (или в `%APPDATA%/7DaysToDie/Mods/`) и
запустите игру. Мод содержит Harmony-библиотеки, поэтому **EAC должен быть выключен**.
1. **Выключите EasyAntiCheat.** Мод содержит Harmony-библиотеки и с античитом не загрузится.
2. Распакуйте архив и положите папку `NecromancerTome` в `Mods` — чтобы получилось
`Mods/NecromancerTome/ModInfo.xml`. Папка `Mods` лежит рядом с игрой или в
`%APPDATA%/7DaysToDie/` (подходят обе, если её нет — создайте).
3. Запустите игру. Навык «Некромантия» появится на панели навыков крафта сразу.
Версия 1.5.0 — для игры 3.3; для игры 3.2 скачайте 1.4.2 (обе лежат на Nexus во вкладке Files).
Версия 1.6.2 — для игры 3.3, версия 1.4.3 — для игры 3.2 (обе на Nexus во вкладке Files).
Обновление поверх 1.5.x: сейвы переносятся.
Рассчитан на одиночную игру. Мод переделывает ряд собственных механик игры (трупы, добычу,
поведение зомби), а сервер не обязательно на такое рассчитан, поэтому в мультиплеере часть вещей
может не работать — например, трупы «Жертвы». Сюжетные сцены там проиграются без остановки
времени: ванильная пауза срабатывает только в сингле.
Рассчитан на одиночную игру. Мод переделывает ряд механик самой игры (трупы, добычу, поведение
зомби), а сервер не обязательно на такое рассчитан, поэтому в мультиплеере часть вещей может не
работать. Сюжетные сцены там проиграются без остановки времени: пауза игры работает только в
сингле.
## Статус
Удалить — стереть папку. Предметы и блоки мода из сейва при этом пропадут.
Версия 1.5.0 — **сборка под игру 3.3** (experimental, проверена на V 3.3 b14). Для игры 3.2 остаётся версия 1.4.2: код под 3.3 на 3.2 не работает и наоборот, и каждая сборка на чужой версии игры сама скажет, что несовместима. Пространственное хранилище из сейва 3.2 переносится: вещи читаются и дальше сохраняются в новом формате. Пирамида Ереси на 3.3 снова работает. **Нож некроманта метит Жертву и при убийстве с одного удара** — раньше такой зомби оставался без добычи. **Красный труп Жертвы больше не превращается в мешок**: он сам открывается, как ящик, с обычным таймером обыска игры. Нетронутый лежит 5 минут, открытый, но не опустошённый, — час, опустошённый исчезает сразу; открытый труп переживает сохранение и загрузку.
## История версий
Версия 1.4.2 — **защита от несовместимой версии игры**. Если мод запущен не на той версии игры, под которую собран (1.4.2 собрана для 3.2.x, 1.5.0 — для 3.3.x), в главном меню появляется окно «мод несовместим, обновите» с единственной кнопкой «Выйти из игры». Если игрок всё же откроет мир, сохранённое Пространственное хранилище не разбирается и записывается обратно байт в байт — вещи дождутся правильной сборки мода.
- **1.6.2** — совместимость: призрачный вид только у торговцев самой игры; NPC других модов (например, NPCCore) больше не становятся полупрозрачными.
- **1.6.1** — исправление: бутоны и цветы кровавой розы висели над грядкой на полклетки.
- **1.6.0** — дух змеи, одержимость зомби и своих питомцев, чертоги разума, книга некроманта с
главами, слуги охраняют хозяина (один за раз), могильная крыса, костяной скакун, гео-оссуарий,
кровавая роза и ступка, кровавый прилив и нож-меч, останки красных трупов, череп и
череп-декорация, меловая доска, девять новых испытаний, свои модели вместо заимствованных.
- **1.5.1** — FPS больше не проседает за сессию (ошибка привязок интерфейса игры), пирамида
текстурирована на низком качестве текстур, призрачные торговцы берут меньше видеопамяти.
- **1.5.0** — сборка под игру 3.3; красный труп открывается как ящик; нож метит Жертву и при
убийстве с одного удара.
- **1.4.x** — питомцы переписаны, своя модель ножа; исправлена утечка видеопамяти от красных
трупов; проверка версии игры при запуске.
- **1.3.0** — 20 упокоенных зомби за уровень (уровень навыка игра хранит одним байтом), шкала и
череп в интерфейсе, подчинённые зомби не дерутся между собой.
- **1.2.0** — заряд браслета (Кровавая сфера и Кровавый камень), в Некромантию идут все убийства с
опытом.
- **1.1.0** — браслет утаскивает блоки в хранилище, призрачные торговцы.
- **1.0.1** — хранилище браслета сохраняется.
Версия 1.4.1 — **исправлено падение FPS**. Красный труп «Жертвы» оставлял в памяти свои текстуры и материалы и не отдавал их до перезапуска игры: за пару часов набегали гигабайты видеопамяти, и FPS падал в разы. Теперь всё убирается вместе с трупом, а сам труп лежит 5 минут, а не час. Урон ножа пересчитывается раз в секунду, а не 10 раз.
**Версии 1.4.x рассчитаны на игру 3.2 и НЕ совместимы с 3.3 experimental**: там перестаёт сохраняться содержимое Пространственного хранилища и ломается Пирамида Ереси. Для 3.3 — версия 1.5.0.
Версия 1.4.0 — **питомцы переписаны заново**, и первый из них теперь доступен рано: **Дух крысы**
(Некромантия 3, 60 упокоенных зомби). Все питомцы идут за вами, сами ни на кого не нападают и
ждут приказа: повторное применение свитка при живом питомце натравливает его на цель под
прицелом. Убийства питомцев засчитываются вам. Пороги призыва разнесены по шкале — крыса 60,
стервятник 500, пёс 1300, медведь 2000, волк 4000 упокоенных; Зомбогриф стал Могильным
стервятником, «Жуки Властелина» — Роем фараона.
Нож некроманта получил **собственную модель** — прямой клинок из бедренной кости, костяную гарду,
верёвочную обмотку и светящиеся руны — и новую иконку. Добыча Жертвы переехала **в сам труп**: он
краснеет, лежит час, а при обыске исчезает и оставляет мешок Жертвы. Подбирать теперь можно и те кровати, которые становятся
точкой возрождения. У Свитка духа крысы своя иконка.
И напоминание к шкале из 1.3.0: уровень Некромантии растёт **раз в 20 упокоенных зомби**, а
продвижение внутри уровня видно на фиолетовой шкале рядом с полосой опыта. При переходе с версий
до 1.3.0 уровень пересчитывается из числа упокоенных, поэтому становится в 20 раз меньше, — сами
убийства при этом не теряются.
Версия 1.3.0 — **шкала Некромантии переведена на 20 упокоенных зомби за уровень** (максимум 250-й
уровень, те же 5000 зомби). Это не косметика, а починка: игра хранит уровень любого навыка одним
байтом, поэтому старая шкала «одно убийство — один уровень» при каждом сохранении откатывала
уровень назад на 256. Внешне это выглядело как самопроизвольно закрывающиеся рецепты — на панели
скилла снова появлялся замок на том, что уже было открыто, — а тиры 500 / 2000 / 3000 / 5000 были
недостижимы вовсе. Число упокоенных зомби никогда не портилось, и уровень теперь считается из него,
в том числе при загрузке: **старые сейвы чинятся сами**, без команд и без новой игры.
Заодно в HUD появились два индикатора: череп в статус-баре показывает уровень Некромантии (раньше
он показывал общее число убийств), а новая фиолетовая шкала рядом с полосой опыта — продвижение
внутри уровня, от 0 до 20. Мод ножа на воду (Слёзы мертвеца) переехал с 30 упокоенных на 20 — на
новой шкале 30 не выражается, и порог опущен, а не поднят.
И третья правка этой версии: **подчинённые зомби больше не дерутся между собой**. Раньше зомби под
девиацией получал приказ «бей зомби» и видел законную цель в таком же подчинённом — чем больше вы
подчиняли, тем чаще ваша свита выясняла отношения вместо того, чтобы драться за вас. Теперь
подчинённый не считает подчинённого целью и выбирает следующего, настоящего врага; это же правило
гасит и месть, если драка началась до подчинения.
Версия 1.2.0 — у Пространственного браслета появился расходник. Забор блоков теперь питается
зарядом в слоте модификаций: **Кровавая сфера** тратит единицу прочности за секунду поглощения и,
опустев, рассыпается; **Кровавый камень** с третьего грейда занимает тот же слот и не тратится
вовсе. С пустым слотом обычная атака отказывает.
В этой же версии починен счёт убийств для Некромантии. Раньше скилл рос только от убийств своей
рукой и только от человекоподобных зомби — мимо проходили зомбопёс, зомбомедведь, зомбокабан и
зомбоворон, а также всё, что убивало за вас: ловушки, огонь, кровотечение. Теперь засчитывается
всё, за что игра начисляет вам опыт. Тот же счётчик задаёт урон Ножа некроманта, так что нож
заодно перестал недобирать. Плюс торговец больше не теряет призрачность после выгрузки чанка, а
создание Крови некроманта сопровождается криком боли — оно и раньше стоило 90% здоровья, просто
молча.
Версия 1.1.0 научила Пространственный браслет забирать блоки прямо в хранилище: зажатая на блоке
обычная атака утаскивает его туда через десять секунд плюс секунда за каждый блок расстояния, с
обесцвечиванием мира на время ожидания (оно же висит и на каналах обоих порталов). Не поддаются
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира.
Плюс все торговцы стали чёрно-белыми, полупрозрачными и матовыми.
Версия 1.0.1 закрывала первый баг-репорт с Nexus: содержимое браслета больше не пропадает после
выхода из игры (хранилище сохраняется в файле игрока, рядом с рюкзаком).
Весь заявленный контент реализован. Основное проверено в игре: призыв, отзыв и следование всех
питомцев, приказ атаковать, зачёт убийств питомцами, новая модель ножа в руке, красный труп
Жертвы, который открывается как ящик (и после загрузки сейва), подобранная и заново поставленная кровать как точка возрождения,
заряд браслета и
расход Кровавой сферы, счёт убийств Некромантии (включая зомби-зверей, кровотечение и огонь),
призрачность торговцев, крик при создании Крови некроманта.
> **Эта сборка проверена не полностью.** Из новшеств 1.4.0 не проверено поведение питомцев
> **в кровавую луну и на транспорте**.
> Из прошлых версий ещё не проверены: **новая шкала уровней и оба индикатора** (череп с уровнем
> и фиолетовая шкала прогресса), **починка старых сейвов на загрузке**, **мир между подчинёнными
> зомби**, **Кровавый камень** (нужен
> уровень Некромантии 100, он же 2000 упокоенных, и химстанция), **новый порог Кровавой сферы**
> (переехала на уровень 1, туда же, где открывается Пространственный браслет), а также **награды
> испытаний и два новых испытания** — на Камень духов и Синий портальный камень. Если что-то
> поведёт себя не так, это ожидаемые места для сюрприза. Из запланированного не
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
Полная история — в журнале коммитов репозитория.
---
# Necromancer's Tome (English)
**Version 1.5.0** - for 7 Days to Die 3.3 (experimental). For game 3.2 use version 1.4.2. Each build tells you itself if it is started on the wrong game version. By Alex Cube.
**Version 1.6.2** — for 7 Days to Die 3.3. For game 3.2 use version 1.4.3. Each build tells you if it is started on the wrong game version. Author: Alex Cube.
- Mod page: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
- Repository: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
- The author's YouTube channel: https://www.youtube.com/@alex_cube
- News on Telegram: https://t.me/necromancerstome
A 7 Days to Die mod about the road from ordinary survivor to necromancer - with its own
progression tree, cursed weapons, summonable creatures and a story ending.
You were left in this world to die, and your memory was wiped. Every survivor here ends the same
way: one bad night, one horde, and you are part of it. This mod gives you the other road.
Necromancy is how you take back power over death - and find out who did this to you.
## Premise
## What awaits you
It all starts with a note, and reading it shows the character a blurred flashback.
**A skill that grows from the dead you put to rest, not from perk points.** Every zombie you put
down brings the next rank closer - from Adept to Master - and each rank opens another part of the
arsenal. Its own challenges point the way.
Necromancy in this mod is an answer to a curse, not a side branch of crafting. Instead of falling
before the horde one day and joining it, the player learns to bend the dead to their will: to
infect zombies with madness, turn them on each other, raise their own creatures against them. Not
survival in spite of death, but power over it.
**The Necromancer's Tome.** Its chapters open as you play: recipes, the habits of your servants and
pieces of someone else's story. It all starts with a note and a blurred flashback.
## Progression
**A knife carved from a thighbone.** It heals its owner, grows with the skill and turns the slain
into Victims, whose red corpses are the necromancer's pantry. Now and then they hold something
worth putting on a shelf.
A dedicated **"Necromancy"** skill grows not from experience but from a count of zombies put to
rest - its own counter, its own mechanic. Anything the game gives you XP for counts: kills finished
by a trap, by fire, by a bleed you applied, and zombie animals alongside the humanoids.
**The Snake Spirit.** At night you can leave your body lying defenceless and go hunting as a
snake - through walls, through the air, for the undead only.
**Twenty zombies put to rest make one Necromancy level**, up to level 250 (5000 zombies). Two
indicators track it on the HUD: the skull in the status bar shows the current level, and the purple
bar next to the experience bar shows how many zombies you have gathered inside the level - it fills,
the level goes up, the bar resets.
**Possession.** Slip into a stranger's corpse and walk in its skin: the screamer, the cop and many
others each have their own tricks. Or step into your own beast and see the world through
its eyes.
Five tiers, each opening part of the arsenal (the table gives both the zombie count and the level):
**Servants.** Grimoires raise a Grave Rat, a Zombie Dog, a Grave Vulture, a Zombie Wolf and a Zombie
Bear - they follow you and defend their master. Scrolls turn whole crowds.
| Tier | Threshold | Level | What unlocks |
|---|---|---|---|
| Adept | from the start | 0 | Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits |
| Adept (extra) | 20 zombies | 1 | Spatial Bracelet, Blood Sphere, the Tears of the Dead knife mod |
| Adept (extra) | 60 / 100 / 300 zombies | 3 / 5 / 15 | Scroll of the Rat Spirit; knife mods: Scavenger's Feast, Grave's Repose, Dark Sense |
| Journeyman | 500 zombies | 25 | Scroll of Deviation, Summon Grave Vulture |
| Journeyman (extra) | 1300 zombies | 65 | Summon Zombie Dog |
| Journeyman (extra) | 1400 / 1700 zombies | 70 / 85 | Knife mods: Dead Man's Grip, Dead Storm |
| Apprentice | 2000 zombies | 100 | Summon Zombie Bear, Pharaoh’s Swarm, Blood Stone |
| Necromancer | 3000 zombies | 150 | Banshee's Scroll |
| Necromancer (extra) | 4000 zombies | 200 | Summon Zombie Wolf |
| Master | 5000 zombies | 250 | Black Portal Stone |
**The Mind Palace.** A place inside your own head where, behind locked doors, lies what was taken
from you.
## Arsenal
**An economy of blood.** Your blood is currency. It feeds the Bone Steed, the Geo-Ossuary that mines
ore on its own, and the bracelet's pocket dimension. And a black rose can give you the same without
opening your veins.
- **Spirit Stone** - a thrown stone lit with necromantic energy. It catches a single zombie: that
one switches to your side and starts attacking other zombies instead of you.
- **Scroll of Deviation** - the same effect, but stronger: on impact it wins over every zombie in
the blast area at once rather than one. Consumed on use.
- **Necromancer's Knife** - a blade carved from a thighbone: a straight edge like an amputation
knife, a bone guard, a rope-wrapped grip and glowing runes. Its damage grows with the Necromancy
skill: nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for
half the damage dealt and marks the wounded zombie as a Victim (even one killed with a single
blow) - its corpse turns red and opens like a container, with the Victim's ingredients inside.
An untouched corpse stays for five minutes, an opened but not emptied one for an hour, and an
emptied one vanishes at once.
- **Six mods for the Necromancer's Knife only** - ordinary knife mods will not fit this weapon,
and these will not fit any other: **Tears of the Dead** (2 water from every zombie killed with
the blade), **Scavenger's Feast** (2 food per corpse), **Grave's Repose** (protection from heat
and cold while the knife is held), **Dead Man's Grip** (a zombie wounded by the blade is
slowed), **Dead Storm** (the power attack hits an area and causes bleeding, for 10 health
instead of 5 and double the stamina), **Dark Sense** (every nearby zombie is marked on the
compass and map while the knife is held).
- **Necromancer's Blood** - a ritual resource: a jar takes an empty jar, any knife in hand and 90%
of your current health per portion. An ingredient for the darkest recipes - the Black Portal and
the Knife itself.
- **Victim's Skin** and **Zombie Ash** - left by a zombie marked as a Victim by the Necromancer's
Knife. Ingredients for the summoning books and for most necromantic recipes respectively.
- **Blue Portal Stone** - hold the use button for 10 seconds to teleport to your bedroll.
Interrupted by any damage, or by a power attack before the time is up. Not consumed.
- **Spatial Bracelet** - a power attack opens a personal storage rift whose size grows with your
Necromancy level. Hold the regular attack on a block and that block is pulled straight into the
rift: ten seconds up close, one more per block of distance, behind the same circular indicator a
workbench pickup uses. The world drains to black and white while it runs. Damaged blocks,
containers with anything inside, a trader's ground and indestructible things like the world's
floor all refuse, each with its own message. The power attack interrupts the pull and opens the
rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the
regular attack refuses.
- **Blood Sphere** - the bracelet's charge. 500 points of durability, one spent per second of a
pull; emptied, the sphere crumbles. Available from the very start and hand-crafted without a
workbench, two at a time, from Necromancer's Blood and Zombie Ash.
- **Blood Stone** - the same slot, but never spent at all: the endgame replacement for the sphere.
Opens at 2000 zombies put to rest (level 100) and is brewed at a chemistry station from zombie ash, bones,
ordinary blood and Necromancer's Blood.
- **Tin cans** (empty / with river water / with boiled water) - a reusable cycle instead of
single-use jars: fill them with water, boil it right on a campfire without a pot, drink, and the
can comes back empty. River water from a can can cause dysentery, like any murky water; boiled
water is safe. They hold less water than glass jars.
**The Blood Tide.** On the night of the blood moon the spirit is chained to the body. The knife
becomes a sword instead.
## Pets
**Defence that converts.** The Pyramid of Negation turns attackers against each other.
Summoning books raise allied creatures that fight zombies rather than the player. Every summoned
creature looks like a washed-out ghost - the transparency is tuned with the `necroghost` console
command, the same one the ghost traders use.
**A real ending.** The Black Portal finishes the story the note began and leaves the choice to you.
- **Rat Spirit** - the first pet available (Necromancy 3), and the only one that behaves
deliberately rather than by inheritance: it never attacks on its own, keeps two blocks to your
right and just behind you, and where an obstacle stands it passes straight through - it never
gnaws blocks. While it is out, an ordinary use of the scroll becomes an order: the rat goes for
whatever your crosshair rests on (traders excepted); with nothing to attack there, you get "No
target to attack". A power attack recalls it. Its damage is next to nothing (5), but its bite
does three things: it slows heavily, it tears a vein (a bleed that will not close while the rat
keeps at it), and it leaves a **spirit mark** - for half a minute the marked zombie shows on
your compass and map and takes a quarter more damage from everything, whatever you hit it with.
The rat itself shows on the map in yellow. Gnawing on the corpse of a zombie it finished off
heals it back up. Hitting the rat makes it bite back - never at its owner, though.
- **Zombie Dog**, **Zombie Bear**, **Zombie Wolf**, **Grave Vulture** - permanent companions that
behave exactly like the Rat Spirit: they follow you, attack nobody on their own and wait for an
order. What differs is force - the damage climbs from the griffin up to the wolf, and unlike the rat
these kill quickly; the dog, the bear and the wolf can also tear limbs off. Their bite draws blood
but leaves no spirit mark. One of each kind can be kept
at a time; a power attack recalls them into the book.
- **Pharaoh’s Swarm** - a one-shot scroll releasing a swarm. They scatter over a wide
radius on their own and sting zombies; a stung zombie switches to your side just like with the
Spirit Stone. The swarm cannot be recalled and only one can be active. Consumed on use.
- **Banshee's Scroll** - single use: on opening it screams with a banshee's voice and raises a
small hostile horde next to the player. These are not allies - they are as dangerous to you as
any other zombies.
**The traders are dead.** Black and white, half-transparent: nobody alive is still trading out
here.
## Base defence
- **Pyramid of Spirits** - a deployable block, not a held item. While you stand in its radius it
charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning
it against the other zombies instead of you or your base. Its block menu toggles the effect and
shows the edge of the radius.
## The Black Portal - the story's ending
**The Black Portal Stone unlocks at the top of the progression (5000 zombies) and is the mod's
ending.** Activating it opens a confirmation dialogue, stops the game and unfolds a full-screen
finale - it finishes the story the note started on day one. The scene closes on a choice of two:
one ends the story and returns to the main menu, the other puts the player back into the world to
keep playing.
The story texts live in `Config/Localization.csv` under the `necroFinal*` keys. They are
deliberately not retold here: a README gets read before the playthrough.
## Small things
- The opening note also pauses the game and plays a short flashback.
- Some blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the key, like
a workbench. A bed you then place with your own hands becomes a respawn point, like a bedroll.
- Every trader looks different: black and white, half-transparent and matte. The necromancer deals
with the dead, and the only people still trading are not quite alive.
## Localization
**13 languages, complete:** Russian, English, German, Spanish, French, Italian, Japanese, Korean,
Polish, Brazilian Portuguese, Turkish, Simplified and Traditional Chinese. All 165 keys in
`Config/Localization.csv` are filled in, with no empty cells.
Fully localized into 13 languages: English, German, Spanish, French, Italian, Japanese, Korean,
Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese.
## Installation
Unpack the `NecromancerTome` folder into `<game folder>/Mods/` (or into
`%APPDATA%/7DaysToDie/Mods/`) and launch the game. The mod ships Harmony libraries, so **EAC must
be turned off**.
1. **Turn EasyAntiCheat off.** The mod ships Harmony libraries and will not load with EAC on.
2. Unpack the archive and put the `NecromancerTome` folder into `Mods`, so that you end up with
`Mods/NecromancerTome/ModInfo.xml`. The `Mods` folder sits next to the game or in
`%APPDATA%/7DaysToDie/` (either works; create it if it is not there).
3. Start the game. The Necromancy skill appears in the crafting skills panel right away.
Version 1.5.0 is for game 3.3; for game 3.2 download 1.4.2 (both are on the Nexus Files tab).
Version 1.6.2 is for game 3.3, version 1.4.3 is for game 3.2 (both on the Nexus Files tab).
Drop-in over 1.5.x: saves carry over.
Built for single-player. The mod reworks a number of the game's own mechanics (corpses, loot,
zombie behaviour), and a server isn't necessarily built for that, so in multiplayer some things may
not work - the Victim corpses, for one. The story scenes also play there without stopping time,
since the vanilla pause only applies in single-player.
not work. The story scenes also play there without stopping time, since the game pauses only in
single-player.
## Status
To uninstall, delete the folder. The mod's items and blocks will be gone from the save.
Version 1.5.0 - **the build for game 3.3** (experimental, tested on V 3.3 b14). Version 1.4.2 stays for game 3.2: code built for 3.3 does not run on 3.2 and vice versa, and each build tells you itself if it is started on the wrong game version. The Spatial Vault from a 3.2 save carries over: its items are read and from then on saved in the new format. The Pyramid of Heresy works again on 3.3. **The Necromancer's Knife marks a Victim even with a single killing blow** - before, such a zombie left no loot. **The red Victim corpse no longer turns into a bag**: it opens by itself like a container, with the game's usual search timer. An untouched corpse stays for 5 minutes, an opened but not emptied one for an hour, an emptied one vanishes at once; an opened corpse survives saving and loading.
## Version history
Version 1.4.2 - **protection against an incompatible game version**. If the mod runs on a game version it was not built for (1.4.2 is built for 3.2.x, 1.5.0 for 3.3.x), the main menu shows a "mod is incompatible, please update" window with a single "Quit game" button. And if a world does get opened, the saved Spatial Vault is not parsed but written back byte for byte - its items wait for the right build of the mod.
- **1.6.2** - compatibility: only the game's own traders get the ghost look; NPCs from other mods (for example NPCCore) no longer turn translucent.
- **1.6.1** - fix: blood rose buds and flowers floated half a block above the planter.
- **1.6.0** - the Snake Spirit, possession of zombies and of your own servants, the Mind Palace, the
Necromancer's Tome with chapters, servants that guard their master (one at a time), the Grave
Rat, the Bone Steed, the Geo-Ossuary, the blood rose and the mortar, the Blood Tide and the
knife-sword, remains of red corpses, the skull and the skull decoration, a chalkboard, nine new
challenges, the mod's own models instead of borrowed ones.
- **1.5.1** - FPS no longer sinks over a session (a bug in the game's UI bindings), the pyramid is
textured at low texture quality, ghost traders use less video memory.
- **1.5.0** - build for game 3.3; the red corpse opens like a crate; the knife marks a Victim even on
a one-hit kill.
- **1.4.x** - pets rewritten, the knife's own model; video memory leak from red corpses fixed; game
version check at startup.
- **1.3.0** - 20 zombies per level (the game stores a skill level in one byte), the bar and the skull
in the HUD, charmed zombies no longer fight each other.
- **1.2.0** - bracelet charge (Blood Sphere and Blood Stone), every kill that gives XP counts toward
Necromancy.
- **1.1.0** - the bracelet pulls blocks into storage, ghost traders.
- **1.0.1** - the bracelet's storage is saved.
Version 1.4.1 - **the FPS drop is fixed**. A red Victim corpse left its textures and materials in memory and never gave them back until the game was restarted: within a couple of hours that was gigabytes of video memory, and the frame rate fell several times over. Everything is now cleaned up with the corpse, and the corpse itself stays for 5 minutes instead of an hour. The knife's damage is refreshed once a second instead of 10 times.
**Versions 1.4.x are built for game version 3.2 and are NOT compatible with 3.3 experimental**: there the Spatial Vault stops saving its contents and the Pyramid of Heresy breaks. For 3.3 use version 1.5.0.
Version 1.4.0 - **pets have been rewritten**, and the first of them now comes early: the **Rat
Spirit** (Necromancy 3, 60 zombies put to rest). Every pet follows you, attacks nobody on its own
and waits for an order: using the scroll again while the pet is alive sends it after whatever is
under your crosshair. Your pets' kills count as yours. The summon thresholds are spread across the
scale - rat 60, vulture 500, dog 1300, bear 2000, wolf 4000 zombies put to rest; the Zombie Griff
became the Grave Vulture, and the Beetles of the Lord became the Pharaoh's Swarm.
The Necromancer's Knife has **its own model** - a straight thighbone blade, a bone guard, a rope
grip and glowing runes - and a new icon. A Victim's loot has moved **into the corpse itself**: it
turns red, stays for an hour, and searching it makes it vanish and leave the Victim's bag. The beds that become a respawn point can
now be picked up too. The Scroll of the Rat Spirit has its own icon.
And a reminder about the 1.3.0 scale: Necromancy rises **once every 20 zombies put to rest**, and
the progress inside a level shows on the purple bar next to the experience bar. Coming from a
version before 1.3.0, the level is recalculated from the zombies put to rest, so it becomes twenty
times smaller - the kills themselves are not lost.
Version 1.3.0 - **Necromancy now scales at 20 zombies put to rest per level** (up to level 250,
the same 5000 zombies). This is a fix, not a facelift: the game stores any skill's level in a
single byte, so the old "one kill, one level" scale rolled the level back by 256 on every save.
From the outside it looked like recipes closing on their own - the skill panel would put the lock
back on something already unlocked - and the 500 / 2000 / 3000 / 5000 tiers were unreachable
altogether. The count of zombies put to rest was never damaged, and the level is now derived from
it, including on load: **old saves repair themselves**, with no commands and no new game.
Two HUD indicators come with it: the skull in the status bar now shows the Necromancy level (it
used to show the total kill count), and a new purple bar next to the experience bar shows the
progress inside the level, from 0 to 20. The water knife mod (Tears of the Dead) moved from 30
zombies to 20 - 30 does not land on the new scale, and the threshold was lowered rather than
raised.
A third fix ships with this version: **charmed zombies no longer fight each other**. A zombie under
deviation used to be told "attack zombies" and saw a perfectly valid target in another charmed one -
the more of them you charmed, the more of your retinue settled scores among themselves instead of
fighting for you. Now a charmed zombie does not count another charmed zombie as a target and picks
the next, real enemy instead; the same rule cancels revenge if the fight started before the
charming.
Version 1.2.0 - the Spatial Bracelet now runs on a charge. Pulling blocks draws on whatever sits
in its mod slot: the **Blood Sphere** spends a point of durability per second of the pull and
crumbles once empty, while the **Blood Stone** from the third grade takes the same slot and is never
spent at all. With the slot empty, the regular attack refuses.
The same version fixes Necromancy's kill count. The skill used to rise only from kills by your own
hand, and only from humanoid zombies - zombie dogs, bears, boars and vultures never counted, and
neither did anything that killed on your behalf: traps, fire, bleeding. Now everything the game
gives you XP for counts. The same counter drives the Necromancer's Knife's damage, so the knife
stopped coming up short as well. On top of that, a trader no longer loses its ghostly look after
its chunk is unloaded, and crafting Necromancer's Blood now comes with a cry of pain - it always
cost 90% of your health, it was just silent about it.
Version 1.1.0 taught the Spatial Bracelet to take blocks straight into the vault: hold its regular
attack on a block and it is pulled in after ten seconds, plus one more per block of distance, with
the world draining to black and white for the wait (which also covers both portal channels).
Damaged blocks, containers with anything inside, a trader's ground and indestructible things like
the world's floor all refuse. Every trader is now rendered in black and white, half-transparent and
matte.
Version 1.0.1 closed the first bug report from Nexus: the bracelet's contents no longer disappear
after leaving the game (the storage is saved in the player's own file, next to the backpack).
All the announced content is implemented. The core of it is confirmed in game: summoning,
dismissing and following for every pet, the attack order, pets' kills counting as yours, the new
knife model in hand, the red Victim corpse that opens like a container (also after loading a save), a bed picked up
and placed again becoming a respawn point, the bracelet's charge and the Blood Sphere's spending, Necromancy's kill count
(zombie animals, bleeding and fire included), the traders' ghostly look, and the cry when
Necromancer's Blood is made.
> **This build has not been fully tested.** Of what is new in 1.4.0, not yet checked: how pets
> behave **during a blood moon and on vehicles**. From
> earlier versions, not yet checked: the **new level scale and both indicators**
> (the skull showing the level and the purple progress bar), the **self-repair of old saves on
> load**, the **truce between charmed zombies**, the **Blood Stone** (it needs Necromancy level 100, i.e. 2000 zombies put to rest, and a
> chemistry station), the **Blood Sphere's new threshold** (it moved to level 1, where the Spatial
> Bracelet itself unlocks), and the **challenge rewards plus two new challenges** for the Spirit
> Stone and the Blue Portal Stone. If anything misbehaves, that is where to look. Of what was planned, only part of the mod's own sound effects is missing. The description text for the website
(RU + EN) is in `SITE_DESCRIPTION.html` (WordPress block markup). The full technical history of
development and the text of the finale sit next to the mod in `BACKLOG.md` and `FINAL_TEXT.md` -
they are not part of the repository (spoilers and back-of-house).
The full history is in the repository's commit log.
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.
+54 -92
View File
@@ -1,68 +1,48 @@
<strong>Мод для 7 Days to Die 3.3: путь от обычного выжившего до некроманта — со своей веткой прогрессии, тёмным оружием, призывной нежитью и сюжетной концовкой.</strong>
<strong>Мод для 7 Days to Die 3.3: путь от обычного выжившего до некроманта — своя ветка прогрессии, слуги из мёртвых, одержимость, дух змеи и сюжетная концовка. Версия 1.6.0 (для игры 3.2 — 1.4.3).</strong>
Всё начинается с записки. Читая её, персонаж видит неясный флэшбек — обрывок того, что с ним случилось до пробуждения в этом мире.
Вас бросили в этот мир умирать и стёрли вам память. Каждый выживший здесь кончает одинаково: одна плохая ночь, одна орда — и ты уже часть её. Этот мод даёт другую дорогу. Некромантия — способ вернуть себе власть над смертью и узнать, кто сделал это с вами.
Некромантия здесь — ответ на проклятие, а не побочная ветка крафта. Вместо того чтобы однажды пасть перед ордой и присоединиться к ней, вы учитесь подчинять мёртвых: заражать зомби безумием, стравливать их друг с другом, поднимать против них собственных тварей. Не выживание вопреки смерти, а власть над ней.
<h2>Что вас ждёт</h2>
<h2>Прогрессия, которая растёт от убийств</h2>
<strong>Навык, который растёт от упокоенных мёртвых, а не от очков.</strong> Каждый добитый зомби приближает следующий ранг — от Адепта до Мастера, и каждый ранг открывает новую часть арсенала. Свои испытания подскажут, куда двигаться дальше.
Отдельный навык <strong>«Некромантия»</strong> качается не за очки перков и не за книги, а по счётчику упокоенных зомби: двадцать упокоенных — один уровень, до 250-го (5000 зомби). Уровень показывает череп в статус-баре, а фиолетовая шкала рядом с полосой опыта — сколько набрано внутри уровня. Пять тиров, каждый порог открывает часть арсенала (в таблице — зомби, в скобках уровень):
<strong>Книга некроманта.</strong> Главы открываются по ходу игры: в них рецепты, повадки слуг и обрывки чужой истории. Начинается всё с записки и неясного флэшбека.
<table><thead><tr><th>Тир</th><th>Порог</th><th>Что открывается</th></tr></thead><tbody><tr><td>Адепт</td><td>сразу (0)</td><td>Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов</td></tr><tr><td>Адепт</td><td>20 (1)</td><td>Пространственный браслет, Кровавая сфера, мод ножа «Слёзы мертвеца»</td></tr><tr><td>Адепт</td><td>60 / 100 / 300 (3 / 5 / 15)</td><td>Моды ножа: Пир падальщика, Могильный покой, Тёмное чутьё</td></tr><tr><td>Подмастерье</td><td>500 / 1400 / 1700 (25 / 70 / 85)</td><td>Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря</td></tr><tr><td>Ученик</td><td>2000 (100)</td><td>Призыв зомбособаки, Рой фараона, Призыв могильного стервятника, Кровавый камень</td></tr><tr><td>Некромант</td><td>3000 (150)</td><td>Призыв зомбомедведя, Призыв зомбоволка, Свиток банши</td></tr><tr><td>Мастер</td><td>5000 (250)</td><td>Чёрный портальный камень</td></tr></tbody></table>
<strong>Нож из бедренной кости.</strong> Лечит хозяина, растёт вместе с навыком и превращает убитых в Жертв: их красные трупы — кладовая некроманта. Иногда в них находится кое-что, что стоит поставить на полку.
<h2>Арсенал</h2>
<strong>Дух змеи.</strong> Ночью можно оставить тело лежать беззащитным и выйти на охоту змеёй — сквозь стены, по воздуху, только по нежити.
<ul>
<li><strong>Камень духов</strong> — брошенный камень, светящийся некромантской энергией. Задетый зомби переходит на вашу сторону и начинает рвать других зомби вместо вас.</li>
<li><strong>Свиток девиации</strong> — то же самое, но по площади: переманивает сразу всех зомби в радиусе взрыва. Расходуется при использовании.</li>
<li><strong>Нож некроманта</strong> — клинок, выточенный из бедренной кости: прямое лезвие, как у ампутационного ножа, костяная гарда, рукоять в верёвочной обмотке и светящиеся руны. Урон растёт вместе с навыком Некромантии: почти бесполезен в неопытных руках, смертоносен у прокачанного игрока. Лечит владельца на половину нанесённого урона и метит раненого зомби как Жертву (даже убитого с одного удара) — труп такого зомби краснеет и открывается, как ящик: внутри ингредиенты Жертвы. Нетронутый труп лежит пять минут, открытый, но не опустошённый, — час, опустошённый исчезает сразу.</li>
<li><strong>Шесть модов только для этого ножа</strong> — вода и еда прямо из трупов (Слёзы мертвеца, Пир падальщика), защита от жары и холода (Могильный покой), замедление раненых (Хватка мертвеца), силовая атака-буря по площади за двойную цену здоровья и выносливости (Мёртвая буря) и подсветка всех зомби поблизости на карте и компасе (Тёмное чутьё). Обычные моды в этот нож не встают, а эти не встают ни во что другое.</li>
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
<li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li>
<li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, мир на это время обесцвечивается. Повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира не поддаются. Силовая атака прерывает утаскивание и открывает хранилище. Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака отказывает.</li>
<li><strong>Кровавая сфера</strong> — заряд браслета: 500 прочности, каждая секунда утаскивания блока тратит единицу, опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две за раз, из крови некроманта и праха зомби.</li>
<li><strong>Кровавый камень</strong> — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на 2000 упокоенных и варится на химической станции из праха зомби, костей, обычной крови и крови некроманта.</li>
<li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li>
</ul>
<strong>Одержимость.</strong> Вселитесь в чужого мертвеца и походите в его шкуре: у кричащей, у полицейского и у многих других свои умения. Или войдите в своего зверя и посмотрите на мир его глазами.
<h2>Питомцы</h2>
<strong>Слуги.</strong> Гримуары поднимают могильную крысу, зомбособаку, могильного стервятника, зомбоволка и зомбомедведя — они идут за вами и защищают хозяина. Свитки обращают целые толпы.
Призывные книги поднимают союзников, которые дерутся с зомби, а не с вами. Питомец бродит сам по себе, а если отойдёт дальше 32 блоков и не занят боем — телепортируется обратно к владельцу.
<strong>Чертоги разума.</strong> Место внутри собственной головы, где за запертыми дверями лежит то, что у вас отняли.
<ul>
<li><strong>Зомбособака, зомбомедведь, зомбоволк, могильный стервятник</strong> — постоянные спутники, по одному экземпляру каждого вида одновременно. Отзываются обратно в книгу силовой атакой.</li>
<li><strong>Рой фараона</strong> — разовый свиток с роем. Жуки разлетаются по широкому радиусу и жалят зомби; ужаленный переходит на вашу сторону, как от Камня духов. Рой не отзывается, активен может быть только один.</li>
<li><strong>Свиток банши</strong> — одноразовый: вопит голосом банши и поднимает рядом небольшую орду. Это <strong>не</strong> союзники — эти зомби так же опасны для вас, как любые другие.</li>
</ul>
<strong>Хозяйство на крови.</strong> Ваша кровь — валюта. Ею питаются костяной скакун, гео-оссуарий, который сам добывает руду, и карманное измерение браслета. А из чёрной розы можно получить то же самое, не вскрывая себе вены.
<h2>Оборона базы</h2>
<strong>Кровавый прилив.</strong> В ночь кровавой луны дух прикован к телу. Зато нож становится мечом.
<strong>Пирамида духов</strong> — устанавливаемый блок. Пока вы в её радиусе, она сама заряжает девиацией любого незаряженного зомби поблизости и поджигает его холодным фиолетовым пламенем, обращая против других зомби вместо вашей базы. В меню блока эффект включается и выключается, границу радиуса можно показать.
<strong>Оборона, которая обращает.</strong> Пирамида отрицания натравливает нападающих друг на друга.
<h2>Чёрный портал — финал истории</h2>
<strong>Настоящий финал.</strong> Чёрный портал завершает историю, начатую запиской, и оставляет выбор за вами.
<strong>Чёрный портальный камень открывается на вершине прогрессии, на 5000 убийств, и является концовкой мода.</strong> Он уносит некроманта туда, откуда всё началось, — и досказывает историю, начатую запиской в первый день.
<strong>Торговцы — мёртвые.</strong> Чёрно-белые и полупрозрачные: живых, кто ещё торгует, здесь не осталось.
Активация просит подтверждения, останавливает игру и разворачивает полноэкранную финальную сцену. В конце — выбор из двух вариантов; один из них закрывает историю и выходит в главное меню, другой позволяет вернуться и играть дальше. Что там, по ту сторону портала, лучше увидеть самому.
<h2>По мелочи</h2>
<ul>
<li>Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.</li>
<li>Часть блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как верстак. Кровать, поставленная после этого своими руками, становится точкой возрождения, как спальник.</li>
<li>Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с мёртвыми, и торгуют с ним те, кто уже не совсем жив.</li>
</ul>
<h2>Локализация</h2>
<strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 149 строк переведены, пустых ячеек нет.
Полностью переведён на 13 языков: русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный.
<h2>Установка</h2>
Требуется <strong>7 Days to Die 3.3</strong> (для игры 3.2 — версия мода 1.4.2). Распакуйте папку <code>NecromancerTome</code> в <code>&lt;папка игры&gt;/Mods/</code> (или в <code>%APPDATA%/7DaysToDie/Mods/</code>) и запустите игру. Мод содержит Harmony-библиотеки, поэтому <strong>EAC должен быть выключен</strong>.
<ol>
<li><strong>Выключите EasyAntiCheat.</strong> Мод содержит Harmony-библиотеки и с античитом не загрузится.</li>
<li>Распакуйте архив и положите папку <code>NecromancerTome</code> в <code>Mods</code> — чтобы получилось <code>Mods/NecromancerTome/ModInfo.xml</code>. Папка <code>Mods</code> лежит рядом с игрой или в <code>%APPDATA%/7DaysToDie/</code> (подходят обе, если её нет — создайте).</li>
<li>Запустите игру. Навык «Некромантия» появится на панели навыков крафта сразу.</li>
</ol>
Рассчитан на одиночную игру. Мод переделывает ряд собственных механик игры (трупы, добычу, поведение зомби), а сервер не обязательно на такое рассчитан, поэтому в мультиплеере часть вещей может не работать — например, трупы «Жертвы». Сюжетные сцены там проиграются без остановки времени: ванильная пауза срабатывает только в сингле.
Версия 1.6.2 — для игры 3.3, версия 1.4.3 — для игры 3.2 (обе на Nexus во вкладке Files). Обновление поверх 1.5.x: сейвы переносятся.
Рассчитан на одиночную игру. Мод переделывает ряд механик самой игры (трупы, добычу, поведение зомби), а сервер не обязательно на такое рассчитан, поэтому в мультиплеере часть вещей может не работать. Сюжетные сцены там проиграются без остановки времени: пауза игры работает только в сингле.
Удалить — стереть папку. Предметы и блоки мода из сейва при этом пропадут.
<h2>Ссылки</h2>
@@ -70,77 +50,58 @@
<li>Скачать на Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
<li>Исходники: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
<li>YouTube-канал автора: <a href="https://www.youtube.com/@alex_cube" target="_blank" rel="noreferrer noopener">@alex_cube</a></li>
<li>Новости в Telegram: <a href="https://t.me/necromancerstome" target="_blank" rel="noreferrer noopener">t.me/necromancerstome</a></li>
</ul>
<hr>
<h2>Necromancer's Tome — English description</h2>
<strong>A 7 Days to Die 3.3 mod: the road from ordinary survivor to necromancer — its own progression tree, cursed weapons, summonable undead and a story ending.</strong>
<strong>A 7 Days to Die 3.3 mod about the road from an ordinary survivor to a necromancer - its own progression, servants raised from the dead, possession, the Snake Spirit and a story ending. Version 1.6.0 (for game 3.2: 1.4.3).</strong>
It starts with a note. Reading it, your character sees a blurred flashback — a fragment of whatever happened to them before they woke up in this world.
You were left in this world to die, and your memory was wiped. Every survivor here ends the same way: one bad night, one horde, and you are part of it. This mod gives you the other road. Necromancy is how you take back power over death - and find out who did this to you.
Necromancy here is an answer to a curse, not a side branch of crafting. Instead of falling before the horde one day and joining it, you learn to command the dead: infect zombies with madness, turn them on each other, raise your own creatures against them. Not survival in spite of death — power over it.
<h3>What awaits you</h3>
<h3>Progression driven by kills</h3>
<strong>A skill that grows from the dead you put to rest, not from perk points.</strong> Every zombie you put down brings the next rank closer - from Adept to Master - and each rank opens another part of the arsenal. Its own challenges point the way.
A dedicated <strong>Necromancy</strong> skill levels not from perk points and not from books, but from your zombie kill count: twenty zombies put to rest make one level, up to level 250 (5000 zombies). The skull in the status bar shows the level, and the purple bar beside the experience bar shows how far you are into it. Five tiers, each threshold opening part of the arsenal (the table gives zombies, with the level in brackets):
<strong>The Necromancer's Tome.</strong> Its chapters open as you play: recipes, the habits of your servants and pieces of someone else's story. It all starts with a note and a blurred flashback.
<table><thead><tr><th>Tier</th><th>Threshold</th><th>What unlocks</th></tr></thead><tbody><tr><td>Adept</td><td>from the start (0)</td><td>Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits</td></tr><tr><td>Adept</td><td>20 (1)</td><td>Spatial Bracelet, Blood Sphere, the Tears of the Dead knife mod</td></tr><tr><td>Adept</td><td>60 / 100 / 300 (3 / 5 / 15)</td><td>Knife mods: Scavenger's Feast, Grave's Repose, Dark Sense</td></tr><tr><td>Journeyman</td><td>500 / 1400 / 1700 (25 / 70 / 85)</td><td>Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm</td></tr><tr><td>Apprentice</td><td>2000 (100)</td><td>Summon Zombie Dog, Pharaoh’s Swarm, Summon Grave Vulture, Blood Stone</td></tr><tr><td>Necromancer</td><td>3000 (150)</td><td>Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll</td></tr><tr><td>Master</td><td>5000 (250)</td><td>Black Portal Stone</td></tr></tbody></table>
<strong>A knife carved from a thighbone.</strong> It heals its owner, grows with the skill and turns the slain into Victims, whose red corpses are the necromancer's pantry. Now and then they hold something worth putting on a shelf.
<h3>Arsenal</h3>
<strong>The Snake Spirit.</strong> At night you can leave your body lying defenceless and go hunting as a snake - through walls, through the air, for the undead only.
<ul>
<li><strong>Spirit Stone</strong> — a thrown stone lit with necromantic energy. The zombie it touches switches to your side and starts tearing into other zombies instead of you.</li>
<li><strong>Scroll of Deviation</strong> — the same effect, but area-wide: it charms every zombie in the blast radius at once. Consumed on use.</li>
<li><strong>Necromancer's Knife</strong> — a blade carved from a thighbone: a straight edge like an amputation knife, a bone guard, a rope-wrapped grip and glowing runes. Its damage scales with the Necromancy skill: nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for half the damage dealt and marks the wounded zombie as a Victim (even one killed with a single blow) — its corpse turns red and opens like a container, with the Victim's ingredients inside. An untouched corpse stays for five minutes, an opened but not emptied one for an hour, an emptied one vanishes at once.</li>
<li><strong>Six mods for this knife only</strong> — water and food straight out of corpses (Tears of the Dead, Scavenger's Feast), protection from heat and cold (Grave's Repose), slowed victims (Dead Man's Grip), an area power-attack storm at double the health and stamina cost (Dead Storm), and every nearby zombie marked on your map and compass (Dark Sense). Ordinary knife mods will not fit this weapon, and these will not fit any other.</li>
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
<li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li>
<li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level. Hold the regular attack on a block and it is pulled straight into the rift: ten seconds up close, one more per block of distance, with the world draining to black and white while it runs. Damaged blocks, containers with anything inside, a trader's ground and indestructible things like the world's floor all refuse. The power attack interrupts the pull and opens the rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the regular attack refuses.</li>
<li><strong>Blood Sphere</strong> — the bracelet's charge: 500 points of durability, one spent per second of a pull, and the emptied sphere crumbles. Available from the very start and hand-crafted without a workbench, two at a time, from Necromancer's Blood and Zombie Ash.</li>
<li><strong>Blood Stone</strong> — the same slot, but never spent at all: the endgame replacement for the sphere. Opens at 2000 zombies put to rest and is brewed at a chemistry station from zombie ash, bones, ordinary blood and Necromancer's Blood.</li>
<li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li>
</ul>
<strong>Possession.</strong> Slip into a stranger's corpse and walk in its skin: the screamer, the cop and many others each have their own tricks. Or step into your own beast and see the world through its eyes.
<h3>Pets</h3>
<strong>Servants.</strong> Grimoires raise a Grave Rat, a Zombie Dog, a Grave Vulture, a Zombie Wolf and a Zombie Bear - they follow you and defend their master. Scrolls turn whole crowds.
Summoning books raise allies that fight zombies, not you. A pet wanders on its own, and if it strays more than 32 blocks away while out of combat, it teleports back to its owner.
<strong>The Mind Palace.</strong> A place inside your own head where, behind locked doors, lies what was taken from you.
<ul>
<li><strong>Zombie dog, bear, wolf and griffin</strong> — permanent companions, one of each kind at a time. A power attack recalls them into the book.</li>
<li><strong>Pharaoh’s Swarm</strong> — a one-shot scroll releasing a swarm. They scatter over a wide radius and sting zombies; a stung zombie switches to your side just like with the Spirit Stone. The swarm cannot be recalled, and only one can be active.</li>
<li><strong>Banshee's Scroll</strong> — single use: it screams with a banshee's voice and raises a small horde next to you. These are <strong>not</strong> allies — they are as dangerous to you as any other zombies.</li>
</ul>
<strong>An economy of blood.</strong> Your blood is currency. It feeds the Bone Steed, the Geo-Ossuary that mines ore on its own, and the bracelet's pocket dimension. And a black rose can give you the same without opening your veins.
<h3>Base defence</h3>
<strong>The Blood Tide.</strong> On the night of the blood moon the spirit is chained to the body. The knife becomes a sword instead.
<strong>Pyramid of Spirits</strong> — a deployable block. While you stand in its radius, it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the other zombies instead of your base. Its block menu toggles the effect and shows the radius.
<strong>Defence that converts.</strong> The Pyramid of Negation turns attackers against each other.
<h3>The Black Portal — the story's ending</h3>
<strong>A real ending.</strong> The Black Portal finishes the story the note began and leaves the choice to you.
<strong>The Black Portal Stone unlocks at the top of the progression, at 5000 kills, and it is the mod's ending.</strong> It carries the necromancer back to where all of this began, and finishes the story the note started on day one.
<strong>The traders are dead.</strong> Black and white, half-transparent: nobody alive is still trading out here.
Activating it asks for confirmation, stops the game and unfolds a full-screen finale. It closes on a choice of two: one ends the story and returns you to the main menu, the other lets you come back and keep playing. What waits on the far side of the portal is better seen than described.
<h3>Small things</h3>
<ul>
<li>The opening note also pauses the game and plays a short flashback.</li>
<li>Some blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the pick-up key, like a workbench. A bed you then place with your own hands becomes a respawn point, like a bedroll.</li>
<li>Every trader looks different: black and white, half-transparent and matte. The necromancer deals with the dead, and the only people still trading are not quite alive.</li>
</ul>
<h3>Localization</h3>
<strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 149 strings are translated, with no empty cells.
Fully localized into 13 languages: English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese.
<h3>Installation</h3>
Requires <strong>7 Days to Die 3.3</strong> (for game 3.2 use mod version 1.4.2). Unpack the <code>NecromancerTome</code> folder into <code>&lt;game folder&gt;/Mods/</code> (or <code>%APPDATA%/7DaysToDie/Mods/</code>) and launch the game. The mod ships Harmony libraries, so <strong>EAC must be turned off</strong>.
<ol>
<li><strong>Turn EasyAntiCheat off.</strong> The mod ships Harmony libraries and will not load with EAC on.</li>
<li>Unpack the archive and put the <code>NecromancerTome</code> folder into <code>Mods</code>, so that you end up with <code>Mods/NecromancerTome/ModInfo.xml</code>. The <code>Mods</code> folder sits next to the game or in <code>%APPDATA%/7DaysToDie/</code> (either works; create it if it is not there).</li>
<li>Start the game. The Necromancy skill appears in the crafting skills panel right away.</li>
</ol>
Built for single-player. The mod reworks a number of the game's own mechanics (corpses, loot, zombie behaviour), and a server isn't necessarily built for that, so in multiplayer some things may not work - the Victim corpses, for one. The story scenes also play there without stopping time, since the vanilla pause only applies in single-player.
Version 1.6.2 is for game 3.3, version 1.4.3 is for game 3.2 (both on the Nexus Files tab). Drop-in over 1.5.x: saves carry over.
Built for single-player. The mod reworks a number of the game's own mechanics (corpses, loot, zombie behaviour), and a server isn't necessarily built for that, so in multiplayer some things may not work. The story scenes also play there without stopping time, since the game pauses only in single-player.
To uninstall, delete the folder. The mod's items and blocks will be gone from the save.
<h3>Links</h3>
@@ -148,4 +109,5 @@ Built for single-player. The mod reworks a number of the game's own mechanics (c
<li>Download on Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
<li>Source: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
<li>The author's YouTube channel: <a href="https://www.youtube.com/@alex_cube" target="_blank" rel="noreferrer noopener">@alex_cube</a></li>
<li>News on Telegram: <a href="https://t.me/necromancerstome" target="_blank" rel="noreferrer noopener">t.me/necromancerstome</a></li>
</ul>
+28
View File
@@ -0,0 +1,28 @@
namespace NecromancerTome
{
/// <summary>
/// Запись состояния урны-цинерария (TEFeatureCinerarium, HarmonySrc/Cinerarium.cs). Здесь, а не в
/// основном проекте, по той же причине, что и PyramidWardWriteHelper: PooledBinaryWriter.Write
/// оттуда не компилируется (см. NecromancerTEPersistence.csproj). Первым байтом - версия, чтобы
/// поля урны (дух, его работа) можно было дописывать, не ломая старые сейвы.
/// </summary>
public static class CinerariumWriteHelper
{
public static void Write(PooledBinaryWriter _bw, byte _version, bool _hasSkull, bool _zoneShown)
{
_bw.Write(_version);
_bw.Write(_hasSkull);
_bw.Write(_zoneShown);
}
/// <summary>Версия 2 (16.10, нити): число, затем сами числа - координаты нитей (см. TEFeatureCinerarium.Write).</summary>
public static void WriteInts(PooledBinaryWriter _bw, int[] _values)
{
_bw.Write(_values.Length);
foreach (int v in _values)
{
_bw.Write(v);
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB