115 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 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
82 changed files with 11015 additions and 260 deletions
+206 -55
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>
+156 -7
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>
@@ -464,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>
@@ -519,4 +527,145 @@
<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 - см. большой
+188 -35
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"/>
@@ -605,4 +592,170 @@
<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>
+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"));
}
}
}
+54 -5
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: «пугается и убегает как при отрицании»).
@@ -825,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).
@@ -956,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);
@@ -971,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 - переключатель, и с ним стервятник не сел бы никогда.
@@ -1524,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;
@@ -1678,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))
{
+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"));
}
}
}
+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 ? "да" : "нет"));
}
}
}
+1 -1
View File
@@ -4,6 +4,6 @@
<DisplayName value="Necromancer's Tome" />
<Description value="A dark necromancy progression for 7 Days to Die 3.3: a 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.1" />
<Version value="1.7.0" />
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
</xml>
+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.
+1 -1
View File
@@ -42,7 +42,7 @@ Updating from 1.5.x: replace the folder, saves carry over. To uninstall, delete
[size=5][b]Requirements[/b][/size]
[list]
[*][b]7 Days to Die 3.3: version 1.6.1. 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]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 story scenes also play there without stopping time, since vanilla pauses only in single-player.
+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.
+6 -4
View File
@@ -2,7 +2,7 @@
*English version below — scroll past the Russian half.*
**Версия 1.6.1** — для 7 Days to Die 3.3. Для игры 3.2 — версия 1.4.3. Каждая сборка на чужой версии игры сама скажет, что несовместима. Автор: 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
@@ -63,7 +63,7 @@
`%APPDATA%/7DaysToDie/` (подходят обе, если её нет — создайте).
3. Запустите игру. Навык «Некромантия» появится на панели навыков крафта сразу.
Версия 1.6.1 — для игры 3.3, версия 1.4.3 — для игры 3.2 (обе на Nexus во вкладке Files).
Версия 1.6.2 — для игры 3.3, версия 1.4.3 — для игры 3.2 (обе на Nexus во вкладке Files).
Обновление поверх 1.5.x: сейвы переносятся.
Рассчитан на одиночную игру. Мод переделывает ряд механик самой игры (трупы, добычу, поведение
@@ -75,6 +75,7 @@
## История версий
- **1.6.2** — совместимость: призрачный вид только у торговцев самой игры; NPC других модов (например, NPCCore) больше не становятся полупрозрачными.
- **1.6.1** — исправление: бутоны и цветы кровавой розы висели над грядкой на полклетки.
- **1.6.0** — дух змеи, одержимость зомби и своих питомцев, чертоги разума, книга некроманта с
главами, слуги охраняют хозяина (один за раз), могильная крыса, костяной скакун, гео-оссуарий,
@@ -99,7 +100,7 @@
# Necromancer's Tome (English)
**Version 1.6.1** — 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.
**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
@@ -162,7 +163,7 @@ Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chine
`%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.6.1 is for game 3.3, version 1.4.3 is for game 3.2 (both 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,
@@ -174,6 +175,7 @@ To uninstall, delete the folder. The mod's items and blocks will be gone from th
## Version history
- **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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -38,7 +38,7 @@
<li>Запустите игру. Навык «Некромантия» появится на панели навыков крафта сразу.</li>
</ol>
Версия 1.6.1 — для игры 3.3, версия 1.4.3 — для игры 3.2 (обе на Nexus во вкладке Files). Обновление поверх 1.5.x: сейвы переносятся.
Версия 1.6.2 — для игры 3.3, версия 1.4.3 — для игры 3.2 (обе на Nexus во вкладке Files). Обновление поверх 1.5.x: сейвы переносятся.
Рассчитан на одиночную игру. Мод переделывает ряд механик самой игры (трупы, добычу, поведение зомби), а сервер не обязательно на такое рассчитан, поэтому в мультиплеере часть вещей может не работать. Сюжетные сцены там проиграются без остановки времени: пауза игры работает только в сингле.
@@ -97,7 +97,7 @@ Fully localized into 13 languages: English, German, Spanish, French, Italian, Ja
<li>Start the game. The Necromancy skill appears in the crafting skills panel right away.</li>
</ol>
Version 1.6.1 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.
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.
+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