89 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5.5 64fc0c060f Главы книги некроманта на 11 языках (#39)
Все 46 строк книги (оглавление, кнопки, восемь глав) переведены на немецкий,
испанский, французский, итальянский, японский, корейский, польский,
бразильский португальский, турецкий и оба китайских. Названия предметов,
кнопок урн и термины взяты из уже переведённых строк мода. В английском
убран лишний «\» перед апострофом на четырёх страницах.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:18:11 +03:00
AlexCubeandClaude Opus 5.5 9d54cab98f Резная кость: отдельный инструмент нитей цинерариев (#86)
Новый предмет necroCarvedBone: бедренная кость с железным колпачком, кольцом
и светящимися рунами; в руке как нож некроманта, хват всегда нитяной и делает
всё то же. Рецепт: кость, 10 кованого железа, щепоть праха; открывается вместе
с цинерариями. Иконка, лист книги, тексты на 13 языках.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:24:15 +03:00
AlexCubeandClaude Opus 5.5 96d930e5bb Ремонтник и лутер: зона из 3x3 квадратов, лишние выключаются (#84)
Сетка «Где работать» в «Содержимом» урны, квадраты очерчены линиями при
показе границ; выключенные квадраты обход пропускает. Сохраняется пятым
числом v6 (старая DLL его пропустит). Консоль: necrourn x y z sectors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:21:39 +03:00
AlexCubeandClaude Opus 5.5 9a567b6ac0 DLL: черепа в урне (#82), скорость змеи на Shift
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:58:16 +03:00
AlexCubeandClaude Opus 5.5 95d5117cfc Змея: скорость на Shift 16.5 -> 12.5 (на четверть меньше)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:51:59 +03:00
AlexCubeandClaude Opus 5.5 3cd0e3d6cb Урна: черепа не пропадают при Shift+клике стопкой (#82); рецепты из кузни на верстак, не больше 8 компонентов, долгие ÷4 (#83)
Проверка «Содержимого» урны - в следующем кадре: игра звала onModified посреди перекладки,
лишние черепа возвращались в исходную ячейку рюкзака и затирались пустотой.
Кузня делает только из вплавленного - урны логиста, лутера, старателя и гео-оссуарий на верстаке
(на ломе). Зомбособака 9 -> 7 компонентов (окно показывает 8). Время долгих вещей вчетверо меньше.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:47:45 +03:00
AlexCubeandClaude Opus 5.5 b5a4ec4a2c Пыль и хранилища магии: свои модели, бандл necromagic, хват книги (#40)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:25:41 +03:00
AlexCubeandClaude Opus 5.5 cf61d98bb7 Одержимый зомби: оружие на поясе по ремеслу (#37), карта по M (#36)
- Пояс - нижний ряд сумки зомби; оружие ближнего боя в руку, если этот тип зомби
  им владеет (ZombieWeapons: таблица умений, утверждена 25.10). Удар как у персонажа:
  ItemActionAttack.Hit, урон предмета без перков зомби, износ, темп оружия.
  Модель на кость RightWeapon, хват WeaponHoldType; иконки и износ на поясе HUD.
  Неумелое или сломанное - кулаки и подсказка (13 языков). Выключатель
  necroarms weapon on|off; dev-команда necropossess для проверок без мыши.
- M в зомби и волке открывал сумку вместо карты (перехват windowpaging) - исправлено;
  туман карты и карта на одержимом - у любого одержимого зомби, как у стервятника.
- Глава «Одержимость» (EN/RU): пояс - оружие, M - карта.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:26:55 +03:00
AlexCubeandClaude Opus 5.5 5ebd09dacd Ремонтник: сначала то, на что есть материал; ячейка нехватки видна всегда
- Блоки без материалов откладываются (_lacking) и не держат очередь;
  к ним ремонтник возвращается, когда чинить больше нечего, и чинит, как
  только материал появился. Зона не останавливается, пока они есть.
- Кнопка жёлтая, только когда кроме ждущих чинить нечего; ячейка «не
  хватает» видна, пока хоть один блок ждёт.
- «Запустить» повторно отпускает старые взятые блоки (Claimed) - раньше
  они не попадали в обход заново.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:47:13 +03:00
AlexCubeandClaude Opus 5.5 3d08511e5b Ремонтник: «с привязкой к урне» вместо «с нитью», «!» и число поверх иконки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:35:07 +03:00
AlexCubeandClaude Opus 5.5 7452eba0d9 Ремонтник: в «Содержимом» урны ячейка с недостающим материалом и «!»
Пока ремонтник ждёт материал, под блоком лимита урн видна ячейка: иконка
материала, сколько доложить в ящики с нитями, красный «!» и подпись.
Только показ, не ячейка сумки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:32:22 +03:00
AlexCubeandClaude Opus 5.5 c208e3b9f9 Урна рабочего: подкладка сырья в кузню до изготовления (#19)
После изготовления очередь до подкладки не доходила: переплавленного
каждый шаг хватало на один цемент. Теперь подкладка первой, но только
в пустую или наполовину опустевшую ячейку входа.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:07:52 +03:00
AlexCubeandClaude Opus 5.5 cc9c0366f0 Урна рабочего: вход сырья кузни держать полным (#19)
Сырьё подкладывалось, только когда переплавленного не хватало на одну
штуку - кузня плавила остатки, цемент шёл по одному. Теперь отдельное
дело: есть место во входе под материал рецепта образца - доложить сырьё
до стака (после изготовления).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:01:57 +03:00
AlexCubeandClaude Opus 5.5 b4335a0448 Урна рабочего: в кузню только сырьё, булыжник не плавить (#19)
Проверка «нет рецепта» не отличала камни от булыжника: у камня тоже есть
рецепты (разбор, выплавка из переплавленного), и дух брал то, что лежит
в ящике раньше. Теперь «сделано руками» = есть рецепт изготовления, не
разбор и не выплавка; такое в кузню не кладётся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:56:59 +03:00
AlexCubeandClaude Opus 5.5 fb9c50cdf9 Урна рабочего: партия до стака, одна ячейка очереди на предмет, сланец не плавить (#19)
- Рецепт ставится в очередь сразу на столько, на сколько хватает всех
  ингредиентов, не больше стака любого ингредиента и стака готового
  (было по одной штуке за шаг - «очень медленно»).
- Пока партия предмета в очереди станции, второй такой не ставится -
  прочие ячейки остаются другим урнам на той же станции.
- Горючее сырьё (нефтяной сланец) в кузню не кладётся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:45:10 +03:00
AlexCubeandClaude Opus 5.5 3938f1786a Игра 3.4: аналитика пояса падала на кинжале некроманта - нож не переключался (#79)
Inventory.SetStackAt до записи в слот зовёт LogToolBeltUsageEvent, та считает
passive_effect без сущности и падает на значении из CVar (@$necroKnifeDamageCVar;
в игре так же у мода modMeleeFortifyingGrip и админской одежды). Исключение
телеметрии глотается финализатором, слот пишется как обычно.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:21:09 +03:00
AlexCubeandClaude Opus 5.5 a747f5aa8d Порт на игру 3.4: замок ячеек, бросок на пустое место окна, проверка версии (#79)
- 3.4 убрала XUiC_ContainerStandardControls.ToggleLockMode: замок при обыске
  крысой/волком и у сумки волка запрещается в сеттере UserLockMode.
- 3.4 кладёт предмет, брошенный на пустое место окна, прямо в ящик/мешок/
  рюкзак (dropItemToGrid) мимо запертых ячеек - при обыске отказ.
- GameVersionGuard: мод собран под 3.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:57:50 +03:00
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
85 changed files with 7650 additions and 556 deletions
+200 -102
View File
File diff suppressed because one or more lines are too long
+69 -2
View File
@@ -210,9 +210,9 @@
</append>
<!-- Фильтр урны-логиста (16.10.2026, HarmonySrc/CinerariumFilterWindow.cs, CinerariumGroups.cs): значки групп предметов
в окне «Содержимое» рядом с ячейкой черепа. Окно в группе bagStorage (xui.xml) - контроллер прячет его, если окно
открыто не урной-логистом (сумки транспорта и волка). f0..f21 - рамки, g0..g21 - значки (иконки ставит контроллер). -->
открыто не урной-логистом (сумки транспорта и волка). f0..f25 - рамки, g0..g25 - значки (иконки ставит контроллер). -->
<append xpath="/windows">
<window name="necroUrnFilter" width="348" height="279" depth="5" panel="Left" backgroundcolor="[black]" cursor_area="true" controller="NecromancerTome.NecroUrnFilter, NecromancerHarmony">
<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" />
@@ -262,6 +262,73 @@
<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>
<!-- Квадраты зоны ремонтника и лутера (#84, 28.10.2026, HarmonySrc/CinerariumZoneWindow.cs): сетка 3x3 в окне «Содержимое»,
север сверху, урна в среднем. Окно в группе bagStorage (xui.xml), вне урн с большой зоной скрыто. s0..s8 - квадраты
(столбец + строка * 3, строка 0 - юг), f0..f8 - их рамки; цвета ставит контроллер. -->
<append xpath="/windows">
<window name="necroUrnZone" width="228" height="296" depth="5" panel="Left" backgroundcolor="[black]" cursor_area="true" controller="NecromancerTome.NecroUrnZone, NecromancerHarmony">
<rect name="header" height="43" depth="0">
<sprite depth="1" name="headerbackground" sprite="ui_game_panel_header" width="228" height="43" />
<sprite depth="3" name="windowIcon" style="icon32px" pivot="center" pos="21,-21" sprite="ui_game_symbol_frames" />
<label depth="2" name="title" pos="39,-6" width="183" height="32" text_key="necroUrnZoneTitle" font_size="26" />
</rect>
<label depth="2" name="north" pos="0,-49" width="228" height="22" justify="center" font_size="20" color="200,200,205" text_key="necroUrnZoneNorth" />
<sprite depth="2" name="f6" pos="10,-74" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s6" pos="12,-76" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f7" pos="80,-74" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s7" pos="82,-76" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f8" pos="150,-74" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s8" pos="152,-76" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f3" pos="10,-144" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s3" pos="12,-146" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f4" pos="80,-144" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s4" pos="82,-146" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f5" pos="150,-144" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s5" pos="152,-146" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f0" pos="10,-214" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s0" pos="12,-216" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f1" pos="80,-214" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s1" pos="82,-216" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="2" name="f2" pos="150,-214" width="68" height="68" sprite="menu_empty2px" type="sliced" fillcenter="false" />
<sprite depth="3" name="s2" pos="152,-216" width="64" height="64" sprite="menu_empty2px" type="sliced" on_press="true" sound="[paging_click]" />
<sprite depth="4" name="urn" pos="114,-178" width="36" height="36" pivot="center" sprite="ui_game_symbol_skull" color="[white]" />
</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>
<!-- Чего не хватает ремонтнику (24.10.2026): ячейка с материалом и «!» под блоком лимита урн, только показ
(HarmonySrc/CinerariumRepairNeed.cs). -->
<append xpath="/windows/window[@name='windowBagStorage']">
<rect name="necroRepairNeed" pos="87,-132" width="420" height="75" depth="6" controller="NecromancerTome.NecroRepairNeed, NecromancerHarmony">
<sprite depth="6" pos="0,0" width="420" height="75" color="0,0,0,190" sprite="menu_empty2px" type="sliced" />
<sprite depth="7" pos="0,0" width="75" height="75" sprite="menu_empty3px" color="[black]" type="sliced" fillcenter="false" />
<sprite depth="7" pos="2,-2" width="71" height="71" sprite="menu_empty3px" color="200,40,40,255" type="sliced" fillcenter="false" />
<sprite depth="8" name="icon" pos="37,-37" width="64" height="64" atlas="ItemIconAtlas" pivot="center" foregroundlayer="true" />
<label depth="9" name="excl" pos="7,-2" width="24" height="38" font_size="38" color="235,40,40,255" text="!" effect="outline" foregroundlayer="true" />
<label depth="9" name="count" pos="4,-50" width="66" height="22" font_size="22" justify="right" color="[white]" effect="outline" foregroundlayer="true" />
<label depth="7" pos="85,-14" width="330" height="48" font_size="20" color="[white]" text_key="necroCinerariumRepairNeed" />
</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>
+1
View File
@@ -62,5 +62,6 @@
<!-- Фильтр урны-логиста - в окне хранилища игры (bagStorage), рядом с ячейкой «Содержимого» (windows.xml). -->
<append xpath="/xui/window_group[@name='bagStorage']">
<window name="necroUrnFilter" />
<window name="necroUrnZone" />
</append>
</xui>
+95 -10
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>
@@ -529,6 +537,7 @@
включает 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"/>
@@ -568,19 +577,95 @@
</block>
<!-- Урна-логист (16.10.2026): «Я не могу ставить урны рядом. Может нам для логиста вообще сделать отдельные урны?»
Без зоны (ставится вплотную к любым урнам), только нити и перевозка (CinerariumThreads.cs), лимит урн общий.
Модель - та же; отличие - код (TEFeatureCinerarium.SyncGlow): зелёные руны и глазницы
(Resources/necroCinerarium_e_logist.png), зеленоватая глазурь. Иконка - своя (зелёные руны, нити из горлышка).
Своя модель логиста - позже, в Blender. -->
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 роз. Заменяет кровь некроманта в копиях
@@ -2035,4 +1913,87 @@
<property name="CustomIconTint" value="150, 205, 255"/>
</item>
</append>
<!-- Резная кость (#86, 28.10.2026): отдельный инструмент нитей для урн - делает то же, что нож хватом для нитей
(HarmonySrc/CinerariumThreads.cs, IsThreadTool), для тех, кто хват у ножа не нашёл. Наследует нож для нитей:
хват 68 + поза рук мода (нож в левой руке), действия ножа перехвачены нитями. В руке лежит ровно как нож:
модель - копия префаба ножа со своей сеткой (BlenderWork/necroCarvedBone, Unity NecroCarvedBoneSetup.cs,
бандл necroknife): бедренная кость длиной с нож, головка на месте клинка, железный колпачок, кольцо на месте
гарды, руны цвета нитей. Новый предмет - сейвов ножа не касается. -->
<append xpath="/items">
<item name="necroCarvedBone">
<property name="Extends" value="necroWpnBladeNecroKnifeThread"/>
<property name="CreativeMode" value="Player"/>
<property name="Group" value="Tools/Traps,Necromancy"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroknife?necroCarvedBonePrefab.prefab"/>
<property name="CustomIcon" value="necroCarvedBone"/>
<property name="CustomIconTint" value="255, 255, 255"/>
<property name="DescriptionKey" value="necroCarvedBoneDesc"/>
<property name="ShowQuality" value="false"/>
<property name="EconomicValue" value="0"/>
</item>
</append>
<!-- Магические ингредиенты (18.10.2026): магическая пыль, хранилище магии (колба-«батарейка» в латунных клеммах с
маркировкой MAGIA KONSERVUJO / KAPACITO ✶ 30) и повреждённое хранилище. Пыль - из ступки (4 из повреждённого,
30 из целого); пыль - будущий ингредиент камня портала, целое хранилище - деталь батарей/портала, сами хранилища
позже появятся в префабе. Модели свои (26.10.2026, #40): necroMagic.blend -> бандл necromagic (Unity NecroMagicSetup.cs);
хват книги (21) - пыль горкой лежит на ладони (указание 26.10), колба лежит вдоль ладони. -->
<append xpath="/items">
<item name="necroMagicDust">
<property name="DescriptionKey" value="necroMagicDustDesc"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necromagic?necroMagicDustPrefab.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="21"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necromagic?necroManaVesselPrefab.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="21"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necromagic?necroManaVesselBrokenPrefab.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>
+3 -1
View File
@@ -181,7 +181,9 @@
пользуется, вода сидит на necroNecromancyLvl20. -->
<unlock_entry item="necroModKnifeScavengersFeast,bookSummonRatSpirit" unlock_tier="3"/>
<unlock_entry item="necroModKnifeGravesRepose" unlock_tier="4"/>
<unlock_entry item="necroModKnifeDarkSense,necroCinerarium,necroCinerariumLogist" unlock_tier="5"/>
<!-- 22.10.2026: урны - одной иконкой с recipes= (как комплекты брони в ванили): в окне навыка у строки только 14 иконок, в группе было 17 - последние урны не показывались («Не все рецепты цинерария доступны»). Клик по иконке - все урны списком. -->
<unlock_entry item="necroModKnifeDarkSense" unlock_tier="5"/>
<unlock_entry item="necroCinerarium" unlock_tier="5" recipes="necroCinerarium,necroCarvedBone,necroCinerariumLogist,necroCinerariumWorker,necroCinerariumRepair,necroCinerariumLoot,necroCinerariumProspector"/>
</display_entry>
<!-- Ступенчатая разблокировка внутри группы. ПЕРЕРАСПРЕДЕЛЕНО 2026-09-09: четыре из
шести модов ножа (вода/еда/покой/чутьё) уехали отсюда в группу 1 - см. большой
+111 -47
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 +
@@ -60,7 +33,7 @@
weapon - "он не является начальным оружием... больше про комфорт" - so the same
circular-dependency concern doesn't apply, and the user wants it to actually cost a
real chunk of ash rather than a token amount. -->
<recipe name="thrownStonePortalBlue" count="1" tags="packMuleCrafting">
<recipe name="thrownStonePortalBlue" count="1" craft_time="31" tags="packMuleCrafting">
<ingredient name="resourceRockSmall" count="1"/>
<ingredient name="drinkJarPureMineralWater" count="3"/>
<ingredient name="resourceYuccaFibers" count="15"/>
@@ -117,7 +90,7 @@
their current HP per unit to make (NecromancerBloodPatch.cs) - this recipe shouldn't
demand many. -->
<append xpath="/recipes">
<recipe name="necroHeresyPyramid" count="1" craft_area="workbench" tags="workbenchCrafting,necroNecromancyAdept">
<recipe name="necroHeresyPyramid" count="1" craft_area="workbench" craft_time="191" tags="workbenchCrafting,necroNecromancyAdept">
<ingredient name="resourceZombieAsh" count="1200"/>
<ingredient name="resourceRockSmall" count="300"/>
<ingredient name="resourceYuccaFibers" count="200"/>
@@ -151,15 +124,15 @@
personal crafting). workbenchCrafting is just the same UI-categorization tag vanilla's
own workbench recipes carry alongside craft_area, not a separate gate. -->
<append xpath="/recipes">
<recipe name="bookSummonZombieDog" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyLvl1300">
<!-- 26.10.2026: окно рецепта показывает только 8 компонентов - девятый (прах) был не виден, книга «не делалась».
Сокращено до 7 (без монет и ткани). Время - вчетверо меньше расчётного игрой (50 гнилого мяса по 95 с = 80 мин). -->
<recipe name="bookSummonZombieDog" count="1" craft_area="workbench" craft_time="1210" tags="learnable,workbenchCrafting,necroNecromancyLvl1300">
<ingredient name="foodRottingFlesh" count="50"/>
<ingredient name="medicalBloodBag" count="3"/>
<ingredient name="drinkJarBoiledWater" count="4"/>
<ingredient name="resourceYuccaFibers" count="50"/>
<ingredient name="resourceCloth" count="3"/>
<ingredient name="resourceLeather" count="10"/>
<ingredient name="resourceBone" count="40"/>
<ingredient name="casinoCoin" count="5"/>
<ingredient name="resourceZombieAsh" count="10"/>
</recipe>
</append>
@@ -187,7 +160,7 @@
REDISTRIBUTED 2026-08-30, was necroNecromancyLvl200 before) - not specified by the user,
guessed consistent with the other "advanced ritual" summon items. -->
<append xpath="/recipes">
<recipe name="bookBanshee" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyNecromancer">
<recipe name="bookBanshee" count="1" craft_area="workbench" craft_time="241" tags="learnable,workbenchCrafting,necroNecromancyNecromancer">
<ingredient name="resourcePaper" count="15"/>
<ingredient name="resourceGlue" count="3"/>
<ingredient name="foodRottingFlesh" count="10"/>
@@ -480,12 +453,14 @@
именно тогда (правило «порог по нужде, а не по силе»). Тег и unlock_level группы 2 в
progression.xml обязаны совпадать. -->
<append xpath="/recipes">
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<!-- 22.10.2026 (#66): котёл кованый («Он весь из себя кованный, так что будет логично»): железо вместо дерева
и юкки, кости и прах - ноги и содержимое. 26.10: из кузни - на верстак, на ломе: в кузне делается только то, что
в неё вплавлено, а кости и прах в неё не кладутся (стрим 26.10). -->
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<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 +468,7 @@
гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по
указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. -->
<append xpath="/recipes">
<recipe name="necroBoneSteedPlaceable" count="1" craft_time="120" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<recipe name="necroBoneSteedPlaceable" count="1" craft_time="30" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/>
@@ -546,7 +521,7 @@
<ingredient name="resourceYuccaFibers" count="15"/>
<ingredient name="resourceZombieAsh" count="20"/>
</recipe>
<recipe name="necroHeresyPyramid" count="1" craft_area="workbench" tags="workbenchCrafting,necroNecromancyAdept">
<recipe name="necroHeresyPyramid" count="1" craft_area="workbench" craft_time="191" tags="workbenchCrafting,necroNecromancyAdept">
<ingredient name="resourceZombieAsh" count="1200"/>
<ingredient name="resourceRockSmall" count="300"/>
<ingredient name="resourceYuccaFibers" count="200"/>
@@ -580,14 +555,16 @@
<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): котёл кованый («Он весь из себя кованный, так что будет логично»): железо вместо дерева
и юкки, кости и прах - ноги и содержимое. 26.10: из кузни - на верстак, на ломе: в кузне делается только то, что
в неё вплавлено, а кости и прах в неё не кладутся (стрим 26.10). -->
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceScrapIron" 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_time="120" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<recipe name="necroBoneSteedPlaceable" count="1" craft_time="30" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/>
@@ -621,20 +598,107 @@
<ingredient name="resourceBone" count="10"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
<!-- Урна-логист (16.10: «Рецепт с набором для шитья») - рецепт урны и набор для шитья; копия на соке чёрной розы. -->
<recipe name="necroCinerariumLogist" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<!-- Урна-логист (16.10: «Рецепт с набором для шитья») - набор для шитья; копия на соке чёрной розы.
18.10: своя модель - латунное кадило («Сейчас кадило выглядит бронзовым. Учти это в рецепте, используя латунь и кузню»):
вместо глины латунь, плюс немного песка и угля («добавь в рецепт немного песка и угля») и воск - покрытие, чтобы латунь не тускнела («Это кстати будет шикарным поводом ставить пасеку»), делается в кузне.
26.10: на верстаке, на латунном ломе, без песка и угля - кузня не берёт кости и прах (стрим 26.10). -->
<recipe name="necroCinerariumLogist" count="1" craft_area="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyLvl300">
<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="resourceBeeswax" count="2"/>
</recipe>
<recipe name="necroCinerariumLogist" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceClayLump" count="30"/>
<recipe name="necroCinerariumLogist" count="1" craft_area="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyLvl300">
<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="resourceBeeswax" count="2"/>
</recipe>
<!-- Резная кость (#86, 28.10.2026) - инструмент нитей: «Рецепт простой - одна кость и 10 железа» (кованое), «В рецепт
добавь щепотку праха». В рюкзаке, открывается вместе с урнами (progression.xml, та же строка). -->
<recipe name="necroCarvedBone" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl300">
<ingredient name="resourceBone" count="1"/>
<ingredient name="resourceForgedIron" count="10"/>
<ingredient name="resourceZombieAsh" count="5"/>
</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) - железная банка с крышкой-кадилом, шов пропаян свинцом («в рецепте железо и немного свинца»):
железный и свинцовый лом вместо глины, отмычка; на крови и на соке. 26.10: из кузни - на верстак (кузня не берёт кости и прах). -->
<recipe name="necroCinerariumLoot" count="1" craft_area="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyLvl300">
<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="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyLvl300">
<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 вместо железа и свинца, железная кирка вместо отмычки;
на крови и на соке. Открывается вместе с остальными урнами. 26.10: из кузни - на верстак (кузня не берёт кости и прах). -->
<recipe name="necroCinerariumProspector" count="1" craft_area="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyLvl300">
<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="workbench" craft_time="60" tags="learnable,workbenchCrafting,necroNecromancyLvl300">
<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>
+2 -2
View File
@@ -75,14 +75,14 @@ namespace NecromancerTome
}
}
/// <summary>Череп и нож (меч прилива) - модели предметов: в руке и на земле.</summary>
/// <summary>Череп, нож (меч прилива) и резная кость (#86) - модели предметов: в руке и на земле.</summary>
[HarmonyPatch(typeof(ItemClass), nameof(ItemClass.CloneModel))]
public static class Patch_ItemClass_CloneModel_BundleShaderFix
{
public static void Postfix(ItemClass __instance, AssetManagement.InstanceHandle<GameObject> __result)
{
string n = __instance?.Name;
if (n != "necroSkull" && n != SnakeMode.KnifeName)
if (n != "necroSkull" && n != SnakeMode.KnifeName && n != CinerariumThreads.BoneName)
{
return;
}
+645 -124
View File
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
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;
}
}
// 2а. Кузня: вход сырья держать полным (24.10, пользователь: «Места не должны пустовать. Нужно чтобы заполнены были
// обе ячейки»). Раньше сырьё подкладывалось, только когда переплавленного не хватало даже на одну штуку, - кузня
// плавила остатки, и дух ставил цемент по одному. Подкладка - ДО изготовления (после него очередь до неё не
// доходила: переплавленного каждый шаг хватало на одну штуку), но только в пустую или наполовину опустевшую ячейку -
// кузня плавит непрерывно, и иначе подкладка отнимала бы все шаги.
if (hasSample)
{
string feedName = sample.itemValue.ItemClass?.GetItemName();
foreach (Station st in stations)
{
if (!st.Te.isModuleUsed[MaterialModule] || !OutputHasRoom(st.Te, sample) && !outRoom)
{
continue;
}
foreach (Recipe r in CraftingManager.GetRecipes(feedName))
{
if (r.IsScrap || !AreaFits(r, st.Areas) || owner != null && !r.IsUnlocked(owner))
{
continue;
}
foreach (ItemStack ing in r.ingredients)
{
string iname = ing?.itemValue?.ItemClass?.GetItemName();
if (iname == null || UnitSlot(st.Te, iname) < 0)
{
continue;
}
if (FeedMaterial(st, iname, src, _dry, out Vector3i? rawAt, true))
{
_from = rawAt;
if (!_dry)
{
Ignite(st.Te, src, false);
}
return st.Pos;
}
}
}
}
}
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; // выход полон и ящики «куда» полны (или их нет) - ниже станция погаснет
}
if (InQueue(st.Te, sample.itemValue.type))
{
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, out int batches))
{
// Кузня: не хватает переплавленного материала - положить сырьё во вход (плавит сама, с огнём).
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, batches);
Enqueue(st.Te, slot, r, batches);
Ignite(st.Te, src, false);
Debug.Log("[NecromancerTome] Cinerarium: рабочий поставил " + name + " x" + batches + " на станцию " + 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;
}
/// <summary>Есть ли в очереди станции рецепт этого предмета. 24.10, пользователь: партия - до стака, и «если бы их было
/// двое, то тут нужно занимать очередь на ячейку»: один предмет держит в очереди одну ячейку, прочие - другим урнам на
/// той же станции (иначе одна урна забила бы очередь стаками на часы вперёд).</summary>
static bool InQueue(TileEntityWorkstation _te, int _itemType)
{
foreach (RecipeQueueItem q in _te.Queue)
{
if (q != null && q.Recipe != null && q.Multiplier > 0 && q.Recipe.itemValueType == _itemType)
{
return true;
}
}
return false;
}
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 - какого переплавленного не хватило (кузне можно подложить сырья). _batches - сколько раз рецепт ставится
/// в очередь за одно дело (24.10, пользователь: «в бетономешалку кладётся по одному камушку... ожидается, что он будет
/// класть хотя бы по целому стаку, а если на стак не хватает, то сколько найдёт»): на сколько хватает каждого
/// ингредиента, но не больше стака ни одного ингредиента и не больше стака готового.</summary>
static bool Ingredients(Recipe _r, TileEntityWorkstation _te, List<(Vector3i p, TEFeatureStorage box)> _src, out Vector3i? _srcAt, out string _shortUnit, out int _batches)
{
_srcAt = null;
_shortUnit = null;
ItemClass outClass = ItemClass.GetForId(_r.itemValueType);
_batches = outClass != null && _r.count > 0 ? Mathf.Max(1, outClass.MaxCount / _r.count) : 1;
_batches = Mathf.Min(_batches, short.MaxValue);
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;
}
_batches = Mathf.Min(_batches, _te.Input[us].count / ing.count);
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;
}
int stack = ing.itemValue.ItemClass != null ? ing.itemValue.ItemClass.MaxCount : ing.count;
_batches = Mathf.Min(_batches, Mathf.Max(1, stack / ing.count), have / ing.count);
}
_batches = Mathf.Max(1, _batches);
return true;
}
static void TakeIngredients(Recipe _r, TileEntityWorkstation _te, List<(Vector3i p, TEFeatureStorage box)> _src, int _batches)
{
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 * _batches;
inputChanged = true;
continue;
}
int need = ing.count * _batches;
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, int _batches)
{
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 = (short)_batches,
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>
/// <remarks>_halfEmpty - только в пустую ячейку или заполненную меньше чем наполовину (подкладка впрок, шаг 2а).</remarks>
static bool FeedMaterial(Station _st, string _unit, List<(Vector3i p, TEFeatureStorage box)> _src, bool _dry, out Vector3i? _at, bool _halfEmpty = false)
{
_at = null;
string mat = _unit.Substring(5);
ItemStack[] input = _st.Te.Input;
// 23.10 («вместо камней вплавляет булыжник»): сначала сырьё, у которого нет рецепта (камни, лом), сделанное руками
// (булыжник) - только если сырья нет. Раньше - первое подходящее по порядку ячеек.
// 24.10 («работник положил сланец в переплавку в печку»): горючее (нефтяной сланец - сырьё для бензина) не плавится
// вовсе. 24.10 («и снова засунул булыжник!!!»): плавится только сырьё - сделанное руками (булыжник) не плавится
// никогда. Прежняя проверка «нет рецепта» не отличала камни от булыжника: у камня тоже есть рецепты (разбор в лом,
// выплавка из переплавленного) - см. Handmade.
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_") || ItemClass.GetFuelValue(g.itemValue) > 0 || Handmade(c))
{
return -1;
}
return 0;
}))
{
{
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 (_halfEmpty && !empty && input[i].count * 2 > ic.MaxCount)
{
room = 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;
}
/// <summary>Сделано руками: есть рецепт изготовления - не разбор (IsScrap, wildcard_forge_category) и не выплавка в кузне
/// из переплавленного (material_based). Камни и лом - сырьё (только такие рецепты), булыжник - сделан руками.</summary>
static bool Handmade(ItemClass _c)
{
foreach (Recipe r in CraftingManager.GetRecipes(_c.GetItemName()))
{
if (!r.IsScrap && !r.wildcardForgeCategory && !r.materialBasedRecipe)
{
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;
}
}
}
}
+223 -40
View File
@@ -25,6 +25,8 @@ namespace NecromancerTome
/// <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>
@@ -49,11 +51,11 @@ namespace NecromancerTome
}
return (pl.GetPosition() - Origin.position - _scenePos).sqrMagnitude > SleepDistance * SleepDistance;
}
/// <summary>Руки (16.10: «Т-поза бесит»): плечо от вертикали вбок и вперёд, локоть согнут вперёд, градусы.
/// Модель в позе покоя, аниматора нет - кости ставятся один раз при появлении.</summary>
public static float ArmOut = 12f;
public static float ArmFwd = 15f;
public static float Elbow = 35f;
/// <summary>Руки в жесте: плечо от вертикали вбок, локоть согнут вперёд, градусы. Покой - «смирно» (18.10: «Всё Смирно и
/// будет позой для бездельничающих некротехов») запечён в позу покоя модели (pose_attention.json при выгрузке FBX),
/// аниматора нет - без жеста кости не трогаются.</summary>
public static float ArmOut = 3f;
public static float Elbow = 0f;
static GameObject _prefab;
static bool _tried;
@@ -72,7 +74,19 @@ namespace NecromancerTome
return _prefab != null;
}
public static GameObject Spawn(Transform _urnModel)
/// <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())
{
@@ -103,7 +117,15 @@ namespace NecromancerTome
{
Object.Destroy(c);
}
g.AddComponent<CinerariumGenieMotion>().Pose();
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;
}
@@ -149,8 +171,7 @@ namespace NecromancerTome
m.EnableKeyword("_EMISSION");
m.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None;
}
m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
SetLook(m);
SetLook(m); // смешивание, глубина и очередь - тоже там (Solid)
_ghostMats[_src] = m;
return m;
}
@@ -160,12 +181,33 @@ namespace NecromancerTome
/// <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);
@@ -230,16 +272,52 @@ namespace NecromancerTome
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 class CinerariumGenieMotion : MonoBehaviour
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>
@@ -247,8 +325,14 @@ namespace NecromancerTome
{
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;
@@ -268,14 +352,15 @@ namespace NecromancerTome
Quaternion[] _armRest;
static bool _armLogged;
/// <summary>Опустить руки из позы покоя: направление плеча и предплечья задаётся в осях модели (вперёд - +Z),
/// поворот - от текущего направления кости к нужному, так что оси самих костей значения не имеют.</summary>
/// <summary>Покой - руки в позе модели («смирно»).</summary>
public void Pose()
{
Pose(CinerariumGenie.ArmFwd, CinerariumGenie.Elbow, CinerariumGenie.Elbow);
Pose(0f, CinerariumGenie.Elbow, CinerariumGenie.Elbow);
}
void Pose(float _fwd, float _elbowL, float _elbowR)
/// <summary>Жест долей _w от покоя: плечо вперёд на GestureFwd, направление плеча и предплечья задаётся в осях модели
/// (вперёд - +Z), поворот - от текущего направления кости к нужному, так что оси самих костей значения не имеют.</summary>
void Pose(float _w, float _elbowL, float _elbowR)
{
if (_arm == null)
{
@@ -311,6 +396,10 @@ namespace NecromancerTome
_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];
@@ -319,11 +408,11 @@ namespace NecromancerTome
continue;
}
float sign = Mathf.Sign(transform.InverseTransformPoint(up.position).x);
float o = CinerariumGenie.ArmOut * Mathf.Deg2Rad, f = _fwd * Mathf.Deg2Rad;
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.FromToRotation(fore.position - up.position, transform.TransformDirection(upDir)) * up.rotation;
fore.rotation = Quaternion.FromToRotation(hand.position - fore.position, transform.TransformDirection(foreDir)) * fore.rotation;
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;
}
}
@@ -343,11 +432,23 @@ namespace NecromancerTome
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, CinerariumGenie.OffsetY + (beside ? 0.15f : 0.8f), 0f),
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);
@@ -355,14 +456,46 @@ namespace NecromancerTome
prev = st.Hover;
_stops.Add(st);
}
_backFly = Mathf.Max(BackSeconds, Vector3.Distance(prev, home) / FlySpeed);
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()
{
return new Vector3(0f, CinerariumGenie.OffsetY, 0f) + Quaternion.Euler(0f, _yaw, 0f) * new Vector3(0f, 0f, CinerariumGenie.Forward);
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>
@@ -378,8 +511,8 @@ namespace NecromancerTome
{
if (_w > 0f)
{
float wave = Mathf.Sin(_t * Mathf.PI * 2f * 1.5f) * GestureWave * _w;
Pose(Mathf.Lerp(CinerariumGenie.ArmFwd, GestureFwd, _w), CinerariumGenie.Elbow + 15f * _w + wave, CinerariumGenie.Elbow + 15f * _w - wave);
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)
@@ -438,29 +571,74 @@ namespace NecromancerTome
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;
bool asleep = CinerariumGenie.PlayerFar(transform.position);
if (asleep && !_asleep)
// Уснул - игрок далеко или дух не среди ближайших (CinerariumLoad.AnimateNearest): замирает дома, работа идёт.
bool asleep = CinerariumGenie.PlayerFar(transform.position) || !CinerariumLoad.Animated(this);
if (asleep && !_asleep && !Digging) // старатель под землёй - не возвращать домой
{
_t = -1f; // полёт прерван - дело всё равно выполнится
Gesture(0f);
transform.localPosition = Home();
}
_asleep = asleep;
if (_hiddenApplied != CinerariumGenie.Hidden)
}
// Виден ли: не скрыт командой; дух логиста - только в полёте (дома сидит в кадиле). Рендереры - только при смене
// (16.10, проверка на утечки: раньше список рендереров собирался каждые 0.5 с - мусор для GC).
bool show = !CinerariumGenie.Hidden && (!Small || _t >= 0f) && !DigHidden;
if (_hiddenApplied != !show)
{
// Только при смене (16.10, проверка на утечки: раньше список рендереров собирался каждые 0.5 с - мусор для GC).
_hiddenApplied = CinerariumGenie.Hidden;
_hiddenApplied = !show;
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
{
r.enabled = !_hiddenApplied;
r.enabled = show;
}
}
if (DigUpdate(_asleep))
{
return; // старатель: спуск, под землёй, подъём (CinerariumProspector.cs)
}
if (_stuck || _asleep)
{
@@ -484,7 +662,7 @@ namespace NecromancerTome
return;
}
acc += st.Fly;
if (_t < acc + WorkSeconds)
if (!st.Pass && _t < acc + WorkSeconds)
{
transform.localPosition = st.Hover;
Face(st.Look - st.Hover); // за работой - лицом к месту
@@ -492,7 +670,10 @@ namespace NecromancerTome
transform.localRotation = Quaternion.Euler(0f, _yaw, 0f);
return;
}
if (!st.Pass)
{
acc += WorkSeconds;
}
from = st.Hover;
}
if (_t < acc + _backFly)
@@ -506,6 +687,11 @@ namespace NecromancerTome
_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
@@ -544,7 +730,8 @@ namespace NecromancerTome
"necrogenie alpha <0-1> - body brightness (the body adds light, it is not alpha-blended)\n" +
"necrogenie glow <0-1> - body's own glow (visible at night)\n" +
"necrogenie tint <r> <g> <b> - body colour\n" +
"necrogenie arms <out> <fwd> <elbow> - arm pose, degrees: upper arm from vertical sideways and forward, elbow bend\n" +
"necrogenie 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";
}
@@ -589,18 +776,14 @@ namespace NecromancerTome
break;
case "arms":
CinerariumGenie.ArmOut = v;
if (_params.Count >= 3 && float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float af))
{
CinerariumGenie.ArmFwd = af;
}
if (_params.Count >= 4 && float.TryParse(_params[3], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float ae))
if (_params.Count >= 3 && float.TryParse(_params[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float ae))
{
CinerariumGenie.Elbow = ae;
}
foreach (CinerariumGenieMotion m in CinerariumGenie.Live)
{
m.Pose();
}
break;
case "solid":
CinerariumGenie.Solid = v != 0f;
CinerariumGenie.ApplyAlpha();
break;
case "tint":
if (_params.Count >= 4 &&
@@ -620,7 +803,7 @@ namespace NecromancerTome
", bob " + CinerariumGenie.BobHeight.ToString("F2") + " / " + CinerariumGenie.BobPeriod.ToString("F1") + " s, alpha " +
CinerariumGenie.Alpha.ToString("F2") + ", glow " + CinerariumGenie.Glow.ToString("F2") + ", tint " +
CinerariumGenie.Tint.r.ToString("F2") + " " + CinerariumGenie.Tint.g.ToString("F2") + " " + CinerariumGenie.Tint.b.ToString("F2") + ", arms " + CinerariumGenie.ArmOut.ToString("F0") + " " +
CinerariumGenie.ArmFwd.ToString("F0") + " " + CinerariumGenie.Elbow.ToString("F0") + ", sleep " + CinerariumGenie.SleepDistance.ToString("F0") + " m" + (CinerariumGenie.Hidden ? ", HIDDEN" : "") + "; spirits on screen: " + CinerariumGenie.Live.Count);
CinerariumGenie.Elbow.ToString("F0") + ", sleep " + CinerariumGenie.SleepDistance.ToString("F0") + " m" + (CinerariumGenie.Hidden ? ", HIDDEN" : "") + "; spirits on screen: " + CinerariumGenie.Live.Count);
}
}
}
+55 -13
View File
@@ -17,8 +17,15 @@ namespace NecromancerTome
{
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;
public const int Count = 22;
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>
@@ -26,11 +33,12 @@ namespace NecromancerTome
{
"foodMeatStew", "foodCanChili", "foodRawMeat", "foodCropCorn", "plantedChrysanthemum3Harvest", "plantedCorn1",
"drinkJarBoiledWater", "medicalFirstAidKit", "resourceRockSmall", "resourceForgedSteel", "resourceMechanicalParts",
"resourceWood", "resourceGunPowder", "meleeToolPickT1IronPickaxe", "gunHandgunT1Pistol", "ammo9mmBulletBall",
"armorMinerHelmet", "modGunLaserSight", "bookArtOfMiningLuckyStrike", "cobblestoneMaster", "necroSkull", "oldCash"
"resourceCloth", "resourceGunPowder", "meleeToolPickT1IronPickaxe", "gunHandgunT1Pistol", "ammo9mmBulletBall",
"armorMinerHelmet", "modGunLaserSight", "bookArtOfMiningLuckyStrike", "cobblestoneMaster", "necroSkull", "oldCash",
"resourceChickenFeed", "domesticatedChicken", "vehicleMinibikePlaceable", "resourceConcreteMix"
};
/// <summary>Ключ названия группы в Localization.csv: necroGroup0..necroGroup21.</summary>
/// <summary>Ключ названия группы в Localization.csv: necroGroup0..necroGroup25.</summary>
public static string NameKey(int _g) => "necroGroup" + _g;
static readonly HashSet<string> OreNames = new HashSet<string>
@@ -38,9 +46,14 @@ namespace NecromancerTome
"resourceRockSmall", "resourceRockSmallBundle", "resourceScrapIron", "resourceScrapIronBundle", "resourceScrapLead",
"resourceLeadBundle", "resourceCoal", "resourceCoalBundle", "resourcePotassiumNitratePowder",
"resourcePotassiumNitratePowderBundle", "resourceOilShale", "resourceOilShaleBundle", "resourceClayLump",
"resourceCrushedSand", "resourceCobblestones", "resourceGoldNugget", "resourceSilverNugget", "resourceRawDiamond",
"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>
{
@@ -49,8 +62,8 @@ namespace NecromancerTome
};
static readonly HashSet<string> MaterialNames = new HashSet<string>
{
"resourceWood", "resourceWoodBundle", "resourceCloth", "resourceLeather", "resourceBone", "resourceFeather",
"resourceYuccaFibers", "resourcePaper", "resourceCement", "resourceConcreteMix", "resourceBeeswax", "resourceCandleStick",
"resourceCloth", "resourceLeather", "resourceBone", "resourceFeather",
"resourceYuccaFibers", "resourcePaper", "resourceBeeswax", "resourceCandleStick",
"resourceAnimalFat"
};
/// <summary>Детали, которые теги отнесли бы не туда (гвозди - патроны гвоздомёта).</summary>
@@ -62,11 +75,12 @@ namespace NecromancerTome
};
static readonly HashSet<string> RawNames = new HashSet<string>
{
"foodRawMeat", "foodRawMeatBundle", "foodEgg", "foodHoney", "foodRottingFlesh", "resourceChickenFeed"
"foodRawMeat", "foodRawMeatBundle", "foodEgg", "foodHoney", "foodRottingFlesh"
};
/// <summary>Предметы и блоки самого мода - по его Config/items.xml и blocks.xml (у многих нет «necro» в имени: книги
/// призыва, браслет, камни портала, прах зомби, кожа жертвы). Читается один раз.</summary>
/// <summary>Предметы и блоки самого мода - по его Config/items.xml, blocks.xml и item_modifiers.xml (у многих нет «necro»
/// в имени: книги призыва, браслет, камни портала, прах зомби, кожа жертвы; 23.10 - кровавая сфера и кровавый камень,
/// они модификаторы, и без item_modifiers.xml уходили в «Модификаторы»). Читается один раз.</summary>
static HashSet<string> _modNames;
static HashSet<string> ModNames()
@@ -76,13 +90,13 @@ namespace NecromancerTome
return _modNames;
}
_modNames = new HashSet<string>();
foreach (string f in new[] { "items.xml", "blocks.xml" })
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]"))
foreach (XmlNode n in doc.SelectNodes("//item[@name] | //block[@name] | //item_modifier[@name]"))
{
_modNames.Add(n.Attributes["name"].Value);
}
@@ -120,10 +134,34 @@ namespace NecromancerTome
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;
@@ -140,6 +178,10 @@ namespace NecromancerTome
{
return Books;
}
if (BuildingNames.Contains(n))
{
return Building;
}
if (OreNames.Contains(n))
{
return Ore;
+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 : ""));
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Чего не хватает ремонтнику (24.10.2026, пользователь: «сделать в его урне блок текста, который показывает какой материал
/// нужен в данный момент? А то не угадать», «Пусть показывает в виде ячейки с материалом», «И восклицательный знак. Чтобы
/// было понятно что именно это требуется»). Ячейка дописана в окно игры windowBagStorage (Config/XUi_InGame/windows.xml)
/// под блоком лимита урн: иконка материала, сколько доложить в ящики с нитями, красный «!». Только показ - не ячейка
/// сумки, взять из неё нечего. Видна у ремонтника, пока хоть один блок ждёт материал (TEFeatureCinerarium.LackType/LackCount) - и когда он чинит
/// остальное.
/// </summary>
[Preserve]
public class XUiC_NecroRepairNeed : XUiController
{
XUiV_Sprite _icon;
XUiV_Label _count;
float _nextRefresh;
public override void Init()
{
base.Init();
_icon = GetChildById("icon")?.ViewComponent as XUiV_Sprite;
_count = GetChildById("count")?.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 + 0.5f;
TEFeatureCinerarium urn = CinerariumContents.Urn;
ItemClass ic = urn != null && urn.IsRepairer && urn.LackType > 0 ? ItemClass.GetForId(urn.LackType) : null;
ViewComponent.IsVisible = ic != null;
if (ic == null)
{
return;
}
if (_icon != null)
{
_icon.SpriteName = ic.GetIconName();
_icon.Color = ic.GetIconTint();
}
if (_count != null)
{
_count.Text = urn.LackCount.ToString();
}
}
}
}
+139 -13
View File
@@ -21,11 +21,23 @@ namespace NecromancerTome
/// (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>Резная кость (#86, 28.10.2026): отдельный инструмент нитей - делает то же, что нож хватом для нитей, хват
/// всегда для нитей, меню хватов по R у неё нет. Для тех, кто не нашёл хват у ножа (пользователь: «чтобы игроки, которые
/// не обнаружили нитяной хват у ножа, легко могли найти отдельный инструмент управления цинерариями»).</summary>
public const string BoneName = "necroCarvedBone";
/// <summary>Как у провода электрики: ItemActionConnectPower.maxWireLength = 15.</summary>
public const float MaxLength = 15f;
public const int MaxFrom = 8;
@@ -35,7 +47,17 @@ namespace NecromancerTome
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>Инструмент нитей в руке: нож хватом для нитей или резная кость.</summary>
public static bool IsThreadTool(ItemClass _i) => _i != null && IsThreadToolName(_i.Name);
public static bool IsThreadToolName(string _n) => _n == ThreadKnifeName || _n == BoneName;
// ------------------------------------------------------------------
// Хват.
@@ -66,14 +88,21 @@ namespace NecromancerTome
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);
@@ -98,6 +127,16 @@ namespace NecromancerTome
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;
@@ -112,7 +151,24 @@ namespace NecromancerTome
{
return;
}
bool ok = Resolve(w, hi.hit.blockPos, out Vector3i owner, out TEFeatureCinerarium urn);
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)
@@ -135,13 +191,14 @@ namespace NecromancerTome
}
if (!_start.HasValue)
{
if (urn != null && !urn.IsLogist)
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");
@@ -150,24 +207,56 @@ namespace NecromancerTome
}
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.IsLogist)
if (end == null || !end.IsZoneless)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadNeedLogist"));
return;
}
if (Vector3.Distance(s.ToVector3(), owner.ToVector3()) > MaxLength)
if (Vector3.Distance(last.ToVector3(), owner.ToVector3()) > MaxLength)
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTooFar"));
return;
@@ -179,12 +268,25 @@ namespace NecromancerTome
{
return;
}
u.SetThreadTo(owner);
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTo"));
if (u.IsWorker)
{
// У рабочего ящиков «куда» сколько угодно - туда готовое.
if (!u.AddThreadOut(owner, via))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadLimit"));
return;
}
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadOut"));
}
else
{
if (!urn.AddThreadFrom(s))
u.SetThreadTo(owner, via);
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadTo"));
}
}
else
{
if (!urn.AddThreadFrom(s, via))
{
GameManager.ShowTooltip(_pl, Localization.Get("necroThreadLimit"));
return;
@@ -194,6 +296,29 @@ namespace NecromancerTome
_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)
{
@@ -321,7 +446,7 @@ namespace NecromancerTome
public static bool ToolInHand()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
return pl != null && CinerariumThreads.IsThreadKnife(pl.inventory?.holdingItem);
return pl != null && CinerariumThreads.IsThreadTool(pl.inventory?.holdingItem);
}
void Update()
@@ -374,7 +499,7 @@ namespace NecromancerTome
void Update()
{
EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pl == null || !CinerariumThreads.Start.HasValue || !CinerariumThreads.IsThreadKnife(pl.inventory?.holdingItem))
if (pl == null || !CinerariumThreads.Start.HasValue || !CinerariumThreads.IsThreadTool(pl.inventory?.holdingItem))
{
CinerariumThreads.Cancel();
return;
@@ -383,8 +508,9 @@ namespace NecromancerTome
{
return;
}
Vector3i s = CinerariumThreads.Start.Value;
Vector3 a = World.blockToTransformPos(s) - Origin.position + Vector3.up * (CinerariumThreads.StartIsUrn ? 0.5f : 0.6f);
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;
@@ -398,13 +524,13 @@ namespace NecromancerTome
}
}
/// <summary>Нож для нитей не бьёт: ЛКМ и ПКМ - нити (на отпускание кнопки).</summary>
/// <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))
if (!CinerariumThreads.IsThreadTool(__instance))
{
return true;
}
+69 -3
View File
@@ -1,3 +1,4 @@
using System.Collections;
using UnityEngine;
namespace NecromancerTome
@@ -9,6 +10,8 @@ namespace NecromancerTome
/// ячейкой коллектора не принимало череп ни перетаскиванием, ни Shift+кликом). Своё здесь одно: после каждого изменения
/// в ячейке остаётся один череп, всё остальное (лишние черепа, чужие предметы) - назад в рюкзак, и урна узнаёт, есть ли
/// в ней череп. Череп - обычный предмет (ItemClass): он уже в сейвах, менять ему класс на модификацию нельзя.
/// У урны рабочего (18.10) ячеек две: вторая - образец (любой предмет, стопка целиком - сколько держать в запасе).
/// У старателя (22.10) вторая ячейка - что добывать: один предмет из TEFeatureCinerarium.Ores, лишнее и чужое - назад.
/// </summary>
public static class CinerariumContents
{
@@ -30,13 +33,17 @@ namespace NecromancerTome
}
_urn = _u;
_player = _p;
_bag = new Bag(new Vector2i(1, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
_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, () =>
Localization.Get("necroCinerariumTrunk"), CheckLater, () =>
{
Check();
_urn = null;
@@ -46,13 +53,72 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] Cinerarium: «Содержимое» урны " + _u.ToWorldPos() + " открыто, череп " + (_u.HasSkull ? "есть" : "нет"));
}
/// <summary>Оставить в ячейке один череп, остальное - игроку; отметить в урне.</summary>
static bool _checkQueued;
/// <summary>Изменение в ячейках - проверка в следующем кадре. Игра зовёт onModified посреди своей перекладки: при Shift+клике
/// стопки черепов из рюкзака она сначала кладёт стопку в урну (тут срабатывал Check и возвращал лишние черепа в ту же ячейку
/// рюкзака), а потом пишет в исходную ячейку пустоту - и вернувшиеся черепа пропадали (стрим 26.10: «черепа исчезли из сумки»).</summary>
static void CheckLater()
{
if (_checkQueued)
{
return;
}
_checkQueued = true;
GameManager.Instance.StartCoroutine(CheckNextFrame());
}
static IEnumerator CheckNextFrame()
{
yield return null;
_checkQueued = false;
Check();
}
/// <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())
+49 -11
View File
@@ -61,15 +61,40 @@ namespace NecromancerTome
/// <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);
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. Расходники.
@@ -81,6 +106,7 @@ namespace NecromancerTome
{
c.SetModified();
}
LastWhat = "расходники коллектору";
return z.Where[c];
}
}
@@ -96,6 +122,7 @@ namespace NecromancerTome
c.Items = items;
c.SetModified();
}
LastWhat = "готовое из коллектора";
return z.Where[c];
}
}
@@ -108,6 +135,7 @@ namespace NecromancerTome
{
w.Output = output;
}
LastWhat = "готовое со станции";
return z.Where[w];
}
}
@@ -116,6 +144,7 @@ namespace NecromancerTome
{
if (Harvest(_world, p, z, ref blocked))
{
LastWhat = "сбор урожая";
return p;
}
}
@@ -124,6 +153,7 @@ namespace NecromancerTome
Vector3i? planted = PlantRandom(_world, z);
if (planted.HasValue)
{
LastWhat = "посадка";
return planted;
}
}
@@ -132,10 +162,11 @@ namespace NecromancerTome
}
/// <summary>Перевозка по нитям: одна стопка из ящика «откуда» в ящик «куда». Только предметы включённых в урне групп
/// (фильтр логиста, CinerariumGroups; 16.10 заменил прежнее правило «по образцу»); «куда» полон - ждём (дух не чернеет). Открытые у игрока ящики и закреплённые
/// ячейки не трогаются. Возвращает ящик «куда», LastFrom - откуда взято.</summary>
static Vector3i? Ferry(World _world, TEFeatureCinerarium _urn)
/// (фильтр логиста, 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;
@@ -159,16 +190,23 @@ namespace NecromancerTome
for (int k = 0; k < src.ItemGrid.Length; k++)
{
ItemStack s = src.ItemGrid[k];
if (s == null || s.IsEmpty() || Locked(src, k) || !_urn.GroupOn(CinerariumGroups.Of(s.itemValue)) || !Fits(dst, s))
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)
@@ -177,14 +215,14 @@ namespace NecromancerTome
}
Take(src, k, moved);
dst.SetModified();
Debug.Log("[NecromancerTome] Cinerarium: дух перенёс " + moved + " " + s.itemValue.ItemClass?.GetItemName() + " из " + f + " в " + _urn.ThreadTo);
Debug.Log("[NecromancerTome] Cinerarium: дух перенёс " + moved + " " + what + " из " + f + " в " + _urn.ThreadTo);
return _urn.ThreadTo;
}
}
return null;
}
static TEFeatureStorage BoxAt(World _world, Vector3i _p)
internal static TEFeatureStorage BoxAt(World _world, Vector3i _p)
{
return _world.GetTileEntity(_p) is TileEntityComposite c && !c.IsUserAccessing() &&
!c.TryGetSelfOrFeature<TEFeatureCinerarium>(out TEFeatureCinerarium _) &&
@@ -285,7 +323,7 @@ namespace NecromancerTome
return _z.Bag != null && Fits(_z.Bag, _s);
}
static bool Fits(TEFeatureStorage _t, ItemStack _s)
internal static bool Fits(TEFeatureStorage _t, ItemStack _s)
{
for (int i = 0; i < _t.ItemGrid.Length; i++)
{
@@ -519,7 +557,7 @@ namespace NecromancerTome
return false;
}
static void Place(ItemStack _s, IList<TEFeatureStorage> _dst)
internal static void Place(ItemStack _s, IList<TEFeatureStorage> _dst)
{
for (int i = 0; i < _dst.Count && _s.count > 0; i++)
{
@@ -548,13 +586,13 @@ namespace NecromancerTome
}
/// <summary>Ячейка, закреплённая игроком (замок в окне ящика), - не трогать.</summary>
static bool Locked(TEFeatureStorage _src, int _slot)
internal static bool Locked(TEFeatureStorage _src, int _slot)
{
PackedBoolArray locks = _src.ItemGrid.SlotLocks;
return locks != null && _slot < locks.Length && locks[_slot];
}
static void Take(TEFeatureStorage _src, int _slot, int _count)
internal static void Take(TEFeatureStorage _src, int _slot, int _count)
{
ItemStack s = _src.ItemGrid[_slot].Clone();
s.count -= _count;
+879
View File
@@ -0,0 +1,879 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Урны с большой зоной - ремонтник и лутер (решения 17.10 и 18.10, BACKLOG «Урна ремонтника», «Урна лутера»).
///
/// Общее. Зона - квадрат со стороной блока притязания (LandClaimSize сервера, по умолчанию 41) или меньше (31/21/11, пункт
/// меню «Размер зоны»), по высоте - весь столб мира; делится на 3x3 квадрата, любой можно выключить (#84, ZoneOff); зоны
/// таких урн могут пересекаться. Работа - только по кнопке (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;
/// <summary>Ремонтник: какого материала и сколько не хватило последней проверке (ячейка в «Содержимом»,
/// XUiC_NecroRepairNeed). Не сохраняется.</summary>
public int LackType, LackCount;
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>();
/// <summary>Ремонтник: блоки, на которые нет материалов (24.10, пользователь: «Если не может починить что-то для чего
/// нет ресурсов, пусть сначала чинит то, для чего они есть»). Не держат очередь обхода; к ним - когда больше нечего.</summary>
readonly List<Vector3i> _lacking = new List<Vector3i>();
const int LackingMax = 256;
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];
}
}
/// <summary>Квадраты зоны (28.10, #84, пользователь: «разделить уже имеющуюся зону на 9 квадратов, с цинерарием посередине...
/// по умолчанию все 9 были выбраны, но можно было какой-нибудь отключить. Иначе он начинает ремонтировать близстоящие
/// здания»): сетка ZoneGrid x ZoneGrid, урна в среднем квадрате. Номер квадрата - столбец + строка * ZoneGrid, столбец 0 -
/// запад (меньший x), строка 0 - юг (меньший z). Бит в ZoneOff - квадрат выключен; 0 - все включены. Сохраняется (v6,
/// пятое число). Выключать - в «Содержимом» (XUiC_NecroUrnZone) или «necrourn x y z sectors».</summary>
public int ZoneOff;
public const int ZoneGrid = 3;
public const int ZoneSectors = ZoneGrid * ZoneGrid;
public bool SectorOn(int _s) => (ZoneOff & (1 << _s)) == 0;
/// <summary>Полоса сетки клетки _i (0..side-1, от западного/южного края зоны).</summary>
static int Band(int _i, int _side) => _i * ZoneGrid / _side;
/// <summary>Первая клетка полосы _k от края зоны; _k = ZoneGrid - сторона зоны.</summary>
public static int BandStart(int _k, int _side) => (_k * _side + ZoneGrid - 1) / ZoneGrid;
/// <summary>Работает ли урна в столбе мира (x, z): в зоне и квадрат включён.</summary>
public bool InZone(int _x, int _z)
{
int side = ZoneSideNow, half = side / 2;
Vector3i c = ToWorldPos();
int ix = _x - c.x + half, iz = _z - c.z + half;
if (ix < 0 || iz < 0 || ix >= side || iz >= side)
{
return false;
}
return SectorOn(Band(ix, side) + Band(iz, side) * ZoneGrid);
}
/// <summary>Включить/выключить квадрат. Блоки выключенного квадрата, уже взятые в очередь, отпускаются; линии перерисовать.</summary>
public void SetZoneOff(int _mask)
{
ZoneOff = _mask & ((1 << ZoneSectors) - 1);
DropOutside(_queue);
DropOutside(_lacking);
if (_zonePending && IsRepairer && !InZone(_zoneTarget.x, _zoneTarget.z))
{
_zonePending = false;
Claimed.Remove(_zoneTarget);
}
if (ZoneShown)
{
RemoveBounds();
SpawnBounds();
}
SetModified();
Debug.Log("[NecromancerTome] Cinerarium: урна " + ToWorldPos() + " - квадраты зоны " + SectorsText());
}
public void ToggleSector(int _s) => SetZoneOff(ZoneOff ^ (1 << _s));
/// <summary>Квадраты строками с севера: «+» включён, «-» выключен (для лога и консоли).</summary>
public string SectorsText()
{
System.Text.StringBuilder sb = new System.Text.StringBuilder();
for (int r = ZoneGrid - 1; r >= 0; r--)
{
for (int col = 0; col < ZoneGrid; col++)
{
sb.Append(SectorOn(col + r * ZoneGrid) ? '+' : '-');
}
if (r > 0)
{
sb.Append('/');
}
}
return sb.ToString();
}
void DropOutside(List<Vector3i> _l)
{
for (int i = _l.Count - 1; i >= 0; i--)
{
if (!InZone(_l[i].x, _l[i].z))
{
Claimed.Remove(_l[i]);
_l.RemoveAt(i);
}
}
}
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;
ReleaseClaims();
_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)
{
ReleaseClaims();
_zonePending = false;
if (ZoneRunning && _log)
{
Debug.Log("[NecromancerTome] Cinerarium: " + (IsLooter ? "лутер" : "ремонтник") + " " + ToWorldPos() + " остановлен");
}
ZoneRunning = false;
ZoneLacking = false;
}
/// <summary>Очередь и отложенные блоки - отпустить (Claimed) и забыть.</summary>
void ReleaseClaims()
{
foreach (Vector3i p in _queue)
{
Claimed.Remove(p);
}
foreach (Vector3i p in _lacking)
{
Claimed.Remove(p);
}
_queue.Clear();
_lacking.Clear();
LackType = 0;
LackCount = 0;
}
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 (ZoneOff != 0 && !InZone(x, z))
{
continue; // квадрат выключен (#84)
}
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 && _lacking.Count == 0 && !_zonePending)
{
Debug.Log("[NecromancerTome] Cinerarium: ремонтник " + c + " обошёл зону - ожидание");
ZoneStop(false);
SetModified();
}
}
/// <summary>Следующий блок: сначала из очереди обхода, на что есть материалы (на что нет - в _lacking), потом из _lacking,
/// если материал появился. Нечего чинить, кроме ждущих материала, - кнопка жёлтая. Ячейка «не хватает» в «Содержимом» -
/// первый блок из _lacking (LackType/LackCount), пока он есть.</summary>
void RepairPick(World _world, CinerariumGenieMotion _motion)
{
while (_queue.Count > 0)
{
Vector3i p = _queue[0];
_queue.RemoveAt(0);
BlockValue bv = _world.GetBlock(p);
if (bv.isair || bv.damage <= 0 || bv.Block?.RepairItems == null)
{
Claimed.Remove(p);
continue; // починили до нас или блок убран
}
if (!HasMaterials(_world, bv, false))
{
if (_lacking.Count < LackingMax)
{
_lacking.Add(p);
}
else
{
Claimed.Remove(p); // найдётся при следующем обходе
}
continue;
}
Go(_world, _motion, p);
return;
}
int lackType = 0, lackCount = 0;
for (int i = 0; i < _lacking.Count; i++)
{
Vector3i p = _lacking[i];
BlockValue bv = _world.GetBlock(p);
if (bv.isair || bv.damage <= 0 || bv.Block?.RepairItems == null)
{
_lacking.RemoveAt(i--);
Claimed.Remove(p);
continue;
}
if (HasMaterials(_world, bv, false))
{
_lacking.RemoveAt(i);
Go(_world, _motion, p);
LackType = lackType;
LackCount = lackCount;
return;
}
if (lackType == 0)
{
lackType = LackType;
lackCount = LackCount;
}
}
LackType = lackType;
LackCount = lackCount;
bool stuck = _lacking.Count > 0;
if (stuck && !ZoneLacking)
{
ItemClass ic = ItemClass.GetForId(LackType);
Debug.Log("[NecromancerTome] Cinerarium: ремонтнику " + ToWorldPos() + " нечего чинить без материалов: ждут " + _lacking.Count +
" блок(ов), первым не хватает " + LackCount + " " + ic?.GetItemName());
}
ZoneLacking = stuck;
}
void Go(World _world, CinerariumGenieMotion _motion, Vector3i _p)
{
ZoneLacking = false;
_zoneTarget = _p;
FlyThenAct(_world, _motion, _p);
}
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))
{
_lacking.Add(_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)
{
LackType = iv.type;
LackCount = n - have;
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)
{
continue;
}
Vector3i ep = World.worldToBlockPos(e.GetPosition());
if (ZoneOff != 0 && !InZone(ep.x, ep.z))
{
continue; // квадрат выключен (#84)
}
if (!_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);
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Квадраты зоны ремонтника и лутера (28.10.2026, #84): сетка 3x3 в окне «Содержимое» - окно necroUrnZone в группе окон
/// игры bagStorage (Config/XUi_InGame), как фильтр логиста. Север сверху, в среднем квадрате - урна. Щелчок включает /
/// выключает квадрат (TEFeatureCinerarium.ToggleSector); выключенный - тёмный с красной рамкой. Видна только у урн с
/// большой зоной; у прочих урн и сумок транспорта и волка скрыта. s0..s8 в XML - номера квадратов (столбец + строка * 3,
/// строка 0 - юг), f0..f8 - их рамки.
/// </summary>
[Preserve]
public class XUiC_NecroUrnZone : XUiController
{
readonly XUiController[] _cells = new XUiController[TEFeatureCinerarium.ZoneSectors];
readonly XUiController[] _frames = new XUiController[TEFeatureCinerarium.ZoneSectors];
static readonly Color On = new Color(0.3f, 0.6f, 0.45f, 0.9f);
static readonly Color Off = new Color(0.12f, 0.12f, 0.12f, 0.9f);
static readonly Color FrameOn = new Color(0.55f, 0.9f, 0.75f, 1f);
static readonly Color FrameOff = new Color(0.95f, 0.3f, 0.25f, 1f);
public override void Init()
{
base.Init();
for (int i = 0; i < _cells.Length; i++)
{
int s = i;
_cells[i] = GetChildById("s" + i);
_frames[i] = GetChildById("f" + i);
if (_cells[i] != null)
{
_cells[i].OnPress += (_x, _m) => Toggle(s);
}
}
}
public override void OnOpen()
{
base.OnOpen();
Refresh();
}
void Toggle(int _s)
{
TEFeatureCinerarium urn = CinerariumContents.Urn;
if (urn == null)
{
return;
}
urn.ToggleSector(_s);
Refresh();
}
void Refresh()
{
TEFeatureCinerarium urn = CinerariumContents.Urn;
bool show = urn != null && urn.IsBigZone;
ViewComponent.IsVisible = show;
if (!show)
{
return;
}
for (int i = 0; i < _cells.Length; i++)
{
bool on = urn.SectorOn(i);
if (_cells[i]?.ViewComponent is XUiV_Sprite sp)
{
sp.Color = on ? On : Off;
sp.ToolTip = Localization.Get(on ? "necroZoneSectorOn" : "necroZoneSectorOff");
}
if (_frames[i]?.ViewComponent is XUiV_Sprite fr)
{
fr.Color = on ? FrameOn : FrameOff;
}
}
}
}
}
+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") || _name == "necroCinerariumPrefab";
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; // проглотить: стрела долетает и попадает как обычно
}
}
}
+73 -1
View File
@@ -68,6 +68,9 @@ namespace NecromancerTome
static GameObject _rig;
static SDCSUtils.TransformCatalog _bones;
/// <summary>Модель оружия с пояса в правой руке (#37) и чья она (тип предмета; -1 - кулаки).</summary>
static AssetManagement.InstanceHandle<GameObject> _weapon;
static int _weaponType = -1;
static bool _layerWasShown;
public static bool Active => _rig != null;
@@ -279,6 +282,65 @@ namespace NecromancerTome
}
}
/// <summary>Оружие с пояса в руках (#37): модель предмета на кость RightWeapon, как у персонажа от первого лица
/// (ItemInventoryData.SeatModel - без сдвига), и хват WeaponHoldType этого предмета; null - кулаки (хват 0).
/// Ставится каждый кадр, пересобирается только при смене предмета.</summary>
public static void SetWeapon(ItemValue _iv)
{
int type = _iv != null && !_iv.IsEmpty() ? _iv.type : -1;
if (_rig == null || type == _weaponType)
{
return;
}
DestroyWeapon();
_weaponType = type;
Animator a = Anim;
ItemClass ic = type >= 0 ? _iv.ItemClass : null;
Transform hand = _rig.transform.FindInChilds("RightWeapon");
if (ic != null && hand != null)
{
_weapon = ic.CloneModel(_iv, hand, BlockShape.MeshPurpose.Hold);
GameObject go = _weapon.Instance;
if (go != null)
{
go.transform.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity);
if (!ic.GetCorrectionScale().Equals(Vector3.zero))
{
go.transform.localScale = ic.GetCorrectionScale();
}
int layer = LayerMask.NameToLayer("HoldingItem");
foreach (Transform t in go.GetComponentsInChildren<Transform>(true))
{
t.gameObject.layer = layer;
}
foreach (Collider c in go.GetComponentsInChildren<Collider>(true))
{
c.enabled = false;
}
go.SetActive(!ic.HoldingItemHidden);
}
}
if (a != null)
{
SetParam(a, "WeaponHoldType", ic != null ? ic.HoldType.Value : 0f);
SetParam(a, "ItemHasChangedTrigger", 1f);
}
Debug.Log("[NecromancerTome] FpArms: в руке " + (ic != null ? ic.Name + " (хват " + ic.HoldType.Value + (_weapon.Instance != null ? "" : ", БЕЗ модели") + ")" : "ничего - кулаки") +
(hand == null && ic != null ? " - кость RightWeapon не найдена" : ""));
}
static void DestroyWeapon()
{
if (_weapon.Instance != null)
{
_weapon.Instance.transform.SetParent(null, false);
_weapon.Instance.SetActive(false);
_weapon.Destroy();
}
_weapon = default(AssetManagement.InstanceHandle<GameObject>);
_weaponType = -1;
}
/// <summary>Анимация удара рук вошла в состояние удара (AnimatorMeleeAttackState у наших рук): через RaycastTime -
/// миг удара, как у персонажа (impactStart). Руки на это время уходят в нижнее положение (под прицел).</summary>
public static void OnSwingEnter(float _raycastTime)
@@ -421,6 +483,7 @@ namespace NecromancerTome
{
_player?.ShowHoldingItemLayer(false);
}
DestroyWeapon();
SDCSUtils.UnloadViz(_rig);
_rig = null;
_bones = null;
@@ -437,7 +500,7 @@ namespace NecromancerTome
public class ConsoleCmdNecroArms : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroarms" };
public override string getDescription() => "NecromancerTome: руки одержимого от первого лица - [pos x y z | parent cam|ufps | anim | mat | set <param> <value> | trigger <name> | tint r g b | albedo on|off | punch <trigger> | fov <deg>]";
public override string getDescription() => "NecromancerTome: руки одержимого от первого лица - [pos x y z | parent cam|ufps | anim | mat | set <param> <value> | trigger <name> | tint r g b | albedo on|off | punch <trigger> | fov <deg> | weapon [on|off]]";
void Out(string _s)
{
@@ -477,6 +540,15 @@ namespace NecromancerTome
{
FpArms.ArmsFov = fov;
}
else if (cmd == "weapon")
{
if (_params.Count >= 2)
{
ZombieWeapons.Enabled = _params[1] != "off";
}
Out(Possession.Active && Possession.Zombie != null ? ZombieWeapons.Dump(Possession.Zombie) : "оружие " + (ZombieWeapons.Enabled ? "вкл" : "ВЫКЛ") + " (не в одержимости)");
return;
}
else if (cmd == "anim")
{
Out(FpArms.DumpAnim());
+3 -3
View File
@@ -29,10 +29,10 @@ namespace NecromancerTome
/// </summary>
public static class GameVersionGuard
{
/// <summary>Под какую версию собран мод: Major и десяток Minor (3.3.x = 3 и 3; сборка 1.4.x была под 3.2).</summary>
/// <summary>Под какую версию собран мод: Major и десяток Minor (3.4.x = 3 и 4; 1.5-1.6 были под 3.3, 1.4.x - под 3.2).</summary>
public const int SupportedMajor = 3;
public const int SupportedMinorTens = 3;
public const string SupportedText = "3.3";
public const int SupportedMinorTens = 4;
public const string SupportedText = "3.4";
private static bool? supported;
+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)
+1 -1
View File
@@ -64,7 +64,7 @@ namespace NecromancerTome
{
// Только от первого лица (16.10: «В виде от третьего лица что-то страшное»): поза и перенос ножа - для рук первого
// лица; от третьего лица модель в руке - у тела, и нож улетал к невидимой руке первого лица.
if (_player != null && _player.inventory?.holdingItem?.Name == CinerariumThreads.ThreadKnifeName &&
if (_player != null && CinerariumThreads.IsThreadToolName(_player.inventory?.holdingItem?.Name) &&
(_player.emodel as EModelSDCS)?.IsFPV == true && !SnakeMode.Active && !MindPalace.Active && !Possession.Active && !_player.IsDead())
{
if (_threadSince < 0f)
+763
View File
@@ -0,0 +1,763 @@
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 все|нет|sectors all|N|-маска - урна
/// necroclick 0|1 - отпущенная ЛКМ/ПКМ ножом для нитей по блоку под прицелом
/// necropossess near [класс]|fp|hit|look id / x y z|slot N|belt N предмет [качество]|info|exit - одержимость без мыши (#37, 25.10)
/// </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 "necropossess": Possess(w, pl, _params); 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>
/// <summary>necropossess: вселиться в ближайшего зомби (класс - начало имени), вид от первого лица, ЛКМ, ячейка и
/// предмет пояса, состояние, выход.</summary>
static void Possess(World _w, EntityPlayerLocal _pl, List<string> _p)
{
string cmd = _p.Count > 0 ? _p[0] : "info";
EntityAlive z = Possession.Zombie;
switch (cmd)
{
case "near":
{
string cls = _p.Count > 1 ? _p[1] : "";
EntityAlive best = null;
float bestD = 40f * 40f;
foreach (EntityAlive e in _w.EntityAlives)
{
if (!Possession.CanPossess(e) || !EntityClass.list.TryGetValue(e.entityClass, out EntityClass ec) ||
!ec.entityClassName.StartsWith(cls, System.StringComparison.OrdinalIgnoreCase))
{
continue;
}
float d = (e.position - _pl.position).sqrMagnitude;
if (d < bestD)
{
bestD = d;
best = e;
}
}
Out("necropossess: " + (best == null ? "зомби рядом нет" : best.EntityName + " (" + best.entityId + ") - " + (Possession.Enter(_pl, best) ? "вселился" : "не вышло")));
return;
}
case "fp": Possession.DevView = true; break;
case "hit": Possession.DevAttack = true; break;
case "look":
{
// look id - на существо (грудь), look x y z - на точку
Vector3 at = _p.Count >= 4 ? new Vector3(F(_p[1]), F(_p[2]), F(_p[3]))
: (_w.GetEntity(int.Parse(_p[1])) as EntityAlive)?.getChestPosition() ?? Vector3.zero;
Possession.DevLookAt(at);
break;
}
case "slot": Possession.BeltSlot = Mathf.Clamp(int.Parse(_p[1]) - 1, 0, Possession.BeltSlots - 1); break;
case "belt":
{
ItemValue iv = ItemClass.GetItem(_p[2]);
if (iv == null || iv.IsEmpty())
{
Out("necropossess: нет предмета " + _p[2]);
return;
}
iv = new ItemValue(iv.type, _p.Count > 3 ? int.Parse(_p[3]) : 1, _p.Count > 3 ? int.Parse(_p[3]) : 1);
Out("necropossess: " + (ZombieBag.DevSetBelt(int.Parse(_p[1]) - 1, new ItemStack(iv, 1)) ? "положено" : "сумки нет"));
break;
}
case "exit": Possession.Exit("necropossess"); Out("necropossess: выход"); return;
}
Out("necropossess: " + (z == null ? "не в одержимости" : z.EntityName + ", от первого лица " + Possession.FirstPerson + "\n" + ZombieWeapons.Dump(z)));
}
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;
// necrourn x y z sectors all|N|-N - квадраты зоны (#84): all - все включить; N - переключить квадрат N (0..8, столбец +
// строка * 3, 0 - юго-запад); -N - маска выключенных числом.
case "sectors":
if (_p.Count > 4)
{
if (_p[4] == "all" || _p[4] == "все") u.SetZoneOff(0);
else if (_p[4].StartsWith("-")) u.SetZoneOff(int.Parse(_p[4].Substring(1)));
else u.ToggleSector(int.Parse(_p[4]));
}
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.SectorsText() : "-") + ", образец " + (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 ConsoleCmdNecroPossess : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necropossess" };
public override string getDescription() => "NecromancerTome (разработка): одержимость без мыши - near [класс] | fp | hit | slot N | belt N предмет [качество] | info | exit";
public override bool AllowedInMainMenu => false;
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo) => NecroDev.Run("necropossess", _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);
}
}
+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);
}
}
}
}
+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"));
}
}
}
+118 -17
View File
@@ -55,6 +55,9 @@ namespace NecromancerTome
public static EntityAlive Zombie;
/// <summary>Вселились в своего питомца (книга призыва), а не в чужого зомби.</summary>
public static bool IsPet;
/// <summary>Разведка (туман карты вокруг одержимого, карта открывается на нём): стервятник (08.10) и любой одержимый
/// зомби (#36, 25.10); прочие питомцы - нет.</summary>
public static bool Scouts => Active && (Flying || !IsPet);
/// <summary>Питомец-стервятник: летает (FlyTick из его updateTasks).</summary>
public static bool Flying;
/// <summary>Враг у тела (CheckBody) - для надписи в PossessionHud.</summary>
@@ -106,8 +109,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;
@@ -149,7 +156,13 @@ namespace NecromancerTome
public static bool FirstPerson;
/// <summary>Пояс одержимого (13.10): 2 ячейки, пока пустые; выбор 1/2 или колесом, только от первого лица.</summary>
public const int BeltSlots = 2;
/// <summary>Чаще руки не успевают доиграть замах (ножи в игре - до 100+ ударов в минуту).</summary>
const float MinWeaponSwing = 0.45f;
public static int BeltSlot;
/// <summary>Нажатия от necropossess (проверка без мыши): ЛКМ и смена вида - срабатывают в ближайшем кадре.</summary>
public static bool DevAttack, DevView;
/// <summary>Ячейка и тип предмета в ней при прошлой проверке - подсказка о неумелом оружии один раз на смену.</summary>
static int _beltKey = -1;
public const float FpForward = 0.12f, FpSmooth = 12f;
static Vector3 _fpEye;
static readonly List<Renderer> _hiddenRenderers = new List<Renderer>();
@@ -214,6 +227,18 @@ namespace NecromancerTome
/// <summary>Игрок, который сейчас в одержимом (тело).</summary>
public static EntityPlayerLocal Player => _player;
static float _yaw, _pitch;
/// <summary>necropossess look: взгляд одержимого на точку (проверка без мыши).</summary>
public static void DevLookAt(Vector3 _pos)
{
if (Zombie == null)
{
return;
}
Vector3 d = _pos - Zombie.getHeadPosition();
_yaw = Mathf.Atan2(d.x, d.z) * Mathf.Rad2Deg;
_pitch = Mathf.Atan2(d.y, new Vector2(d.x, d.z).magnitude) * Mathf.Rad2Deg;
}
/// <summary>Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») -
/// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера.</summary>
static float _bodyYaw;
@@ -445,10 +470,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: «пугается и убегает как при отрицании»).
@@ -561,9 +588,9 @@ namespace NecromancerTome
Exit("кровавый прилив - дух выброшен в тело");
return;
}
if (Flying)
if (Scouts)
{
RevealMap(world, z); // разведка - только стервятник (указание 08.10)
RevealMap(world, z); // разведка - стервятник (указание 08.10) и одержимый зомби (#36)
}
// Подсказка тела (тест 08.10: «стоял перед дверью - подсказка осталась в стервятнике»): её гасит
// PlayerMoveController.Update, а он на время одержимости выключен. Прочие сообщения (голод и т.п.) -
@@ -609,7 +636,8 @@ namespace NecromancerTome
move = new Vector2(input.Move.X, input.Move.Y);
run = input.Run.IsPressed;
jump = input.Jump.WasPressed;
attack = input.Primary.WasPressed;
attack = input.Primary.WasPressed || DevAttack;
DevAttack = false;
ranged = input.Secondary.WasPressed;
rangedHeld = input.Secondary.IsPressed;
// E на клавиатуре - в PlayerActionsPermanent.Activate; у PlayerActionsLocal.Activate только
@@ -624,8 +652,9 @@ namespace NecromancerTome
}
// Только гуманоиды обычной комплекции (указание 13.10, FpArms.Allows) - у остальных ~/ё ничего не делает.
if (input != null && !gui && FpArms.Allows(z) &&
(input.CameraChange.WasPressed || input.QuickMenu.WasPressed || input.PermanentActions.QuickMenu.WasPressed))
(input.CameraChange.WasPressed || input.QuickMenu.WasPressed || input.PermanentActions.QuickMenu.WasPressed || DevView))
{
DevView = false;
FirstPerson = !FirstPerson;
_fpEye = Vector3.zero;
if (FirstPerson)
@@ -659,6 +688,21 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] Possession: пояс - ячейка " + (BeltSlot + 1));
}
}
if (FirstPerson && ZombieBag.Active)
{
// Оружие с пояса (#37): в руки - если зомби им владеет; сменился выбранный предмет - подсказка, если нет.
ItemStack sel = ZombieBag.Belt(BeltSlot);
int key = BeltSlot * 100000 + (sel != null ? sel.itemValue.type : -1);
if (key != _beltKey)
{
_beltKey = key;
if (sel != null)
{
ZombieWeapons.Hint(_player, z, sel.itemValue);
}
}
FpArms.SetWeapon(ZombieWeapons.Current(z));
}
if (input != null && !gui && input.ToggleCrouch.WasPressed)
{
_crouchToggled = !_crouchToggled;
@@ -839,7 +883,8 @@ namespace NecromancerTome
{
// Tab в зомби - окно его сумки, а не рюкзак персонажа (указание 13.10), как у волка.
GUIWindowManager zwm = LocalPlayerUI.GetUIForPlayer(_player)?.windowManager;
if (zwm != null && zwm.IsWindowOpen("windowpaging") && !ZombieBag.WindowOpen && !RatLoot.Viewing)
// M открывает то же окно windowpaging (с картой) - карту не подменять (#36: M в зомби открывал сумку).
if (zwm != null && zwm.IsWindowOpen("windowpaging") && !zwm.IsWindowOpen("map") && !ZombieBag.WindowOpen && !RatLoot.Viewing)
{
zwm.CloseAllOpenModalWindows();
zwm.Close("windowpaging");
@@ -856,7 +901,7 @@ namespace NecromancerTome
{
// Tab в волке - окно его сумки, а не рюкзак персонажа (тест 10.10: «в волке Tab - вижу сумку игрока»).
GUIWindowManager wm = LocalPlayerUI.GetUIForPlayer(_player)?.windowManager;
if (wm != null && wm.IsWindowOpen("windowpaging") && !WolfBag.WindowOpen && !RatLoot.Viewing)
if (wm != null && wm.IsWindowOpen("windowpaging") && !wm.IsWindowOpen("map") && !WolfBag.WindowOpen && !RatLoot.Viewing)
{
wm.CloseAllOpenModalWindows();
wm.Close("windowpaging");
@@ -899,7 +944,10 @@ namespace NecromancerTome
// до точки удара (FpArms -> FpImpact), а не по его собственному замаху.
PossessionPowers.BreakInvis(z, "удар");
FpArms.Punch();
_nextAttack = Time.time + Mathf.Max(FpArms.SwingTime, z.GetAttackTimeoutTicks() / 20f);
ItemValue weapon = ZombieWeapons.Current(z);
// С оружием - темп оружия, как у персонажа (#37); кулаками - темп зомби.
_nextAttack = Time.time + (weapon != null ? Mathf.Max(MinWeaponSwing, ZombieWeapons.SwingTime(weapon))
: Mathf.Max(FpArms.SwingTime, z.GetAttackTimeoutTicks() / 20f));
}
else if (attack && Time.time >= _nextAttack)
{
@@ -961,7 +1009,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);
@@ -976,7 +1025,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 - переключатель, и с ним стервятник не сел бы никогда.
@@ -2379,6 +2428,18 @@ namespace NecromancerTome
{
return;
}
if (ZombieWeapons.Current(z) != null)
{
// Оружие с пояса (#37) - удар как у персонажа, луч из глаз (камера одержимости - в голове зомби). Камера - в
// координатах Unity, луч мира - с Origin (тест 25.10: без него «луч ничего не задел» вплотную).
Transform cam = _player?.playerCamera != null ? _player.playerCamera.transform : null;
Vector3 head = z.getHeadPosition();
if (!ZombieWeapons.Strike(z.world, z, cam != null ? new Ray(cam.position + Origin.position, cam.forward) : new Ray(head, (AimPoint() - head).normalized)))
{
Debug.Log("[NecromancerTome] ZombieWeapons: удар мимо");
}
return;
}
z.SetLookPosition(MeleeAim(z.world, z));
_meleeLookUntil = Time.time + 0.6f;
if (!StartStrike(z))
@@ -2583,14 +2644,13 @@ namespace NecromancerTome
}
}
/// <summary>Карта в одержимом стервятнике открывается на нём, а не на теле (указание 08.10: разведка -
/// только у стервятника).</summary>
/// <summary>Карта в одержимом стервятнике и зомби открывается на нём, а не на теле (указание 08.10, #36).</summary>
[HarmonyPatch(typeof(XUiC_MapArea), nameof(XUiC_MapArea.OnOpen))]
public static class Patch_XUiC_MapArea_OnOpen_Possession
{
public static void Postfix(XUiC_MapArea __instance)
{
if (Possession.Active && Possession.Flying && Possession.Zombie != null)
if (Possession.Scouts && Possession.Zombie != null)
{
__instance.PositionMapAt(Possession.Zombie.GetPosition());
}
@@ -2603,7 +2663,7 @@ namespace NecromancerTome
{
public static bool Prefix(XUiC_MapArea __instance)
{
if (!Possession.Active || !Possession.Flying || Possession.Zombie == null)
if (!Possession.Scouts || Possession.Zombie == null)
{
return true;
}
@@ -2864,8 +2924,9 @@ namespace NecromancerTome
}
}
/// <summary>Пояс одержимого от первого лица (указание 13.10: «собственный пояс на 2 ячейки», пока пустой) - на месте
/// пояса персонажа, внизу по центру; выбранная ячейка в рамке. Ячейки пустые - бьёт кулаками.</summary>
/// <summary>Пояс одержимого от первого лица (указание 13.10: «собственный пояс на 2 ячейки») - на месте пояса персонажа,
/// внизу по центру; выбранная ячейка в рамке. В ячейке - иконка предмета из нижнего ряда сумки (#37), полоска износа;
/// чем зомби не владеет или что сломано - бледное (бьёт кулаками).</summary>
static void DrawBelt(float _scale)
{
float size = 64f * _scale, gap = 6f * _scale, frame = 3f * _scale;
@@ -2883,11 +2944,51 @@ namespace NecromancerTome
GUI.color = Back;
GUI.DrawTexture(r, Texture2D.whiteTexture);
GUI.color = prev;
ItemStack st = ZombieBag.Belt(i);
if (st != null)
{
bool fits = ZombieWeapons.Enabled && ZombieWeapons.Check(Possession.Zombie, st.itemValue) == ZombieWeapons.Fit.Ok;
DrawIcon(new Rect(r.x + 6f * _scale, r.y + 6f * _scale, r.width - 12f * _scale, r.height - 12f * _scale), st.itemValue, fits ? 1f : 0.3f);
if (st.itemValue.MaxUseTimes > 0)
{
float left = 1f - Mathf.Clamp01(st.itemValue.UseTimes / st.itemValue.MaxUseTimes);
GUI.color = Color.Lerp(new Color(0.85f, 0.2f, 0.15f), new Color(0.35f, 0.8f, 0.3f), left);
GUI.DrawTexture(new Rect(r.x + 4f * _scale, r.yMax - 6f * _scale, (r.width - 8f * _scale) * left, 3f * _scale), Texture2D.whiteTexture);
GUI.color = prev;
}
if (st.count > 1)
{
GUI.Label(new Rect(r.x, r.yMax - 22f * _scale, r.width - 4f * _scale, 18f * _scale), st.count.ToString(), _style);
}
}
GUI.Label(new Rect(r.x + 4f * _scale, r.y + 2f * _scale, r.width, 18f * _scale), (i + 1).ToString(), _style);
}
GUI.color = prev;
}
/// <summary>Иконка предмета из атласа ItemIconAtlas (как у ячеек игры) - кусок текстуры атласа по данным спрайта.</summary>
static void DrawIcon(Rect _r, ItemValue _iv, float _alpha)
{
ItemClass ic = _iv?.ItemClass;
string name = ic?.GetIconName();
INGUIAtlas atlas = string.IsNullOrEmpty(name) ? null : LocalPlayerUI.GetUIForPrimaryPlayer()?.xui?.GetAtlasByName("ItemIconAtlas", name);
UISpriteData sd = atlas?.GetSprite(name);
Texture tex = atlas?.texture;
if (sd == null || tex == null || tex.width == 0 || tex.height == 0)
{
return;
}
Rect uv = new Rect((float)sd.x / tex.width, 1f - (float)(sd.y + sd.height) / tex.height, (float)sd.width / tex.width, (float)sd.height / tex.height);
// Иконка не растягивается: вписать по большей стороне.
float k = Mathf.Min(_r.width / sd.width, _r.height / sd.height);
Rect fit = new Rect(_r.center.x - sd.width * k / 2f, _r.center.y - sd.height * k / 2f, sd.width * k, sd.height * k);
Color prev = GUI.color;
Color tint = ic.GetIconTint(_iv);
GUI.color = new Color(tint.r, tint.g, tint.b, tint.a * _alpha);
GUI.DrawTextureWithTexCoords(fit, tex, uv);
GUI.color = prev;
}
static void DrawBar(Rect _r, float _frac, Color _fill, string _text)
{
Color prev = GUI.color;
+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"));
}
}
}
+42 -1
View File
@@ -487,7 +487,6 @@ namespace NecromancerTome
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillAndSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillStacks));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.ToggleLockMode));
}
public static bool Prefix(XUiC_ContainerStandardControls __instance, System.Reflection.MethodBase __originalMethod)
@@ -504,6 +503,48 @@ namespace NecromancerTome
}
}
/// <summary>Замок ячеек при обыске не включается. В 3.4 ToggleLockMode нет: замок - свойство UserLockMode, кнопка -
/// XUiC_ButtonSelectable (её подсветку снимаем, иначе кнопка осталась бы «нажатой»).</summary>
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.UserLockMode), MethodType.Setter)]
public static class Patch_UserLockMode
{
public static bool Prefix(XUiC_ContainerStandardControls __instance, bool value)
{
if (!value || !Viewing)
{
return true;
}
if (__instance.btnToggleLockMode != null)
{
__instance.btnToggleLockMode.IsSelected = false;
}
return false;
}
}
/// <summary>3.4: предмет, брошенный на пустое место окна (не в ячейку), игра кладёт прямо в ящик, мешок или рюкзак
/// (dropItemToGrid) - мимо запертых ячеек. При обыске - отказ, предмет остаётся в руке.</summary>
[HarmonyPatch]
public static class Patch_DropItemToGrid
{
static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(XUiC_LootContainer), nameof(XUiC_LootContainer.dropItemToGrid));
yield return AccessTools.Method(typeof(XUiC_BagContainer), nameof(XUiC_BagContainer.dropItemToGrid));
yield return AccessTools.Method(typeof(XUiC_Backpack), nameof(XUiC_Backpack.dropItemToGrid));
}
public static bool Prefix()
{
if (!Viewing)
{
return true;
}
Deny();
return false;
}
}
/// <summary>Щелчок по запертой ячейке контейнера - отказ «рюкзак полон».</summary>
[HarmonyPatch(typeof(XUiC_ItemStack), nameof(XUiC_ItemStack.Update))]
public static class Patch_ItemStack_Update
+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"));
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ namespace NecromancerTome
public static class SnakeControl
{
public const float WalkSpeed = 6f; // велосипед, vehicles.xml velocityMax
public const float RunSpeed = 16.5f; // мотоцикл турбо с нагнетателем
public const float RunSpeed = 12.5f; // было 16.5 (мотоцикл турбо); 26.10: «Уменьши скорость змеи на шифте на четверть» - фризы подгрузки карты
public const float Gravity = 20f;
/// <summary>Прыжок на Пробел (указание 28.09: «иначе не выбраться из ям»). 10 м/с при g = 20 -
/// подъём на 2,5 м: вместе с заползанием на блок хватает на стену в три блока.</summary>
+7
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));
+2 -2
View File
@@ -33,7 +33,7 @@ namespace NecromancerTome
}
foreach (EntityPlayer p in w.Players.list)
{
if (p == null || p.inventory?.holdingItem?.Name != CinerariumThreads.ThreadKnifeName || p.emodel == null ||
if (p == null || !CinerariumThreads.IsThreadToolName(p.inventory?.holdingItem?.Name) || p.emodel == null ||
p.emodel.IsFPV && !p.isEntityRemote)
{
continue;
@@ -74,7 +74,7 @@ namespace NecromancerTome
public static bool Applies(EntityAlive _e)
{
return TpHold > 0 && _e != null && _e.inventory?.holdingItem?.Name == CinerariumThreads.ThreadKnifeName;
return TpHold > 0 && _e != null && CinerariumThreads.IsThreadToolName(_e.inventory?.holdingItem?.Name);
}
}
+32
View File
@@ -0,0 +1,32 @@
using System;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Баг игры 3.4 (найден 24.10.2026: «словил ошибку при переключении ножа», #79). Inventory.SetStackAt
/// ДО записи в слот пояса зовёт новую аналитику LogToolBeltUsageEvent; та считает характеристики
/// предмета через EffectManager.GetValue без сущности (_entity = null), и у passive_effect со
/// значением из CVar (у кинжала некроманта value="@$necroKnifeDamageCVar") PassiveEffect.ModifyValue
/// падает на _ea.Buffs. Исключение вылетает раньше ItemGrid.SetItem - нож не меняется, слот не пишется.
///
/// Лечение: аналитика - только телеметрия игры, её исключение глотаем (Finalizer), SetStackAt
/// продолжает и пишет слот как обычно. Предупреждение в лог - один раз за запуск.
/// </summary>
[HarmonyPatch(typeof(Inventory), nameof(Inventory.LogToolBeltUsageEvent))]
public static class Patch_Inventory_LogToolBeltUsageEvent_Guard
{
static bool _warned;
public static Exception Finalizer(Exception __exception)
{
if (__exception != null && !_warned)
{
_warned = true;
Debug.LogWarning("[NecromancerTome] ToolBeltAnalyticsGuard: game analytics failed in Inventory.LogToolBeltUsageEvent, ignored: " + __exception.Message);
}
return null;
}
}
}
+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 ? "да" : "нет"));
}
}
}
+18 -1
View File
@@ -690,7 +690,6 @@ namespace NecromancerTome
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillAndSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveFillStacks));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveSmart));
yield return AccessTools.Method(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.ToggleLockMode));
}
public static bool Prefix(XUiC_ContainerStandardControls __instance)
@@ -699,6 +698,24 @@ namespace NecromancerTome
}
}
/// <summary>Замок ячеек у сумки волка не включается (в 3.4 вместо ToggleLockMode - свойство UserLockMode).</summary>
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.UserLockMode), MethodType.Setter)]
public static class Patch_ContainerStandardControls_UserLockMode_WolfBag
{
public static bool Prefix(XUiC_ContainerStandardControls __instance, bool value)
{
if (!value || !WolfBagControls.Blocked(__instance))
{
return true;
}
if (__instance.btnToggleLockMode != null)
{
__instance.btnToggleLockMode.IsSelected = false;
}
return false;
}
}
/// <summary>Кнопка замка видна по привязке {hasslotlocks} - у волка её нет.</summary>
[HarmonyPatch(typeof(XUiC_BagContainer), nameof(XUiC_BagContainer.GetBindingValueInternal))]
public static class Patch_BagContainer_Binding_WolfBag
+34 -2
View File
@@ -11,6 +11,8 @@ namespace NecromancerTome
/// - при выходе из одержимости зомби сбрасывает рюкзаком всё, что было в сумке (и на поясе - когда в поясе
/// появятся предметы);
/// - гибель одержимого - мешок на месте и метка на карте, как у сумки волка.
/// ПОЯС (#37, 25.10) - нижний ряд той же сумки: Possession.BeltSlots ячеек под верхним рядом, оружие в руке берётся
/// оттуда (ZombieWeapons). Хранение, выброс мешком и метка - общие с сумкой.
/// Лут (указание 13.10: «раз у них E свободно, подключай лутание») - как у волка: E на ящик или мешок - обыск с
/// переносом в сумку, E на предмет на земле - подобрать (WolfBag.Target - сумка зомби, пока она есть).
/// Только зомби из списка вида от первого лица (FpArms.Allows), не питомцы. Окно - то же, что у сумки волка
@@ -28,12 +30,41 @@ namespace NecromancerTome
public const string WaypointName = "necroZombieBagWaypoint";
public static bool WindowOpen;
static Bag _bag;
/// <summary>Ячеек в ряду: верхний ряд - сумка, нижний - пояс.</summary>
static int _cols;
static string FilePath => Path.Combine(GameIO.GetSaveGameDir(), FileName);
public static bool Active => _bag != null;
public static Bag Current => _bag;
/// <summary>Ячейка пояса (0..BeltSlots-1) - нижний ряд сумки; нет сумки - null.</summary>
public static ItemStack Belt(int _i)
{
ItemStack[] items = _bag?.ItemGrid?.items;
int idx = _cols + _i;
return items != null && _i >= 0 && _i < Possession.BeltSlots && idx < items.Length && !items[idx].IsEmpty() ? items[idx] : null;
}
/// <summary>necropossess belt: положить предмет в ячейку пояса (проверка без мыши).</summary>
public static bool DevSetBelt(int _i, ItemStack _s)
{
if (_bag == null || _i < 0 || _i >= Possession.BeltSlots)
{
return false;
}
_bag.SetSlot(_cols + _i, _s);
Save();
return true;
}
/// <summary>Предмет пояса изменился на месте (износ) - окно и файл.</summary>
public static void BeltChanged()
{
_bag?.onBackpackChanged();
Save();
}
/// <summary>Записать после лута (WolfBag.SaveTarget).</summary>
public static void SaveNow() => Save();
@@ -64,8 +95,9 @@ namespace NecromancerTome
{
Debug.LogWarning("[NecromancerTome] ZombieBag: файл сумки не прочитан (" + e.Message + ") - оставлен как есть");
}
_bag = new Bag(new Vector2i(SlotsFor(_z), 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
Debug.Log("[NecromancerTome] ZombieBag: сумка " + _z.EntityName + " - ячеек " + _bag.SlotCount);
_cols = Mathf.Max(SlotsFor(_z), Possession.BeltSlots);
_bag = new Bag(new Vector2i(_cols, 2), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
Debug.Log("[NecromancerTome] ZombieBag: сумка " + _z.EntityName + " - ячеек " + _bag.SlotCount + ", из них пояс " + Possession.BeltSlots);
}
/// <summary>Вселение кончилось: всё из сумки - мешком на месте зомби; погиб - ещё и метка на карте.</summary>
+339
View File
@@ -0,0 +1,339 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Оружие на поясе одержимого зомби (#37, решения пользователя 25.10). Пояс - две ячейки нижнего ряда сумки зомби
/// (ZombieBag.Belt), туда попадает только то, что зомби нашёл сам или что положили в сумку. Чем зомби владеет -
/// по его типу («зомби строитель сможет пользоваться простыми инструментами: топоры, молотки и т.п. Медсестра -
/// ножами»; список утверждён 25.10, карточка #37). Неумелое или сломанное в выбранной ячейке - кулаки и подсказка.
///
/// Только ближний бой (ItemActionDynamic - всё оружие и инструменты ближнего боя персонажа). Бензопила и бур в игре
/// стрелковые (Ranged, топливо), гвоздомёт - метатель: они придут вместе с огнестрелом (#80).
///
/// Удар - от первого лица, в миг удара рук (FpArms -> Possession.FpImpact): луч из глаз зомби на дальность оружия и
/// ItemActionAttack.Hit, как у персонажа (урон по существам и блокам, кровь, звук, отрубание, износ). Урон - самого
/// предмета с его модами, без перков и баффов зомби (holdingEntity = null): «урон от оружия».
/// </summary>
public static class ZombieWeapons
{
/// <summary>Выключатель (necroarms weapon on|off): выкл - все бьют кулаками, как до #37.</summary>
public static bool Enabled = true;
// Группы предметов: имена групп (не путать с концовками имён предметов, их тоже можно писать в умениях).
const string Knife = "Knife", SmallKnife = "SmallKnife", Club = "Club", Baton = "Baton", Knuckles = "Knuckles", Spear = "Spear",
Sledge = "Sledge", Axe = "Axe", Hammer = "Hammer", Pick = "Pick", Shovel = "Shovel", Wrench = "Wrench", Light = "Light";
static readonly HashSet<string> GroupNames = new HashSet<string>
{ Knife, SmallKnife, Club, Baton, Knuckles, Spear, Sledge, Axe, Hammer, Pick, Shovel, Wrench, Light };
/// <summary>Умения по началу имени класса зомби (как FpArms.Allows; Feral/Radiated/Charged/Infernal - как базовый).
/// Строка - группа или концовка имени предмета игры (StoneAxe подходит и к TazasStoneAxe).</summary>
static readonly Dictionary<string, string[]> Skills = new Dictionary<string, string[]>
{
{ "zombieUtilityWorker", new[] { Hammer, Pick, Shovel, Axe, Sledge, Wrench, "ImpactDriver", "Auger", "Nailgun" } },
{ "zombieLumberjack", new[] { Axe, "Chainsaw", Sledge } },
{ "zombieTomClark", new[] { Shovel, Axe, Spear } },
{ "zombieJanitor", new[] { Wrench, Shovel, Light, Club } },
{ "zombieNurse", new[] { Knife } },
{ "zombieLab", new[] { Knife, "StunBaton", "PlasmaBaton" } },
{ "zombieMaleHazmat", new[] { Shovel, Light, Wrench } },
{ "zombieSoldier", new[] { Knife, Baton, Spear } },
{ "zombieBiker", new[] { Knuckles, Club, "PipeBaton", Wrench } },
{ "zombieInmate", new[] { "BoneKnife", Knuckles, "PipeBaton" } },
{ "zombieYo", new[] { Knuckles, "BaseballBat", "PipeBaton" } },
{ "zombieSkateboarder", new[] { "BaseballBat", "PipeBaton" } },
{ "zombieBowler", new[] { Club, Sledge } },
{ "zombieBoe", new[] { Club, Knuckles, "StoneAxe", "StoneShovel" } },
{ "zombieJoe", new[] { Club, Knuckles, "StoneAxe", "StoneShovel" } },
{ "zombieMoe", new[] { Sledge, Club } },
{ "zombieSteve", new[] { Club, "StoneAxe", "StoneShovel" } },
{ "zombieBusinessMan", new[] { "BaseballBat", SmallKnife } },
{ "zombieBurnt", new[] { "Torch", Club } },
{ "zombieDarlene", new[] { Knife, "WoodenClub" } },
{ "zombieArlene", new[] { Knife, "BaseballBat" } },
{ "zombieMarlene", new[] { Knife, "BaseballBat" } },
{ "zombiePartyGirl", new[] { "CandyKnife", "CandyClub", "BaseballBat" } },
};
static readonly Dictionary<int, HashSet<string>> _groups = new Dictionary<int, HashSet<string>>();
static bool Tag(ItemClass _ic, string _tag) => _ic.ItemTags.Test_AnySet(FastTags<TagGroup.Global>.Parse(_tag));
/// <summary>Группы предмета: у предметов игры - по имени, у предметов других модов - по тегам (как у игры).</summary>
static HashSet<string> Groups(ItemClass _ic)
{
if (_groups.TryGetValue(_ic.Id, out HashSet<string> g))
{
return g;
}
g = new HashSet<string>();
string n = _ic.Name ?? "";
bool motor = Tag(_ic, "motorTool") || Tag(_ic, "nailgun");
if (n.StartsWith("meleeWpnBlade") || Tag(_ic, "knife") || Tag(_ic, "machete"))
{
g.Add(Knife);
if (!n.Contains("Machete") && !Tag(_ic, "machete"))
{
g.Add(SmallKnife);
}
}
if (n.StartsWith("meleeWpnClub") || Tag(_ic, "club"))
{
g.Add(Club);
}
if (n.StartsWith("meleeWpnBaton") || Tag(_ic, "batonJunkie") || Tag(_ic, "stunBaton"))
{
g.Add(Baton);
}
if (n.StartsWith("meleeWpnKnuckles") || Tag(_ic, "knuckleSkill"))
{
g.Add(Knuckles);
}
if (n.StartsWith("meleeWpnSpear") || Tag(_ic, "spearSkill"))
{
g.Add(Spear);
}
if (n.StartsWith("meleeWpnSledge") || Tag(_ic, "sledge"))
{
g.Add(Sledge);
}
if (!motor && (n.StartsWith("meleeToolAxe") || Tag(_ic, "axe")))
{
g.Add(Axe);
}
if (n.Contains("ClawHammer") || !motor && Tag(_ic, "repairTool") && !Tag(_ic, "axe"))
{
g.Add(Hammer);
}
if (n.StartsWith("meleeToolShovel") || Tag(_ic, "shovel"))
{
g.Add(Shovel);
}
else if (!motor && (n.StartsWith("meleeToolPick") || Tag(_ic, "miningTool") && !Tag(_ic, "axe")))
{
g.Add(Pick);
}
if (!n.Contains("ImpactDriver") && (n.StartsWith("meleeToolSalvage") || Tag(_ic, "salvageTool")))
{
g.Add(Wrench);
}
if (n.Contains("Flashlight") || n.Contains("Torch") || Tag(_ic, "heldTorch"))
{
g.Add(Light);
}
_groups[_ic.Id] = g;
return g;
}
static string[] SkillsOf(EntityAlive _z)
{
if (_z == null || !EntityClass.list.TryGetValue(_z.entityClass, out EntityClass cls) || cls?.entityClassName == null)
{
return null;
}
foreach (KeyValuePair<string, string[]> kv in Skills)
{
if (cls.entityClassName.StartsWith(kv.Key))
{
return kv.Value;
}
}
return null;
}
/// <summary>Зомби этого типа владеет предметом.</summary>
public static bool Knows(EntityAlive _z, ItemClass _ic)
{
string[] skills = SkillsOf(_z);
if (skills == null || _ic == null)
{
return false;
}
HashSet<string> g = Groups(_ic);
string n = _ic.Name ?? "";
foreach (string s in skills)
{
if (GroupNames.Contains(s) ? g.Contains(s) : n.EndsWith(s))
{
return true;
}
}
return false;
}
public enum Fit { Fists, Ok, Unknown, Broken }
/// <summary>Чем бьёт зомби с этим предметом в выбранной ячейке.</summary>
public static Fit Check(EntityAlive _z, ItemValue _iv)
{
if (_iv == null || _iv.IsEmpty() || _iv.ItemClass == null)
{
return Fit.Fists;
}
ItemClass ic = _iv.ItemClass;
if (!(ic.Actions != null && ic.Actions.Length > 0 && ic.Actions[0] is ItemActionDynamic) || !Knows(_z, ic))
{
return Fit.Unknown;
}
if (_iv.MaxUseTimes > 0 && _iv.UseTimes >= _iv.MaxUseTimes)
{
return Fit.Broken;
}
return Fit.Ok;
}
/// <summary>Оружие в руке одержимого сейчас: выбранная ячейка пояса, если зомби им владеет и оно цело; иначе null.</summary>
public static ItemValue Current(EntityAlive _z)
{
if (!Enabled || !ZombieBag.Active)
{
return null;
}
ItemStack s = ZombieBag.Belt(Possession.BeltSlot);
return s != null && Check(_z, s.itemValue) == Fit.Ok ? s.itemValue : null;
}
/// <summary>Пауза между ударами этим оружием - как у персонажа (удары в минуту).</summary>
public static float SwingTime(ItemValue _iv)
{
float apm = EffectManager.GetValue(PassiveEffects.AttacksPerMinute, _iv, 60f, null, null, _iv.ItemClass.ItemTags);
return apm > 1f ? 60f / apm : 1f;
}
/// <summary>Удар оружием в миг удара рук: луч из глаз зомби (камера одержимости) на дальность оружия. Мимо - false
/// (звука нет, как у персонажа - замах в воздух).</summary>
public static bool Strike(World _world, EntityAlive _z, Ray _ray)
{
ItemStack stack = ZombieBag.Belt(Possession.BeltSlot);
ItemValue iv = stack?.itemValue;
if (iv == null || Check(_z, iv) != Fit.Ok || !(iv.ItemClass.Actions[0] is ItemActionDynamic act))
{
return false;
}
FastTags<TagGroup.Global> tags = ItemActionAttack.PrimaryTag | iv.ItemClass.ItemTags;
float range = EffectManager.GetValue(PassiveEffects.MaxRange, iv, act.RangeDefault, null, null, tags);
float blockRange = EffectManager.GetValue(PassiveEffects.BlockRange, iv, range, null, null, tags);
float sphere = act.SphereRadius;
float dist = Mathf.Max(range, blockRange) + sphere;
_ray.origin -= sphere * _ray.direction;
// Свои коллайдеры зомби - мимо луча (как GetExecuteActionTarget у игры). Камера одержимости - в голове зомби:
// если луч всё же задел его самого (тест 25.10: все удары «мимо» вплотную), - продолжить луч за ним.
int layer = _z.GetModelLayer();
_z.SetModelLayer(2);
WorldRayHitInfo hit = null;
float passed = 0f;
try
{
for (int i = 0; i < 4 && hit == null; i++)
{
Ray ray = new Ray(_ray.origin + _ray.direction * passed, _ray.direction);
EntityAlive target = null;
bool any = Voxel.Raycast(_world, ray, dist - passed, -538767389, 128, sphere);
if (any)
{
target = ItemActionAttack.GetEntityFromHit(Voxel.voxelRayHitInfo) as EntityAlive;
}
if (target == null)
{
any = Voxel.Raycast(_world, ray, dist - passed, -538488845, 128, sphere);
target = any ? ItemActionAttack.GetEntityFromHit(Voxel.voxelRayHitInfo) as EntityAlive : null;
}
if (!any)
{
break;
}
if (target == _z)
{
passed += Mathf.Sqrt(Voxel.voxelRayHitInfo.hit.distanceSq) + 0.05f;
continue;
}
hit = Voxel.voxelRayHitInfo.Clone();
}
}
finally
{
_z.SetModelLayer(layer);
}
if (hit?.tag == null)
{
string phys = "ничего";
if (Physics.Raycast(_ray.origin - Origin.position, _ray.direction, out RaycastHit ph, 5f, -5, QueryTriggerInteraction.Ignore))
{
phys = ph.transform.name + " слой " + LayerMask.LayerToName(ph.transform.gameObject.layer) + " в " + ph.distance.ToString("F2");
}
Debug.Log("[NecromancerTome] ZombieWeapons: луч ничего не задел (дальность " + dist.ToString("F1") + (passed > 0f ? ", сквозь себя " + passed.ToString("F1") : "") +
"), луч " + _ray.origin.ToString("F2") + " -> " + _ray.direction.ToString("F2") + ", голова " + _z.getHeadPosition().ToString("F2") + ", физика: " + phys);
return false;
}
bool entity = hit.tag.StartsWith("E_");
float d = Vector3.Distance(_ray.origin, hit.hit.pos);
if (entity ? d > range + sphere : d > blockRange + sphere)
{
Debug.Log("[NecromancerTome] ZombieWeapons: далеко - " + hit.tag + " в " + d.ToString("F1") + " м, достаёт " + (entity ? range : blockRange).ToString("F1"));
return false;
}
if (entity && ItemActionAttack.GetEntityFromHit(hit) is EntityAlive e && (e == _z || e is EntityPlayer || SnakeControl.IsNecroEntity(e)))
{
Debug.Log("[NecromancerTome] ZombieWeapons: под ударом свой - " + e.EntityName);
return false; // хозяина и его нежить одержимый не бьёт - как кулаками (MeleeAim)
}
BlockValue bv = ItemActionAttack.GetBlockHit(_world, hit);
float blockDamage = entity || bv.isair ? 0f : act.GetDamageBlock(iv, bv, null, 0);
float entityDamage = act.GetDamageEntity(iv, null, 0);
string tagHit = hit.tag;
FastTags<TagGroup.Global> dTags = tags;
if (tagHit == "E_BP_Head")
{
dTags |= FastTags<TagGroup.Global>.Parse("head");
}
float dismember = EffectManager.GetValue(PassiveEffects.DismemberChance, iv, 0f, null, null, dTags);
ItemActionAttack.AttackHitInfo details = new ItemActionAttack.AttackHitInfo { WeaponTypeTag = ItemActionAttack.MeleeTag };
ItemActionAttack.Hit(hit, _z.entityId, act.DamageType, blockDamage, entityDamage, 1f, 1f, 0f, dismember,
iv.ItemClass.MadeOfMaterial.SurfaceCategory, new DamageMultiplier(), new List<string>(), details, 1, 0, 0f, null,
act.ToolBonuses, ItemActionAttack.EnumAttackMode.RealNoHarvesting, act.HitSoundOverrides, -1, iv);
// Износ - как у персонажа (ItemActionDynamic.hitTarget); сломалось - звук, дальше кулаки.
if (iv.MaxUseTimes > 0)
{
iv.UseTimes += EffectManager.GetValue(PassiveEffects.DegradationPerUse, iv, 1f, null, null, tags) * ItemAction.ItemDegradationModifier;
if (iv.UseTimes >= iv.MaxUseTimes)
{
Audio.Manager.BroadcastPlay(_z, "itembreak");
Debug.Log("[NecromancerTome] ZombieWeapons: " + iv.ItemClass.Name + " сломалось");
}
ZombieBag.BeltChanged();
}
Debug.Log("[NecromancerTome] ZombieWeapons: удар " + iv.ItemClass.Name + " по " + (entity ? tagHit : bv.Block?.GetBlockName()) +
", урон " + (entity ? entityDamage : blockDamage).ToString("F0") + (details.bKilled ? ", убит" : ""));
return true;
}
/// <summary>Подсказка при смене выбранного предмета: неумелое или сломанное - кулаки.</summary>
public static void Hint(EntityPlayerLocal _player, EntityAlive _z, ItemValue _iv)
{
Fit f = Enabled ? Check(_z, _iv) : Fit.Fists;
if (f == Fit.Unknown)
{
GameManager.ShowTooltip(_player, string.Format(Localization.Get("necroZombieWeaponUnknown"), _iv.ItemClass.GetLocalizedItemName()));
}
else if (f == Fit.Broken)
{
GameManager.ShowTooltip(_player, string.Format(Localization.Get("necroZombieWeaponBroken"), _iv.ItemClass.GetLocalizedItemName()));
}
}
/// <summary>necroarms weapon: что умеет текущий одержимый и что в его поясе.</summary>
public static string Dump(EntityAlive _z)
{
string[] sk = SkillsOf(_z);
string s = "оружие " + (Enabled ? "вкл" : "ВЫКЛ") + ", умения: " + (sk != null ? string.Join(", ", sk) : "нет");
for (int i = 0; i < Possession.BeltSlots; i++)
{
ItemStack st = ZombieBag.Belt(i);
s += "\n ячейка " + (i + 1) + (i == Possession.BeltSlot ? " (выбрана)" : "") + ": " +
(st == null || st.IsEmpty() ? "пусто" : st.itemValue.ItemClass.Name + " [" + string.Join(",", Groups(st.itemValue.ItemClass)) + "] - " + Check(_z, st.itemValue));
}
return s;
}
}
}
+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.
+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.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 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: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

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