53 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5.5 84b8b57671 Версия 1.6.1 (рабочая линия 1.6.x до выката всех фич)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:08:47 +03:00
AlexCubeandClaude Opus 5.5 027c707233 Кровавая роза: семена, куст, ступка, сок чёрной розы
- Кровавые семена (50 семян травы + кровь некроманта), три этапа роста на грядке:
  росток -> бутоны-черепа -> куст роз с черепом в сердцевине (бандл necrorose).
  Урожай - чёрные розы (3 базово, бонусы фермерства через ванильные теги).
- Ступка (верстак, 1 куб, руны на боку; бандл necromortar) - на Подмастерье.
- Сок чёрной розы: 4 розы + пустая банка на ступке; банка - копия банки крови
  с чернильно-чёрным содержимым (в бандле necroblood).
- Копии всех рецептов на крови некроманта с соком: банка сока = две банки крови
  (при одной крови - одна), из сока 3 кровавые сферы вместо 2.
- Иконки семян, ступки, сока и чёрной розы; тексты на 13 языках.
- DecorColliderTagPatch: хитбокс ступки и куста роз.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:30:02 +03:00
AlexCubeandClaude Opus 5.5 fdd3782024 Змея: выход из текстур, полёт на ПКМ; одержимый: приседание, удар по прицелу; коза
- Змея: проверка «внутри текстуры» лучами (земля игры - поверхность), каждый кадр выброс
  на ближайшее свободное место (на земле - из любых текстур, в полёте - из грунта);
  шаг и прыжок в текстуры не пускают. Полёт пока зажата ПКМ: 1.1 м/с (половина шага
  зомби), сквозь постройки можно, в грунт нельзя; Пробел вверх, C/Ctrl вниз.
  E - аварийный выход. Детализация зданий и область тела - как у одержимого.
- Одержимый: будит спящих (комнаты зданий и сам вид одержимого), приседание на C и Ctrl,
  удар по точке под прицелом с наведением на грудь ближнего врага.
- Хваты: толчок руки к прицелу на ЛКМ; «пиф-паф» вселяет на пике толчка.
  «Отрицание» стало «Козой» (13 языков): средний и безымянный поджаты, большой прижат;
  звук - бросок змеи.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:28:40 +03:00
AlexCubeandClaude Opus 5.5 bb7a66dd8e Банки с водой: хват стеклянной банки, вода до стенки
- tinCanRiverWater/tinCanBoiledWater: HoldType 14 -> 3 (хват drinkJar*),
  питьё сразу, без фазы вскрытия консервы.
- necrocan: диск воды расширен с r 0.0389 до 0.0425 - край уходит в
  толщину двойной стенки, щели у стенки больше нет. Проверено в игре.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:00:09 +03:00
AlexCubeandClaude Opus 5.5 66a0dcf403 Чертоги разума, хваты пустой руки, одержимость, книга с оглавлением
- Чертоги разума: дух в префабе chertogi под землёй, книга памяти, нож и плакат;
  кровать префаба - мебель necroPalaceBed (иначе становилась точкой возрождения);
  охрана выбрасывает и игроков (их нет в EntityAlives).
- Хваты пустой руки по R: «отрицание» и «пиф-паф» - поза рук костями поверх аниматора,
  остальные кости заморожены, ЛКМ - заклинание, прицел краснеет на зомби.
- «Отрицание»: зомби в ужасе бежит дальше 40 м и теряет игрока как цель.
- «Пиф-паф»: одержимость зомби-гуманоидом - бег, прыжок, удар, дальняя атака,
  подрыв, крик крикуньи; тело цепенеет, зомби танкуют одержимого; область
  вокруг тела сжата; полоска здоровья; спавн и прорисовка у одержимого.
- Книга с оглавлением (окно necroTome), глава «Некроэнергия».
- Прорисовка дверей у змеи и одержимого (CheckLODs от камеры, а не от тела).
- Почтовый ящик консоли и команды necrobones/necropose/necroshot для разработки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:10:05 +03:00
AlexCubeandClaude Opus 5.5 c828202c66 Режим змеи: леденящий ужас, Стиратель, радиация без таймера
- Радиация: проверка раз в 2 с, в поясе змея сразу развеивается (таймер 30 с убран).
- Леденящий ужас: учуянные змеёй зомби (30 м) бегут, ломая препятствия;
  очередь испуга, отметка места змеи для поиска пути, выключен в кровавую луну.
  «Шум» (зомби шли к змее) удалён. Диагностика бегства в логе пока оставлена.
- Стиратель (zombieEraser): в орде игрока-змеи с шансом 0.5; взрывается на любой
  атаке и при разрыве змеёй, отбрасывает змею; тело - чёрный разрыв. Вид: своя
  раскраска на модели гавайца (Resources/Eraser), свечение маджента, звуки спайдера.
- Фиолетовая змея на карте (necroSnakeSpirit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:32:20 +03:00
AlexCubeandClaude Opus 5.5 6ded684d2c Пирамида без огня, банка крови как в ванили, руны ножа на другой плоскости
- пирамида: огонь убран; включённая модель светится ярко (кристалл, глазницы),
  выключенная тускло; граница зоны - светящееся фиолетовое кольцо по земле
  (LineRenderer, точки опущены на рельеф) вместо огоньков; старые огни снимаются
- банка крови: снято зеркалирование - стоит как ванильная, в руке
- нож: руны перенесены на противоположную плоскость клинка

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:37:50 +03:00
AlexCubeandClaude Opus 5.5 20ee89323d Пирамида отрицания: своя костяная модель, новое имя, иконка
- модель: ступени из костей, черепа с тлеющими глазницами, костяная ось насквозь,
  наверху цветок из рваной плоти на коротком шипастом стебле из скрученных жил,
  в нём фиолетовый кристалл; бандл necroward (копия префаба оссуария)
- блок на Shape="ModelEntity" как оссуарий; шейдер и теги коллайдера ставит
  GeoOssuaryPatch (теперь для обоих префабов); свой коллайдер-коробка
- «Пирамида духов» переименована в «Пирамиду отрицания» (13 языков, и в описаниях);
  внутреннее имя блока прежнее - уже поставленные пирамиды в сейвах сохранятся
- иконка - рендер модели

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:08:52 +03:00
AlexCubeandClaude Opus 5.5 0dcb449f25 Кровь некроманта: новая иконка - рендер новой банки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:42:16 +03:00
AlexCubeandClaude Opus 5.5 13cc801ab1 Кровь некроманта: своя банка (гигиена ассетов)
- стекло, крышка и кровь построены заново по размерам игровой банки
- крышка - свернувшаяся кровь: бугристая, пористая, тёмно-бордовая с почти чёрными
  пятнами и прожилками фибрина
- на стекле процарапаны руны: «SANGO DE NEKROMANTO» и «NE TUŠU» (эсперанто,
  старший футарк); потёки крови по стеклу над уровнем
- кровь тёмная с фиолетовым отливом, слабо светится, со светящимися искрами
- стекло - отдельный прозрачный материал, крышка и кровь - непрозрачный со свечением;
  ванильные меши masonJar* и masonJar_n из бандла убраны

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:41:15 +03:00
AlexCubeandClaude Opus 5.5 027cc88d4a Нож: новая иконка - рендер новой модели
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:29:06 +03:00
AlexCubeandClaude Opus 5.5 9de39c8637 Нож: мыщелки навершия разного размера
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:25:28 +03:00
AlexCubeandClaude Opus 5.5 6026cf9ac0 Нож: навершие - суставной конец кости (расширение и два мыщелка), без шейки над ним
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:24:50 +03:00
AlexCubeandClaude Opus 5.5 44fa5501b0 Нож некроманта: своя модель и свои текстуры (гигиена ассетов)
- кость и клинок построены заново по профилю прежнего ножа - длина и положение
  навершия, рукояти, гарды и острия те же
- облик костяного кинжала: навершие-сустав, рукоять со скруглённо-прямоугольным
  сечением и рифлением как у рифлёного листа, крестообразная гарда, клинок
  обоюдоострый ромбического сечения, у гарды во всю ширину; проволочная обмотка
  убрана; резьба по кости на навершии и у основания клинка
- текстуры свои (кость, трещины с пурпуром, резьба); руны пользователя перенесены
  со старой модели запеканием; ванильные boneShiv_n и перекраска boneShiv_d убраны

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:23:08 +03:00
AlexCubeandClaude Opus 5.5 87f6e045c5 Банки: дно - внутри тот же лак, что и стенки, снаружи жесть; на дне концентрические выштамповки вместо полос шлифовки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:05:01 +03:00
AlexCubeandClaude Opus 5.5 4f295650f4 Банки: жесть - металл, светлая луженая сталь с шлифовкой по окружности, внутри пищевой лак
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:03:40 +03:00
AlexCubeandClaude Opus 5.5 2a298eb69c Гигиена: банки - своя модель (жесть с рёбрами, завальцованный край) и своя текстура чистой жести без ржавчины; ванильные меш и cannedfood01 убраны
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:01:59 +03:00
AlexCubeandClaude Opus 5.5 cef2127324 Гигиена: скакун и оссуарий без ванильных ассетов
- скакун: меш коллайдеров колёс - свой цилиндр тех же размеров; у материалов
  выключенных ореола и стекла фары сняты ванильные текстуры
- оссуарий: коллайдер - своя коробка по габаритам ванильного меша

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:56:55 +03:00
AlexCubeandClaude Opus 5.5 b9b89b4e84 Чистая вода: без блика и отражений, матовая
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:28:03 +03:00
AlexCubeandClaude Opus 5.5 f47dcb11c2 Чистая вода в банке: непрозрачная, нарисованная (как кровь) - тёмная сине-зелёная толща с проступающим дном
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:26:38 +03:00
AlexCubeandClaude Opus 5.5 ae4129367d Банки: диаметр x0.88, выше на 2 см; чистая вода - режим Fade, прозрачность 0.35
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:34:39 +03:00
AlexCubeandClaude Opus 5.5 eef2f59f09 Банки: размер как у ванильной консервы (хват руки), вода - Standard Specular с текстурой цвета
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:24:15 +03:00
AlexCubeandClaude Opus 5.5 9c0953e966 Банки с водой: вода матовее и темнее, без отражения неба (была белой)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:20:35 +03:00
AlexCubeandClaude Opus 5.5 d7a43318ff Испытания: названия - Кровавый запас, Фараон на минималках, Кровь за кровь земли
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:12:38 +03:00
AlexCubeandClaude Opus 5.5 934bc3c7f5 Испытания некромантии: 7 новых (скакун, оссуарий, сферы, камень, свиток крысы, браслет, пирамида)
- Всадник на костях: создать Костяного скакуна (2500)
- Недра платят кровью: поставить Гео-оссуарий (2000)
- Запас крови: создать 10 кровавых сфер (1000)
- Кровь, ставшая камнем: создать Кровавый камень (2500)
- Крысиный король: создать Свиток духа крысы (1000)
- Карман в пустоте: создать Пространственный браслет (2000)
- Ересь: поставить Пирамиду духов (2000)
Тексты на 13 языках.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:10:30 +03:00
AlexCubeandClaude Opus 5.5 fa9c5e619d Банки с мутной и кипячёной водой: свои модели с водой внутри
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:05:36 +03:00
AlexCubeandClaude Opus 5.5 12a5559315 Пустая консервная банка: своя модель без крышки (бандл necrocan)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:02:18 +03:00
AlexCubeandClaude Opus 5.5 7f1f61fe40 Книги собаки и стервятника: свои модели, обложки из референсов, новые иконки
- Префабы necroBookDogPrefab и necroBookVulturePrefab в бандле necrobooks: модель
  книги крысы, свои текстуры - рисунки z-dog и z-bird, кожа в тон шкуре зомбопса
  (серо-бурая) и перьям зомбостервятника (красно-бурая).
- bookSummonZombieDog и bookSummonZombieGriffin переведены на них, тинт снят.
- Иконки SummonZombieDog и SummonZombieGriffin - рендер моделей, как у книги крысы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:56:39 +03:00
AlexCubeandClaude Opus 5.5 4973ac2242 Книга крысы: новая иконка - рендер модели с обложкой z-rat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:51:32 +03:00
AlexCubeandClaude Opus 5.5 677056eaa3 Книга крысы: рисунок обложки из референса z-rat (череп крысы в рунном круге)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:49:31 +03:00
AlexCubeandClaude Opus 5.5 de5a6a22a7 Скакун: глазница гаснет вместе с фарой
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:29:54 +03:00
AlexCubeandClaude Opus 5.5 9bf3c06e6b Скакун: выключен ванильный ореол фары (розовый квадрат - материал без шейдера)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:28:03 +03:00
AlexCubeandClaude Opus 5.5 0ebf529d49 Сборка DLL: шифт-клик сфер в скакуна
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:21:28 +03:00
AlexCubeandClaude Opus 5.5 dec5d9851d Скакун: сфера и камень ставятся шифт-кликом
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:18:39 +03:00
AlexCubeandClaude Opus 5.5 d35b77be1b Кровавый камень - вечное топливо скакуна и оссуария; сферы чинятся пакетом крови
- Камень в любой ячейке скакуна - вечно полный бак, сферы рядом не тратятся.
- Камень в отсеке топлива гео-оссуария - работает вечно (FuelTypes + removeFuel).
- Кровавая сфера чинится пакетом крови: 250 прочности за пакет (2 пакета - полная).
- Описания сферы, камня, оссуария и скакуна дополнены (13 языков); в описании
  скакуна по-корейски и по-китайски название сферы приведено к принятому в моде.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:10:41 +03:00
AlexCubeandClaude Opus 5.5 a6e3f12eb4 Книга крысы: ещё x0.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:09:40 +03:00
AlexCubeandClaude Opus 5.5 427d139cad Книга крысы: ещё x0.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:02:45 +03:00
AlexCubeandClaude Opus 5.5 65421be787 Книга крысы: развёрнута корешком к игроку, корешок на месте ванильного среза страниц
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:01:04 +03:00
AlexCubeandClaude Opus 5.5 dbc43b5952 Книга крысы: уменьшена до 0.84 (длина как у ванильной), срез страниц и низ - по ванильной
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:00:15 +03:00
AlexCubeandClaude Opus 5.5 5cf66b3597 Скакун: гудок - вопль умирающей зомби-собаки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:49:40 +03:00
AlexCubeandClaude Opus 5.5 1debf47717 Скакун: без стука педалей - слышны только колёса, как у мотоцикла с пустым баком
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:48:13 +03:00
AlexCubeandClaude Opus 5.5 f1c1e61e8d Скакун: без крови не заводится, звук велосипеда, без дыма
- Мотор не заводится без сфер и глохнет, когда кровь кончилась (ваниль заводила
  его и с пустым баком - ехал на полной тяге); вставили сферу в седле - заводится.
- Звуки мотора у скакуна молчат; на ходу - стук педалей bicycle_pedal,
  гудок и колёса - велосипедные.
- Частицы дыма/огня убраны (розовые кубы - ванильные частицы без материалов).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:47:42 +03:00
AlexCubeandClaude Opus 5.5 7b315cda6b Скакун: без слота покраски (краситель размножался)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:32:01 +03:00
AlexCubeandClaude Opus 5.5 3cd5706e7f Скакун: рога по оси ванильной рукоятки, кончики загнуты за кулаком
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:30:06 +03:00
AlexCubeandClaude Opus 5.5 7d4ff86dee Сборка DLL: расход сфер скакуна
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:29:15 +03:00
AlexCubeandClaude Opus 5.5 7496aa0944 Скакун: расход сфер только событием двигателя, прямые записи бака не сжигают сферу
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:26:57 +03:00
AlexCubeandClaude Opus 5.5 83b1cc47ce Скакун: выключены ванильные детали модов мотоцикла (выхлоп и др.), их меши убраны из бандла
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:18:00 +03:00
AlexCubeandClaude Opus 5.5 5dc1ce0e26 Книга крысы: передний срез страниц на уровне ванильной книги, лишняя ширина - к корешку
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:16:16 +03:00
AlexCubeandClaude Opus 5.5 c73502ad51 Сборка DLL: ячейки скакуна, кнopка заправки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:15:42 +03:00
AlexCubeandClaude Opus 5.5 4ef7aac01f Скакун: 4 ячейки и старому предмету, кнопка «Заправить» выключена
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:13:21 +03:00
AlexCubeandClaude Opus 5.5 24d6e9f675 1.7.0: скакун на сферах в ячейках, ремонт костями; правки моделей скакуна и книги крысы
- Топливо скакуна - кровавые сферы в 4 ячейках модификаций, тратятся по очереди
  (одна сфера = 120 ед., бак 480); уровень бака считается по прочности сфер,
  выработанная сфера исчезает. Заправка канистрой убрана. В ячейки - только сферы.
- Причина «не едет» и «-2147483648%»: Extends не наследует effect_group, у предмета
  скакуна не было ни ячеек, ни прочности. Своя группа: ModSlots 4, DegradationMax 4000;
  здоровье закреплено кодом (DegradationMax сфер в ячейках его перебивал бы).
- Ремонт - 50 костей за ход вместо ремкомплекта; подсказка при нехватке, 13 языков.
- Модель скакуна: сиденье и рама прогнуты по ванили, рога по оси руля,
  череп меньше и короче, свет фары в глазнице, крыло над передним колесом,
  зубы под черепом убраны.
- Книга крысы: повёрнута как ванильная (палец на корешке), без металлических уголков.

В игре не проверено.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:51:03 +03:00
AlexCubeandClaude Opus 5.5 11db1116b0 1.7.0: костяной скакун, книга Духа крысы, турели, расход сфер в оссуарии
- Костяной скакун (necroBoneSteed): мотоцикл из костей, сухожилий и сосудов
  с черепом-циклопом вместо фары; своя модель (бандл necrobonesteed),
  ванильная система предмета-транспорта (necroBoneSteedPlaceable, предпросмотр
  при установке); топливо - кровавая сфера, одна сфера = полный бак; теги
  коллайдеров и шейдер копируются с ванильного мотоцикла; рецепт на верстаке,
  Подмастерье
- Книга Духа крысы: своя модель (бандл necrobooks) - чёрная кожа, светящийся
  узор со скелетом крысы и семиконечной звездой, ржавые уголки, ремешок
- Турели не стреляют по существам мода (питомцы, рой, змея)
- Гео-оссуарий тратит 1/10 прочности сферы на стак вместо целой сферы

В игре не проверено.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 01:58:18 +03:00
AlexCubeandClaude Opus 5.5 f2dfeed126 1.7.0: охрана питомцами, один питомец за раз, прицел книг, гео-оссуарий
Питомцы:
- охрана хозяина: враг ближе 20 м (в режиме змеи - к телу) атакуется сам,
  поводок 40 м, охраняют все кроме крысы; приказ прицелом охрана не перебивает
- IsFoe исключает существ мода (necro*): питомец выбирал целью себя
- призыв нового спутника отзывает прежнего
- прицел книг призыва - как у арбалета, краснеет на годной цели

Гео-оссуарий (necroGeoOssuary): станция добычи на кровавой сфере
- основа - коллектор росы; в 3 ячейки модов любой из 5 ресурсов
  (камень, уголь, селитра, железо, сланец), 1 сфера = стак 6000 за 5-8 ч
- работает без открытого неба; верстак, Подмастерье (ур. 25)
- своя модель (бандл necroossuary): кости, корни, сосуды, клыки, черепа
  с глазами-углями; шейдер и теги коллайдера берутся у ванильного коллектора

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:36:45 +03:00
87 changed files with 6865 additions and 247 deletions
+154 -8
View File
File diff suppressed because one or more lines are too long
+27
View File
@@ -116,6 +116,33 @@
<sprite name="blackout" depth="0" sprite="menu_empty" type="sliced" color="[black]" anchor_left="#cam,0,-10" anchor_right="#cam,1,10" anchor_bottom="#cam,0,-10" anchor_top="#cam,1,10" globalopacitymod="0"/>
</window>
</append>
<!-- Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»): оглавление кнопками,
глава по страницам, «Назад/Далее», «Оглавление», «Закрыть». Содержание и условия открытия глав -
в HarmonySrc/NecroTome.cs, тексты - Localization.csv (necroTome*). Открывает книга памяти в
чертогах (BlockNecroMemoryBook.Open) и консольная команда necrotome. -->
<append xpath="/windows">
<window name="necroTome" anchor="Center" pos="-650,420" width="1300" height="840" cursor_area="true" depth="1001" lock_navigation="true" controller="NecromancerTome.NecroTome, NecromancerHarmony">
<sprite depth="0" name="bg" sprite="menu_empty" color="14,10,10,250" type="sliced" />
<sprite depth="1" name="frame" sprite="menu_empty2px" color="120,18,14,255" type="sliced" fillcenter="false" />
<label depth="2" name="lblTitle" pos="0,-22" width="1300" height="50" justify="center" font_size="38" color="205,40,30,255" effect="outline" upper_case="false" />
<sprite depth="1" name="rule" pos="200,-82" width="900" height="2" sprite="menu_empty" color="120,18,14,255" />
<label depth="2" name="lblText" pos="60,-104" width="1180" height="640" justify="left" font_size="27" color="215,205,190,255" overflow="shrinkcontent" upper_case="false" />
<simplebutton name="btnCh1" depth="3" pos="350,-130" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh2" depth="3" pos="350,-190" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh3" depth="3" pos="350,-250" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh4" depth="3" pos="350,-310" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh5" depth="3" pos="350,-370" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh6" depth="3" pos="350,-430" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh7" depth="3" pos="350,-490" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnCh8" depth="3" pos="350,-550" width="600" height="48" font_size="30" visible="false" />
<simplebutton name="btnToc" depth="3" pos="40,-790" width="260" height="46" font_size="26" caption_key="necroTomeToc" />
<simplebutton name="btnPrev" depth="3" pos="440,-790" width="160" height="46" font_size="26" caption_key="necroTomePrev" />
<label depth="2" name="lblPage" pos="600,-772" width="100" height="40" justify="center" font_size="24" color="160,150,140,255" />
<simplebutton name="btnNext" depth="3" pos="700,-790" width="160" height="46" font_size="26" caption_key="necroTomeNext" />
<simplebutton name="btnClose" depth="3" pos="1000,-790" width="260" height="46" font_size="26" caption_key="necroTomeClose" />
</window>
</append>
<!-- ФИОЛЕТОВАЯ ШКАЛА НЕКРОМАНТИИ, 2026-09-17. Продиктовано: "Есть индикатор опыта. Это шкала
до уровня. А мы сделаем ещё одну шкалу, которая заполняется каждые 20 зомби... Каждое
заполнение индикатора пусть добавляет уровень некромантии, а индикатор обнуляет."
+17
View File
@@ -42,4 +42,21 @@
<window name="necroFinalBlack" />
</window_group>
</append>
<!-- Книга с оглавлением (см. windows.xml, necroTome). -->
<append xpath="/xui">
<window_group name="necroTome">
<window name="necroTome" />
</window_group>
</append>
<!-- Окно ступки (05.10.2026): копия ванильной workstation_cementMixer. Имя группы = workstation_ +
имя блока - по нему игра и открывает окно верстака. -->
<append xpath="/xui">
<window_group name="workstation_necroMortar" controller="XUiC_WorkstationWindowGroup" open_backpack_on_open="true" close_compass_on_open="true" defaultselected="bp.content">
<window name="windowCraftingList"/>
<window name="craftingInfoPanel"/>
<window name="windowCraftingQueue"/>
<window name="windowOutput" />
<window name="windowNonPagingHeader" />
</window_group>
</append>
</xui>
+205 -2
View File
@@ -80,10 +80,15 @@
_private/tools/make_pyramid_textures.py, сборка _private/Extracted/ShapesUnityProject,
бандл Resources/necropyramid. Чтобы вернуться к нему, достаточно заменить Shape на
ModelEntity и раскомментировать строку Model ниже. -->
<property name="Shape" value="New" />
<!-- СВОЯ КОСТЯНАЯ МОДЕЛЬ, 01.10.2026 («пирамида отрицания»): ступени из костей, черепа с
тлеющими глазницами, ось насквозь, наверху цветок из рваной плоти на шипастом стебле
из жил, в нём кристалл. Путь тот же, что у гео-оссуария (там он отлажен): ModelEntity,
префаб - копия оссуария (бандл necroward), шейдер и теги коллайдера ставит
GeoOssuaryPatch по ванильному коллектору росы. Свой коллайдер-коробка 0.95x0.5x0.95. -->
<property name="Shape" value="ModelEntity" />
<!-- Своя модель отключена вместе с возвратом на Shape="New" (см. комментарий выше).
Строка сохранена: она рабочая, ею подключается наш бандл. -->
<property name="Model" value="@:Shapes/pyramid.fbx" />
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroward?necroWardPrefab.prefab" />
<!-- <property name="Model" value="#@modfolder(NecromancerTome):Resources/necropyramid?necroPyramidPrefab.prefab" /> -->
<!-- ModelOffset - ПОЛОЖЕНИЕ МОДЕЛИ, и задавать его надо именно здесь.
@@ -179,5 +184,203 @@
<property name="SellableToTrader" value="false" />
<drop event="Destroy" name="resourceScrapIron" count="10,20" />
</block>
<!-- Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере.
Тело - ванильный коллектор росы (Class=Collector): окно, топливо, таймеры, работа без
игрока рядом - всё его. Своего кода два места, оба в HarmonySrc/GeoOssuaryPatch.cs:
- каждая из трёх ячеек модификаций принимает любой из пяти ресурсов (ваниль даёт
ячейке ровно один предмет из RequiredMods);
- ячейка продукции i выдаёт полный стак того ресурса, что лежит в ячейке
модификации i (ваниль смотрит только "занята/пуста", а не на предмет), пустая
модификация = ячейка стоит и сферу не ест; проверка неба снята.
Отсюда три OutputType os1..os3 на три ячейки продукции: у каждого свой таймер и своя
ячейка. OutputItem в них - заглушка, настоящий ресурс подставляет патч.
Топливо общее: 1 сфера = 1 стак (FuelCost 1, AdditionalFuelCost 0). Время как у росы.
ModTypes=Modify - флаг, который ваниль ни на что не тратит: Speed/Count/Expand
изменили бы скорость, выход или размер сетки.
Модель своя (Blender: кости, корни, сосуды, клыки, черепа с глазами-углями), бандл
necroossuary, собрана из префаба коллектора росы: LODGroup, слои и коллайдер ванильные.
Шейдер материалу ставит GeoOssuaryPatch (шейдер ванильного коллектора). -->
<block name="necroGeoOssuary">
<property name="Extends" value="cntDewCollector" />
<property name="UnlockedBy" value="craftingNecroNecromancy" />
<property name="DescriptionKey" value="necroGeoOssuaryDesc" />
<property name="LootLabelKey" value="xuiNecroGeoOssuary" />
<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" />
<property name="FuelGridHeight" value="2" />
<property name="RequiredMods" value="resourceRockSmall,resourceRockSmall,resourceRockSmall" />
<property name="RequiredModsOnly" value="true" />
<property name="ModTypes" value="Modify,Modify,Modify" />
<property name="ModTransformEnableNames" value=",," />
<property name="EmptyText" value="necroGeoOssuaryEmpty" />
<property name="HasItem1Text" value="necroGeoOssuaryHasItems" />
<property name="HasItem2Text" value="necroGeoOssuaryHasItems" />
<property name="FuelTitleText" value="necroGeoOssuaryFuel" />
<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" />
<property name="CustomIcon" value="necroGeoOssuary" />
<property name="EconomicValue" value="0" />
<property name="SellableToTrader" value="false" />
<property class="RepairItems">
<property name="resourceBone" value="50" />
</property>
</block>
</append>
<!-- «Книга памяти» (03.10.2026): единственный интерактивный предмет чертогов разума, по E - окно
«Память» (HarmonySrc/BlockNecroMemoryBook.cs). Вид - одиночная книга игры, без хранилища.
CreativeMode Dev - чтобы можно было поставить в редакторе префабов. -->
<append xpath="/blocks">
<block name="necroMemoryBook">
<property name="Extends" value="decoEntityClothMaster"/>
<property name="Class" value="NecromancerTome.NecroMemoryBook, NecromancerHarmony"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="necroMemoryBook"/> <!-- 03.10: свой рендер книги памяти -->
<!-- 03.10: своя модель - книга некроманта: чёрная кожа, кроваво-алый рисунок (z-mind), крупнее и толще книг призыва -->
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookMindPrefab.prefab"/>
<property name="ModelOffset" value="0,0,0"/>
<property name="GndAlign" value="1"/>
<property name="WaterFlow" value="permitted"/>
<property name="CanPickup" value="false"/>
</block>
</append>
<!-- Нож-декорация в чертогах разума (03.10.2026): копия ножа некроманта на столе с реактивами,
просто декорация, руны светятся (эмиссия материала ножа). Модель - свой бандл necroknife.
Поворот задаёт MindPalace.KnifeRotation (подбирается командой necropalace knife N). -->
<append xpath="/blocks">
<block name="necroKnifeDecor">
<property name="Extends" value="decoEntityClothMaster"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="NecromancersKnife"/> <!-- 03.10: иконка ножа-предмета (имя предмета иконкой не работает) -->
<!-- 03.10: своя модель-декорация - нож уже лежит плашмя, низом на дне клетки (как нижняя обложка
книги памяти). Модель ножа-предмета стоит вертикально (для руки), а блоки-модели в 3.x ставятся
«реквизитом», для которого код блока не вызывается - поэтому плашмя в самой модели, не в коде. -->
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroknife?necroKnifeDecorPrefab.prefab"/>
<property name="ModelOffset" value="0,0,0"/>
<property name="WaterFlow" value="permitted"/>
<property name="CanPickup" value="false"/>
</block>
</append>
<!-- Кровать чертогов (03.10.2026): копия bunkBedMadeWhite без Class="SleepingBag" - просто мебель.
Чертоги ставятся без игрока, и игра делала кровать префаба точкой возрождения: синий камень
портала отправлял в чертоги. MindPalace.Expected подменяет ею кровать префаба при постановке. -->
<append xpath="/blocks">
<block name="necroPalaceBed">
<property name="Extends" value="bunkBedMadeWhite" param1="Class,DescriptionKey,DowngradeBlock"/>
<property name="CreativeMode" value="None"/>
<property name="CanPickup" value="false"/>
</block>
</append>
<!-- Плакаты-схемы в чертогах (03.10.2026): по образцу ванильной pictureFrameGunSchematic, крупнее.
Блок 2×1×1, рамка-плакат 1.405×0.95 м (полотно 3:2 под плакат), тонкий багет 2 см; модель - бандл
necroposters (на каждый плакат свой префаб с тем же багетом). Исходник рамки:
BlenderWork/necroSchemeFramePrefab/build_frame.py. -->
<append xpath="/blocks">
<block name="necroPosterAnatomy">
<property name="Extends" value="pictureFrame_01a"/>
<property name="CreativeMode" value="Dev"/>
<property name="CustomIcon" value="necroPosterAnatomy"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroposters?necroPosterAnatomyPrefab.prefab"/>
<property name="ModelOffset" value="0,0,-0.5"/>
<property name="MultiBlockDim" value="2,1,1"/>
<property name="CanPickup" value="false"/>
</block>
</append>
<!-- Кровавая роза (05.10.2026): «кровавые семена» из 50 семян травы и банки крови некроманта.
Три этапа по образцу ванильного хлопка: росток -> бутон -> чёрный цветок (урожай).
Сажается только на грядку (FertileLevel 15 от cropsGrowingMaster), каждый этап - GrowthRate
мастера (63 мин). Росток - ванильный хлопок; бутоны и цветущий куст - бандл necrorose (BlenderWork/necroBloodRosePrefab:
build_rose.py, set_quality('game'); Unity: NecroRoseSetup.cs). Тег коллайдера - DecorColliderTagPatch.
Иконка BloodSeeds - рендер _private/Extracted/BlenderWork/necroBloodSeed (make_icon.py). -->
<append xpath="/blocks">
<block name="plantedNecroBloodRose1">
<property name="Extends" value="cropsGrowingMaster" param1="CustomIcon"/>
<property name="CreativeMode" value="Player"/>
<property name="CustomIcon" value="BloodSeeds"/>
<property name="DescriptionKey" value="plantedNecroBloodRose1Desc"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="@:Entities/Plants/plantCottonSproutPrefab.prefab"/>
<property class="PlantGrowing">
<property name="Next" value="plantedNecroBloodRose2"/>
</property>
<property name="PlaceAsRandomRotation" value="true"/>
<drop event="Destroy" name="plantedNecroBloodRose1" count="1"/>
<property name="SortOrder1" value="B953"/>
<property name="SortOrder2" value="0100"/>
<property name="Group" value="Resources"/>
<property name="SoundPickup" value="seeds_generic1_grab"/>
<property name="SoundPlace" value="seeds_generic1_place"/>
</block>
<block name="plantedNecroBloodRose2">
<property name="Extends" value="cropsGrowingMaster"/>
<property name="CreativeMode" value="None"/>
<property name="CustomIcon" value="BloodSeeds"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrorose?necroBloodRose2Prefab.prefab"/>
<property class="PlantGrowing">
<property name="Next" value="plantedNecroBloodRose3HarvestPlayer"/>
</property>
<drop event="Destroy" name="plantedNecroBloodRose1" count="1"/>
<property name="SortOrder1" value="B953"/>
<property name="SortOrder2" value="0101"/>
</block>
<block name="plantedNecroBloodRose3HarvestPlayer">
<property name="Extends" value="cropsHarvestableMaster"/>
<property name="CreativeMode" value="None"/>
<property name="CustomIcon" value="BloodSeeds"/>
<property name="DescriptionKey" value="plantedNecroBloodRose3HarvestPlayerDesc"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necrorose?necroBloodRose3Prefab.prefab"/>
<drop event="Harvest" name="resourceNecroBlackRose" count="3" tag="cropHarvest"/>
<drop event="Harvest" name="resourceNecroBlackRose" count="1" tag="farmerBonusHarvest"/>
<drop event="Harvest" name="resourceNecroBlackRose" prob="0.5" count="1" tag="bonusCropHarvest"/>
<drop event="Harvest" name="plantedNecroBloodRose1" count="1" tag="farmerBonusSeed"/>
<drop event="Destroy" name="plantedNecroBloodRose1" count="1" prob="0.5"/>
<property name="SortOrder1" value="B953"/>
<property name="SortOrder2" value="0102"/>
</block>
</append>
<!-- Ступка (05.10.2026): верстак 1 куб, на нём сок чёрной розы. Устроен как cementMixer (Workstation
с одним модулем output, без топлива); окно - workstation_necroMortar в XUi_InGame/xui.xml.
Модель - бандл necromortar (BlenderWork/necroMortarPrefab, руны запечены в текстуру; Unity:
NecroMortarSetup.cs + NecroAuto.ImportMeshAndBuild). BoxCollider на корне, тег - DecorColliderTagPatch. -->
<append xpath="/blocks">
<block name="necroMortar">
<property name="Class" value="Workstation"/>
<property class="Workstation">
<property name="Modules" value="output"/>
</property>
<property name="CreativeMode" value="Player"/>
<property name="DescriptionKey" value="necroMortarDesc"/>
<property name="CustomIcon" value="necroMortar"/>
<property name="WorkstationIcon" value="ui_game_symbol_skull"/>
<property name="CraftIcon" value="ui_game_symbol_skull"/>
<property name="CraftActionName" value="lblContextActionCraft"/>
<property name="Stacknumber" value="1"/>
<property name="Material" value="Mstone"/>
<property name="Shape" value="ModelEntity"/>
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necromortar?necroMortarPrefab.prefab"/>
<property name="ImposterDontBlock" value="true"/>
<property name="WaterFlow" value="permitted"/>
<property name="Place" value="TowardsPlacer"/>
<property name="OnlySimpleRotations" value="true"/>
<property name="IsTerrainDecoration" value="true"/>
<property name="StabilitySupport" value="false"/>
<property name="MaxDamage" value="300"/>
<property name="EconomicValue" value="50"/>
<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="FilterTags" value="MC_playerBlocks,SC_decor"/>
<drop event="Destroy" name="resourceRockSmall" count="10"/>
</block>
</append>
</config>
+15
View File
@@ -343,4 +343,19 @@
</buff>
</append>
<!-- «Стиратель» (02.10.2026): свечение маджента - свободный цвет (у игры зелёный/голубой/огонь,
у мода зелёный, фиолетовый, жёлто-зелёный, бирюзовый). Цвет даёт ParticlePatch.cs (EraserGlow).
Вешается свойством Buffs сущности zombieEraser. -->
<append xpath="/buffs">
<buff name="buffNecroEraser" hidden="true" remove_on_death="false">
<stack_type value="ignore"/>
<duration value="0"/>
<effect_group>
<triggered_effect trigger="onSelfBuffStart" action="AttachParticleEffectToEntity" particle="RadiatedParticlesOnMesh" local_offset="0,0.75,0"/>
<triggered_effect trigger="onSelfBuffRemove" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
<triggered_effect trigger="onSelfDied" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
</effect_group>
</buff>
</append>
</config>
+42
View File
@@ -141,5 +141,47 @@
<objective type="Craft" item="thrownStonePortalBlue"/>
</challenge>
<!-- ИСПЫТАНИЯ 1.7.0 (01.10.2026, указание: «добавь испытания по линии некромантии, сам
придумай; часть - первое создание новых предметов»). Пять - первое создание нового
(скакун, оссуарий, запас сфер, камень, свиток крысы), два - вещи, на которые испытаний
ещё не было (браслет, пирамида). Награды по тому же счёту, что выше: 1000 - быстрые
вещи (10 сфер, свиток), 2000 - станция/браслет/пирамида, 2500 - скакун и камень
(дорогие рецепты на верстаке и химстанции). Иконки - только ui_game_symbol_*. -->
<challenge name="necroBoneSteed" title_key="challengeNecroBoneSteedTitle" icon="ui_game_symbol_motorcycle" group="NecroPath"
short_description_key="challengeNecroBoneSteedShort" description_key="challengeNecroBoneSteedDesc"
reward_text_key="challenge_reward_2500xp" reward_event="challenge_reward_2500">
<objective type="Craft" item="necroBoneSteedPlaceable"/>
</challenge>
<challenge name="necroGeoOssuary" title_key="challengeNecroGeoOssuaryTitle" icon="ui_game_symbol_challenge_harvesting_cobblestone" group="NecroPath"
short_description_key="challengeNecroGeoOssuaryShort" description_key="challengeNecroGeoOssuaryDesc"
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
<objective type="BlockPlace" block="necroGeoOssuary" count="1"/>
</challenge>
<challenge name="necroBloodReserve" title_key="challengeNecroBloodReserveTitle" icon="ui_game_symbol_laceration" group="NecroPath"
short_description_key="challengeNecroBloodReserveShort" description_key="challengeNecroBloodReserveDesc"
reward_text_key="challenge_reward_1000xp" reward_event="challenge_reward_1000">
<objective type="Craft" item="resourceBloodSphere" count="10"/>
</challenge>
<challenge name="necroBloodStone" title_key="challengeNecroBloodStoneTitle" icon="ui_game_symbol_resource" group="NecroPath"
short_description_key="challengeNecroBloodStoneShort" description_key="challengeNecroBloodStoneDesc"
reward_text_key="challenge_reward_2500xp" reward_event="challenge_reward_2500">
<objective type="Craft" item="resourceBloodStone"/>
</challenge>
<challenge name="necroRatKing" title_key="challengeNecroRatKingTitle" icon="ui_game_symbol_book" group="NecroPath"
short_description_key="challengeNecroRatKingShort" description_key="challengeNecroRatKingDesc"
reward_text_key="challenge_reward_1000xp" reward_event="challenge_reward_1000">
<objective type="Craft" item="bookSummonRatSpirit"/>
</challenge>
<challenge name="necroPocketVault" title_key="challengeNecroPocketVaultTitle" icon="ui_game_symbol_backpack" group="NecroPath"
short_description_key="challengeNecroPocketVaultShort" description_key="challengeNecroPocketVaultDesc"
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
<objective type="Craft" item="braceletSpatialVault"/>
</challenge>
<challenge name="necroHeresy" title_key="challengeNecroHeresyTitle" icon="ui_game_symbol_7th_curse" group="NecroPath"
short_description_key="challengeNecroHeresyShort" description_key="challengeNecroHeresyDesc"
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
<objective type="BlockPlace" block="necroHeresyPyramid" count="1"/>
</challenge>
</append>
</config>
+45
View File
@@ -430,4 +430,49 @@
<property name="ExperienceGain" value="0"/>
</entity_class>
</append>
<!-- Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей. Модель - бандл necrobonesteed
(копия ванильного VMotorcycleP, переделанная в Blender); теги коллайдеров и шейдер
ставит BoneSteedPatch с ванильного мотоцикла, топливо - кровавая сфера (там же). -->
<append xpath="/entity_classes">
<entity_class name="necroBoneSteed" extends="vehicleMotorcycle">
<property name="Prefab" value="#@modfolder(NecromancerTome):Resources/necrobonesteed?necroBoneSteedP.prefab"/>
<property name="Tags" value="vehicle,bikerSetBonus,necroSteed"/>
<property name="SurfaceCategory" value="bone"/>
<property name="LootList" value="vehicleMotorcycle"/>
</entity_class>
</append>
<!-- «Стиратель» (02.10.2026, HarmonySrc/EraserPatch.cs): античит к режиму змеи - приходит в орду
кровавой луны игрока-змеи. Маленький, быстрый; взрывается, когда пытается сломать блок или
когда его разрывает змея. Имя НЕ на necro (IsNecroEntity - иначе змея не считает врагом).
Временный вид: тело толстого гавайца по ссылке игры + свечение маджента (buffNecroEraser);
свои вздутия и морда спайдера - потом. -->
<append xpath="/entity_classes">
<entity_class name="zombieEraser" extends="zombieFatHawaiian">
<property name="UserSpawnType" value="Menu"/>
<property name="Tags" value="entity,zombie,walker,eraser"/>
<property name="SizeScale" value="0.75"/>
<property name="MoveSpeedAggro" value="1.0, 1.6"/>
<!-- Звуки спайдера (указание 02.10) -->
<property name="SoundRandom" value="spiderroam"/>
<property name="SoundAlert" value="spideralert"/>
<property name="SoundAttack" value="spiderattack"/>
<property name="SoundDeath" value="spiderdeath"/>
<property name="SoundHurt" value="spiderpain"/>
<property name="SoundSense" value="spidersense"/>
<property name="SoundStepType" value="barestep"/>
<property name="Buffs" value="zombieBuffStatusCheck01,buffNecroEraser"/>
<property class="Explosion">
<property name="ParticleIndex" value="2"/>
<property name="RadiusBlocks" value="4"/>
<property name="RadiusEntities" value="5"/>
<property name="BlockDamage" value="2500"/>
<property name="EntityDamage" value="300"/>
<property class="DamageBonus">
<property name="earth" value=".1"/>
</property>
</property>
</entity_class>
</append>
</config>
+4
View File
@@ -414,6 +414,10 @@
<!-- true: опустевшая сфера должна исчезать, а не лежать "сломанной" в ожидании
ремонта. Само удаление из слота делает SpendCharge - см. SpatialVaultPickupPatch. -->
<property name="DegradationBreaksAfter" value="true"/>
<!-- Ремонт обычным пакетом крови (01.10: «пусть сферы можно будет чинить обычной ванильной
кровью»): пункт «Починить» в инвентаре; сколько чинит пакет - RepairAmount у
medicalBloodBag в items.xml. Пустая сфера исчезает, чинить можно только початую. -->
<property name="RepairTools" value="medicalBloodBag"/>
<effect_group name="resourceBloodSphere" tiered="false">
<passive_effect name="DegradationMax" operation="base_set" value="500"/>
</effect_group>
+92 -18
View File
@@ -33,7 +33,10 @@
<property name="Extends" value="drinkJarEmpty"/>
<property name="CustomIcon" value="tinCanEmpty"/>
<property name="DescriptionKey" value="tinCanEmptyDesc"/>
<property name="Meshfile" value="@:Other/Items/Food/can_emptyPrefab.prefab"/>
<!-- Своя модель (01.10.2026): ванильная консервная банка без отогнутой крышки - пустая
открытая банка. Бандл necrocan; текстура пока ванильная cannedfood01 - до релиза
перерисовать (гигиена ассетов). Банки с водой - свои модели с водой внутри. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrocan?necroTinCanPrefab.prefab"/>
<property name="HoldType" value="14"/>
<property name="Material" value="Mmetal"/>
<property class="Action1"> <!-- UseAction -->
@@ -48,8 +51,10 @@
<property name="Extends" value="drinkJarRiverWater"/>
<property name="CustomIcon" value="tinCanRiverWater"/>
<property name="DescriptionKey" value="tinCanRiverWaterDesc"/>
<property name="Meshfile" value="@:Other/Items/Food/can_emptyPrefab.prefab"/>
<property name="HoldType" value="14"/>
<!-- Своя модель (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"/>
@@ -90,8 +95,9 @@
<property name="Extends" value="drinkJarBoiledWater"/>
<property name="CustomIcon" value="tinCanBoiledWater"/>
<property name="DescriptionKey" value="tinCanBoiledWaterDesc"/>
<property name="Meshfile" value="@:Other/Items/Food/can_emptyPrefab.prefab"/>
<property name="HoldType" value="14"/>
<!-- Своя модель (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"/>
@@ -534,12 +540,10 @@
how bookPrefab.prefab was actually authored/rigged to be held. No flight mesh
needed here (unlike the thrown books above) since this item is never thrown. -->
<property name="HoldType" value="21"/>
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
<!-- Своя модель книги (01.10.2026): кожа в тон шкуре зомбопса (серо-бурая), светящийся фиолетовый рисунок
из референса; та же модель, что у книги крысы (бандл necrobooks), корешком к игроку. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookDogPrefab.prefab"/>
<property name="Material" value="Mpaper"/>
<!-- Yellow-green (per user request), distinct from the pure green of the Spirit
Stone/Grimoire above - keeps the "summon" books visually distinguishable from the
"throw" books at a glance even before real icon art exists. -->
<property name="TintColor" value="160, 220, 40"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
@@ -832,9 +836,10 @@
<property name="DescriptionKey" value="bookSummonZombieGriffinDesc"/>
<property name="DisplayType" value="ammoGrenadeFire"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
<!-- Своя модель книги (01.10.2026): кожа в тон перьям зомбостервятника (буро-красная), светящийся фиолетовый рисунок
из референса; та же модель, что у книги крысы (бандл necrobooks), корешком к игроку. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookVulturePrefab.prefab"/>
<property name="Material" value="Mpaper"/>
<property name="TintColor" value="160, 220, 40"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
@@ -892,9 +897,10 @@
<property name="DescriptionKey" value="bookSummonZombieBearDesc"/>
<property name="DisplayType" value="ammoGrenadeFire"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
<!-- Своя модель книги (03.10.2026): кожа тёмно-бурая, в тон шкуре зомбомедведя, светящийся фиолетовый рисунок из референса;
та же модель, что у книги крысы (бандл necrobooks). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookBearPrefab.prefab"/>
<property name="Material" value="Mpaper"/>
<property name="TintColor" value="160, 220, 40"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
@@ -948,9 +954,10 @@
<property name="DescriptionKey" value="bookSummonZombieWolfDesc"/>
<property name="DisplayType" value="ammoGrenadeFire"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
<!-- Своя модель книги (03.10.2026): кожа серая, в тон шкуре зомбоволка, светящийся фиолетовый рисунок из референса;
та же модель, что у книги крысы (бандл necrobooks). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookWolfPrefab.prefab"/>
<property name="Material" value="Mpaper"/>
<property name="TintColor" value="160, 220, 40"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
@@ -1067,9 +1074,11 @@
<property name="DescriptionKey" value="bookSummonRatSpiritDesc"/>
<property name="DisplayType" value="ammoGrenadeFire"/>
<property name="HoldType" value="21"/>
<property name="Meshfile" value="@:Other/Items/Misc/bookPrefab.prefab"/>
<!-- Своя модель книги (01.10.2026, по иконке RatBook): чёрная кожа, фиолетовый светящийся
узор со скелетом крысы и семиконечной звездой, ржавые уголки, кожаный ремешок.
Бандл necrobooks, основа - копия ванильного bookPrefab (тот же размер и хват). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobooks?necroBookRatPrefab.prefab"/>
<property name="Material" value="Mpaper"/>
<property name="TintColor" value="120, 135, 120"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="10"/>
<property name="EconomicValue" value="0"/>
@@ -1889,4 +1898,69 @@
</effect_group>
</item>
</append>
<!-- Костяной скакун - предмет-транспорт (1.7.0), ванильная система: в руке призрачный
предпросмотр (ItemActionSpawnVehicle грузит его из Meshfile - поэтому Meshfile = модель
скакуна), применение ставит, подбор возвращает предмет с прочностью и модами. Решение
01.10: «не книга призыва, а та же система, что у ванильного транспорта». -->
<append xpath="/items">
<item name="necroBoneSteedPlaceable">
<property name="Extends" value="vehicleMotorcyclePlaceable"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necrobonesteed?necroBoneSteedP.prefab"/>
<property name="CustomIcon" value="necroBoneSteedPlaceable"/>
<property name="DescriptionKey" value="necroBoneSteedPlaceableDesc"/>
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
<property name="TraderStageTemplate" value=""/>
<property class="Action1">
<property name="Vehicle" value="necroBoneSteed"/>
</property>
<!-- Extends НЕ наследует effect_group (ItemClassesFromXml парсит эффекты только со
своего узла) - без этой группы у скакуна не было ни ячеек, ни прочности: здоровье 0,
бак не заполнялся (тест 01.10). 4 ячейки - под кровавые сферы (BoneSteedFuelPatch),
здоровье 4000 как у мотоцикла; кодом оно закреплено, чтобы DegradationMax сфер
в ячейках его не перебивал. -->
<effect_group name="necroBoneSteedPlaceable" tiered="false">
<passive_effect name="ModSlots" operation="base_set" value="4"/>
<passive_effect name="DegradationMax" operation="base_set" value="4000"/>
</effect_group>
</item>
</append>
<!-- Пакет крови чинит Кровавую сферу (RepairTools сферы, 01.10): 250 из 500 прочности за пакет,
два пакета - полная сфера. У ванили пакет ничего не чинит, свойства новые. Кровь некроманта
наследует пакет (Extends) - ей эти свойства ни на что не влияют: её ремонтом не чинят. -->
<append xpath="/items/item[@name='medicalBloodBag']">
<property name="RepairAmount" value="250"/>
<property name="RepairTime" value="2"/>
</append>
<!-- Чёрная роза (05.10.2026): урожай кровавой розы (blocks.xml, plantedNecroBloodRose*).
Пока ни во что не идёт - рецепты под неё будут отдельно. Вид в руке временный (мешочек как у
ванильных цветов); иконка NecroBlackRose - рендер BlenderWork/necroBloodRosePrefab (make_icon.py). -->
<append xpath="/items">
<item name="resourceNecroBlackRose">
<property name="DescriptionKey" value="resourceNecroBlackRoseDesc"/>
<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="CustomIcon" value="NecroBlackRose"/>
<property name="Material" value="MresourceCrop"/>
<property name="Stacknumber" value="500"/>
<property name="EconomicValue" value="40"/>
<property name="Group" value="Resources"/>
<property name="SoundPickup" value="plantfibers_grab"/>
<property name="SoundPlace" value="plantfibers_place"/>
</item>
</append>
<!-- Сок чёрной розы (05.10.2026): банка, на ступке из 4 роз. Заменяет кровь некроманта в копиях
рецептов (recipes.xml). Наследует кровь (стак, звуки, хват банки); модель - necroJuicePrefab в бандле
necroblood (копия банки крови, текстуры make_blood_jar_textures.py juice; Unity: NecroJuiceSetup.cs).
Кровь в свою очередь наследует medicalBloodBag - сок, как и кровь, ничем не лечит. -->
<append xpath="/items">
<item name="resourceNecroBlackRoseJuice">
<property name="Extends" value="resourceNecromancerBlood"/>
<property name="DescriptionKey" value="resourceNecroBlackRoseJuiceDesc"/>
<property name="CustomIcon" value="NecroBlackRoseJuice"/>
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroJuicePrefab.prefab"/>
</item>
</append>
</config>
+15
View File
@@ -84,4 +84,19 @@
</compass_settings>
</nav_object_class>
</append>
<!-- Дух змеи на карте (02.10.2026, просьба пользователя): фиолетовая змея - иконка следа змеи
(ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же
змеи бессмысленна. Вешается в SnakeControl.Begin, снимается в End. -->
<append xpath="/nav_object_classes">
<nav_object_class name="necroSnakeSpirit">
<map_settings>
<property name="sprite_name" value="ui_game_symbol_tracking_snake"/>
<property name="min_distance" value="0"/>
<property name="max_distance" value="-1"/>
<property name="color" value="170,60,255,255"/>
<property name="has_pulse" value="false"/>
</map_settings>
</nav_object_class>
</append>
</config>
+1 -1
View File
@@ -235,7 +235,7 @@
ОБЯЗАНЫ совпадать. display_entry рисует замок сам по себе, по unlock_tier, и про
теги не знает - разойдутся, и на панели будет замок на доступном рецепте. -->
<display_entry icon="ScrollOfDeviation" name_key="craftingNecroNecromancyTier2Name" has_quality="false" unlock_level="25,65,70,85">
<unlock_entry item="thrownBookGrimoireDeviation,bookSummonZombieGriffin" unlock_tier="1"/>
<unlock_entry item="thrownBookGrimoireDeviation,bookSummonZombieGriffin,necroGeoOssuary,necroBoneSteedPlaceable,necroMortar" unlock_tier="1"/>
<!-- Зомбогриф, 18.09: "гриф пусть тоже будет на подмастерье, но 1300 (65 уровень
некромантии)". 65 вставлен в unlock_level МЕЖДУ 25 и 70, поэтому unlock_tier
у двух модов ножа ниже сдвинулись на единицу (были 2 и 3, стали 3 и 4).
+117
View File
@@ -473,4 +473,121 @@
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
</append>
<!-- Гео-оссуарий (1.7.0, 2026-09-30). Рецепт придуман по указанию «туда явно нужен флакон
крови некроманта и много костей»; дерево и волокна - корни, которыми
оплетены кости (добавлено тем же вечером). Порог - «Подмастерье» (уровень 25, как Гримуар
девиации): к этому времени игрок строит базу и кузницу, и камень с железом ему нужны
именно тогда (правило «порог по нужде, а не по силе»). Тег и unlock_level группы 2 в
progression.xml обязаны совпадать. -->
<append xpath="/recipes">
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<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>
<!-- Костяной скакун (1.7.0, 01.10.2026). Порог - «Подмастерье» (уровень 25), как у
гео-оссуария: транспорт нужен, когда карта уже велика для пеших ходок. Состав - по
указанию пользователя: кости, гнилое мясо, прах, кожа жертвы, два флакона крови. -->
<append xpath="/recipes">
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/>
<ingredient name="resourceVictimSkin" count="3"/>
<ingredient name="resourceNecromancerBlood" count="2"/>
</recipe>
</append>
<!-- Кровавые семена (05.10.2026): «50 семян травы + банка крови некроманта». Порог - как у
самой крови: рецепт крови без тегов разблокировки, значит и этот тоже. -->
<append xpath="/recipes">
<recipe name="plantedNecroBloodRose1" count="1" tags="packMuleCrafting">
<ingredient name="plantedtreeGrassSeed1" count="50"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
</append>
<!-- Ступка и сок чёрной розы (05.10.2026). Ступка - с рук, на Подмастерье (указание: «рецепт
ступки пусть открывается на уровне подмастерья»; пара - unlock_entry в progression.xml).
Сок: 4 розы + пустая банка на ступке. Прикидка: куст без перка даёт 3 розы, то есть ~3/4 банки
сока (~1.5 крови на семя, которое стоило 1 кровь); с перком «Жизнь на природе» - вдвое-вчетверо. -->
<append xpath="/recipes">
<recipe name="necroMortar" count="1" tags="learnable,packMuleCrafting,necroNecromancyJourneyman">
<ingredient name="resourceRockSmall" count="20"/>
<ingredient name="resourceWood" count="2"/>
</recipe>
<recipe name="resourceNecroBlackRoseJuice" count="1" craft_time="10" craft_area="necroMortar">
<ingredient name="resourceNecroBlackRose" count="4"/>
<ingredient name="drinkJarEmpty" count="1"/>
</recipe>
</append>
<!-- Копии всех рецептов на крови некроманта, где кровь заменена соком чёрной розы (05.10.2026).
Указание: где крови больше одной банки, банка сока = две банки крови (нечётное - вверх: 3 -> 2);
где одна - одна банка сока. Сферы: из банки сока 3 вместо 2. Имена те же - игра показывает
их как второй вариант рецепта, теги и порог у копии те же, что у оригинала.
Новый рецепт на крови - сразу копию сюда. -->
<append xpath="/recipes">
<recipe name="thrownStonePortalBlack" count="1" tags="learnable,packMuleCrafting,necroNecromancyMaster">
<ingredient name="resourceRockSmall" count="1"/>
<ingredient name="medicalBloodBag" count="5"/>
<ingredient name="resourceNecroBlackRoseJuice" count="5"/>
<ingredient name="casinoCoin" count="20"/>
<ingredient name="resourceYuccaFibers" count="15"/>
<ingredient name="resourceZombieAsh" count="20"/>
</recipe>
<recipe name="necroHeresyPyramid" count="1" craft_area="workbench" tags="workbenchCrafting,necroNecromancyAdept">
<ingredient name="resourceZombieAsh" count="1200"/>
<ingredient name="resourceRockSmall" count="300"/>
<ingredient name="resourceYuccaFibers" count="200"/>
<ingredient name="resourceScrapIron" count="300"/>
<ingredient name="resourceNecroBlackRoseJuice" count="2"/>
</recipe>
<recipe name="necroWpnBladeNecroKnife" count="1" tags="packMuleCrafting">
<ingredient name="resourceBone" count="5"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceYuccaFibers" count="10"/>
</recipe>
<recipe name="necroModKnifeDeadMansGrip" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1400">
<ingredient name="resourceZombieAsh" count="10"/>
<ingredient name="resourceBone" count="15"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceYuccaFibers" count="10"/>
</recipe>
<recipe name="necroModKnifeDeadStorm" count="1" tags="learnable,packMuleCrafting,necroNecromancyLvl1700">
<ingredient name="resourceZombieAsh" count="15"/>
<ingredient name="resourceElectricParts" count="5"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceYuccaFibers" count="10"/>
</recipe>
<recipe name="resourceBloodSphere" count="3" tags="learnable,packMuleCrafting,necroNecromancyLvl20">
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceZombieAsh" count="5"/>
</recipe>
<recipe name="resourceBloodStone" count="1" craft_area="chemistryStation" tags="learnable,chemStationCrafting,necroNecromancyApprentice">
<ingredient name="resourceZombieAsh" count="50"/>
<ingredient name="resourceBone" count="20"/>
<ingredient name="medicalBloodBag" count="4"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="400"/>
<ingredient name="resourceZombieAsh" count="200"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
<ingredient name="resourceWood" count="100"/>
<ingredient name="resourceYuccaFibers" count="50"/>
</recipe>
<recipe name="necroBoneSteedPlaceable" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="300"/>
<ingredient name="foodRottingFlesh" count="30"/>
<ingredient name="resourceZombieAsh" count="150"/>
<ingredient name="resourceVictimSkin" count="3"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
<recipe name="plantedNecroBloodRose1" count="1" tags="packMuleCrafting">
<ingredient name="plantedtreeGrassSeed1" count="50"/>
<ingredient name="resourceNecroBlackRoseJuice" count="1"/>
</recipe>
</append>
</config>
+146
View File
@@ -0,0 +1,146 @@
<config>
<!-- Костяной скакун (1.7.0, 01.10.2026). vehicles.xml наследования не знает - это копия
ванильного vehicleMotorcycle (игра 3.3) под именем сущности necroBoneSteed. Отличия:
recipeName -> предмет necroBoneSteedPlaceable (его и возвращает подбор скакуна), без
honkEvent (гудок открывает двери торговца - скакуну незачем). Мотор молчит
(BoneSteedEnginePatch) - слышны только колёса, гудок - вопль умирающей
зомби-собаки (zombiedogdeath). Топливо - кровавые сферы
в ячейках модификаций (BoneSteedFuelPatch), канистрой не заправить. -->
<append xpath="/vehicles">
<vehicle name="necroBoneSteed">
<property name="cameraDistance" value="3.5, 5"/>
<property name="cameraTurnRate" value=".2, .35"/>
<property name="upAngleMax" value="70"/>
<property name="upForce" value="1"/>
<property name="steerRate" value="130"/>
<property name="steerCenteringRate" value="90"/>
<property name="tiltAngleMax" value="20"/>
<property name="tiltThreshold" value="3"/>
<property name="tiltDampening" value=".22"/>
<property name="tiltDampenThreshold" value="8"/>
<property name="tiltUpForce" value="5"/>
<property name="motorTorque_turbo" value="1400, 500, 2100, 650"/>
<property name="velocityMax_turbo" value="9.8, 6, 14, 8"/>
<property name="brakeTorque" value="3000"/>
<property name="hopForce" value="1, .8"/>
<property name="unstickForce" value="1"/>
<property name="waterDrag_y_velScale_velMaxScale" value="1.7, .9, .1"/>
<property name="wheelPtlScale" value="1"/>
<property name="hornSound" value="zombiedogdeath"/><!-- вопль умирающей зомби-собаки (01.10) -->
<property name="recipeName" value="necroBoneSteedPlaceable" />
<property class="chassis">
<property name="class" value="Chassis"/>
<property name="paint" value="M"/>
</property>
<property class="engine">
<property name="class" value="Engine"/>
<!-- Частиц нет: ванильные дым/огонь в нашем бандле без материалов - розовые кубы (01.10). -->
<property name="fuelKmPerL" value=".2"/>
<property name="foodDrain" value=".002,.0101"/><!-- driving/turbo, motorcycle -->
<property name="gear1" value="
500,2500, -1400,800,0, 700,2000,900,
Vehicles/Motorbike/motorbike_accel_1,
,
0, .7, 1, .1, -9, .15, .1, Vehicles/Motorbike/motorbike_idle_lp,
-.3, .1, .1, .4, -.05, 9, .2, Vehicles/Motorbike/motorbike_run_lp
"/>
<property name="gear2" value="
500,2500, -2000,1000,2000, 620,2200,800,
Vehicles/Motorbike/motorbike_accel_2,
Vehicles/Motorbike/motorbike_decel,
-.2, -.02, .1, .2, -1, 1, .1, Vehicles/Motorbike/motorbike_run_lp
"/>
<property name="gear3" value="
500,2500, -2000,1000,2000, 570,2200,1000,
Vehicles/Motorbike/motorbike_accel_3,
Vehicles/Motorbike/motorbike_decel,
-.21, -.05, .1, .2, -1, 1, .1, Vehicles/Motorbike/motorbike_run_lp
"/>
<property name="gear4" value="
500,2500, -2000,1000,2000, 500,9999,9999,
,
Vehicles/Motorbike/motorbike_decel,
-.25, -.08, .55, .55, -1, 1, .1, Vehicles/Motorbike/motorbike_run_lp
"/>
<property name="sound_start" value="Vehicles/Motorbike/minibike_start_"/>
<property name="sound_shut_off" value="Vehicles/Motorbike/minibike_shutoff_"/>
<property name="sound_no_fuel_shut_off" value="Vehicles/Motorbike/motorbike_empty"/>
</property>
<property class="fuelTank">
<property name="class" value="FuelTank"/>
<!-- Уровень бака скакуна считается по сферам в ячейках (BoneSteedFuelPatch):
4 сферы x 120. Мода бака нет - ячейки заняты кровью. -->
<property name="capacity" value="480"/>
</property>
<property class="handlebars">
<property name="class" value="Steering"/>
<property name="transform" value="M/Forks"/>
<property name="steerAngle" value="0, 30, 0"/>
<property name="IKHandLPosition" value="-.3362, .4901, -.1789"/>
<property name="IKHandLRotation" value="-7.2919, 77.3004, 29.6909"/>
<property name="IKHandRPosition" value=".3353, .4747, -.1735"/>
<property name="IKHandRRotation" value="4.1479, 105.6533, -158.7339"/>
<property name="paint" value="handlebar"/>
</property>
<property class="seat0">
<property name="class" value="Seat"/>
<property name="pose" value="30"/>
<property name="position" value="0, 0, 0"/>
<property name="rotation" value="0, 0, 0"/>
<property name="exit" value="-.9,0,0 ~ .9,0,0 ~ 0,0,-1.5 ~ 0,0,1.6 ~ 0,1.5,0"/>
</property>
<property class="seat1">
<property name="class" value="Seat"/>
<property name="pose" value="31"/>
<property name="position" value="0, 0, 0"/>
<property name="rotation" value="0, 0, 0"/>
<property name="exit" value="-.9,0,-.7 ~ .9,0,-.7 ~ -.6,0,-1.5 ~ -.6,0,1.6 ~ 0,1.5,-.6"/>
<property name="mod" value="seat"/>
<property name="modT" value="M/Seat"/>
</property>
<property class="wheel0">
<property name="tireTransform" value="M/Forks/Wheel0"/>
<property name="torqueScale_motor_brake" value=".15, .5"/>
</property>
<property class="wheel1">
<property name="tireTransform" value="Wheel1"/>
<property name="torqueScale_motor_brake" value="1, 1"/>
</property>
<property class="headlight">
<property name="class" value="Headlight"/>
<property name="bright" value=".9"/>
<property name="transform" value="M/Forks/HeadLights/Lights"/>
<property name="matT" value="M/Forks/HeadLights"/>
<property name="matEmissive" value="808080"/>
<property name="mod" value="light"/>
<property name="modT" value="M/Forks/LightMod"/>
<property name="modOnT" value="M/Forks/LightMod/Lights"/>
<property name="modMatEmissive" value="909090"/>
<property name="tailEmissive" value="c0c0c0"/>
</property>
<property class="armor">
<property name="class" value="VehiclePart"/>
<property name="mod" value="armor"/>
<property name="modT" value="M/Forks/Armor"/>
<property name="modRBT" value="Armor"/>
</property>
<property class="storage">
<property name="class" value="Storage"/>
<property name="mod" value="storage"/>
<property name="modT" value="M/Storage"/>
</property>
</vehicle>
</append>
</config>
+200
View File
@@ -0,0 +1,200 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// «Книга памяти» в чертогах разума (указание 03.10): единственный предмет чертогов, с которым
/// можно взаимодействовать. При наведении - «Нажмите E, чтобы прочитать», по E - окно «Память»
/// (потом доделаем, как окна концовки в FinalSlides.cs). Блок подключается в blocks.xml через
/// Class="NecromancerTome.NecroMemoryBook, NecromancerHarmony": игра (ReflectionHelpers.
/// GetTypeWithPrefix) вставляет приставку Block перед именем после последней точки.
/// Вид - одиночная книга игры (bookInvisibleKillerPrefab, как cntBookPile09 на столе префаба
/// chertogi), но без хранилища и лута.
/// </summary>
[Preserve]
public class BlockNecroMemoryBook : Block
{
public const string ReadCommand = "read";
static readonly BlockActivationCommand[] Commands =
{
new BlockActivationCommand(ReadCommand, "book", true)
};
public override bool HasBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
{
return true;
}
public override BlockActivationCommand[] GetBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
{
return Commands;
}
public override string GetActivationText(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
{
return Localization.Get("necroMemoryBookPrompt");
}
public override bool OnBlockActivated(string _commandName, WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
{
return _commandName == ReadCommand && OnBlockActivated(_world, _blockPos, _blockValue, _player);
}
public override bool OnBlockActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
{
Open(_player);
return true;
}
/// <summary>Окно книги (оглавление, главы). Отдельным методом - его же позовёт дух в чертогах (своё E).</summary>
public static void Open(EntityPlayerLocal _player)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
if (ui == null)
{
return;
}
// 03.10: вместо окна-сообщения - книга с оглавлением (NecroTome.cs).
NecroTomeBook.Open(_player);
}
// ---------------------------------------------------------------- автор на обложке
/// <summary>Шрифт надписи - тот же готический, что у названия в текстуре (UnifrakturMaguntia, OFL).</summary>
const string FontAsset = "#@modfolder(NecromancerTome):Resources/necrobooks?UnifrakturMaguntia-Book.ttf";
const string AuthorChild = "NecroAuthor";
// Место под автора - просвет под рисунком (make_rat_book_textures.py, Mind), в осях корня модели
// necroBookMindPrefab: из Blender (x, y, z) -> Unity (-x, z, -y); книга в модели повёрнута на -65°.
// Подбирается на ходу командой necroauthor (x y z, размер, ширина).
public static Vector3 AuthorPos = new Vector3(0.0944f, 0.0885f, -0.1534f);
public static Vector3 AuthorUp = new Vector3(-0.4226f, 0f, 0.9063f);
public static float AuthorHeight = 0.030f, AuthorMaxWidth = 0.21f;
public static Color AuthorColor = new Color(0.85f, 0.06f, 0.05f, 1f);
static Font _font;
public override void OnBlockEntityTransformAfterActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, BlockEntityData _ebcd)
{
base.OnBlockEntityTransformAfterActivated(_world, _blockPos, _blockValue, _ebcd);
try
{
if (_ebcd?.transform != null)
{
PlaceAuthor(_ebcd.transform);
}
}
catch (System.Exception e)
{
Debug.LogWarning("[NecromancerTome] книга памяти: автор не встал - " + e.Message);
}
}
/// <summary>Ник игрока на обложке, как подпись на ящике: TextMesh поверх модели (модель общая,
/// а ник у каждого свой, в текстуру его не запечь). Чертоги у каждого свои - пишем ник своего игрока.</summary>
public static void PlaceAuthor(Transform _root)
{
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
string name = player != null ? player.PlayerDisplayName : null;
Transform old = _root.Find(AuthorChild);
if (string.IsNullOrEmpty(name))
{
if (old != null) Object.Destroy(old.gameObject);
return;
}
if (_font == null)
{
_font = DataLoader.LoadAsset<Font>(FontAsset);
}
GameObject go = old != null ? old.gameObject : new GameObject(AuthorChild);
go.layer = _root.gameObject.layer;
Transform t = go.transform;
t.SetParent(_root, false);
t.localPosition = AuthorPos;
// лицом вверх (обложка), низ строки - к нижнему краю обложки
t.localRotation = Quaternion.LookRotation(Vector3.down, AuthorUp.normalized);
t.localScale = Vector3.one;
TextMesh tm = go.GetComponent<TextMesh>() ?? go.AddComponent<TextMesh>();
MeshRenderer mr = go.GetComponent<MeshRenderer>();
if (_font != null)
{
tm.font = _font;
mr.sharedMaterial = _font.material;
if (mr.sharedMaterial != null && (mr.sharedMaterial.shader == null || !mr.sharedMaterial.shader.isSupported))
{
Shader sh = Shader.Find("GUI/Text Shader");
if (sh != null) mr.sharedMaterial.shader = sh;
}
}
tm.text = name;
tm.anchor = TextAnchor.MiddleCenter;
tm.alignment = TextAlignment.Center;
tm.fontSize = 96;
tm.color = AuthorColor;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
tm.characterSize = 1f;
// Размер подгоняется кадром позже: меш надписи TextMesh строит не сразу.
NecroAuthorFit fit = go.GetComponent<NecroAuthorFit>() ?? go.AddComponent<NecroAuthorFit>();
fit.enabled = true;
}
}
/// <summary>Подгонка надписи автора: высота строки AuthorHeight, длинный ник ужимается до AuthorMaxWidth.</summary>
public class NecroAuthorFit : MonoBehaviour
{
void LateUpdate()
{
MeshRenderer mr = GetComponent<MeshRenderer>();
Bounds b = mr != null ? mr.localBounds : default;
if (b.size.y < 0.0001f)
{
return; // меш ещё не построен
}
float k = BlockNecroMemoryBook.AuthorHeight / b.size.y;
if (b.size.x * k > BlockNecroMemoryBook.AuthorMaxWidth)
{
k = BlockNecroMemoryBook.AuthorMaxWidth / b.size.x;
}
transform.localScale = new Vector3(k, k, k);
enabled = false;
}
}
/// <summary>necroauthor [x y z] [h N] [w N] - подобрать место и размер ника на книге памяти на ходу.</summary>
[Preserve]
public class ConsoleCmdNecroAuthor : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroauthor" };
public override string getDescription() => "NecromancerTome: ник на книге памяти - x y z | h N | w N";
public override void Execute(System.Collections.Generic.List<string> _params, CommandSenderInfo _senderInfo)
{
System.Globalization.CultureInfo ci = System.Globalization.CultureInfo.InvariantCulture;
if (_params.Count == 3)
{
BlockNecroMemoryBook.AuthorPos = new Vector3(float.Parse(_params[0], ci), float.Parse(_params[1], ci), float.Parse(_params[2], ci));
}
else if (_params.Count == 2 && _params[0] == "h")
{
BlockNecroMemoryBook.AuthorHeight = float.Parse(_params[1], ci);
}
else if (_params.Count == 2 && _params[0] == "w")
{
BlockNecroMemoryBook.AuthorMaxWidth = float.Parse(_params[1], ci);
}
int n = 0;
foreach (NecroAuthorFit f in Object.FindObjectsOfType<NecroAuthorFit>(true))
{
BlockNecroMemoryBook.PlaceAuthor(f.transform.parent);
n++;
}
Vector3 p = BlockNecroMemoryBook.AuthorPos;
SdtdConsole.Instance.Output(string.Format(ci, "автор: {0:0.####} {1:0.####} {2:0.####}, h {3}, w {4}; надписей {5}",
p.x, p.y, p.z, BlockNecroMemoryBook.AuthorHeight, BlockNecroMemoryBook.AuthorMaxWidth, n));
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Двигатель костяного скакуна (тест 01.10.2026):
///
/// - «ДАЖЕ БЕЗ СФЕР ЕДЕТ БЫСТРО». Ванильный VPEngine.startEngine ставит isRunning без
/// проверки топлива, а глохнет только по событию FuelEmpty - то есть когда бак пустеет на
/// ходу. Заведённый с пустым баком мотор так и работал на полной тяге. Здесь: без крови мотор
/// не заводится, кончилась на ходу - глохнет; вставили сферу, сидя в седле, - заводится сам.
/// Без мотора у ванили остаётся тяга x50 - еле ползёт, как пустой мотоцикл.
/// - «ЗВУК МОТОЦИКЛА УБРАТЬ». Все звуки мотора (заводка, глушение, обороты) у скакуна
/// молчат - слышно только то, что слышно у мотоцикла с пустым баком (колёса). Стук педалей
/// пробовали - «тоже не нужен» (01.10).
/// Дым и огонь (розовые кубы - частицы без материалов) убраны в vehicles.xml и префабе.
/// </summary>
public static class BoneSteedEngine
{
public static bool Is(VehiclePart part) => part?.vehicle != null && BoneSteed.Is(part.vehicle.entity);
public static bool HasBlood(Vehicle v) => BoneSteedFuel.FuelOf(v) > 0f;
}
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.startEngine))]
public static class Patch_VPEngine_startEngine_BoneSteed
{
public static bool Prefix(VPEngine __instance) =>
!BoneSteedEngine.Is(__instance) || BoneSteedEngine.HasBlood(__instance.vehicle);
}
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.Update))]
public static class Patch_VPEngine_Update_BoneSteed
{
public static void Postfix(VPEngine __instance, float _dt)
{
if (!BoneSteedEngine.Is(__instance))
return;
Vehicle v = __instance.vehicle;
bool blood = BoneSteedEngine.HasBlood(v);
if (__instance.isRunning && !blood)
__instance.stopEngine(true);
else if (!__instance.isRunning && blood && v.entity.HasDriver && !__instance.IsBroken())
__instance.startEngine();
}
}
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.playSound))]
public static class Patch_VPEngine_playSound_BoneSteed
{
public static bool Prefix(VPEngine __instance) => !BoneSteedEngine.Is(__instance);
}
[HarmonyPatch(typeof(VPEngine), nameof(VPEngine.updateEngineSounds))]
public static class Patch_VPEngine_updateEngineSounds_BoneSteed
{
public static bool Prefix(VPEngine __instance) => !BoneSteedEngine.Is(__instance);
}
/// <summary>Глазница черепа гаснет вместе с фарой (01.10). Свечение глазницы - карта эмиссии
/// в текстуре вилки, ваниль её не переключает. На все уровни детализации вилки ставится
/// MaterialPropertyBlock (материал не копируется): эмиссия белая при включённой фаре, чёрная -
/// при выключенной. Обе ручки - _EmissionColor и _EmissionMultiply: какую из них читает
/// шейдер транспорта, по декомпилятору не видно, а лишняя не мешает.</summary>
public static class BoneSteedEye
{
static readonly MaterialPropertyBlock block = new MaterialPropertyBlock();
public static void Apply(EntityVehicle e, bool on)
{
if (e == null)
return;
foreach (Renderer r in e.transform.GetComponentsInChildren<Renderer>(true))
{
if (!r.name.StartsWith("frontFork_LOD"))
continue;
r.GetPropertyBlock(block);
block.SetColor("_EmissionColor", on ? Color.white : Color.black);
block.SetFloat("_EmissionMultiply", on ? 1f : 0f);
r.SetPropertyBlock(block);
}
}
}
[HarmonyPatch(typeof(VPHeadlight), nameof(VPHeadlight.UpdateOn))]
public static class Patch_VPHeadlight_UpdateOn_BoneSteedEye
{
public static void Postfix(VPHeadlight __instance)
{
if (BoneSteedEngine.Is(__instance))
BoneSteedEye.Apply(__instance.vehicle.entity, __instance.IsOn());
}
}
/// <summary>При появлении скакуна фара выключена - глазница тоже тёмная.</summary>
[HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.PostInit))]
public static class Patch_EntityVehicle_PostInit_BoneSteedEye
{
public static void Postfix(EntityVehicle __instance)
{
if (!BoneSteed.Is(__instance))
return;
VPHeadlight light = null;
foreach (VehiclePart part in __instance.GetVehicle().vehicleParts)
if (part is VPHeadlight h)
light = h;
BoneSteedEye.Apply(__instance, light != null && light.IsOn());
}
}
}
+389
View File
@@ -0,0 +1,389 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Топливо костяного скакуна - кровавые сферы в ячейках модификаций (указание 01.10.2026:
/// «4 ячейки под кровавые сферы, которые будут постепенно по очереди тратиться; заправку
/// через канистру убрать»). Ванильный бак (VPFuelTank) остаётся частью машины - двигатель,
/// HUD и глохнутие читают его, - но для скакуна его уровень не хранится, а СЧИТАЕТСЯ по
/// сферам: каждая сфера - FuelPerSphere единиц, пропорционально остатку прочности.
///
/// Расход: двигатель шлёт событие FuelRemove; его префикс переводит количество в прочность
/// первой сферы с остатком (ячейки по порядку); выработанная сфера исчезает из ячейки, как в
/// браслете и оссуарии. Прямые записи уровня (SetFuelLevel: загрузка, синхронизация,
/// заправка) игнорируются - кровь приходит только сферами.
///
/// Ячейки: у ванили в слот модификации идёт только модификация с подходящими тегами и не
/// больше MaxModsAllowed одного вида. Сфера - модификация браслета, поэтому для скакуна
/// CanSwap решает сам: только сфера с остатком прочности, прочие моды не ставятся.
///
/// Здоровье: Extends не наследует effect_group (ItemClassesFromXml парсит эффекты только
/// со своего узла), поэтому у necroBoneSteedPlaceable свой DegradationMax в items.xml. Но
/// эффекты установленных модификаций применяются к хозяину, а у сферы DegradationMax
/// base_set 500 - со сферами здоровье скакуна упало бы до 500 и прыгало бы при их смене.
/// Поэтому MaxUseTimesBase скакуна закреплён кодом.
/// </summary>
public static class BoneSteedFuel
{
public const int Slots = 4;
public const float FuelPerSphere = 120f;
public const float MaxFuel = Slots * FuelPerSphere;
public const int MaxHealth = 4000;
static int steedItemType = -1;
public static int SteedItemType
{
get
{
if (steedItemType < 0)
{
ItemValue iv = ItemClass.GetItem(BoneSteed.ItemName);
steedItemType = iv != null ? iv.type : 0;
}
return steedItemType;
}
}
public static bool IsSteedItem(ItemValue iv) =>
iv != null && iv.type != 0 && iv.type == SteedItemType;
public static bool IsSphere(ItemValue iv) =>
iv != null && !iv.IsEmpty() && iv.ItemClass != null && iv.ItemClass.Name == BoneSteed.FuelItem;
/// <summary>Кровавый камень - неиссякаемая сфера (01.10: «полная замена сферы»): один камень
/// в любой ячейке - вечный полный бак, сферы рядом с ним не тратятся.</summary>
public const string StoneItem = "resourceBloodStone";
public static bool IsStone(ItemValue iv) =>
iv != null && !iv.IsEmpty() && iv.ItemClass != null && iv.ItemClass.Name == StoneItem;
public static bool HasStone(ItemValue item)
{
if (item == null)
return false;
for (int i = 0; i < item.ModificationCount; i++)
if (IsStone(item.GetModification(i)))
return true;
return false;
}
/// <summary>Остаток сферы 0..1.</summary>
static float Left(ItemValue sphere)
{
int max = sphere.MaxUseTimes;
if (max <= 0)
return 1f;
return Mathf.Clamp01(1f - sphere.UseTimes / max);
}
public static float FuelOf(Vehicle v)
{
ItemValue item = v?.itemValue;
if (item == null)
return 0f;
if (HasStone(item))
return MaxFuel;
float fuel = 0f;
for (int i = 0; i < item.ModificationCount; i++)
{
ItemValue m = item.GetModification(i);
if (IsSphere(m))
fuel += Left(m) * FuelPerSphere;
}
return fuel;
}
/// <summary>Снимает топливо со сфер по очереди. Возвращает остаток топлива.</summary>
public static float Burn(Vehicle v, float amount)
{
ItemValue item = v.itemValue;
if (HasStone(item))
return MaxFuel;
for (int i = 0; i < item.ModificationCount && amount > 0f; i++)
{
ItemValue m = item.GetModification(i);
if (!IsSphere(m))
continue;
int max = m.MaxUseTimes;
if (max <= 0)
{
item.SetModification(i, ItemValue.None.Clone());
amount -= FuelPerSphere;
continue;
}
float have = Left(m) * FuelPerSphere;
float take = Mathf.Min(have, amount);
m.UseTimes += take / FuelPerSphere * max;
amount -= take;
if (m.UseTimes >= max - 0.001f)
{
item.SetModification(i, ItemValue.None.Clone());
Debug.Log("[NecromancerTome] BoneSteed: сфера в ячейке " + i + " выработана и исчезла");
}
}
return FuelOf(v);
}
public static bool IsSteedTank(VPFuelTank tank) =>
tank?.vehicle != null && BoneSteed.Is(tank.vehicle.entity);
}
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetFuelLevel))]
public static class Patch_VPFuelTank_GetFuelLevel_BoneSteed
{
public static bool Prefix(VPFuelTank __instance, ref float __result)
{
if (!BoneSteedFuel.IsSteedTank(__instance))
return true;
__result = BoneSteedFuel.FuelOf(__instance.vehicle);
return false;
}
}
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetMaxFuelLevel))]
public static class Patch_VPFuelTank_GetMaxFuelLevel_BoneSteed
{
public static bool Prefix(VPFuelTank __instance, ref float __result)
{
if (!BoneSteedFuel.IsSteedTank(__instance))
return true;
__result = BoneSteedFuel.MaxFuel;
return false;
}
}
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetFuelLevelPercent))]
public static class Patch_VPFuelTank_GetFuelLevelPercent_BoneSteed
{
public static bool Prefix(VPFuelTank __instance, ref float __result)
{
if (!BoneSteedFuel.IsSteedTank(__instance))
return true;
__result = Mathf.Clamp01(BoneSteedFuel.FuelOf(__instance.vehicle) / BoneSteedFuel.MaxFuel);
return false;
}
}
/// <summary>Любая прямая запись уровня бака (загрузка из Meta, сетевая синхронизация,
/// ванильная заправка) для скакуна ничего не меняет: уровень - производная от сфер. Тест 01.10:
/// после установки сферы игра записала в бак прежний 0, и сфера сгорала целиком.</summary>
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.SetFuelLevel))]
public static class Patch_VPFuelTank_SetFuelLevel_BoneSteed
{
public static bool Prefix(VPFuelTank __instance)
{
if (!BoneSteedFuel.IsSteedTank(__instance))
return true;
__instance.fuelLevel = BoneSteedFuel.FuelOf(__instance.vehicle);
return false;
}
}
/// <summary>Расход - только событием двигателя FuelRemove: снимается прочность сфер.</summary>
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.HandleEvent))]
public static class Patch_VPFuelTank_HandleEvent_BoneSteed
{
public static bool Prefix(VPFuelTank __instance, VehiclePart.Event _event, float _amount)
{
if (_event != VehiclePart.Event.FuelRemove || !BoneSteedFuel.IsSteedTank(__instance))
return true;
Vehicle v = __instance.vehicle;
bool had = BoneSteedFuel.FuelOf(v) > 0f;
float left = _amount > 0f ? BoneSteedFuel.Burn(v, _amount) : BoneSteedFuel.FuelOf(v);
__instance.fuelLevel = left;
if (had && left <= 0f && EntityVehicle.VehicleFuelUsageModifier > 0f)
v.FireEvent(VehiclePart.Event.FuelEmpty, __instance, 0f);
return false;
}
}
[HarmonyPatch(typeof(Vehicle), nameof(Vehicle.SetItemValue))]
public static class Patch_Vehicle_SetItemValue_BoneSteed
{
public static bool Loading;
/// <summary>Скакун, сделанный до появления ячеек (или выданный без них), получает свои
/// 4 ячейки здесь: ItemValue размечает ячейки один раз при создании и сам не дорастает.</summary>
public static void Prefix(ItemValue _itemValue)
{
Loading = true;
if (!BoneSteedFuel.IsSteedItem(_itemValue) || _itemValue.ModificationCount >= BoneSteedFuel.Slots)
return;
var mods = new ItemValue[BoneSteedFuel.Slots];
for (int i = 0; i < mods.Length; i++)
mods[i] = i < _itemValue.ModificationCount && _itemValue.modifications[i] != null
? _itemValue.modifications[i]
: ItemValue.None.Clone();
_itemValue.modifications = mods;
}
public static void Finalizer(Vehicle __instance)
{
Loading = false;
if (!BoneSteed.Is(__instance.entity))
return;
// Слота покраски у скакуна нет (01.10: в нём сам собой появлялся коричневый краситель,
// вынимался и появлялся снова). Ваниль в SetItemValue заводит его заново, если пусто,
// поэтому снимаем каждый раз после неё. Что там лежало - в лог, для поиска источника.
ItemValue iv = __instance.itemValue;
if (iv.CosmeticModCount > 0)
{
for (int i = 0; i < iv.CosmeticModCount; i++)
{
ItemValue c = iv.GetCosmeticMod(i);
if (c != null && !c.IsEmpty())
Debug.Log("[NecromancerTome] BoneSteed: в слоте покраски было '" + c.ItemClass?.Name + "' - снято");
}
iv.ResizeCosmeticMods(0);
}
Debug.Log("[NecromancerTome] BoneSteed: ячеек " + __instance.itemValue.ModificationCount +
", топливо " + BoneSteedFuel.FuelOf(__instance).ToString("0.#") + "/" + BoneSteedFuel.MaxFuel +
", здоровье " + __instance.entity.Health + "/" + __instance.GetMaxHealth() +
", расход x" + __instance.EffectFuelUsePer.ToString("0.##"));
}
}
/// <summary>В ячейки скакуна - только кровавые сферы с остатком прочности и кровавые камни.</summary>
[HarmonyPatch(typeof(XUiC_ItemPartStack), "CanSwap")]
public static class Patch_XUiC_ItemPartStack_CanSwap_BoneSteed
{
public static bool Prefix(XUiC_ItemPartStack __instance, ItemStack stack, ref bool __result)
{
ItemStack current = __instance.xui?.AssembleItem?.CurrentItem;
if (current == null || !BoneSteedFuel.IsSteedItem(current.itemValue))
return true;
ItemValue here = __instance.ItemValue;
ItemValue add = stack.itemValue;
bool blood = BoneSteedFuel.IsStone(add) ||
BoneSteedFuel.IsSphere(add) && add.UseTimes < add.MaxUseTimes;
__result = blood && (here == null || here.IsEmpty() || BoneSteedFuel.IsSphere(here) || BoneSteedFuel.IsStone(here));
return false;
}
}
/// <summary>Шифт-клик сферой или камнем в окне скакуна (01.10: «в оссуарий сфера вставляется
/// по шифт-клику, а в мотоцикл только перетаскиванием»). Ваниль ставит деталь шифт-кликом
/// через AddPartToItem, а он пускает модификацию только по InstallableTags хозяина - у сферы
/// и камня там браслет. Для скакуна - первая пустая ячейка, как у ванили.</summary>
[HarmonyPatch(typeof(XUiM_AssembleItem), nameof(XUiM_AssembleItem.AddPartToItem))]
public static class Patch_XUiM_AssembleItem_AddPartToItem_BoneSteed
{
public static bool Prefix(XUiM_AssembleItem __instance, ItemStack _partStack, out ItemStack _resultStack, ref bool __result)
{
_resultStack = _partStack;
ItemStack current = __instance.CurrentItem;
if (current == null || current.IsEmpty() || !BoneSteedFuel.IsSteedItem(current.itemValue))
return true;
__result = false;
ItemValue add = _partStack.itemValue;
bool blood = BoneSteedFuel.IsStone(add) ||
BoneSteedFuel.IsSphere(add) && add.UseTimes < add.MaxUseTimes;
if (!blood)
return false;
ItemValue item = current.itemValue;
for (int i = 0; i < item.ModificationCount; i++)
{
ItemValue m = item.GetModification(i);
if (m != null && !m.IsEmpty())
continue;
item.SetModification(i, add.Clone());
__instance.RefreshAssembleItem();
__instance.UpdateAssembleWindow();
_resultStack = ItemStack.Empty;
__result = true;
return false;
}
return false;
}
}
/// <summary>Здоровье скакуна не зависит от сфер в ячейках (у сферы DegradationMax 500).</summary>
[HarmonyPatch(typeof(ItemValue), nameof(ItemValue.MaxUseTimesBase), MethodType.Getter)]
public static class Patch_ItemValue_MaxUseTimesBase_BoneSteed
{
public static void Postfix(ItemValue __instance, ref int __result)
{
if (BoneSteedFuel.IsSteedItem(__instance))
__result = BoneSteedFuel.MaxHealth;
}
}
/// <summary>Ремонт скакуна - костями вместо ремкомплекта (указание 01.10): BonesPerRepair
/// костей за ход, как ванильный ремкомплект (1000 здоровья + 10% за уровень «Механика»).</summary>
[HarmonyPatch(typeof(XUiM_Vehicle), nameof(XUiM_Vehicle.RepairVehicle))]
public static class Patch_XUiM_Vehicle_RepairVehicle_BoneSteed
{
public const string BoneItem = "resourceBone";
public const int BonesPerRepair = 50;
public static bool Prefix(XUi _xui, Vehicle _vehicle, ref bool __result)
{
Vehicle v = _vehicle ?? _xui.Vehicle.CurrentVehicle?.GetVehicle();
if (v == null || !BoneSteed.Is(v.entity))
return true;
__result = false;
EntityPlayerLocal player = _xui.playerUI.entityPlayer;
ItemValue bone = ItemClass.GetItem(BoneItem);
if (bone == null || bone.IsEmpty() || v.GetRepairAmountNeeded() <= 0)
return false;
int have = player.bag.GetItemCount(bone) + player.inventory.GetItemCount(bone);
if (have < BonesPerRepair)
{
Audio.Manager.PlayInsidePlayerHead("misc/missingitemtorepair");
GameManager.ShowTooltip(player, string.Format(Localization.Get("necroBoneSteedNeedBones"), BonesPerRepair),
string.Empty, "ui_denied");
return false;
}
float perk = 0f;
ProgressionValue pv = player.Progression.GetProgressionValue("perkGreaseMonkey");
if (pv != null)
perk += pv.Level * 0.1f;
v.RepairParts(1000, perk);
int left = BonesPerRepair;
left -= player.inventory.DecItem(bone, left);
if (left > 0)
player.bag.DecItem(bone, left);
_xui.CollectedItemList.RemoveItemStack(new ItemStack(bone, BonesPerRepair));
Audio.Manager.PlayInsidePlayerHead("craft_complete_item");
__result = true;
return false;
}
}
/// <summary>Кнопка «Заправить» в окне обслуживания: у скакуна выключена, текст - «нельзя».</summary>
[HarmonyPatch(typeof(XUiC_VehicleFrameWindow), nameof(XUiC_VehicleFrameWindow.Update))]
public static class Patch_XUiC_VehicleFrameWindow_Update_BoneSteed
{
public static void Postfix(XUiC_VehicleFrameWindow __instance)
{
if (__instance.btnRefuel_Background != null && __instance.Vehicle != null && BoneSteed.Is(__instance.Vehicle))
__instance.btnRefuel_Background.Enabled = false;
}
}
[HarmonyPatch(typeof(XUiC_VehicleFrameWindow), "GetBindingValueInternal")]
public static class Patch_XUiC_VehicleFrameWindow_Binding_BoneSteed
{
public static void Postfix(XUiC_VehicleFrameWindow __instance, ref string value, string bindingName)
{
if (bindingName == "refueltext" && __instance.Vehicle != null && BoneSteed.Is(__instance.Vehicle))
value = Localization.Get("xuiRefuelNotAllowed");
}
}
/// <summary>Страховка: любая ванильная заправка скакуна (кнопка, меню) ничего не делает.</summary>
[HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.AddFuelFromInventory))]
public static class Patch_EntityVehicle_AddFuelFromInventory_BoneSteed
{
public static bool Prefix(EntityVehicle __instance, ref bool __result)
{
if (!BoneSteed.Is(__instance))
return true;
__result = false;
return false;
}
}
}
+123
View File
@@ -0,0 +1,123 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Костяной скакун (1.7.0, 01.10.2026): мотоцикл из костей, призывается книгой, ездит на
/// кровавых сферах. Сущность necroBoneSteed в entityclasses.xml - наследник vehicleMotorcycle
/// со своей моделью (бандл necrobonesteed, собран из копии ванильного VMotorcycleP.prefab).
///
/// Топливо - кровавые сферы в четырёх ячейках модификаций, ремонт - костями: всё это в
/// BoneSteedFuelPatch. Здесь только запрет заправки канистрой (GetFuelItem).
///
/// Модель выросла из рипнутого префаба, и у неё те же две беды, что у оссуария: игровые теги
/// коллайдеров потеряны (без них на скакуна не сесть и по нему не попасть) и шейдер-заглушка.
/// Оба лечатся с ванильного мотоцикла - теги по путям объектов, шейдер у его материала.
/// </summary>
public static class BoneSteed
{
public const string EntityClassName = "necroBoneSteed";
public const string FuelItem = "resourceBloodSphere";
public const string ItemName = "necroBoneSteedPlaceable";
const string VanillaPrefab = "@:Entities/Vehicles/VMotorcycle/VMotorcycleP.prefab";
public static bool Is(Entity e)
{
return e != null && EntityClass.list.TryGetValue(e.entityClass, out EntityClass cls) &&
cls != null && cls.entityClassName == EntityClassName;
}
static Transform vanilla;
static Shader vanillaShader;
public static void FixModel(EntityVehicle ev)
{
if (vanilla == null)
{
vanilla = DataLoader.LoadAsset<Transform>(VanillaPrefab);
if (vanilla == null)
{
Debug.LogWarning("[NecromancerTome] BoneSteed: ванильный мотоцикл не найден - теги и шейдер не поправлены");
return;
}
foreach (Renderer r in vanilla.GetComponentsInChildren<Renderer>(true))
{
if (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_RMOM"))
{
vanillaShader = r.sharedMaterial.shader;
break;
}
}
}
var byPath = new Dictionary<string, Transform>();
foreach (Transform t in vanilla.GetComponentsInChildren<Transform>(true))
byPath[PathOf(t, vanilla)] = t;
int tags = 0, mats = 0;
foreach (Transform t in ev.transform.GetComponentsInChildren<Transform>(true))
{
if (!byPath.TryGetValue(PathOf(t, ev.transform), out Transform src))
continue;
if (t.tag != src.tag || t.gameObject.layer != src.gameObject.layer)
{
t.tag = src.tag;
t.gameObject.layer = src.gameObject.layer;
tags++;
}
}
if (vanillaShader != null)
{
foreach (Renderer r in ev.transform.GetComponentsInChildren<Renderer>(true))
{
foreach (Material m in r.sharedMaterials)
{
if (m != null && m.HasProperty("_RMOM") && m.shader != vanillaShader)
{
m.shader = vanillaShader;
m.enableInstancing = true;
mats++;
}
}
}
}
Debug.Log("[NecromancerTome] BoneSteed: теги поправлены у " + tags + " объектов, шейдер '" +
(vanillaShader != null ? vanillaShader.name : "-") + "' поставлен " + mats + " материалам");
}
/// <summary>Путь объекта от корня префаба. Корень - пустая строка: у экземпляра корень
/// называется иначе (necroBoneSteedP(Clone)), чем у ванильного префаба.</summary>
static string PathOf(Transform t, Transform root)
{
if (t == root)
return "";
string p = t.name;
for (Transform x = t.parent; x != null && x != root; x = x.parent)
p = x.name + "/" + p;
return p;
}
}
[HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.PostInit))]
public static class Patch_EntityVehicle_PostInit_BoneSteed
{
public static void Postfix(EntityVehicle __instance)
{
if (BoneSteed.Is(__instance))
BoneSteed.FixModel(__instance);
}
}
/// <summary>Канистрой и вообще из рюкзака скакуна не заправить (указание 01.10): кровь
/// вставляется только в ячейки. Пустой предмет топлива прячет пункт «Заправить» в меню.</summary>
[HarmonyPatch(typeof(Vehicle), nameof(Vehicle.GetFuelItem))]
public static class Patch_Vehicle_GetFuelItem_BoneSteed
{
public static void Postfix(Vehicle __instance, ref string __result)
{
if (BoneSteed.Is(__instance.entity))
__result = "";
}
}
}
+5 -2
View File
@@ -21,6 +21,9 @@ namespace NecromancerTome
/// property, quest tracking, anything else keyed off EntityFlags) is ever affected outside
/// this one call. A Stack (not a single field) survives re-entrant DamageEntity calls
/// correctly (e.g. explosions/knockback triggering further damage inside the same callstack).
///
/// ОДЕРЖИМОСТЬ (03.10, тест: «здоровье противника не снижалось»): то же самое, если атакующий -
/// одержимый зомби или цель - одержимый зомби (зомби бьют его, когда он их танкует).
/// </summary>
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
public static class Patch_EntityAlive_DamageEntity_CharmedZombieVsZombie
@@ -34,8 +37,8 @@ namespace NecromancerTome
{
EntityAlive attacker = __instance.world.GetEntity(_damageSource.getEntityId()) as EntityAlive;
if (attacker is EntityZombie attackerZombie
&& attackerZombie.Buffs != null
&& attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
&& (attackerZombie.Buffs != null && attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
|| Possession.IsPossessed(attackerZombie) || Possession.IsPossessed(__instance))
&& (attackerZombie.entityFlags & EntityFlags.Zombie) != EntityFlags.None)
{
attackerZombie.entityFlags &= ~EntityFlags.Zombie;
+44
View File
@@ -0,0 +1,44 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Хитбокс наших декораций (03.10: «Ты не добавляешь к декору хитбокс? А как удалять, если не туда
/// поставил?»). В префабах книги памяти, ножа-декора и плакатов на корне стоит BoxCollider, но игра
/// понимает, что луч попал в блок, только по игровому тегу на объекте с коллайдером. В нашем
/// Unity-проекте такого тега нет (AssetRipper знает его как unknown_20004) - при сборке он стал
/// Untagged. Поэтому при первой загрузке модели корню ставим тег и слой корня ванильной рамки
/// (postersFramedGunSchematicPrefab - у неё коллайдер тоже на корне). Префаб из бандла общий,
/// правка одна на все экземпляры. Тот же приём, что у оссуария (GeoOssuaryPatch.CopyTags).
/// </summary>
[HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
public static class Patch_BlockShapeModelEntity_getPrefab_DecorColliderTag
{
const string VanillaModel = "@:Entities/Decor/postersFramedGunSchematicPrefab.prefab";
static readonly System.Collections.Generic.HashSet<string> done = new System.Collections.Generic.HashSet<string>();
static bool Ours(string _name)
{
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name == "necroMortarPrefab" || _name.StartsWith("necroBloodRose") || _name.StartsWith("necroPoster");
}
public static void Postfix(Transform __result)
{
if (__result == null || !Ours(__result.name) || !done.Add(__result.name))
{
return;
}
Transform vanilla = DataLoader.LoadAsset<Transform>(VanillaModel);
if (vanilla == null)
{
Debug.LogWarning("[NecromancerTome] DecorCollider: ванильная рамка не загрузилась - тег не поставлен");
return;
}
__result.tag = vanilla.tag;
__result.gameObject.layer = vanilla.gameObject.layer;
Debug.Log("[NecromancerTome] DecorCollider: '" + __result.name + "' тег " + vanilla.tag + ", слой " + vanilla.gameObject.layer +
", коллайдер " + (__result.GetComponent<Collider>() != null ? "есть" : "НЕТ"));
}
}
}
+282
View File
@@ -0,0 +1,282 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// «Стиратель» (указание 02.10) - античит к режиму змеи: если игрок в кровавую луну стал змеёй,
/// в его орде с большой вероятностью приходят Стиратели - маленькие быстрые зомби с
/// нитроглицериновыми вздутиями. Взрываются, когда пытаются сломать блок и когда их разрывает
/// змея; взрыв ломает блоки, ранит тело игрока и соседних зомби, змею не ранит (она бессмертна),
/// а отбрасывает.
///
/// Сущность - zombieEraser (entityclasses.xml): имя НЕ на necro, иначе IsNecroEntity сочтёт её
/// своей и змея перестанет видеть в ней врага. Параметры взрыва - блок Explosion там же,
/// подрыв - как у ванильного EntityZombieCop: ExplosionServer + SetDead.
/// </summary>
public static class EraserPatch
{
public const string EntityClassName = "zombieEraser";
/// <summary>Доля Стирателей в орде игрока-змеи («с большой вероятностью»).</summary>
public const float HordeChance = 0.5f;
/// <summary>Отброс змеи: дальше этого от взрыва змею не задевает.</summary>
public const float KnockRange = 8f;
static int _classId = -1;
public static bool IsEraser(Entity _e)
{
if (_e == null)
{
return false;
}
if (_classId == -1)
{
_classId = EntityClass.FromString(EntityClassName);
}
return _e.entityClass == _classId;
}
static System.Collections.IEnumerator BurstNextFrame(EntityAlive _e)
{
yield return null;
if (_e != null && _e.world != null && _e.world.GetEntity(_e.entityId) != null)
{
NecroBurst.Burst(_e, _e);
}
}
/// <summary>Подрыв на сервере. Возвращает false, если подрывать нечего или не здесь.</summary>
public static bool Explode(EntityAlive _e)
{
if (_e == null || _e.IsDead() || _e.world == null || _e.world.IsRemote())
{
return false;
}
Vector3 pos = _e.GetPosition();
_e.NotifySleeperDeath();
_e.SetModelLayer(2);
GameManager.Instance.ExplosionServer(pos, World.worldToBlockPos(pos), _e.transform.rotation,
EntityClass.list[_e.entityClass].explosionData, _e.entityId, 0f, false);
_e.timeStayAfterDeath = 0;
_e.SetDead();
SnakeControl.Knockback(pos, KnockRange);
// Тела не остаётся (указание 02.10: «после взрыва остаются трупы туристов»): чёрный
// разрыв, как от змеи. Не сразу, а в следующем кадре: подрыв идёт изнутри хода ИИ
// (Attack из задачи), и удаление посреди него роняло EntityAlive.updateTasks (NRE, тест 02.10).
GameManager.Instance.StartCoroutine(BurstNextFrame(_e));
Debug.Log("[NecromancerTome] EraserPatch: Стиратель " + _e.entityId + " взорвался в " + pos);
return true;
}
}
/// <summary>Стиратель упёрся и начинает ломать блок - вместо удара взрыв.</summary>
[HarmonyPatch(typeof(EAIBreakBlock), nameof(EAIBreakBlock.Start))]
public static class Patch_EAIBreakBlock_Start_Eraser
{
public static bool Prefix(EAIBreakBlock __instance)
{
return !(EraserPatch.IsEraser(__instance.theEntity) && EraserPatch.Explode(__instance.theEntity));
}
}
/// <summary>Любая атака Стирателя - по блоку (BreakBlock, DestroyArea) или по цели
/// (ApproachAndAttackTarget) - идёт через EntityAlive.Attack: взрыв вместо удара (тест 02.10:
/// «взрываются не всегда, хотя атакуют»).</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.Attack))]
public static class Patch_EntityAlive_Attack_Eraser
{
public static bool Prefix(EntityAlive __instance, ref bool __result)
{
if (EraserPatch.IsEraser(__instance) && EraserPatch.Explode(__instance))
{
__result = false;
return false;
}
return true;
}
}
/// <summary>
/// Подмешивание в орду. Ванильный образец - AIDirectorBloodMoonParty.SpawnZombie: игрок в
/// транспорте получает в орду стервятников с шансом 0.5. Класс зомби там берётся из
/// EntityGroups.GetRandomEntityFromGroupMaxTier; на время SpawnZombie запоминаем цель и, если
/// это игрок в режиме змеи, с шансом подменяем результат на Стирателя.
/// Режим змеи - состояние локального игрока (SnakeControl.Controlling), поэтому работает в
/// одиночной игре и у хоста; змея подключённого клиента орду не меняет.
/// </summary>
[HarmonyPatch(typeof(AIDirectorBloodMoonParty), "SpawnZombie")]
public static class Patch_BloodMoonParty_SpawnZombie_Eraser
{
[System.ThreadStatic] public static EntityPlayer Target;
public static void Prefix(EntityPlayer _target)
{
Target = _target;
}
public static void Finalizer()
{
Target = null;
}
}
[HarmonyPatch(typeof(EntityGroups), nameof(EntityGroups.GetRandomEntityFromGroupMaxTier))]
public static class Patch_EntityGroups_GetRandom_Eraser
{
public static void Postfix(ref int __result)
{
EntityPlayer target = Patch_BloodMoonParty_SpawnZombie_Eraser.Target;
if (target == null || __result == -1 || !SnakeControl.Controlling ||
!(target is EntityPlayerLocal) || Random.value >= EraserPatch.HordeChance)
{
return;
}
int id = EntityClass.FromString(EraserPatch.EntityClassName);
if (id != -1 && EntityClass.list.ContainsKey(id))
{
__result = id;
}
}
}
/// <summary>
/// Вид Стирателя (02.10): тело - ванильная модель толстого гавайца (скелет, физика, теги,
/// анимации игры), а раскраска своя. Игра в EModelBase.createModel сама копирует материал тела
/// (ветка MatColor/AltMats); после неё подставляем в материал LOD-рендереров свои текстуры
/// (Resources/Eraser/*.png, нарисованы скриптом по развёртке гавайца): _Albedo, _Normal и _RMOE
/// (шероховатость, металл, затенение, в альфе - маска свечения), свечение - _EmissiveColor.
/// Глаза - отдельные рендереры *Eye* - прячем: на их месте в текстуре чёрные провалы.
/// Ступни босые - пальцы и ногти нарисованы в текстуре (слоновья нога масштабом костей не
/// получилась, 02.10).
/// </summary>
[HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))]
public static class Patch_EModelBase_createModel_EraserLook
{
public static readonly Color Emission = new Color(1f, 0.1f, 0.65f) * 2.5f;
static Texture2D _albedo, _normal, _rmoe;
/// <summary>Своя раскраска на материал тела (только у материалов с _Albedo - тело и куски).</summary>
public static void Paint(Renderer _r)
{
// Сначала смотрим общий материал: обращение к .material создаёт копию, и делать её
// у глаз, огня и прочих рендереров без _Albedo незачем. Если копия уже наша - .material
// вернёт её же, повторной копии не будет. Брошенные копии убирает сама игра
// (GameManager: Resources.UnloadUnusedAssets по таймеру).
Material sm = _r.sharedMaterial;
if (sm == null || !sm.HasProperty("_Albedo") || sm.GetTexture("_Albedo") == _albedo && sm.GetColor("_EmissiveColor") == Emission)
{
return;
}
Material m = _r.material;
if (_albedo != null) m.SetTexture("_Albedo", _albedo);
if (_normal != null) m.SetTexture("_Normal", _normal);
if (_rmoe != null) m.SetTexture("_RMOE", _rmoe);
m.SetColor("_EmissiveColor", Emission);
}
static bool _loaded;
static Texture2D Load(string _file, bool _linear)
{
string path = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(typeof(EraserPatch).Assembly.Location), "Resources", "Eraser", _file);
if (!System.IO.File.Exists(path))
{
Debug.LogWarning("[NecromancerTome] EraserLook: нет файла " + path);
return null;
}
Texture2D t = new Texture2D(2, 2, TextureFormat.RGBA32, true, _linear);
if (!ImageConversion.LoadImage(t, System.IO.File.ReadAllBytes(path)))
{
return null;
}
t.name = "Eraser_" + _file;
t.Apply(true, true);
return t;
}
public static void Postfix(EModelBase __instance)
{
Entity e = __instance.entity;
if (!EraserPatch.IsEraser(e))
{
return;
}
if (!_loaded)
{
_loaded = true;
_albedo = Load("eraser_albedo.png", false);
_normal = Load("eraser_normal.png", true);
_rmoe = Load("eraser_rmoe.png", true);
}
foreach (SkinnedMeshRenderer r in __instance.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (r.name.IndexOf("Eye", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
r.enabled = false;
continue;
}
if (!r.CompareTag("LOD"))
{
continue;
}
Paint(r);
}
Debug.Log("[NecromancerTome] EraserLook: вид Стирателя на " + e.entityId);
}
}
/// <summary>
/// Отрубленная конечность (тест 02.10: «если у трупа отрубить руку, он становится туристом»):
/// игра при расчленении подменяет материалы тела и создаёт отлетающий кусок с ванильным
/// материалом гавайца. После RemoveLimb заново красим тело и новые куски.
/// </summary>
[HarmonyPatch(typeof(AvatarZombieController), nameof(AvatarZombieController.RemoveLimb))]
public static class Patch_AvatarZombieController_RemoveLimb_EraserLook
{
public static void Prefix(out int __state)
{
__state = DismembermentManager.Instance?.parts.Count ?? 0;
}
public static void Postfix(AvatarZombieController __instance, int __state)
{
EntityAlive e = Traverse.Create(__instance).Field("entity").GetValue<EntityAlive>();
if (!EraserPatch.IsEraser(e) || e.emodel == null)
{
return;
}
foreach (Renderer r in e.emodel.GetComponentsInChildren<Renderer>(true))
{
Patch_EModelBase_createModel_EraserLook.Paint(r);
}
var parts = DismembermentManager.Instance?.parts;
for (int i = __state; parts != null && i < parts.Count; i++)
{
foreach (Transform t in new[] { parts[i].prefabT, parts[i].detachT })
{
if (t == null) continue;
foreach (Renderer r in t.GetComponentsInChildren<Renderer>(true))
{
Patch_EModelBase_createModel_EraserLook.Paint(r);
}
}
}
}
}
/// <summary>Стиратель убит чем угодно (игроком, взрывом соседа, под бафом жертвы) - чёрный
/// разрыв, трупа не остаётся (указание 02.10). Тот же путь, что у змеи: постфикс ClientKill.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.ClientKill))]
public static class Patch_EntityAlive_ClientKill_EraserBurst
{
public static void Postfix(EntityAlive __instance)
{
if (EraserPatch.IsEraser(__instance) && __instance.IsDead() && __instance.world != null &&
__instance.world.GetEntity(__instance.entityId) != null)
{
NecroBurst.Burst(__instance, __instance.entityThatKilledMe != null ? (Entity)__instance.entityThatKilledMe : __instance);
}
}
}
}
+254
View File
@@ -0,0 +1,254 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере. Блок -
/// necroGeoOssuary в blocks.xml, наследник ванильного коллектора росы; всё, что коллектор
/// умеет сам (окно, топливо, таймеры, работа без игрока рядом), остаётся его. Здесь только то,
/// чего ваниль не умеет, - разобрано по декомпилятору 3.3:
///
/// - ЯЧЕЙКА МОДИФИКАЦИИ ПРИНИМАЕТ ОДИН ПРЕДМЕТ. XUiC_DewCollectorModGrid.SetTileEntity
/// зовёт SetAllowedItemClassSingle(RequiredMods[i]). У оссуария в любую из трёх ячеек идёт
/// любой из пяти ресурсов, по одной штуке (OverrideStackCount = 1). Окно у всех
/// коллекторов одно и то же, поэтому для чужих коллекторов предел штук сбрасывается
/// обратно - иначе коллектор росы после оссуария тоже брал бы по одной.
/// - ВАНИЛЬ НЕ СМОТРИТ, ЧТО ЛЕЖИТ В ЯЧЕЙКЕ. ModTypes=Type - просто флаг HasModConvert
/// (OutputItem -> OutputItemModded), одна замена на весь блок. У оссуария три OutputType
/// os1..os3, по одному на ячейку продукции; os(i+1) выдаёт полный стак ресурса из ячейки
/// модификации i. Пустая или чужая модификация - этот OutputType выключен (isDisabled),
/// значит таймер стоит и сферу он не ест.
/// - ПРОВЕРКА НЕБА. HandleSkyCheck (7 блоков вверх) у ванили безусловна для всех
/// коллекторов; оссуарий работает где угодно, в том числе под землёй (решение 30.09).
/// </summary>
public static class GeoOssuary
{
public const string BlockName = "necroGeoOssuary";
/// <summary>Камень, уголь, селитра, железо (его дают железные жилы), сланец.</summary>
public static readonly string[] ResourceNames =
{
"resourceRockSmall", "resourceCoal", "resourcePotassiumNitratePowder",
"resourceScrapIron", "resourceOilShale",
};
static ItemClass[] resourceClasses;
public static ItemClass[] ResourceClasses
{
get
{
if (resourceClasses == null)
{
resourceClasses = new ItemClass[ResourceNames.Length];
for (int i = 0; i < ResourceNames.Length; i++)
resourceClasses[i] = ItemClass.GetItemClass(ResourceNames[i]);
}
return resourceClasses;
}
}
public static bool Is(Block block) => block != null && block.GetBlockName() == BlockName;
public static bool Is(TileEntityCollector te) => te != null && Is(te.blockValue.Block);
/// <summary>os1 -> 0, os2 -> 1, os3 -> 2; иначе -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';
}
/// <summary>Ресурс в ячейке модификации, если он из списка; иначе null.</summary>
public static ItemClass ResourceIn(TileEntityCollector te, int modSlot)
{
ItemStack[] mods = te.ModSlots;
if (modSlot < 0 || mods == null || modSlot >= mods.Length)
return null;
ItemStack stack = mods[modSlot];
if (stack == null || stack.IsEmpty())
return null;
ItemClass cls = stack.itemValue.ItemClass;
return cls != null && System.Array.IndexOf(ResourceNames, cls.Name) >= 0 ? cls : null;
}
}
[HarmonyPatch(typeof(XUiC_DewCollectorModGrid), nameof(XUiC_DewCollectorModGrid.SetTileEntity))]
public static class Patch_XUiC_DewCollectorModGrid_SetTileEntity_GeoOssuary
{
public static void Postfix(XUiC_DewCollectorModGrid __instance, TileEntityCollector te)
{
bool ossuary = GeoOssuary.Is(te);
foreach (XUiC_ItemStack slot in __instance.itemControllers)
{
if (!(slot is XUiC_RequiredItemStack required))
continue;
required.OverrideStackCount = ossuary ? 1 : -1;
if (ossuary)
{
required.SetAllowedItemClasses(GeoOssuary.ResourceClasses);
required.RequiredItemOnly = true;
}
}
}
}
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.isDisabled))]
public static class Patch_TileEntityCollector_isDisabled_GeoOssuary
{
public static void Postfix(TileEntityCollector __instance, BlockCollector.OutputType _outputType, ref bool __result)
{
if (__result || !GeoOssuary.Is(__instance))
return;
if (GeoOssuary.ResourceIn(__instance, GeoOssuary.ModSlotOf(_outputType)) == null)
__result = true;
}
}
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.newItem))]
public static class Patch_TileEntityCollector_newItem_GeoOssuary
{
public static void Postfix(TileEntityCollector __instance, BlockCollector.OutputType outputType, ref ItemStack __result)
{
if (__result == null || !GeoOssuary.Is(__instance))
return;
ItemClass resource = GeoOssuary.ResourceIn(__instance, GeoOssuary.ModSlotOf(outputType));
if (resource == null)
{
// isDisabled не пускает сюда без ресурса; если всё же дошло - сфера уже списана,
// и отдать хотя бы заглушку честнее, чем молча съесть топливо.
Debug.LogWarning("[NecromancerTome] GeoOssuary: " + outputType.Name + " сработал без ресурса в ячейке модификации");
return;
}
__result = new ItemStack(new ItemValue(resource.Id), resource.MaxCount);
Debug.Log("[NecromancerTome] GeoOssuary: " + outputType.Name + " -> " + resource.Name + " x" + resource.MaxCount + " в " + __instance.ToWorldPos());
}
}
/// <summary>
/// Шейдер модели. Своя модель оссуария лежит в нашем бандле с запасным шейдером
/// NecromancerTome/OssuaryProxy (Standard-поверхность), а у пирамиды 10.09 именно
/// Standard из бандла на расстоянии рисовался маджентой. Поэтому при первой загрузке модели
/// материалу ставится тот шейдер, которым игра рисует ванильный коллектор росы: имена текстур
/// у них общие (_MainTex, _Normal, _RMOM, _Emissive), модель выросла из его префаба. Префаб из
/// бандла - общий ассет, так что правка одна на все экземпляры.
/// </summary>
[HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
public static class Patch_BlockShapeModelEntity_getPrefab_GeoOssuaryShader
{
const string VanillaModel = "@:Entities/Furniture/collectorDewPrefab.prefab";
// Пирамида отрицания (01.10) собрана из копии префаба оссуария - те же имена объектов,
// тот же запасной шейдер, поэтому лечится тем же постфиксом.
static readonly System.Collections.Generic.HashSet<string> models =
new System.Collections.Generic.HashSet<string> { "necroOssuaryPrefab", "necroWardPrefab" };
static readonly System.Collections.Generic.HashSet<string> done = new System.Collections.Generic.HashSet<string>();
public static void Postfix(Transform __result)
{
if (__result == null || !models.Contains(__result.name) || !done.Add(__result.name))
return;
Transform vanilla = DataLoader.LoadAsset<Transform>(VanillaModel);
Renderer src = vanilla != null ? vanilla.GetComponentInChildren<Renderer>(true) : null;
Shader shader = src != null && src.sharedMaterial != null ? src.sharedMaterial.shader : null;
if (shader == null)
{
Debug.LogWarning("[NecromancerTome] GeoOssuary: шейдер коллектора росы не найден - остаётся запасной");
return;
}
int n = 0;
foreach (Renderer r in __result.GetComponentsInChildren<Renderer>(true))
foreach (Material m in r.sharedMaterials)
if (m != null && m.shader != shader) { m.shader = shader; m.enableInstancing = true; n++; }
Debug.Log("[NecromancerTome] GeoOssuary: материалам модели (" + n + ") поставлен шейдер '" + shader.name + "'");
CopyTags(vanilla, __result);
}
/// <summary>Теги коллайдера. У ванильного префаба корень и объект collision помечены
/// игровым тегом (AssetRipper записал его как unknown_20004 - имени он не знал), и именно
/// по тегу игра понимает, что луч попал в блок, с которым можно взаимодействовать. В нашем
/// Unity-проекте такого тега нет, при сборке он стал Untagged - и оссуарий не реагировал
/// на наведение (тест 30.09). Берём теги у ванильного коллектора по именам объектов.</summary>
static void CopyTags(Transform vanilla, Transform ours)
{
var byName = new System.Collections.Generic.Dictionary<string, Transform>();
foreach (Transform t in vanilla.GetComponentsInChildren<Transform>(true))
byName[t == vanilla ? "<root>" : t.name] = t;
foreach (Transform t in ours.GetComponentsInChildren<Transform>(true))
{
Transform src;
if (!byName.TryGetValue(t == ours ? "<root>" : t.name, out src))
continue;
if (t.tag != src.tag || t.gameObject.layer != src.gameObject.layer)
{
Debug.Log("[NecromancerTome] GeoOssuary: '" + t.name + "' тег " + t.tag + " -> " + src.tag +
", слой " + t.gameObject.layer + " -> " + src.gameObject.layer);
t.tag = src.tag;
t.gameObject.layer = src.gameObject.layer;
}
}
}
}
/// <summary>
/// Расход сферы - по прочности, как в пространственном хранилище (указание 01.10: «одна
/// сфера на стак - это жирно; 1/10 прочности на стак»). Ваниль в removeFuel вычитает ШТУКИ
/// топлива, то есть съедала бы сферу целиком за каждый стак. Здесь вместо этого у первой
/// сферы в отсеке списывается ChargePerStack прочности (DegradationMax сферы 500 -> 10 стаков
/// со сферы); выработанная до нуля сфера исчезает из ячейки, как из браслета. Наличие топлива
/// ваниль по-прежнему считает штуками (getFuelCount) - любая сфера с остатком годится.
/// </summary>
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.removeFuel))]
public static class Patch_TileEntityCollector_removeFuel_GeoOssuary
{
public const int ChargePerStack = 50;
public static bool Prefix(TileEntityCollector __instance)
{
if (!GeoOssuary.Is(__instance))
return true;
ItemStack[] fuel = __instance.FuelSlots;
// Кровавый камень в отсеке - вечное топливо (01.10: «полная замена сферы»): не тратится
// ни он, ни сферы рядом.
for (int i = 0; i < fuel.Length; i++)
if (fuel[i] != null && !fuel[i].IsEmpty() && fuel[i].itemValue.ItemClass != null &&
fuel[i].itemValue.ItemClass.Name == BoneSteedFuel.StoneItem)
return false;
for (int i = 0; i < fuel.Length; i++)
{
ItemStack stack = fuel[i];
if (stack == null || stack.IsEmpty() || stack.itemValue.ItemClass == null ||
stack.itemValue.ItemClass.Name != SpatialVaultPickup.ChargeItemName)
continue;
int max = stack.itemValue.MaxUseTimes;
if (max <= 0)
{
// Прочность не задалась (DegradationMax) - ведём себя как ваниль: сфера за стак.
fuel[i] = ItemStack.Empty;
Debug.LogWarning("[NecromancerTome] GeoOssuary: у сферы MaxUseTimes 0 - списана целиком");
return false;
}
stack.itemValue.UseTimes += ChargePerStack;
if (stack.itemValue.UseTimes >= max)
{
fuel[i] = ItemStack.Empty;
Debug.Log("[NecromancerTome] GeoOssuary: сфера в ячейке " + i + " выработана и исчезла");
}
return false;
}
return false;
}
}
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.HandleSkyCheck))]
public static class Patch_TileEntityCollector_HandleSkyCheck_GeoOssuary
{
public static void Postfix(TileEntityCollector __instance, ref bool __result)
{
if (__result && GeoOssuary.Is(__instance))
__result = false;
}
}
}
+350
View File
@@ -0,0 +1,350 @@
using GUI_2;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Хваты пустой руки (03.10, BACKLOG «Заклинания с руки и жесты»): на R - меню из трёх строк:
/// обычная рука, «отрицание», «пиф-паф». Пустая рука - предмет meleeHandPlayer с DynamicMelee,
/// меню на R игра открывает ей так же, как ножу (PlayerMoveController: HasRadial).
///
/// Выбор НЕ сохраняется (указание 03.10: «после перезахода будет снова обычная рука») - просто поле
/// в памяти. Ни cvar, ни предмет не трогаем. На выходе из мира (нет мира или сменился игрок) всё
/// сбрасывается, и ссылки на кости, снимок заморозки и цель отпускаются (HandPose.ResetAll) - чтобы
/// за сессию ничего не копилось (указание: «не допустить утечку памяти на сохранение состояний»).
/// В хвате рука стоит позой (HandPose: кости после аниматора + заморозка остальных), а кнопки мыши
/// не бьют: ExecuteAction у DynamicMelee пустой руки перехвачен.
/// ЛКМ в «отрицании» - зомби под прицелом в леденящем ужасе: бежит от игрока с ускорением и теряет
/// его как цель (NecroFleeSnakeTask.ScareFrom). Звук - как у празднующего убийства Twitch.
/// ЛКМ в «пиф-пафе» - одержимость зомби-гуманоида под прицелом (Possession.cs).
/// ПКМ в хватах - ничего.
/// </summary>
public static class HandHolds
{
public const string HandItem = "meleeHandPlayer";
public const int None = 0, Deny = 1, PifPaf = 2;
/// <summary>Дальность и конус выбора зомби для «отрицания».</summary>
public const float DenyRange = 25f;
public const float DenyConeDeg = 12f;
/// <summary>Бежит, пока игрок не дальше этого: зрение зомби до 30 м, у Чака 40 (entityclasses.xml,
/// SightRange) - указание 03.10: «убегать он должен дальше 40 м, чтобы терять из прицела».</summary>
public const float DenyFarDistance = 40f;
/// <summary>Предел бегства на случай, если застрял и не может уйти на 40 м.</summary>
public const float DenyMaxSeconds = 25f;
/// <summary>Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»).</summary>
public const float DenySpeed = 2.2f;
public const float Cooldown = 0.6f;
/// <summary>Звук «козы» (05.10: «нужен короткий звук» - вместо twitch_celebrate): бросок змеи - тот же,
/// что у рывка духа змеи (SnakeControl.AttackSound): «раз зомби боятся духа змеи, некромант может
/// пугать им и в телесной форме».</summary>
public const string DenySound = SnakeControl.AttackSound;
/// <summary>«Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок
/// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success).</summary>
public const string PifPafFailSound = "glassdestroy";
public const string PifPafPossessSound = "lockpick_success";
/// <summary>Выбранный хват - только на время сессии в мире.</summary>
public static int Hold;
static int _applied = -1;
static int _playerId = -1;
static float _nextCast;
/// <summary>Вселение - на пике толчка руки (05.10): иначе вид сразу уходит к зомби и толчка не видно.
/// Цель берётся в момент щелчка.</summary>
static EntityAlive _pifPafVictim;
static float _pifPafAt = -1f;
public static bool IsHand(ItemClass _item) => _item != null && _item.Name == HandItem;
public static int Current(EntityPlayerLocal _player)
{
if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) ||
SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead())
{
return None;
}
return Hold;
}
/// <summary>Из HandPoseDriver.LateUpdate - до HandPose.Apply. Позу трогаем только при смене
/// хвата: ручная настройка командой necropose держится до следующей смены.</summary>
public static void Update()
{
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
int id = player != null ? player.entityId : -1;
if (id != _playerId)
{
// Вышли из мира, зашли в другой или перезашли - обычная рука и пустые ссылки.
_playerId = id;
Hold = None;
_applied = None;
_target = null;
_targetFrame = -1;
_pifPafVictim = null;
_pifPafAt = -1f;
HandPose.ResetAll();
return;
}
if (_pifPafAt >= 0f && Time.time >= _pifPafAt)
{
PifPafShot(player);
}
int want = Current(player);
if (want == _applied)
{
return;
}
_applied = want;
if (want == Deny)
{
HandPose.PresetDeny();
}
else if (want == PifPaf)
{
HandPose.PresetPifPaf();
}
else
{
HandPose.Enabled = false;
}
}
static void PifPafShot(EntityPlayerLocal _player)
{
EntityAlive victim = _pifPafVictim;
_pifPafVictim = null;
_pifPafAt = -1f;
if (Current(_player) != PifPaf)
{
return; // за полтика сменил хват или руку - выстрела нет
}
if (victim != null && !victim.IsDead() && Possession.CanPossess(victim) && Possession.Enter(_player, victim))
{
_player.PlayOneShot(PifPafPossessSound);
}
else
{
_player.PlayOneShot(PifPafFailSound);
Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид"));
}
}
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
if (_commandIndex >= None && _commandIndex <= PifPaf)
{
Hold = _commandIndex;
}
}
/// <summary>ЛКМ в хвате. True - нажатие наше, удар не выполнять.</summary>
public static void Cast(EntityPlayerLocal _player, int _hold)
{
if (Time.time < _nextCast)
{
return;
}
_nextCast = Time.time + Cooldown;
if (_hold == PifPaf)
{
// Одержимость (решение 03.10): «пиф-паф» по зомби-гуманоиду - управление им.
HandPose.Jab();
_pifPafVictim = Target(_player);
_pifPafAt = Time.time + HandPose.JabTime * 0.5f;
return;
}
HandPose.Jab(); // толчок к прицелу, как у «пиф-пафа» (05.10)
_player.PlayOneShot(DenySound);
World world = _player.world;
if (world.IsRemote())
{
return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно
}
EntityAlive target = Target(_player);
if (target != null)
{
if (target.HasAnyTags(FlyerTag))
{
NecroFleeSnakeTask.ForgetNow(target, _player); // летает - наземного бегства нет, только теряет цель
Debug.Log("[NecromancerTome] HandHolds: стервятник " + target.entityId + " отрицание - потерял цель");
}
else
{
NecroFleeSnakeTask.ScareFrom(target, _player, DenyMaxSeconds, DenySpeed, DenyFarDistance);
}
}
}
static readonly FastTags<TagGroup.Global> ZombieTag = FastTags<TagGroup.Global>.Parse("zombie");
static readonly FastTags<TagGroup.Global> FlyerTag = FastTags<TagGroup.Global>.Parse("vulture");
/// <summary>Только зомби (указание 03.10: «кабаны и змеи хоть и противник, но не зомби, кроме
/// зомбокабана»; «стервятники тоже зомби, обычных в игре нету») - метка zombie в entityclasses.xml:
/// люди-зомби, зомби-собаки, лютые волки, зомби-медведи, зомби-кабаны, стервятники.</summary>
public static bool IsZombie(EntityAlive _e)
{
return _e != null && _e.HasAnyTags(ZombieTag);
}
static int _targetFrame = -1;
static EntityAlive _target;
/// <summary>Зомби под прицелом (раз за кадр): и для заклинания, и для красного прицела.</summary>
public static EntityAlive Target(EntityPlayerLocal _player)
{
if (_targetFrame != Time.frameCount)
{
_targetFrame = Time.frameCount;
_target = FindTarget(_player.world, _player);
}
return _target;
}
/// <summary>Прямая видимость своим лучом: между глазом и точкой нет блоков и земли (сущности на луче
/// не мешают). Не EntityAlive.CanSee: у игрока нет ИИ, и его кэш видимости (seeCache) не обновляется -
/// тест 03.10: Арлин захватывалась, зомби-коп нет.</summary>
static bool Visible(Vector3 _eye, Vector3 _to)
{
Vector3 d = _to - _eye;
return !Physics.Raycast(_eye - Origin.position, d.normalized, d.magnitude - 0.3f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
/// <summary>Зомби ближе всех к линии прицела в конусе DenyConeDeg, не дальше DenyRange,
/// с прямой видимостью.</summary>
static EntityAlive FindTarget(World _world, EntityPlayerLocal _player)
{
Transform cam = _player.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 eye = cam.position + Origin.position;
Vector3 fwd = cam.forward;
EntityAlive best = null;
float bestAngle = DenyConeDeg;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e.IsDead() || !IsZombie(e))
{
continue;
}
Vector3 to = e.getChestPosition() - eye;
float d = to.magnitude;
if (d > DenyRange || d < 0.1f)
{
continue;
}
float angle = Vector3.Angle(fwd, to);
if (angle < bestAngle && Visible(eye, e.getChestPosition()))
{
bestAngle = angle;
best = e;
}
}
return best;
}
}
/// <summary>Меню на R у пустой руки - как у ножа (Patch_ItemAction_HasRadial_SnakeKnife).</summary>
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))]
public static class Patch_ItemAction_HasRadial_HandHolds
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
if (!__result && !SnakeMode.Active && !MindPalace.Active && HandHolds.IsHand(__instance.item))
{
__result = true;
}
}
}
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_HandHolds
{
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (!HandHolds.IsHand(__instance.item))
{
return true;
}
int cur = HandHolds.Hold;
_xuiRadialWindow.ResetRadialEntries();
_xuiRadialWindow.CreateRadialEntry(HandHolds.None, "ui_game_symbol_hand", "UIAtlas", "",
Localization.Get("necroHoldNone"), cur == HandHolds.None);
_xuiRadialWindow.CreateRadialEntry(HandHolds.Deny, "ui_game_symbol_run", "UIAtlas", "",
Localization.Get("necroHoldDeny"), cur == HandHolds.Deny);
_xuiRadialWindow.CreateRadialEntry(HandHolds.PifPaf, "ui_game_symbol_pistol", "UIAtlas", "",
Localization.Get("necroHoldPifPaf"), cur == HandHolds.PifPaf);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
HandHolds.OnRadialCommand, null, cur);
return false;
}
}
/// <summary>В хвате кнопки мыши не бьют: ЛКМ - заклинание хвата, ПКМ - ничего (03.10: «руки
/// деформируются, пытаясь совершить старую анимацию»).</summary>
[HarmonyPatch(typeof(ItemActionDynamicMelee), nameof(ItemActionDynamicMelee.ExecuteAction))]
public static class Patch_ItemActionDynamicMelee_ExecuteAction_HandHolds
{
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
{
if (!(_actionData?.invData?.holdingEntity is EntityPlayerLocal player))
{
return true;
}
int hold = HandHolds.Current(player);
if (hold == HandHolds.None || !HandHolds.IsHand(_actionData.invData.item))
{
return true;
}
if (!_bReleased && _actionData.indexInEntityOfAction == 0)
{
HandHolds.Cast(player, hold);
}
return false;
}
}
/// <summary>Прицел краснеет, когда в нём зомби, в обоих хватах (указание 03.10). Цвет прицела игра
/// берёт из EntityPlayerLocal.crosshairColors[настройка] - на время рисования подменяем его красным.</summary>
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))]
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_HandHolds
{
static readonly Color Red = new Color(1f, 0.15f, 0.1f);
public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
{
__state = null;
int hold = HandHolds.Current(__instance);
EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
// «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
if (target == null || hold == HandHolds.PifPaf && !Possession.CanPossess(target))
{
return;
}
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
{
i = 0;
}
__state = EntityPlayerLocal.crosshairColors[i];
EntityPlayerLocal.crosshairColors[i] = Red;
}
public static void Postfix(Color? __state)
{
if (__state == null)
{
return;
}
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
{
i = 0;
}
EntityPlayerLocal.crosshairColors[i] = __state.Value;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.7.0). Свой хват -
/// это неподвижная поза рук от первого лица, как у браслета (кулак к противнику, без движения
/// при действии): кости рига ставятся в нужную комбинацию после аниматора. Сначала надо знать
/// кости: команда пишет в лог дерево рига рук от первого лица (EModelSDCS.baseRigFP, его создаёт
/// SDCSUtils.CreateVizFP под Camera/UFPSRoot) с локальными поворотами и позициями, и параметры
/// аниматора FPPlayerController (WeaponHoldType и прочие) с текущими значениями.
/// </summary>
[Preserve]
public class ConsoleCmdNecroBones : ConsoleCmdAbstract
{
const string Tag = "[NecromancerTome] necrobones: ";
public override string[] getCommands() => new[] { "necrobones" };
public override string getDescription() => "NecromancerTome: дерево костей рук от первого лица и параметры аниматора - в лог";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
if (player == null)
{
return;
}
if (!(player.emodel is EModelSDCS sdcs) || sdcs.baseRigFP == null)
{
SdtdConsole.Instance.Output("риг рук от первого лица не найден (EModelSDCS.baseRigFP)");
return;
}
Transform root = sdcs.baseRigFP.transform;
ItemClass held = player.inventory?.holdingItem;
Debug.Log(Tag + "в руке " + (held != null ? held.GetItemName() + ", HoldType " + held.HoldType.Value : "ничего") + ", вид от первого лица " + sdcs.IsFPV);
Debug.Log(Tag + "путь рига: " + PathOf(root));
int count = 0;
var sb = new StringBuilder();
Dump(root, 0, sb, ref count);
foreach (string line in sb.ToString().Split('\n'))
{
if (line.Length > 0)
{
Debug.Log(Tag + line);
}
}
Animator anim = root.GetComponentInChildren<Animator>(true) ?? root.GetComponentInParent<Animator>();
if (anim != null)
{
Debug.Log(Tag + "аниматор на " + anim.name + ", контроллер " + (anim.runtimeAnimatorController != null ? anim.runtimeAnimatorController.name : "нет"));
foreach (AnimatorControllerParameter p in anim.parameters)
{
string v = p.type == AnimatorControllerParameterType.Float ? anim.GetFloat(p.nameHash).ToString("0.###")
: p.type == AnimatorControllerParameterType.Int ? anim.GetInteger(p.nameHash).ToString()
: p.type == AnimatorControllerParameterType.Bool ? anim.GetBool(p.nameHash).ToString()
: "trigger";
Debug.Log(Tag + " параметр " + p.name + " (" + p.type + ") = " + v);
}
for (int l = 0; l < anim.layerCount; l++)
{
AnimatorClipInfo[] clips = anim.GetCurrentAnimatorClipInfo(l);
string names = "";
foreach (AnimatorClipInfo c in clips)
{
names += (names.Length > 0 ? ", " : "") + (c.clip != null ? c.clip.name : "?") + " x" + c.weight.ToString("0.##");
}
Debug.Log(Tag + " слой " + l + " " + anim.GetLayerName(l) + " вес " + anim.GetLayerWeight(l).ToString("0.##") + ": " + names);
}
}
else
{
Debug.Log(Tag + "аниматор не найден");
}
SdtdConsole.Instance.Output("костей рук: " + count + " - дерево и параметры аниматора в логе (строки necrobones)");
}
static void Dump(Transform _t, int _depth, StringBuilder _sb, ref int _count)
{
_count++;
Vector3 e = _t.localEulerAngles;
Vector3 p = _t.localPosition;
_sb.Append(new string(' ', _depth * 2)).Append(_t.name)
.Append(_t.gameObject.activeSelf ? "" : " [выкл]")
.Append(" пов. ").Append(e.x.ToString("0.#")).Append(' ').Append(e.y.ToString("0.#")).Append(' ').Append(e.z.ToString("0.#"))
.Append(" поз. ").Append(p.x.ToString("0.###")).Append(' ').Append(p.y.ToString("0.###")).Append(' ').Append(p.z.ToString("0.###"))
.Append('\n');
for (int i = 0; i < _t.childCount; i++)
{
Dump(_t.GetChild(i), _depth + 1, _sb, ref _count);
}
}
static string PathOf(Transform _t)
{
string s = _t.name;
for (Transform p = _t.parent; p != null; p = p.parent)
{
s = p.name + "/" + s;
}
return s;
}
}
}
+502
View File
@@ -0,0 +1,502 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.7.0). Хват - неподвижная
/// поза: после аниматора (LateUpdate) кости рига EModelSDCS.baseRigFP ставятся в заданные локальные
/// повороты. Аниматор игры не трогаем. Снимки necrobones 03.10: кулак браслета (FP_Block_Hold,
/// HoldType 7) - суставы пальцев повёрнуты по локальной Z на 77-92°, у пустой руки 8-38°;
/// направление руки - RightArm, RightArmRoll, RightForeArm (сгиб по Y), RightHand.
/// Пока - инструмент подбора позы (команда necropose); готовую позу потом зашить как хват.
/// </summary>
public static class HandPose
{
public static bool Enabled;
public static readonly Dictionary<string, Vector3> Overrides = new Dictionary<string, Vector3>();
static GameObject _rig;
static readonly Dictionary<string, Transform> _bones = new Dictionary<string, Transform>();
static GameObject _host;
public static void Init()
{
if (_host != null)
{
return;
}
_host = new GameObject("NecroHandPose");
Object.DontDestroyOnLoad(_host);
_host.AddComponent<HandPoseDriver>();
}
public static Transform Bone(string _name)
{
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
GameObject rig = (player?.emodel as EModelSDCS)?.baseRigFP;
if (rig == null)
{
return null;
}
if (rig != _rig)
{
_rig = rig;
_bones.Clear();
foreach (Transform t in rig.GetComponentsInChildren<Transform>(true))
{
if (!_bones.ContainsKey(t.name))
{
_bones[t.name] = t;
}
}
}
if (_bones.TryGetValue(_name, out Transform b) && b == null)
{
_rig = null; // кость уничтожена (риг пересобран) - перечитать в следующий раз
}
return b;
}
/// <summary>Кости, повёрнутые относительно КАМЕРЫ, а не родителя: ставятся после Overrides
/// (03.10: локальные углы кисти «плывут» при каждом изменении локтя - подбор вслепую).</summary>
public static readonly Dictionary<string, Quaternion> CamOverrides = new Dictionary<string, Quaternion>();
public static Transform Camera()
{
return GameManager.Instance?.World?.GetPrimaryPlayer()?.cameraTransform;
}
/// <summary>Заморозка (03.10: «при нажатии на кнопки руки деформируются, пытаясь совершить старую
/// анимацию»). Поза задаёт 20-30 костей, а удар двигает и остальные - позвоночник, ключицы, вспомогательные
/// кости предплечья и кожи (*_sup_*, *Roll*). Поэтому при включении хвата в покое снимается локальное
/// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза.
/// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.</summary>
public static bool Freeze = true;
static readonly List<Transform> _frozen = new List<Transform>();
static readonly List<Vector3> _frozenPos = new List<Vector3>();
static readonly List<Quaternion> _frozenRot = new List<Quaternion>();
static GameObject _frozenRig;
static readonly int ItemActionIndexHash = Animator.StringToHash("ItemActionIndex");
/// <summary>Выход из мира: всё в исходное, ссылки на кости рига отпустить (HandHolds.Update).</summary>
public static void ResetAll()
{
Enabled = false;
Overrides.Clear();
CamOverrides.Clear();
_jabStart = -1f;
Unfreeze();
_bones.Clear();
_rig = null;
}
public static void Unfreeze()
{
_frozen.Clear();
_frozenPos.Clear();
_frozenRot.Clear();
_frozenRig = null;
}
static void FreezeBones()
{
Transform hips = Bone("Hips");
if (hips == null)
{
return;
}
if (_frozenRig != _rig || _frozen.Count == 0 || _frozen[0] == null)
{
Unfreeze();
Animator anim = _rig.GetComponent<Animator>();
if (anim != null && (anim.GetInteger(ItemActionIndexHash) != -1 || anim.IsInTransition(0)))
{
return; // идёт действие - снимок позже, в покое
}
foreach (Transform t in hips.GetComponentsInChildren<Transform>(true))
{
_frozen.Add(t);
_frozenPos.Add(t.localPosition);
_frozenRot.Add(t.localRotation);
}
_frozenRig = _rig;
Debug.Log("[NecromancerTome] HandPose: заморожено костей " + _frozen.Count);
return;
}
for (int i = 0; i < _frozen.Count; i++)
{
Transform t = _frozen[i];
if (t != null)
{
t.localPosition = _frozenPos[i];
t.localRotation = _frozenRot[i];
}
}
}
public static void Apply()
{
if (!Enabled || Overrides.Count == 0 && CamOverrides.Count == 0)
{
if (_frozen.Count > 0)
{
Unfreeze();
}
return;
}
if (Freeze)
{
FreezeBones();
}
foreach (var kv in Overrides)
{
Transform b = Bone(kv.Key);
if (b != null)
{
b.localRotation = Quaternion.Euler(kv.Value);
}
}
Transform cam = CamOverrides.Count > 0 || _jabStart >= 0f ? Camera() : null;
ApplyJab(cam);
if (cam != null)
{
foreach (var kv in CamOverrides)
{
Transform b = Bone(kv.Key);
if (b != null)
{
b.rotation = cam.rotation * kv.Value;
}
}
}
}
/// <summary>Толчок руки к прицелу (указание 05.10, «пиф-паф» на ЛКМ: «чуть сдвигает руку в сторону
/// прицела и возвращает»). Плечо доворачивается так, чтобы кисть прошла JabAmount пути до линии
/// взгляда и вернулась - по синусу за JabTime. Ставится до CamOverrides: кисть сохраняет поворот
/// относительно камеры, сдвигается только её место.</summary>
public const float JabTime = 0.25f;
public const float JabAmount = 0.35f;
static float _jabStart = -1f;
public static void Jab()
{
_jabStart = Time.time;
}
static void ApplyJab(Transform _cam)
{
if (_jabStart < 0f)
{
return;
}
float t = (Time.time - _jabStart) / JabTime;
if (t >= 1f)
{
_jabStart = -1f;
return;
}
Transform arm = Bone("RightArm");
Transform hand = Bone("RightHand");
if (_cam == null || arm == null || hand == null)
{
return;
}
Vector3 toHand = hand.position - arm.position;
Vector3 aim = _cam.position + _cam.forward * Vector3.Dot(hand.position - _cam.position, _cam.forward);
Quaternion full = Quaternion.FromToRotation(toHand, aim - arm.position);
arm.rotation = Quaternion.Slerp(Quaternion.identity, full, Mathf.Sin(t * Mathf.PI) * JabAmount) * arm.rotation;
}
/// <summary>Кисть в осях камеры. Оси кисти правой руки (лог necrobones 03.10): пальцы - локальная +X
/// (суставы пальцев лежат по +X), ладонь - +Y (сгиб пальцев +Z ведёт +X к +Y), +Z - к мизинцу,
/// большой палец - к -Z. Нулевая поза: пальцы вверх, ладонь от игрока, тыльная сторона к камере,
/// большой палец влево. tilt - наклон пальцев влево (+) вокруг взгляда; back - кисть «от себя»
/// (+, верх пальцев уходит вперёд); turn - разворот кисти вокруг вертикали (+ ладонью вправо).</summary>
public static Quaternion PalmToCamera(float _tilt, float _back, float _turn)
{
Quaternion basis = Quaternion.LookRotation(Vector3.right, Vector3.forward);
return Quaternion.AngleAxis(_turn, Vector3.up) * Quaternion.AngleAxis(_back, Vector3.right) * Quaternion.AngleAxis(_tilt, Vector3.forward) * basis;
}
static readonly string[] Fingers = { "Index", "Middle", "Ring", "Pinky" };
/// <summary>Все суставы четырёх пальцев: сгиб по Z (curl), веер по Y у первого сустава
/// (указательный +spread, средний +spread/3, безымянный -spread/3, мизинец -spread).</summary>
public static void SetFingers(string _side, float _curl, float _spread)
{
float[] fan = { 1f, 1f / 3f, -1f / 3f, -1f };
for (int f = 0; f < Fingers.Length; f++)
{
for (int j = 1; j <= 3; j++)
{
Overrides[_side + "Hand" + Fingers[f] + j] = new Vector3(0f, j == 1 ? _spread * fan[f] : 0f, _curl);
}
}
}
/// <summary>Заготовка «раскрытая ладонь»: рука - как у браслета (снимок 03.10), пальцы прямые.</summary>
public static void PresetOpen()
{
Overrides.Clear();
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
Overrides["RightHand"] = new Vector3(13.3f, 325.8f, 7.7f);
SetFingers("Right", 3f, 0f);
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 40.6f);
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
Enabled = true;
}
/// <summary>Левая рука вдоль туловища, вне кадра (общая для хватов «отрицание» и «пиф-паф», 03.10).</summary>
static void LeftArmDown()
{
Overrides["LeftArm"] = new Vector3(30f, 13.6f, 57.3f);
}
/// <summary>Хват «коза» (до 05.10 - «отрицание»; утверждён пользователем 03.10): рука вперёд, как у браслета, ниже прицела,
/// кисть в линию с предплечьем, пальцы раскрыты. 05.10: средний и безымянный поджаты, как в кулаке
/// (сгиб - как у поджатых пальцев «пиф-пафа»), указательный и мизинец прямые.</summary>
public static void PresetDeny()
{
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(345.8f, 34.9f, 24.3f);
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
Overrides["RightHand"] = new Vector3(45f, 0f, 0f);
SetFingers("Right", 3f, 12f);
for (int j = 1; j <= 3; j++)
{
float curl = j == 3 ? 55f : 80f;
Overrides["RightHandMiddle" + j] = new Vector3(0f, 0f, curl);
Overrides["RightHandRing" + j] = new Vector3(0f, 0f, curl);
}
// Большой палец прижимает поджатые (05.10: «полноценная коза»). Подбор на глаз - уточнять necropose.
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 75f);
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 40f);
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 30f);
LeftArmDown();
Unfreeze();
Enabled = true;
}
/// <summary>Хват «пиф-паф» (утверждён 03.10, «Стоп. Замри»): пистолет из пальцев - указательный и средний
/// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.</summary>
public static void PresetPifPaf()
{
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
CamOverrides["RightHand"] = PalmToCamera(-90f, 0f, -119f);
for (int j = 1; j <= 3; j++)
{
Overrides["RightHandIndex" + j] = new Vector3(0f, j == 1 ? -3f : 0f, 3f);
Overrides["RightHandMiddle" + j] = new Vector3(0f, j == 1 ? 2f : 0f, 3f);
float curl = j == 3 ? 55f : 80f;
Overrides["RightHandRing" + j] = new Vector3(0f, 0f, curl);
Overrides["RightHandPinky" + j] = new Vector3(0f, 0f, curl);
}
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 20f);
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
LeftArmDown();
Unfreeze();
Enabled = true;
}
public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture);
public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
}
public class HandPoseDriver : MonoBehaviour
{
void LateUpdate()
{
HandHolds.Update();
HandPose.Apply();
}
}
/// <summary>
/// necropose - подбор позы рук от первого лица:
/// on | off | clear | open (заготовка: ладонь, рука как у браслета)
/// &lt;Кость&gt; x y z - локальный поворот кости (градусы)
/// add &lt;Кость&gt; dx dy dz - добавить к текущему (заданному или от аниматора)
/// fingers Right|Left сгиб [веер] - все суставы четырёх пальцев
/// show &lt;Кость&gt; - текущий поворот кости
/// save - вся поза в лог строками кода
/// </summary>
[Preserve]
public class ConsoleCmdNecroPose : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necropose" };
public override string getDescription() => "NecromancerTome: поза рук от первого лица - on|off|clear|deny|pifpaf|freeze [off]|open|<кость> x y z|aim <кость> наклон от_себя разворот|unaim <кость>|add <кость> dx dy dz|fingers Right|Left сгиб [веер]|show <кость>|save";
static bool Num(string _s, out float _v) => float.TryParse(_s.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necropose: " + _s);
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "";
switch (cmd)
{
case "on":
HandPose.Enabled = true;
Out("поза включена, костей " + HandPose.Overrides.Count);
return;
case "off":
HandPose.Enabled = false;
Out("поза выключена");
return;
case "clear":
HandPose.Overrides.Clear();
HandPose.CamOverrides.Clear();
Out("поза очищена");
return;
case "deny":
HandPose.PresetDeny();
Out("хват «отрицание»");
return;
case "pifpaf":
HandPose.PresetPifPaf();
Out("хват «пиф-паф»");
return;
case "freeze":
HandPose.Freeze = _params.Count < 2 || _params[1] != "off";
HandPose.Unfreeze();
Out("заморозка остальных костей " + (HandPose.Freeze ? "включена (снимок - в покое)" : "выключена"));
return;
case "open":
HandPose.PresetOpen();
Out("заготовка «ладонь» включена, костей " + HandPose.Overrides.Count);
return;
case "save":
{
var sb = new StringBuilder();
foreach (var kv in HandPose.Overrides)
{
Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(kv.Value.x) + "f, " + HandPose.F(kv.Value.y) + "f, " + HandPose.F(kv.Value.z) + "f);");
}
foreach (var kv in HandPose.CamOverrides)
{
Transform cb = HandPose.Bone(kv.Key);
if (cb != null)
{
Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(cb.localEulerAngles.x) + "f, " + HandPose.F(cb.localEulerAngles.y) + "f, " + HandPose.F(cb.localEulerAngles.z) + "f); // от камеры (aim)");
}
}
Out("всего костей " + HandPose.Overrides.Count + " + от камеры " + HandPose.CamOverrides.Count + ", включена " + HandPose.Enabled);
return;
}
case "show":
{
if (_params.Count < 2)
{
break;
}
Transform b = HandPose.Bone(_params[1]);
Out(b == null ? "нет кости " + _params[1] : _params[1] + " = " + HandPose.F(b.localEulerAngles) + (HandPose.Overrides.ContainsKey(_params[1]) ? " (задана)" : " (от аниматора)"));
return;
}
case "fingers":
{
if (_params.Count >= 3 && (_params[1] == "Right" || _params[1] == "Left") && Num(_params[2], out float curl))
{
float spread = 0f;
if (_params.Count >= 4)
{
Num(_params[3], out spread);
}
HandPose.SetFingers(_params[1], curl, spread);
HandPose.Enabled = true;
Out("пальцы " + _params[1] + ": сгиб " + HandPose.F(curl) + ", веер " + HandPose.F(spread));
return;
}
break;
}
case "aim":
{
// aim <Кость> [наклон] [от_себя] [разворот] - кисть в осях камеры (см. HandPose.PalmToCamera)
if (_params.Count >= 2)
{
float tilt = 0f, back = 0f, turn = 0f;
if (_params.Count >= 3) Num(_params[2], out tilt);
if (_params.Count >= 4) Num(_params[3], out back);
if (_params.Count >= 5) Num(_params[4], out turn);
if (HandPose.Bone(_params[1]) == null)
{
Out("нет кости " + _params[1]);
return;
}
HandPose.Overrides.Remove(_params[1]);
HandPose.CamOverrides[_params[1]] = HandPose.PalmToCamera(tilt, back, turn);
HandPose.Enabled = true;
Out(_params[1] + " от камеры: наклон " + HandPose.F(tilt) + ", от себя " + HandPose.F(back) + ", разворот " + HandPose.F(turn));
return;
}
break;
}
case "unaim":
{
if (_params.Count >= 2 && HandPose.CamOverrides.Remove(_params[1]))
{
Out(_params[1] + " - снова от родителя (от аниматора или заданная)");
return;
}
break;
}
case "add":
{
if (_params.Count >= 5 && Num(_params[2], out float dx) && Num(_params[3], out float dy) && Num(_params[4], out float dz))
{
string name = _params[1];
Transform b = HandPose.Bone(name);
if (b == null)
{
Out("нет кости " + name);
return;
}
Vector3 cur = HandPose.Overrides.TryGetValue(name, out Vector3 v) ? v : b.localEulerAngles;
HandPose.Overrides[name] = cur + new Vector3(dx, dy, dz);
HandPose.Enabled = true;
Out(name + " = " + HandPose.F(HandPose.Overrides[name]));
return;
}
break;
}
default:
{
if (_params.Count >= 4 && Num(_params[1], out float x) && Num(_params[2], out float y) && Num(_params[3], out float z))
{
if (HandPose.Bone(cmd) == null)
{
Out("нет кости " + cmd);
return;
}
HandPose.CamOverrides.Remove(cmd);
HandPose.Overrides[cmd] = new Vector3(x, y, z);
HandPose.Enabled = true;
Out(cmd + " = " + HandPose.F(HandPose.Overrides[cmd]));
return;
}
break;
}
}
Out(getDescription());
}
}
}
+304
View File
@@ -0,0 +1,304 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Дух в чертогах разума - камера без сущности (указание 03.10: «тело можно вообще не делать,
/// нужен только прицел»). Ходит по полу как человек: столкновения - по тем же слоям, что у змеи
/// (SnakeControl.WallMask / TerrainMask), гравитация и ступенька. Взаимодействие одно - книга
/// памяти по E; остальное в чертогах неприкосновенно. Камеру ставят патчи камеры режима змеи
/// (SnakeControl.cs), звук и мир вокруг считаются от духа через SnakeAudioSwap.
///
/// СОН (указание: «тёплый фильтр, это как бы сон», эффекты - на моё усмотрение): готовые эффекты
/// экрана игры - Hot (тёплый оттенок), Blur (мягкое размытие), Drunk (медленное покачивание).
/// Силы крутятся командой necrodream на ходу.
/// </summary>
public static class MindControl
{
public const float EyeHeight = 1.6f;
public const float Radius = 0.3f;
public const float Height = 1.75f;
public const float WalkSpeed = 2.2f;
public const float RunSpeed = 3.6f;
public const float Gravity = 20f;
public const float JumpSpeed = 6f;
public const float StepUp = 0.6f;
public const float ReachDistance = 3f;
public static float Hot = 0.45f;
public static float Blur = 0.25f;
public static float Drunk = 0.15f;
public static bool Controlling;
static Vector3 _feet;
static Vector3 _spawn;
static float _yaw, _pitch, _vy;
static bool _grounded;
public static bool HasFocus;
static EntityPlayerLocal _player;
public static Vector3 FeetPos => _feet;
public static void Begin(EntityPlayerLocal _p, Vector3 _at, float _atYaw)
{
_player = _p;
_feet = _at;
_spawn = _at;
_yaw = _atYaw;
_pitch = 0f;
_vy = 0f;
_grounded = false;
HasFocus = false;
Controlling = true;
_p.PlayerUI?.windowManager.Close(SnakeControl.ToolbeltWindow);
Dream(_p, true);
Debug.Log("[NecromancerTome] MindControl: дух в чертогах, " + _at);
}
public static void End(EntityPlayerLocal _p)
{
if (!Controlling)
{
return;
}
Controlling = false;
HasFocus = false;
Dream(_p ?? _player, false);
_player = null;
}
public static void Dream(EntityPlayerLocal _p, bool _on)
{
ScreenEffects fx = _p?.ScreenEffectManager;
if (fx == null)
{
return;
}
fx.SetScreenEffect("Hot", _on ? Hot : 0f, _on ? 2f : 0.5f);
fx.SetScreenEffect("Blur", _on ? Blur : 0f, _on ? 2f : 0.5f);
fx.SetScreenEffect("Drunk", _on ? Drunk : 0f, _on ? 2f : 0.5f);
}
static bool Blocked(Vector3 _p)
{
Vector3 a = _p + Vector3.up * (Radius + 0.05f) - Origin.position;
Vector3 b = _p + Vector3.up * (Height - Radius) - Origin.position;
return Physics.CheckCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
static bool TryMove(Vector3 _d)
{
Vector3 c = _feet + _d;
if (!Blocked(c))
{
_feet = c;
return true;
}
// Ступенька: на полблока выше свободно - шагаем вверх.
if (_grounded && !Blocked(c + Vector3.up * StepUp))
{
_feet = c + Vector3.up * StepUp;
return true;
}
return false;
}
public static void Update(EntityPlayerLocal _p)
{
if (!Controlling)
{
return;
}
float dt = Time.deltaTime;
bool gui = _p.PlayerUI != null && _p.PlayerUI.windowManager.IsModalWindowOpen();
PlayerActionsLocal input = _p.playerInput;
Vector2 move = Vector2.zero;
bool run = false, jump = false;
if (!gui && input != null)
{
PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
_yaw += Input.GetAxis("Mouse X") * sens.x;
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -85f, 85f);
move = new Vector2(input.Move.X, input.Move.Y);
run = input.Run.IsPressed;
jump = input.Jump.WasPressed;
if (HasFocus && (input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed))
{
BlockNecroMemoryBook.Open(_p);
}
}
Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
Vector3 dir = yawRot * new Vector3(move.x, 0f, move.y);
if (dir.sqrMagnitude > 1f)
{
dir.Normalize();
}
if (dir.sqrMagnitude > 0.0001f)
{
Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt;
int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / 0.1f));
Vector3 d = delta / steps;
for (int i = 0; i < steps; i++)
{
if (!TryMove(d) && !TryMove(new Vector3(d.x, 0f, 0f)) && !TryMove(new Vector3(0f, 0f, d.z)))
{
break;
}
}
}
// Падение и прыжок: земля - луч вниз по слою ландшафта и блоков.
if (_grounded && jump)
{
_vy = JumpSpeed;
_grounded = false;
}
_vy -= Gravity * dt;
float dy = _vy * dt;
if (dy > 0f && Blocked(_feet + Vector3.up * dy))
{
_vy = 0f;
dy = 0f;
}
if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
{
_feet.y = hit.point.y + Origin.position.y;
_vy = 0f;
_grounded = true;
}
else
{
_feet.y += dy;
_grounded = false;
}
// Провалился (коллизии свежепоставленных блоков ещё не построены) - назад на точку появления.
if (_feet.y < _spawn.y - 3f)
{
Debug.Log("[NecromancerTome] MindControl: дух провалился - назад на точку появления");
_feet = _spawn + Vector3.up * 0.3f;
_vy = 0f;
}
}
/// <summary>Из патчей камеры режима змеи: глаза духа и поиск книги под прицелом.</summary>
public static void PlaceCamera(Transform _camera)
{
Vector3 eye = _feet + Vector3.up * EyeHeight - Origin.position;
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
_camera.SetPositionAndRotation(eye, rot);
HasFocus = FindBook(_camera.position + Origin.position, _camera.forward);
}
/// <summary>Шагаем по лучу на ReachDistance: первая встреченная книга памяти - цель; твёрдый
/// блок раньше неё - нет цели (сквозь стены не читаем).</summary>
static bool FindBook(Vector3 _from, Vector3 _dir)
{
World world = GameManager.Instance?.World;
if (world == null)
{
return false;
}
Vector3i last = new Vector3i(int.MinValue, 0, 0);
for (float t = 0.1f; t <= ReachDistance; t += 0.05f)
{
Vector3i b = World.worldToBlockPos(_from + _dir * t);
if (b == last)
{
continue;
}
last = b;
Block block = world.GetBlock(b).Block;
if (block is BlockNecroMemoryBook)
{
return true;
}
if (block != null && block.IsCollideMovement && !block.shape.IsTerrain() && block.shape.IsSolidCube)
{
return false;
}
}
return false;
}
}
/// <summary>Прицел духа и подсказка у книги (OnGUI, как прицел змеи).</summary>
public class MindHud : MonoBehaviour
{
static GUIStyle _style;
public static void Create()
{
var go = new GameObject("NecroMindHud");
DontDestroyOnLoad(go);
go.AddComponent<MindHud>();
}
void OnGUI()
{
if (!MindControl.Controlling || Event.current.type != EventType.Repaint)
{
return;
}
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player == null || (player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen()))
{
return;
}
float s = 4f * Screen.height / 1080f;
Color old = GUI.color;
GUI.color = MindControl.HasFocus ? new Color(1f, 0.85f, 0.5f, 1f) : new Color(1f, 0.95f, 0.85f, 0.7f);
GUI.DrawTexture(new Rect(Screen.width * 0.5f - s * 0.5f, Screen.height * 0.5f - s * 0.5f, s, s), Texture2D.whiteTexture);
if (MindControl.HasFocus)
{
if (_style == null)
{
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
}
_style.fontSize = Mathf.RoundToInt(26f * Screen.height / 1080f);
// E в игре привязана к PermanentActions.Activate; у обычного Activate привязок нет - он даёт «Нет».
string key = KeyName(player.playerInput?.PermanentActions?.Activate) ?? KeyName(player.playerInput?.Activate) ?? "E";
string text = string.Format(Localization.Get("necroMemoryBookPrompt"), key);
Rect r = new Rect(0f, Screen.height * 0.5f + 30f * Screen.height / 1080f, Screen.width, 40f * Screen.height / 1080f);
GUI.color = new Color(0f, 0f, 0f, 0.8f);
GUI.Label(new Rect(r.x + 2f, r.y + 2f, r.width, r.height), text, _style);
GUI.color = new Color(1f, 0.9f, 0.7f, 1f);
GUI.Label(r, text, _style);
}
GUI.color = old;
}
private static string KeyName(InControl.PlayerAction action)
{
if (action == null || action.Bindings.Count == 0) return null;
string name = action.GetBindingString(false);
return string.IsNullOrEmpty(name) ? null : name;
}
}
/// <summary>necrodream hot|blur|drunk N - подобрать силу сонных эффектов на ходу.</summary>
[UnityEngine.Scripting.Preserve]
public class ConsoleCmdNecroDream : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrodream" };
public override string getDescription() => "NecromancerTome: сила эффектов сна - hot|blur|drunk N (0-1)";
public override void Execute(System.Collections.Generic.List<string> _params, CommandSenderInfo _senderInfo)
{
if (_params.Count == 2 && float.TryParse(_params[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v))
{
switch (_params[0])
{
case "hot": MindControl.Hot = v; break;
case "blur": MindControl.Blur = v; break;
case "drunk": MindControl.Drunk = v; break;
}
if (MindControl.Controlling)
{
MindControl.Dream(GameManager.Instance.World.GetPrimaryPlayer(), true);
}
}
SdtdConsole.Instance.Output("сон: hot " + MindControl.Hot + ", blur " + MindControl.Blur + ", drunk " + MindControl.Drunk);
}
}
}
+892
View File
@@ -0,0 +1,892 @@
using System.Collections.Generic;
using System.IO;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Ментальная форма - «Чертоги разума» (решения пользователя 02-03.10, BACKLOG «Чертоги разума»).
/// Вход - третья строка меню ножа некроманта на R, только ночью, здоровье не тратит. Тело
/// падает рэгдоллом и лежит беззащитным, как в режиме змеи; дух - камера без сущности
/// (MindControl.cs): ходит по полу, видит только своё, с другими игроками не встречается.
/// Выход - R, утро или смерть тела.
///
/// ГДЕ ЧЕРТОГИ. Под землёй, в радиационном поясе у северо-восточного угла карты: сначала от
/// угла по диагонали ищется пояс (biomeProvider.GetRadiationAt &gt; 2.5); пояс шире префаба -
/// чертоги в его середине, уже (Navezgane: 16 блоков) - в 45 блоках от края. Своё место у каждого
/// игрока - вдоль северного края с шагом SlotStep (дальше дальности показа сущностей). Места
/// пишутся в файл в папке сохранения мира, чтобы смена ширины пояса в новой версии игры их не
/// сдвинула.
///
/// КАК СТАВЯТСЯ. Префаб chertogi (PalacePrefab/, нарисован пользователем в редакторе игры)
/// грузится Prefab.Load и ставится поблочно через World.SetBlocksRPC - НЕ Prefab.CopyIntoLocal:
/// тот перед постановкой вычищает весь столб над префабом до неба от не-земляных блоков,
/// и над чертогами вырубило бы деревья (видно сверху). Заодно: внешний слой оболочки
/// (woodShapes:cube) -&gt; terrBedrock (в меню творчества его нет, рисовали деревом),
/// одиночная книга на столе -&gt; necroMemoryBook, на стол с реактивами - нож-декорация,
/// кровать (SleepingBag) -&gt; necroPalaceBed: иначе игра делает её точкой возрождения игрока.
/// Построены ли - по книге: нет её - ставим заново (самопочинка).
///
/// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
/// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
/// раз в секунду выбрасываются: змея развеивается, игрок - на поверхность, прочие - удаляются.
///
/// ОГРАНИЧЕНИЕ: одиночная игра и хост. Клиенту в мультиплеере вход пока не даётся - чертоги ставит
/// сервер, а камера живёт у клиента (решать отдельно).
/// </summary>
public static class MindPalace
{
public const string PrefabName = "chertogi";
public const string BookBlock = "necroMemoryBook";
public const string KnifeBlock = "necroKnifeDecor";
/// <summary>Кровать без класса SleepingBag - ею заменяется кровать префаба (bunkBedMadeWhite).</summary>
public const string BedBlock = "necroPalaceBed";
public const string ShellFrom = "woodShapes:cube";
public const string ShellTo = "terrBedrock";
/// <summary>Одиночная книга на обеденном столе (cntBookPile09 в префабе).</summary>
public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
/// <summary>Место ножа на столе с реактивами (указание пользователя).</summary>
public static readonly Vector3i KnifeAt = new Vector3i(4, 2, 11);
/// <summary>Входная дверь чертогов - во внутреннюю оболочку, никуда не ведёт: остаётся закрытой
/// (указание 03.10). Остальные четыре - между комнатами - открываются.</summary>
public static readonly Vector3i EntranceDoorAt = new Vector3i(8, 1, 3);
/// <summary>Где появляется дух: комната с книгой, свободная клетка у стола.</summary>
public static readonly Vector3 SpawnAt = new Vector3(11.5f, 1.05f, 11.5f);
public const float SpawnYaw = 180f;
public const int SlotStep = 300;
public const int FallbackInset = 45;
public const int FloorY = 3;
/// <summary>Рамка вокруг чертогов, которая заливается камнем перед постановкой.</summary>
public const int LooseMargin = 3;
public const float RadiationThreshold = 2.5f;
/// <summary>Поворот ножа-декорации (0-23): 9 - плашмя на столешнице (подобрано пользователем
/// 03.10 командой necropalace knife N).</summary>
public static int KnifeRotation = 0;
public static bool Active;
static Vector3i _origin;
static Prefab _prefab;
static ChunkManager.ChunkObserver _observer;
enum Stage { None, Loading, Settling, Ready }
static Stage _stage;
static float _deadline;
static float _settleUntil;
static float _ragdollAt;
static bool _waitStandUp;
static float _standUpDeadline;
static float _guardCheck;
static readonly Dictionary<string, Vector3i> _slots = new Dictionary<string, Vector3i>();
static string _slotsFor;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
MindHud.Create();
}
// ---------------------------------------------------------------- места
static string SlotsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaces.txt");
static void LoadSlots()
{
string dir = GameIO.GetSaveGameDir();
if (_slotsFor == dir)
{
return;
}
_slotsFor = dir;
_slots.Clear();
try
{
if (File.Exists(SlotsFile))
{
foreach (string line in File.ReadAllLines(SlotsFile))
{
string[] p = line.Split(' ');
if (p.Length == 4 && int.TryParse(p[1], out int x) && int.TryParse(p[2], out int y) && int.TryParse(p[3], out int z))
{
_slots[p[0]] = new Vector3i(x, y, z);
}
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать места - " + ex.Message);
}
}
static void SaveSlots()
{
try
{
var lines = new List<string>();
foreach (var kv in _slots)
{
lines.Add(kv.Key + " " + kv.Value.x + " " + kv.Value.y + " " + kv.Value.z);
}
File.WriteAllLines(SlotsFile, lines);
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не записать места - " + ex.Message);
}
}
static string PlayerKey(EntityPlayer _player)
{
string id = GameManager.Instance.persistentLocalPlayer?.PrimaryId?.CombinedString;
return string.IsNullOrEmpty(id) ? "local" : id.Replace(' ', '_');
}
static Vector3i GetSlot(World _world, EntityPlayer _player)
{
LoadSlots();
string key = PlayerKey(_player);
if (_slots.TryGetValue(key, out Vector3i o))
{
return o;
}
o = ComputeSlot(_world, _slots.Count);
_slots[key] = o;
SaveSlots();
Debug.Log("[NecromancerTome] MindPalace: место " + _slots.Count + " для " + key + " - " + o);
return o;
}
/// <summary>Отступ от края: середина радиационного пояса, если он шире префаба, иначе 45.</summary>
static int Inset(World _world, Vector3i _min, Vector3i _max, int _size)
{
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
int start = -1, end = -1;
if (biomes != null)
{
for (int t = 0; t < 600; t += 4)
{
bool rad = biomes.GetRadiationAt(_max.x - t, _max.z - t) > RadiationThreshold;
if (rad && start < 0) start = t;
if (!rad && start >= 0) { end = t; break; }
}
}
if (start >= 0 && end > start && end - start >= _size + 20)
{
return Mathf.Max(FallbackInset, (start + end) / 2 - _size / 2);
}
return FallbackInset;
}
static Vector3i ComputeSlot(World _world, int _index)
{
_world.GetWorldExtent(out Vector3i min, out Vector3i max);
Prefab p = LoadPrefab();
int size = p != null ? Mathf.Max(p.size.x, p.size.z) : 17;
int inset = Inset(_world, min, max, size);
int perEdge = Mathf.Max(1, (max.x - min.x - 2 * inset - size) / SlotStep);
int x, z;
if (_index < perEdge)
{
x = max.x - inset - size - _index * SlotStep;
z = max.z - inset - size;
}
else
{
x = max.x - inset - size;
z = max.z - inset - size - (_index - perEdge + 1) * SlotStep;
}
x = Mathf.FloorToInt(x / 16f) * 16;
z = Mathf.FloorToInt(z / 16f) * 16;
return new Vector3i(x, FloorY, z);
}
// ---------------------------------------------------------------- префаб и постройка
static string ModDir => Path.GetDirectoryName(typeof(MindPalace).Assembly.Location);
static Prefab LoadPrefab()
{
if (_prefab != null)
{
return _prefab;
}
string path = Path.Combine(ModDir, "PalacePrefab", PrefabName + ".tts");
var p = new Prefab();
if (!p.Load(new PathAbstractions.AbstractedLocation(PathAbstractions.EAbstractedLocationType.Mods, PrefabName, path, null, false)))
{
Debug.LogError("[NecromancerTome] MindPalace: префаб не загрузился - " + path);
return null;
}
_prefab = p;
Debug.Log("[NecromancerTome] MindPalace: префаб " + PrefabName + " " + p.size);
return _prefab;
}
static bool ChunksLoaded(World _world, Vector3i _o, Vector3i _size)
{
for (int cx = World.toChunkXZ(_o.x); cx <= World.toChunkXZ(_o.x + _size.x - 1); cx++)
{
for (int cz = World.toChunkXZ(_o.z); cz <= World.toChunkXZ(_o.z + _size.z - 1); cz++)
{
// Не просто «есть в памяти», а сгенерирован и украшен: иначе генерация досыпала
// землю поверх поставленного (тест 03.10: «внутри блоки камня»).
Chunk c = _world.GetChunkSync(cx, cz) as Chunk;
if (c == null || !c.IsInitialized || c.NeedsDecoration)
{
return false;
}
}
}
return true;
}
/// <summary>Стоит ли блок уже в самом префабе (03.10: пользователь расставляет книгу и нож в редакторе
/// сам) - тогда старые принудительные замены (книга в BookAt, нож в KnifeAt) не делаем.</summary>
static bool PrefabHas(Prefab _p, string _block)
{
if (_hasCache == null || _hasCacheFor != _p)
{
_hasCache = new Dictionary<string, bool>();
_hasCacheFor = _p;
}
if (_hasCache.TryGetValue(_block, out bool has))
{
return has;
}
int type = Block.GetBlockValue(_block).type;
for (int y = 0; y < _p.size.y && !has; y++)
for (int z = 0; z < _p.size.z && !has; z++)
for (int x = 0; x < _p.size.x && !has; x++)
{
has = _p.GetBlock(x, y, z).type == type;
}
_hasCache[_block] = has;
return has;
}
static Dictionary<string, bool> _hasCache;
static Prefab _hasCacheFor;
/// <summary>Что должно стоять в клетке префаба после постановки (с заменами). False - клетку
/// не трогаем (дочерняя часть многоблочного предмета).</summary>
static bool Expected(Prefab _p, int _x, int _y, int _z, out BlockValue _bv, out sbyte _d)
{
_bv = _p.GetBlock(_x, _y, _z);
_d = _p.GetDensity(_x, _y, _z);
if (_bv.ischild)
{
return false;
}
Vector3i lp = new Vector3i(_x, _y, _z);
// Пол (y 0) - НЕ bedrock: в префабе он же пол комнат, а bedrock - блок-земля, её сглаженная
// поверхность выпирала из пола в комнаты каменными наплывами (тест 03.10: «в помещении
// блоки камня»). Защита снизу - отдельный слой bedrock под префабом (FloorShield).
bool outer = _x == 0 || _z == 0 || _x == _p.size.x - 1 || _z == _p.size.z - 1 || _y == _p.size.y - 1;
if (outer && !_bv.isair && _bv.Block.GetBlockName() == ShellFrom)
{
_bv = Block.GetBlockValue(ShellTo);
_d = MarchingCubes.DensityTerrain;
}
else if (lp == BookAt && !PrefabHas(_p, BookBlock))
{
BlockValue b = Block.GetBlockValue(BookBlock);
b.rotation = _bv.rotation;
_bv = b;
_d = MarchingCubes.DensityAir;
}
else if (lp == KnifeAt && !PrefabHas(_p, KnifeBlock))
{
BlockValue k = Block.GetBlockValue(KnifeBlock);
k.rotation = (byte)KnifeRotation;
_bv = k;
_d = MarchingCubes.DensityAir;
}
else if (!_bv.isair && _bv.Block is BlockSleepingBag)
{
// Кровать префаба - просто мебель (тест 03.10: синий камень портала отправил в чертоги).
// Чертоги ставятся SetBlocksRPC без игрока, а GameManager.ChangeBlocks тогда считает
// ставившим локального игрока и любую SleepingBag-кровать делает его точкой возрождения.
BlockValue bed = Block.GetBlockValue(BedBlock);
bed.rotation = _bv.rotation;
_bv = bed;
_d = MarchingCubes.DensityAir;
}
else if (_bv.isair || !_bv.Block.shape.IsTerrain())
{
_d = MarchingCubes.DensityAir;
}
return true;
}
/// <summary>Диагностика (03.10, «нож пропал со стола»): что стоит в клетке и есть ли у блока модель.</summary>
static void LogCell(World _world, Vector3i _wp, string _what)
{
BlockValue bv = _world.GetBlock(_wp);
string s = "[NecromancerTome] MindPalace: " + _what + " " + _wp + " - " + bv.Block.GetBlockName() + " пов. " + bv.rotation;
Chunk c = _world.GetChunkFromWorldPos(_wp) as Chunk;
BlockEntityData be = c?.GetBlockEntity(_wp);
if (be == null)
{
s += ", модели нет (нет BlockEntity)";
}
else if (!be.bHasTransform || be.transform == null)
{
s += ", модель ещё не создана";
}
else
{
Renderer[] rs = be.transform.GetComponentsInChildren<Renderer>(true);
int on = 0;
foreach (Renderer r in rs) if (r.enabled && r.gameObject.activeInHierarchy) on++;
s += ", модель " + be.transform.name + " в " + (be.transform.position + Origin.position) + ", активна " + be.transform.gameObject.activeInHierarchy + ", рендеров " + on + "/" + rs.Length;
}
Debug.Log(s);
}
/// <summary>Сколько клеток в мире не совпадает с префабом по типу блока (0 - чертоги целы).</summary>
public static int Mismatches(World _world, Vector3i _o, List<string> _report = null)
{
Prefab p = LoadPrefab();
if (p == null)
{
return -1;
}
int n = 0;
int shieldType = Block.GetBlockValue(ShellTo).type;
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
if (_world.GetBlock(_o + new Vector3i(x, -1, z)).type != shieldType)
{
n++;
}
}
for (int y = 0; y < p.size.y; y++)
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
Vector3i wp = _o + new Vector3i(x, y, z);
if (!Expected(p, x, y, z, out BlockValue want, out sbyte wantD))
{
// Дочерняя клетка: там не должно быть земли/камня.
BlockValue c = _world.GetBlock(wp);
if (c.Block.shape.IsTerrain() || _world.GetDensity(wp.x, wp.y, wp.z) < 0)
{
n++;
if (_report != null && _report.Count < 40)
{
_report.Add("(" + x + "," + y + "," + z + ") дочерняя клетка, стоит " + c.Block.GetBlockName() + " плотн. " + _world.GetDensity(wp.x, wp.y, wp.z));
}
}
continue;
}
BlockValue have = _world.GetBlock(wp);
// Плотность тоже: тип блока - воздух, а плотность земли - и клетка рисуется камнем
// (тест 03.10: «камень под столом, в дверном проёме, под потолком»).
sbyte haveD = _world.GetDensity(wp.x, wp.y, wp.z);
bool densityBad = (wantD < 0) != (haveD < 0);
// И покраска (указание 03.10: «покраску обязательно перенеси из префаба»).
bool paintBad = !want.isair && want.type == p.GetBlock(x, y, z).type &&
_world.GetTextureFullArray(wp.x, wp.y, wp.z) != p.GetTexture(x, y, z);
if (have.type != want.type || densityBad || paintBad)
{
n++;
if (_report != null && _report.Count < 40)
{
_report.Add("(" + x + "," + y + "," + z + ") ждали " + want.Block.GetBlockName() + " плотн. " + wantD +
", стоит " + have.Block.GetBlockName() + " плотн. " + haveD + (paintBad ? ", не та покраска" : ""));
}
}
}
return n;
}
public static bool IsBuilt(World _world, Vector3i _o)
{
return Mismatches(_world, _o) == 0;
}
/// <summary>Все двери чертогов - открыты (в редакторе сохранились закрытыми, а дух дверей не
/// открывает). Состояние двери - в её TEFeatureDoor, не в блоке.</summary>
public static void OpenDoors(World _world, Vector3i _o)
{
Vector3i s = Size;
int opened = 0;
for (int y = 0; y < s.y; y++)
for (int z = 0; z < s.z; z++)
for (int x = 0; x < s.x; x++)
{
if (_world.GetTileEntity(_o + new Vector3i(x, y, z)) is TileEntityComposite tec)
{
TEFeatureDoor door = tec.GetFeature<TEFeatureDoor>();
// Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
bool wantOpen = new Vector3i(x, y, z) != EntranceDoorAt;
if (door != null && door.IsOpen() != wantOpen)
{
door.SetOpen(wantOpen);
opened++;
}
}
}
if (opened > 0)
{
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + opened);
}
}
public static void Stamp(World _world, Vector3i _o)
{
Prefab p = LoadPrefab();
if (p == null)
{
return;
}
// ПОРЯДОК ВАЖЕН (тест 03.10: при очистке всей коробки разом на миг открывалась пустота
// 17x8x17, земля над ней и края теряли опору - обвал, падающие блоки ушли под мир):
// 1) слой bedrock под чертогами;
// 2) оболочка - пол (y 0), стены (края по x/z) и крыша - сразу на место земли, без очистки;
// 3) очистка внутренности (её уже держит оболочка) - в том числе дочерних клеток стола,
// дверей, шкафов, где иначе оставался камень;
// 4) всё внутреннее по префабу.
var list = new List<BlockChangeInfo>();
// 0) Рамка LooseMargin блоков вокруг чертогов, со всех сторон и сверху, - сплошь камнем
// (указание 03.10: «заполни сначала камнем, на 3 блока во все стороны»): земля, песок,
// гравий (осыпаются без опоры) и пустоты пещер -> terrStone. Не земля и не воздух
// (чужие постройки) - не трогаем.
BlockValue stone = Block.GetBlockValue("terrStone");
int loose = 0;
for (int y = -1 - LooseMargin; y < p.size.y + LooseMargin; y++)
for (int z = -LooseMargin; z < p.size.z + LooseMargin; z++)
for (int x = -LooseMargin; x < p.size.x + LooseMargin; x++)
{
bool inside = y >= -1 && y < p.size.y && x >= 0 && x < p.size.x && z >= 0 && z < p.size.z;
if (inside)
{
continue;
}
Vector3i wp = _o + new Vector3i(x, y, z);
if (wp.y < 1)
{
continue; // ниже 1 игра блоки не ставит (дно мира)
}
BlockValue have = _world.GetBlock(wp);
Block b = have.Block;
if (have.type != stone.type && (have.isair || b != null && b.shape.IsTerrain()))
{
list.Add(new BlockChangeInfo(wp, stone, MarchingCubes.DensityTerrain));
loose++;
}
}
if (loose > 0)
{
Debug.Log("[NecromancerTome] MindPalace: рамка вокруг залита камнем - " + loose + " клеток");
}
BlockValue shield = Block.GetBlockValue(ShellTo);
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
list.Add(new BlockChangeInfo(_o + new Vector3i(x, -1, z), shield, MarchingCubes.DensityTerrain));
}
for (int pass = 0; pass < 3; pass++)
for (int yi = 0; yi < p.size.y; yi++)
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
// Очистка (проход 1) - СВЕРХУ ВНИЗ: снизу вверх убранный слой оставлял над собой
// песок/гравий без опоры, тот падал и рушил уже поставленные стены (тест 03.10,
// necropalace test: 30 расхождений сразу, 309 - при следующем входе). Постановка -
// снизу вверх, на опору.
int y = pass == 1 ? p.size.y - 1 - yi : yi;
bool shellCell = y == 0 || y == p.size.y - 1 || x == 0 || z == 0 || x == p.size.x - 1 || z == p.size.z - 1;
Vector3i wp = _o + new Vector3i(x, y, z);
if (pass == 0 && shellCell || pass == 2 && !shellCell)
{
if (Expected(p, x, y, z, out BlockValue bv, out sbyte d))
{
// С покраской из префаба (указание 03.10).
list.Add(new BlockChangeInfo(wp, bv, d, p.GetTexture(x, y, z)));
}
}
else if (pass == 1 && !shellCell)
{
list.Add(new BlockChangeInfo(wp, BlockValue.Air, MarchingCubes.DensityAir));
}
}
_world.SetBlocksRPC(list);
Debug.Log("[NecromancerTome] MindPalace: чертоги поставлены в " + _o + ", блоков " + list.Count);
}
static Vector3i Size => _prefab != null ? _prefab.size : new Vector3i(17, 8, 17);
/// <summary>Проверка постановки в нетронутую землю (указание 03.10): залить камнем коробку на
/// 2 блока шире чертогов во все стороны (включая слой bedrock снизу), подождать, поставить
/// чертоги заново, выставить двери. Сразу заливкой, без предварительной очистки - очистка и
/// есть та пустота без опоры, из-за которой был обвал.</summary>
public static System.Collections.IEnumerator StressTest(World _world, Vector3i _o)
{
Vector3i s = Size;
// Гравием - худший случай: сыпучее, падает без опоры (указание 03.10).
BlockValue stone = Block.GetBlockValue("terrGravel");
var list = new List<BlockChangeInfo>();
for (int y = -3; y < s.y + 2; y++)
for (int z = -2; z < s.z + 2; z++)
for (int x = -2; x < s.x + 2; x++)
{
list.Add(new BlockChangeInfo(_o + new Vector3i(x, y, z), stone, MarchingCubes.DensityTerrain));
}
_world.SetBlocksRPC(list);
Debug.Log("[NecromancerTome] necropalace test: залито гравием " + list.Count + " клеток, ждём 2 с");
yield return new WaitForSeconds(2f);
Stamp(_world, _o);
yield return new WaitForSeconds(1f);
OpenDoors(_world, _o);
Debug.Log("[NecromancerTome] necropalace test: готово, расхождений с префабом " + Mismatches(_world, _o));
}
public static bool InAnyPalace(Vector3i _pos, int _margin = 0)
{
if (_slots.Count == 0)
{
return false;
}
Vector3i s = Size;
foreach (Vector3i o in _slots.Values)
{
if (_pos.x >= o.x - _margin && _pos.x < o.x + s.x + _margin &&
_pos.y >= o.y - 1 - _margin && _pos.y < o.y + s.y + _margin &&
_pos.z >= o.z - _margin && _pos.z < o.z + s.z + _margin)
{
return true;
}
}
return false;
}
// ---------------------------------------------------------------- вход и выход
public static void Enter(EntityPlayerLocal _player)
{
if (Active || SnakeMode.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
{
return;
}
if (!SnakeMode.IsNight())
{
Debug.Log("[NecromancerTome] MindPalace: днём чертоги недоступны");
return;
}
World world = GameManager.Instance.World;
if (world.IsRemote())
{
Debug.Log("[NecromancerTome] MindPalace: в мультиплеере у клиента пока недоступно");
return;
}
if (LoadPrefab() == null)
{
return;
}
_origin = GetSlot(world, _player);
Vector3 center = _origin.ToVector3() + new Vector3(Size.x * 0.5f, 0f, Size.z * 0.5f);
// Дальность как у змеи: при радиусе 2 игра не строила коллизии участка (тест 03.10:
// «чертоги не построились вовремя» - ждали коллизий 30 с).
_observer = GameManager.Instance.AddChunkObserver(center, true, GameUtils.GetViewDistance(), -1);
_stage = Stage.Loading;
_deadline = Time.time + 30f;
Active = true;
_player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 1f, 0.6f);
_player.SetFirstPersonView(false, true);
_ragdollAt = Time.time + SnakeMode.RagdollDelay;
_waitStandUp = false;
Debug.Log("[NecromancerTome] MindPalace: вход, чертоги в " + _origin);
}
public static void Exit(EntityPlayerLocal _player)
{
if (!Active)
{
return;
}
Active = false;
_stage = Stage.None;
MindControl.End(_player);
RemoveObserver();
_player?.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1f);
if (_player == null || _player.IsDead())
{
return;
}
_ragdollAt = 0f;
_player.emodel.ragdollTime = _player.emodel.ragdollDuration;
_waitStandUp = true;
_standUpDeadline = Time.time + 5f;
Debug.Log("[NecromancerTome] MindPalace: выход");
}
static void RemoveObserver()
{
if (_observer != null && GameManager.Instance?.World != null)
{
GameManager.Instance.RemoveChunkObserver(_observer);
}
_observer = null;
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
if (world == null)
{
Active = false;
_stage = Stage.None;
_observer = null;
MindControl.End(null);
_slotsFor = null;
return;
}
EntityPlayerLocal player = world.GetPrimaryPlayer();
Guard(world);
if (!Active)
{
if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline))
{
_waitStandUp = false;
player.SetFirstPersonView(true, true);
}
return;
}
if (player == null)
{
return;
}
if (player.IsDead())
{
Debug.Log("[NecromancerTome] MindPalace: тело убито");
Exit(player);
return;
}
if (!SnakeMode.IsNight())
{
Debug.Log("[NecromancerTome] MindPalace: наступило утро - обратно в тело");
Exit(player);
return;
}
Body(player);
switch (_stage)
{
case Stage.Loading:
if (ChunksLoaded(world, _origin, Size))
{
var report = new List<string>();
int bad = Mismatches(world, _origin, report);
if (bad != 0)
{
Debug.Log("[NecromancerTome] MindPalace: расхождений с префабом " + bad + " - ставим заново");
for (int i = 0; i < report.Count && i < 10; i++)
{
Debug.Log("[NecromancerTome] MindPalace: " + report[i]);
}
Stamp(world, _origin);
}
_stage = Stage.Settling;
_settleUntil = Time.time + 1.5f;
}
else if (Time.time > _deadline)
{
Debug.LogWarning("[NecromancerTome] MindPalace: участки чертогов не загрузились");
Exit(player);
}
break;
case Stage.Settling:
Vector3 spawn = _origin.ToVector3() + SpawnAt;
// Коллизии готовы - или прошло 5 с: тогда всё равно пускаем, от провала страхует
// MindControl (возврат на точку появления).
if (Time.time > _settleUntil && (world.IsChunkAreaCollidersLoaded(spawn) || Time.time > _settleUntil + 5f))
{
Debug.Log("[NecromancerTome] MindPalace: коллизии " + (world.IsChunkAreaCollidersLoaded(spawn) ? "готовы" : "НЕ готовы, пускаем по таймеру"));
_stage = Stage.Ready;
OpenDoors(world, _origin);
LogCell(world, _origin + KnifeAt, "нож");
LogCell(world, _origin + BookAt, "книга");
MindControl.Begin(player, spawn, SpawnYaw);
player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1.5f);
}
else if (Time.time > _deadline)
{
Debug.LogWarning("[NecromancerTome] MindPalace: чертоги не построились вовремя");
Exit(player);
}
break;
case Stage.Ready:
MindControl.Update(player);
break;
}
// Выход по R - когда тело уже лежит и не открыто окно (как у змеи).
PlayerActionsLocal input = player.playerInput;
bool gui = player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen();
if (_ragdollAt <= 0f && player.emodel.IsRagdollActive && input != null && !gui &&
(input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
{
Exit(player);
}
}
/// <summary>Тело падает и лежит - как в режиме змеи (SnakeMode.OnUnityUpdate).</summary>
static void Body(EntityPlayerLocal _player)
{
if (_ragdollAt > 0f)
{
if (Time.time >= _ragdollAt)
{
_ragdollAt = 0f;
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
}
}
else if (!_player.emodel.IsRagdollActive)
{
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
}
}
/// <summary>Раз в секунду: всё живое в коробке чертогов - вон. Дух сущности не имеет.</summary>
static void Guard(World _world)
{
_guardCheck -= Time.deltaTime;
if (_guardCheck > 0f || _world.IsRemote())
{
return;
}
_guardCheck = 1f;
LoadSlots();
if (_slots.Count == 0)
{
return;
}
// Игроков в EntityAlives нет (World.SpawnEntityInWorld кладёт туда только не-игроков) - их
// отдельно, по Players.list (тест 03.10: камень портала привёл в чертоги, охрана молчала).
for (int i = _world.Players.list.Count - 1; i >= 0; i--)
{
EntityPlayer pl = _world.Players.list[i];
if (pl == null || pl.IsDead() || !InAnyPalace(World.worldToBlockPos(pl.position)))
{
continue;
}
Vector3i b = World.worldToBlockPos(pl.position);
Vector3 up = new Vector3(pl.position.x, _world.GetTerrainHeight(b.x, b.z) + 2f, pl.position.z);
Debug.Log("[NecromancerTome] MindPalace: игрок " + pl.entityId + " в чертогах - выброшен на " + up);
if (pl is EntityPlayerLocal)
{
// Как камень портала (PortalStonePatch.TeleportToBedroll).
NetPackageManager.GetPackage<NetPackageTeleportPlayer>().Setup(up, null).ProcessPackage(_world, GameManager.Instance);
}
else
{
pl.Teleport(up);
}
}
for (int i = _world.EntityAlives.Count - 1; i >= 0; i--)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e.IsDead() || !InAnyPalace(World.worldToBlockPos(e.position)))
{
continue;
}
EntityAlive snake = SnakeMode.Snake(_world);
if (snake != null && e == snake)
{
Debug.Log("[NecromancerTome] MindPalace: змея в чертогах - развеяна");
SnakeMode.Exit(_world.GetPrimaryPlayer());
}
else
{
Debug.Log("[NecromancerTome] MindPalace: " + e.EntityName + " в чертогах - удалён");
_world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
}
}
}
}
/// <summary>В чертогах и в рамке камня вокруг ничего не падает (указание 03.10: «почему не
/// отключить просчёт физики в той области»): World.AddFallingBlock - единственная очередь
/// падающих блоков (туда же игра сама не пускает блоки со StabilityIgnore). Тест гравием
/// 03.10: после постановки нижний/верхний ряд внутренней стены всё равно обрушивался.</summary>
[HarmonyPatch(typeof(World), nameof(World.AddFallingBlock))]
public static class Patch_World_AddFallingBlock_MindPalace
{
public static bool Prefix(Vector3i _blockPos)
{
return !MindPalace.InAnyPalace(_blockPos, MindPalace.LooseMargin);
}
}
/// <summary>Чертоги - «зона торговца»: блоки не ломаются ударом и взрывом, строить нельзя
/// (ItemActionAttack, Explosion, Block.DamageBlock проверяют IsWithinTraderArea).</summary>
[HarmonyPatch(typeof(World), nameof(World.IsWithinTraderArea), new[] { typeof(Vector3i) })]
public static class Patch_World_IsWithinTraderArea_MindPalace
{
public static void Postfix(Vector3i _worldBlockPos, ref bool __result)
{
if (!__result && MindPalace.InAnyPalace(_worldBlockPos))
{
__result = true;
}
}
}
/// <summary>
/// necropalace - отладка чертогов: where (где мои), rebuild (поставить заново, если участки
/// загружены), knife N (поворот ножа 0-23 и переставить его).
/// </summary>
[Preserve]
public class ConsoleCmdNecroPalace : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necropalace" };
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
World world = GameManager.Instance.World;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
if (player == null)
{
return;
}
Vector3i o = Traverse.Create(typeof(MindPalace)).Method("GetSlot", world, (EntityPlayer)player).GetValue<Vector3i>();
string cmd = _params.Count > 0 ? _params[0] : "where";
if (cmd == "knife" && _params.Count > 1 && int.TryParse(_params[1], out int r))
{
MindPalace.KnifeRotation = Mathf.Clamp(r, 0, 3);
BlockValue k = Block.GetBlockValue(MindPalace.KnifeBlock);
k.rotation = (byte)MindPalace.KnifeRotation;
world.SetBlockRPC(o + MindPalace.KnifeAt, k, MarchingCubes.DensityAir);
SdtdConsole.Instance.Output("нож: поворот " + MindPalace.KnifeRotation);
}
else if (cmd == "scan")
{
var report = new List<string>();
int n = MindPalace.Mismatches(world, o, report);
SdtdConsole.Instance.Output("расхождений с префабом: " + n);
foreach (string line in report)
{
SdtdConsole.Instance.Output(line);
Debug.Log("[NecromancerTome] necropalace scan: " + line);
}
}
else if (cmd == "test")
{
GameManager.Instance.StartCoroutine(MindPalace.StressTest(world, o));
SdtdConsole.Instance.Output("проверка: заливка гравием, через 2 с - постановка (итог - в логе)");
}
else if (cmd == "rebuild")
{
MindPalace.Stamp(world, o);
SdtdConsole.Instance.Output("чертоги поставлены заново в " + o);
}
else
{
SdtdConsole.Instance.Output("чертоги: " + o + ", построены: " + MindPalace.IsBuilt(world, o));
}
}
}
}
+4
View File
@@ -61,6 +61,10 @@ namespace NecromancerTome
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
GhostTraderPatch.Init();
SnakeMode.Init();
MindPalace.Init();
HandPose.Init();
NecroMailbox.Init();
Possession.Init();
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
+244
View File
@@ -0,0 +1,244 @@
using GamePath;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// «Леденящий ужас» (указание 02.10): зомби, которого учуяла змея (тёмное чутьё, 30 м, сквозь
/// стены), сразу пугается и бежит от неё как можно дальше, ломая препятствия. Спящих будим -
/// «пусть будет хаос». Заменяет «шум» (зомби шли к змее, 28.09).
///
/// ОБРАЗЕЦ - ванильный EAIRunAway (основа RunawayFromEntity у зверей): точка в FleeDistance от
/// угрозы через RandomPositionGenerator.CalcPositionInDirection, пересчёт по таймеру. Отличия:
/// - путь с canBreak: true. У ванили false, и зверь не ломает ничего. Ломает не эта задача, а
/// ванильный BreakBlock (AITask-1 у зомби): он включается, когда moveHelper упёрся
/// (BlockedTime ≥ 0.35) на пути с CanBreakBlocks - а этот флаг приходит из нашего пути.
/// - угроза - позиция змеи, её даёт SnakeControl.Sense; задача сама ничего не ищет.
/// - работает, пока Until не истёк: Sense продлевает его, пока змея чует зомби.
///
/// ПРИОРИТЕТ 1, MutexBits 3. Задачи у зомби: 1 BreakBlock (биты 8), 2 ApproachAndAttackTarget
/// (3), 3 ApproachSpot (3) ... EAITaskList.isBestTask: задача с меньшим номером и общими битами
/// вытесняет остальные, с BreakBlock биты не пересекаются - он работает вместе с бегством.
/// Добавляется в рантайме (AddTask), как NecroFollowOwnerTask; из списка не убирается -
/// с истёкшим Until CanExecute сразу false, а AI-задачи в сейв не пишутся.
/// </summary>
public class NecroFleeSnakeTask : EAIBase
{
public const int Priority = 1;
/// <summary>На сколько блоков вперёд выбирается точка бегства; как у ванили.</summary>
public const float FleeDistance = 20f;
/// <summary>Сколько секунд зомби ещё бежит после последнего «учуяла» (Sense - раз в 1 с).</summary>
public const float FearSeconds = 4f;
/// <summary>Тиков ИИ (20 в секунду) между выборами новой точки и между запросами пути.</summary>
public const int RetargetTicks = 40;
/// <summary>Скорость бегства - максимум бега зомби-людей в entityclasses.xml (MoveSpeedAggro до
/// 1.5; быстрее только звери: медведь 2.0, пума 2.3). Указание 02.10: «пусть убегают быстро».</summary>
public const float FleeSpeed = 1.5f;
public static Vector3 SnakePos;
public float Until;
/// <summary>Своя угроза вместо змеи (хват «отрицание», 03.10): бежать от этой точки.</summary>
public bool HasThreat;
public Vector3 Threat;
public float Speed = FleeSpeed;
/// <summary>Кого забыть, когда бегство кончится (хват «отрицание»: «должен терять меня как цель»).
/// Он же угроза: бежать от его ТЕКУЩЕГО места.</summary>
public EntityAlive Forget;
/// <summary>Больше нуля - бежать, пока Forget ближе этого (и не дольше Until).</summary>
public float FarDistance;
Vector3 targetPos;
int retargetTicks;
public override void Init(EntityAlive _theEntity)
{
base.Init(_theEntity);
MutexBits = 3;
executeDelay = 0.1f;
}
public override bool CanExecute()
{
return Time.time < Until && !FarEnough() && FindFleePos();
}
/// <summary>Сколько криков тревоги уже было в этом кадре: у толпы кричат не все.</summary>
static int _alertFrame = -1, _alertCount;
public const int MaxAlertsPerFrame = 2;
public override void Start()
{
// Тест 02.10: «если пугать зомби одновременно - фризы». Все испуганные за один проход
// чутья стартовали в одном кадре: каждый кричал и сразу просил путь с ломанием блоков
// (дорогой для пафайндера). Теперь первый путь - через случайные 0-1.5 с, крик - не
// больше двух за кадр.
retargetTicks = theEntity.rand.RandomRange(0, 30);
PathFinderThread.Instance.RemovePathsFor(theEntity.entityId);
if (_alertFrame != Time.frameCount)
{
_alertFrame = Time.frameCount;
_alertCount = 0;
}
string alert = theEntity.GetSoundAlert();
if (alert != null && ++_alertCount <= MaxAlertsPerFrame)
{
theEntity.PlayOneShot(alert);
}
}
public override void Reset()
{
if (Forget != null)
{
ForgetNow(theEntity, Forget);
Forget = null;
}
theEntity.moveHelper.Stop();
theEntity.SetLookPosition(Vector3.zero);
}
public override bool Continue()
{
return Time.time < Until && !theEntity.IsDead() && !FarEnough();
}
float _diagTime;
public override void Update()
{
PathEntity path = theEntity.navigator.getPath();
if (path != null)
{
theEntity.moveHelper.CalcIfUnreachablePos();
}
theEntity.SetLookPosition(targetPos + Vector3.up * 0.8f);
if (Time.time > _diagTime)
{
// Диагностика (тест 02.10: «зомби не убегает, стоит на месте») - убрать после проверки.
_diagTime = Time.time + 3f;
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: " + theEntity.entityId + " поз " + theEntity.position.ToString("F0") +
" цель " + targetPos.ToString("F0") + " путь " + (path == null ? "нет" : path.NodeCountRemaining() + " узл.") +
" считается " + PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId) +
" скор " + Speed.ToString("F2"));
}
if (--retargetTicks > 0 || PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId))
{
return;
}
retargetTicks = RetargetTicks;
var sw = System.Diagnostics.Stopwatch.StartNew();
FindFleePos();
theEntity.FindPath(targetPos, Speed, true, this);
if (sw.Elapsed.TotalMilliseconds > 3.0)
{
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: долгий перерасчёт у " + theEntity.entityId + " - " + sw.Elapsed.TotalMilliseconds.ToString("F1") + " мс");
}
}
bool FindFleePos()
{
if (Forget != null && FarDistance > 0f)
{
Threat = Forget.position;
}
Vector3 away = theEntity.position - (HasThreat ? Threat : SnakePos);
away.y = 0f;
if (away.sqrMagnitude < 0.01f)
{
away = UnityEngine.Random.insideUnitSphere;
away.y = 0f;
}
Vector3 p = RandomPositionGenerator.CalcPositionInDirection(theEntity, theEntity.position, away, FleeDistance, 60f);
// Ванильный подбор вернул ноль (нет чанка, не нашлось опоры) - всё равно прочь по прямой:
// путь найдёт пафайндер, а препятствия на нём сломает BreakBlock.
targetPos = p.y != 0f ? p : theEntity.position + away.normalized * FleeDistance;
// Как ванильный ApproachSpot: цель - на опорном блоке, а не в воздухе/стене.
targetPos = theEntity.world.FindSupportingBlockPos(targetPos);
return true;
}
/// <summary>Уже напуган - продлить испуг (дёшево). False - ещё не напуган, нужна очередь.</summary>
public static bool Refresh(EntityAlive _e)
{
NecroFleeSnakeTask task = _e.aiManager?.tasks.GetTask<NecroFleeSnakeTask>();
if (task == null)
{
return false;
}
task.Until = Time.time + FearSeconds;
return true;
}
/// <summary>Из очереди SnakeControl, только на сервере: напугать (или продлить испуг).</summary>
public static void Scare(EntityAlive _e)
{
if (_e.aiManager == null)
{
return;
}
_e.ConditionalTriggerSleeperWakeUp();
NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask<NecroFleeSnakeTask>();
if (task == null)
{
task = new NecroFleeSnakeTask();
task.Init(_e);
_e.aiManager.tasks.AddTask(Priority, task);
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " в ужасе");
}
task.HasThreat = false;
task.Speed = FleeSpeed;
task.Forget = null;
task.FarDistance = 0f;
task.Until = Time.time + FearSeconds;
}
/// <summary>Хват «отрицание» (03.10): напугать зомби жестом игрока - бежит от игрока быстрее обычного
/// _seconds секунд, сразу и в конце бегства теряет игрока как цель.</summary>
bool FarEnough()
{
return FarDistance > 0f && Forget != null && (theEntity.position - Forget.position).sqrMagnitude > FarDistance * FarDistance;
}
public static void ScareFrom(EntityAlive _e, EntityAlive _threat, float _seconds, float _speed, float _farDistance = 0f)
{
if (_e.aiManager == null)
{
return;
}
_e.ConditionalTriggerSleeperWakeUp();
NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask<NecroFleeSnakeTask>();
if (task == null)
{
task = new NecroFleeSnakeTask();
task.Init(_e);
_e.aiManager.tasks.AddTask(Priority, task);
}
task.HasThreat = true;
task.Threat = _threat.position;
task.Speed = _speed;
task.Forget = _threat;
task.FarDistance = _farDistance;
task.Until = Time.time + _seconds;
ForgetNow(_e, _threat);
// Сетка поиска пути строится вокруг игроков - здесь она есть; но зомби может быть на краю.
AstarManager.Instance?.AddLocation(_e.position, 40);
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " отрицание - бежит до " + _farDistance + " м (не дольше " + _seconds + " с), скор " + _speed);
}
public static void ForgetNow(EntityAlive _e, EntityAlive _who)
{
if (_e.GetAttackTarget() == _who)
{
_e.SetAttackTarget(null, 0);
}
if (_e.GetRevengeTarget() == _who)
{
_e.SetRevengeTarget(null);
}
}
public override string ToString()
{
return base.ToString() + ", flee to " + targetPos + ", " + (Until - Time.time).ToString("F1") + " s";
}
}
}
+91
View File
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Почтовый ящик консоли - только для разработки (03.10: ассистент подбирает позу рук сам, без
/// клавиатуры). Работает, лишь если есть папка %APPDATA%\7DaysToDie\NecroMailbox - у игроков её
/// нет. Раз в полсекунды: команды из cmd.txt (по строке) выполняются в консоли игры, как набранные
/// вручную, файл удаляется, ответ - в лог строками «[NecromancerTome] mailbox:».
/// </summary>
public static class NecroMailbox
{
public static string Dir => Path.Combine(GameIO.GetUserGameDataDir(), "NecroMailbox");
static string CmdFile => Path.Combine(Dir, "cmd.txt");
static float _next;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
if (Time.unscaledTime < _next)
{
return;
}
_next = Time.unscaledTime + 0.5f;
if (!File.Exists(CmdFile) || SdtdConsole.Instance == null)
{
return;
}
string[] lines;
try
{
lines = File.ReadAllLines(CmdFile);
File.Delete(CmdFile);
}
catch (IOException)
{
return; // файл ещё пишется - в следующий раз
}
foreach (string raw in lines)
{
string line = raw.Trim();
if (line.Length == 0)
{
continue;
}
Debug.Log("[NecromancerTome] mailbox: > " + line);
try
{
List<string> res = SdtdConsole.Instance.ExecuteSync(line, null);
if (res != null)
{
foreach (string r in res)
{
Debug.Log("[NecromancerTome] mailbox: " + r);
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] mailbox: ошибка - " + ex);
}
}
}
}
/// <summary>necroshot [имя] - снимок экрана игры в %APPDATA%\7DaysToDie\NecroMailbox\shots\имя.png
/// (как F9, только в известное место). Файл появляется в конце кадра.</summary>
[Preserve]
public class ConsoleCmdNecroShot : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroshot" };
public override string getDescription() => "NecromancerTome: снимок экрана в NecroMailbox/shots/<имя>.png";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string dir = Path.Combine(NecroMailbox.Dir, "shots");
Directory.CreateDirectory(dir);
string name = _params.Count > 0 ? _params[0] : System.DateTime.Now.ToString("HHmmss");
string path = Path.Combine(dir, name + ".png");
ScreenCapture.CaptureScreenshot(path);
SdtdConsole.Instance.Output("снимок: " + path);
}
}
}
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»). Окно necroTome
/// (Config/XUi_InGame/windows.xml): оглавление - кнопки глав (до восьми), глава - страницы
/// с «Назад/Далее», «Оглавление» возвращает к списку, «Закрыть» и Esc закрывают.
///
/// СОДЕРЖАНИЕ - список Chapters ниже: ключ названия, ключи страниц (Localization.csv,
/// necroTome*), условие открытия. Закрытая глава в оглавлении не показывается. Условия брать
/// из того, что игра уже хранит (уровень некромантии, рецепты, испытания), своё хранение - только
/// для чистых событий, по cvar на событие.
/// </summary>
public static class NecroTomeBook
{
public const string WindowGroup = "necroTome";
public class Chapter
{
public string TitleKey;
public string[] PageKeys;
public Func<EntityPlayerLocal, bool> Unlocked;
}
public static readonly List<Chapter> Chapters = new List<Chapter>
{
// Глава 1 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
new Chapter
{
TitleKey = "necroTomeCh1Title",
PageKeys = new[] { "necroTomeCh1P1", "necroTomeCh1P2", "necroTomeCh1P3", "necroTomeCh1P4" },
Unlocked = _p => true
},
};
public static void Open(EntityPlayerLocal _player)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
ui?.windowManager.Open(WindowGroup, true);
}
}
[Preserve]
public class XUiC_NecroTome : XUiController
{
const int MaxButtons = 8;
XUiV_Label _title, _text, _page;
XUiC_SimpleButton _toc, _prev, _next, _close;
readonly XUiC_SimpleButton[] _chapterButtons = new XUiC_SimpleButton[MaxButtons];
/// <summary>Глава по кнопке оглавления (только открытые главы, по порядку).</summary>
readonly int[] _buttonChapter = new int[MaxButtons];
int _chapter = -1, _pageIndex;
public override void Init()
{
base.Init();
_title = GetChildById("lblTitle")?.ViewComponent as XUiV_Label;
_text = GetChildById("lblText")?.ViewComponent as XUiV_Label;
_page = GetChildById("lblPage")?.ViewComponent as XUiV_Label;
_toc = GetChildById("btnToc") as XUiC_SimpleButton;
_prev = GetChildById("btnPrev") as XUiC_SimpleButton;
_next = GetChildById("btnNext") as XUiC_SimpleButton;
_close = GetChildById("btnClose") as XUiC_SimpleButton;
if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc();
if (_prev != null) _prev.OnPressed += (_s, _m) => Turn(-1);
if (_next != null) _next.OnPressed += (_s, _m) => Turn(1);
if (_close != null) _close.OnPressed += (_s, _m) => xui.playerUI.windowManager.Close(NecroTomeBook.WindowGroup);
for (int i = 0; i < MaxButtons; i++)
{
int idx = i;
_chapterButtons[i] = GetChildById("btnCh" + (i + 1)) as XUiC_SimpleButton;
if (_chapterButtons[i] != null)
{
_chapterButtons[i].OnPressed += (_s, _m) => ShowChapter(_buttonChapter[idx], 0);
}
}
}
public override void OnOpen()
{
base.OnOpen();
ShowToc();
}
void ShowToc()
{
_chapter = -1;
SetLabel(_title, Localization.Get("necroTomeToc"));
SetLabel(_text, "");
SetLabel(_page, "");
EntityPlayerLocal player = xui.playerUI.entityPlayer;
int b = 0;
for (int c = 0; c < NecroTomeBook.Chapters.Count && b < MaxButtons; c++)
{
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
if (ch.Unlocked != null && !ch.Unlocked(player))
{
continue;
}
_buttonChapter[b] = c;
if (_chapterButtons[b] != null)
{
_chapterButtons[b].Text = Localization.Get(ch.TitleKey);
_chapterButtons[b].ViewComponent.IsVisible = true;
}
b++;
}
for (; b < MaxButtons; b++)
{
if (_chapterButtons[b] != null)
{
_chapterButtons[b].ViewComponent.IsVisible = false;
}
}
SetVisible(_toc, false);
SetVisible(_prev, false);
SetVisible(_next, false);
}
void ShowChapter(int _c, int _p)
{
if (_c < 0 || _c >= NecroTomeBook.Chapters.Count)
{
return;
}
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[_c];
_chapter = _c;
_pageIndex = Mathf.Clamp(_p, 0, ch.PageKeys.Length - 1);
foreach (XUiC_SimpleButton btn in _chapterButtons)
{
SetVisible(btn, false);
}
SetLabel(_title, Localization.Get(ch.TitleKey));
SetLabel(_text, Localization.Get(ch.PageKeys[_pageIndex]));
SetLabel(_page, (_pageIndex + 1) + " / " + ch.PageKeys.Length);
SetVisible(_toc, true);
SetVisible(_prev, true);
SetVisible(_next, true);
if (_prev != null) _prev.Enabled = _pageIndex > 0;
if (_next != null) _next.Enabled = _pageIndex < ch.PageKeys.Length - 1;
}
void Turn(int _d)
{
if (_chapter >= 0)
{
ShowChapter(_chapter, _pageIndex + _d);
}
}
static void SetLabel(XUiV_Label _l, string _s)
{
if (_l != null)
{
_l.Text = _s;
}
}
static void SetVisible(XUiController _c, bool _v)
{
if (_c?.ViewComponent != null)
{
_c.ViewComponent.IsVisible = _v;
}
}
}
/// <summary>necrotome - открыть книгу (проверка окна без чертогов, в том числе через почтовый ящик).</summary>
[Preserve]
public class ConsoleCmdNecroTome : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrotome" };
public override string getDescription() => "NecromancerTome: открыть книгу с оглавлением";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
if (player != null)
{
NecroTomeBook.Open(player);
}
}
}
}
+9
View File
@@ -23,6 +23,15 @@
<HintPath>..\..\..\7DaysToDie_Data\Managed\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- DataLoader.LoadAsset: у метода есть перегрузка с AssetReference (гео-оссуарий, 30.09) -->
<Reference Include="Unity.Addressables">
<HintPath>..\..\..\7DaysToDie_Data\Managed\Unity.Addressables.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.ScreenCaptureModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.ScreenCaptureModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
+5
View File
@@ -153,6 +153,11 @@ namespace NecromancerTome
{
return;
}
// Одержимый зомби, пока в нём игрок, в некромантию не идёт (решение 03.10).
if (Possession.IsPossessed(killedEntity))
{
return;
}
float kills = AddKillsCVar(__instance);
SyncNecromancyLevel(__instance, kills);
+9 -1
View File
@@ -77,6 +77,9 @@ namespace NecromancerTome
/// светятся сильнее обычного; плотнее и крупнее, чем у девиантов.</summary>
public static readonly GlowConfig SnakeSightGlow = new GlowConfig { Tint = new Color(1.1f, 1.5f, 0.15f), SizeFactor = 0.4f, AlphaFactor = 1f, DensityFactor = 3f };
public const string SnakeSightBuffName = "buffNecroSnakeSight";
/// <summary>«Стиратель» (02.10): маджента - этим цветом зомби больше никто не светится.</summary>
public static readonly GlowConfig EraserGlow = new GlowConfig { Tint = new Color(1f, 0.1f, 0.65f), SizeFactor = 0.4f, AlphaFactor = 1f, DensityFactor = 3f };
public const string EraserBuffName = "buffNecroEraser";
public static readonly GlowConfig PortalChannelGlow = new GlowConfig { Tint = new Color(0.1f, 0.85f, 0.8f), SizeFactor = 0.9f, AlphaFactor = 0.9f, DensityFactor = 5f };
public static void Postfix(MinEventActionAttachParticleEffectToEntity __instance, MinEventParams _params)
@@ -97,7 +100,8 @@ namespace NecromancerTome
bool isVictim = entity.Buffs.HasBuff(Patch_EntityAlive_dropItemOnDeath_VictimBag.VictimBuffName);
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
bool isSnakeSight = entity.Buffs.HasBuff(SnakeSightBuffName);
if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight)
bool isEraser = entity.Buffs.HasBuff(EraserBuffName);
if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight && !isEraser)
{
return;
}
@@ -109,6 +113,10 @@ namespace NecromancerTome
{
config = PortalChannelGlow;
}
else if (isEraser)
{
config = EraserGlow;
}
else if (isSnakeSight)
{
config = SnakeSightGlow;
+129 -17
View File
@@ -42,6 +42,32 @@ namespace NecromancerTome
/// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира.</summary>
public const int OrderTicks = 6000;
/// <summary>Кого игрок сейчас держит в прицеле и кого можно назначить целью. null - некого.
/// Общее для приказа и для покраснения прицела (PetCrosshair): прицел краснеет ровно тогда,
/// когда приказ будет принят. Свои питомцы целью не бывают.</summary>
public static EntityAlive TargetUnderCrosshair(EntityPlayerLocal _local)
{
WorldRayHitInfo hit = _local.HitInfo;
if (hit == null || !hit.bHitValid || hit.transform == null)
{
return null;
}
EntityAlive target = ItemActionAttack.GetEntityFromHit(hit) as EntityAlive;
if (target == null || target.IsDead() || target == _local)
{
return null;
}
if (target is EntityPlayer || target is EntityTrader)
{
return null;
}
if (PetFollowPatch.FindOwnerOfPet(target.entityId) != null)
{
return null;
}
return target;
}
/// <summary>Отдаёт питомцу приказ атаковать то, на что смотрит игрок. false - приказа не
/// вышло, и вызывающая сторона показывает "Нет цели для атаки".</summary>
public static bool TryOrderAttack(EntityAlive _owner, EntityAlive _pet)
@@ -59,23 +85,8 @@ namespace NecromancerTome
return false;
}
WorldRayHitInfo hit = local.HitInfo;
if (hit == null || !hit.bHitValid || hit.transform == null)
{
return false;
}
Entity hitEntity = ItemActionAttack.GetEntityFromHit(hit);
EntityAlive target = hitEntity as EntityAlive;
if (target == null || target.IsDead())
{
return false;
}
if (target == _pet || target == _owner)
{
return false;
}
if (target is EntityPlayer || target is EntityTrader)
EntityAlive target = TargetUnderCrosshair(local);
if (target == null || target == _pet)
{
return false;
}
@@ -84,9 +95,110 @@ namespace NecromancerTome
// следующем тике перебить наш приказ тем, кто ударил крысу последним.
_pet.SetRevengeTarget(null);
_pet.SetAttackTarget(target, OrderTicks);
// Охрана (PetFollowPatch.Guard) сама раздаёт цели; приказ игрока она перебивать не должна.
PetFollowPatch.NoteOrder(_pet.entityId, target.entityId);
Debug.Log("[NecromancerTome] PetAttackCommand: pet " + _pet.entityId + " ordered to attack " +
target.entityId + " (" + target.EntityClass.entityClassName + ")");
return true;
}
}
/// <summary>
/// ПРИЦЕЛ КНИГ И СВИТКОВ ПРИЗЫВА - указание 30.09.2026: «сделать другой прицел (более явный, из
/// уже имеющихся в игре) и чтобы он краснел при попадании в него врага. Как у змеи краснеет».
///
/// У ItemActionSpawnEntity своего прицела нет, он получал общий маленький «плюс». Игра рисует
/// прицел одним методом, EntityPlayerLocal.guiDrawCrosshair, и цвет там берётся из настроек,
/// так что подменить один лишь ВИД прицела мало - покраснеть он не сможет. Поэтому для наших
/// книг метод заменяется целиком: те же условия показа, тот же размер и та же готовая
/// прицел арбалета (DrawDynamicCrosshair, 4 штриха), а цвет - наш.
///
/// Красный = приказ будет принят: питомец этой книги призван и под прицелом годная цель
/// (PetAttackCommand.TargetUnderCrosshair - тот же разбор, что у самого приказа). Пока питомец
/// не призван, основное действие - это призыв, и красить прицел незачем.
///
/// Только у книг с приказом (PetInfo.CommandsAttack). Свиток Роя оставлен как был.
/// </summary>
[HarmonyLib.HarmonyPatch(typeof(EntityPlayerLocal), "guiDrawCrosshair")]
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_PetCrosshair
{
/// <summary>Тот же красный, что у прицела змеи (SnakeCrosshair.OnTarget).</summary>
static readonly Color OnTarget = new Color(1f, 0.15f, 0.1f, 1f);
public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen)
{
if (__instance.inventory == null)
{
return true;
}
ItemClass holding = __instance.inventory.holdingItem;
ItemActionSpawnEntity action = holding != null && holding.Actions != null && holding.Actions.Length > 0
? holding.Actions[0] as ItemActionSpawnEntity
: null;
if (action == null ||
!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(action.entityToSpawn,
out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo) ||
!petInfo.CommandsAttack)
{
return true;
}
// Дальше - условия показа из самого guiDrawCrosshair, в том же порядке.
if (!_guiInGame.showCrosshair || Event.current.type != EventType.Repaint || __instance.IsDead() ||
__instance.emodel.IsRagdollActive || __instance.AttachedToEntity != null ||
!GamePrefs.GetBool(EnumGamePrefs.OptionsCrosshairEnabled) || bModalWindowOpen ||
_guiInGame.CrosshairSquare == null)
{
return false;
}
float opacity = GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairOpacity);
int colorIndex = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (colorIndex < 0 || colorIndex >= EntityPlayerLocal.crosshairColors.Count)
{
colorIndex = 0;
}
Color color = EntityPlayerLocal.crosshairColors[colorIndex];
color.a = opacity;
if (PetIsOut(__instance, action.entityToSpawn) && PetAttackCommand.TargetUnderCrosshair(__instance) != null)
{
color = OnTarget;
}
Vector2 center = __instance.GetCrosshairPosition2D();
center.y = Screen.height - center.y;
float size = Screen.height * 0.059f * GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairScale);
// Прицел арбалета (тест 30.09: CrosshairDamage выглядел «кружком со стрелкой вниз»):
// четыре штриха игрового DrawDynamicCrosshair, длина та же, что у стрелкового оружия
// (size / 3); разброс у книги не меняется, поэтому зазор постоянный.
EntityPlayerLocal.DrawDynamicCrosshair(center.x, center.y, size * 0.22f, size * 0.22f, size / 3f,
GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairThickness), color.a, color);
return false;
}
/// <summary>Призван ли сейчас питомец этой книги у этого игрока. По списку PetFollowPatch, а не
/// GetOwnedEntities: тот на каждый вызов заводит новый список, а мы здесь каждый кадр.</summary>
static bool PetIsOut(EntityPlayerLocal _player, string _entityClassName)
{
int classId = EntityClass.GetId(_entityClassName);
World world = _player.world;
if (classId == -1 || world == null)
{
return false;
}
for (int i = 0; i < PetFollowPatch.TrackedPets.Count; i++)
{
PetFollowPatch.TrackedPet tracked = PetFollowPatch.TrackedPets[i];
if (tracked.OwnerEntityId != _player.entityId)
{
continue;
}
Entity pet = world.GetEntity(tracked.PetEntityId);
if (pet != null && pet.entityClass == classId && !pet.IsDead())
{
return true;
}
}
return false;
}
}
}
+135 -1
View File
@@ -74,6 +74,17 @@ namespace NecromancerTome
/// <summary>Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль -
/// питомец не летающий, дом ему не переставляем.</summary>
public int FlyingHomeRadius;
/// <summary>Охраняет ли хозяина сам - копия PetInfo.Guards.</summary>
public bool Guards;
/// <summary>На кого игрок указал прицелом (PetAttackCommand). Пока эта цель жива и питомец
/// занят именно ею, охрана его не перенацеливает. -1 - приказа нет.</summary>
public int OrderedTargetId = -1;
/// <summary>Питомец ушёл дальше GuardLeash и возвращается: пока он не подойдёт к хозяину
/// на GuardRange, любые цели с него снимаются - иначе укус в спину разворачивал бы его.</summary>
public bool Returning;
}
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
@@ -106,7 +117,7 @@ namespace NecromancerTome
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored,
bool wantsLitEyes, int flyingHomeRadius)
bool wantsLitEyes, int flyingHomeRadius, bool guards)
{
TrackedPets.Add(new TrackedPet
{
@@ -116,6 +127,7 @@ namespace NecromancerTome
CollisionIgnored = collisionIgnored,
WantsLitEyes = wantsLitEyes,
FlyingHomeRadius = flyingHomeRadius,
Guards = guards,
});
}
@@ -190,6 +202,121 @@ namespace NecromancerTome
}
}
/// <summary>ОХРАНА ХОЗЯИНА - указание 30.09.2026. Образ: «ночь, мы в виде змеи ползаем на
/// другом конце карты, а тело охраняет стервятник». До этого питомец дрался только по приказу
/// (PetAttackCommand) - отзыв sanovskiy 23.09: «приказ нужен на каждую цель».
///
/// Решения пользователя по пунктам:
/// - радиус охраны 20 м от хозяина (GuardRange); в режиме змеи хозяин - это лежащее тело,
/// позиция игрока там и остаётся;
/// - охраняют все, кроме крысы (PetInfo.Guards);
/// - отходить можно до 40 м (GuardLeash), дальше - возвращается;
/// - бить ближайшего к телу, если не было приказа атаковать конкретного.
///
/// Враг - тот же, что для тревоги у тела в режиме змеи (SnakeControl.IsFoe): живой EntityEnemy,
/// не питомец и не девиант. СПЯЩИЕ НЕ В СЧЁТ: иначе питомец, проходящий сквозь стены, будил бы
/// всё здание, пока игрок крадётся, - к спящему слово «приближается» не относится.
///
/// Уже начатую цель охрана не бросает, пока та в радиусе: выбор «ближайшего» заново каждую
/// секунду заставил бы питомца метаться между двумя зомби и не добить ни одного.</summary>
public const float GuardRange = 20f;
public const float GuardLeash = 40f;
public static void NoteOrder(int petEntityId, int targetEntityId)
{
for (int i = 0; i < TrackedPets.Count; i++)
{
if (TrackedPets[i].PetEntityId == petEntityId)
{
TrackedPets[i].OrderedTargetId = targetEntityId;
TrackedPets[i].Returning = false;
}
}
}
public static EntityAlive NearestFoe(World world, Vector3 center, float range)
{
EntityAlive nearest = null;
float best = range * range;
for (int i = 0; i < world.EntityAlives.Count; i++)
{
EntityAlive e = world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, null) || e.IsSleeping)
{
continue;
}
float distSq = (e.position - center).sqrMagnitude;
if (distSq < best)
{
best = distSq;
nearest = e;
}
}
return nearest;
}
public static void Guard(TrackedPet tracked, EntityAlive pet, EntityAlive owner, World world)
{
if (owner.IsDead())
{
return;
}
float fromOwnerSq = (pet.position - owner.position).sqrMagnitude;
EntityAlive target = pet.GetAttackTarget();
// Поводок 40 м - для всех спутников, и для приказа игрока тоже.
if (tracked.Returning)
{
if (fromOwnerSq > GuardRange * GuardRange)
{
if (target != null)
{
pet.SetRevengeTarget(null);
pet.SetAttackTarget(null, 0);
}
return;
}
tracked.Returning = false;
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " вернулся к хозяину");
}
else if (fromOwnerSq > GuardLeash * GuardLeash)
{
tracked.Returning = true;
tracked.OrderedTargetId = -1;
pet.SetRevengeTarget(null);
pet.SetAttackTarget(null, 0);
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " ушёл на " +
Mathf.Sqrt(fromOwnerSq).ToCultureInvariantString("0") + " м от хозяина - цель снята, возвращается");
return;
}
if (!tracked.Guards)
{
return;
}
bool live = target != null && !target.IsDead();
if (live && target.entityId == tracked.OrderedTargetId)
{
return;
}
tracked.OrderedTargetId = -1;
if (live && SnakeControl.IsFoe(target, null) &&
(target.position - owner.position).sqrMagnitude <= GuardRange * GuardRange)
{
return;
}
EntityAlive foe = NearestFoe(world, owner.position, GuardRange);
if (foe == null || foe == target)
{
return;
}
pet.SetRevengeTarget(null);
pet.SetAttackTarget(foe, PetAttackCommand.OrderTicks);
Debug.Log("[NecromancerTome] PetFollowPatch: охрана - pet " + pet.entityId + " идёт на " + foe.entityId +
" (" + foe.EntityClass.entityClassName + "), до хозяина " +
(foe.position - owner.position).magnitude.ToCultureInvariantString("0.0") + " м");
}
/// <summary>ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как
/// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье".
///
@@ -392,6 +519,13 @@ namespace NecromancerTome
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
}
// Охрана и поводок 40 м - до блока про «дом» летающих: тот в этом же тике должен
// увидеть уже назначенную или снятую цель. Рой (не спутник) сюда не попадает.
if (tracked.Guards || tracked.OwnFollowTask || tracked.FlyingHomeRadius > 0)
{
Guard(tracked, pet, owner, world);
}
// СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую
// секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка
// "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни
+818
View File
@@ -0,0 +1,818 @@
using GamePath;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Одержимость - заклинание хвата «пиф-паф» (решения пользователя 03.10, BACKLOG «Одержимость»).
/// ЛКМ по зомби-гуманоиду (EntityZombie и наследники; не звери и не стервятники) - игрок перехватывает
/// управление им: бегает, прыгает, бьёт. Тело игрока не падает, а цепенеет и стоит (аниматор на паузе,
/// ввод снят) - его могут атаковать, это и есть цена. Любое время суток, и в кровавую луну.
/// Остальные зомби, увидев одержимого, бросаются на него, как на игрока, - можно танковать.
/// Выход - R, гибель одержимого или смерть тела. После выхода зомби пугается и убегает, как при
/// «отрицании». Убийства одержимым игроку не засчитываются (урон от его имени), его собственная
/// смерть - тоже (NecromancyKillCreditPatch).
///
/// КАК ВЕДЁМ ЗОМБИ. Его ИИ выключен (EntityAlive.updateTasks пропущен), а движение - родное:
/// SetMoveForwardWithModifiers, как делает сама игра при DebugStopEnemiesMoving и EntityMoveHelper;
/// прыжок - Jumping + SetJumpDistance, как EntityMoveHelper.StartJump; удар - Attack(false/true),
/// как EAIApproachAndAttackTarget. Походка, анимация и урон - самого зомби.
///
/// КАК СТОИТ ТЕЛО. PlayerMoveController.Update пропущен (и сам вызывает stopMoving, как у тела в
/// рэгдолле), вид - от третьего лица, аниматор модели на паузе. Камера - за головой зомби (постфикс
/// к vp_FPCamera.LateUpdate, как у змеи).
///
/// ПРИЁМЫ ЗОМБИ (указание 03.10: «у копа есть плевок и подрыв - чтобы такие спеллы становились доступны»):
/// ПКМ - дальняя атака, если у зомби есть задача ИИ EAIRangedAttackTarget (Ранчер, Чак, коп, мутант):
/// её параметры берутся из задачи захваченного зомби, а шаги повторяют её Start/Update/Reset -
/// замах (StartAnimAction startAnimType+3000), отпуск (ContinueAnimAction +1), выстрел родным
/// оружием (UseHoldingItem itemType), пока держишь кнопку и не вышло attackDuration. Направление -
/// точка под прицелом камеры (SetLookPosition), как у ИИ - голова цели.
/// Пробел - прыжок на собственную дальность зомби (jumpMaxDistance из JumpMaxDistance): у паука 7-9 м,
/// как его EAILeap, у остальных их 1-4 м.
/// ПКМ у крикуньи - её крик (SoundAlert): все зомби в ScreamRange бросаются на неё, даже не видя (её
/// ванильный зов орды - часть разведки AIScoutHordeSpawner, напрямую не вызываем).
/// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько
/// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело.
///
/// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время
/// одержимости; на выходе и при выходе из мира всё обнуляется.
/// </summary>
public static class Possession
{
public static bool Active;
public static EntityAlive Zombie;
/// <summary>Камера - позади и выше головы зомби.</summary>
public const float CamBack = 2.6f;
public const float CamUp = 0.6f;
/// <summary>Как далеко зомби замечают одержимого: зрение зомби - 30 м (entityclasses.xml SightRange).</summary>
public const float TauntRange = 30f;
public const float TauntPeriod = 1f;
public const int TauntTicks = 1200;
/// <summary>Спящие просыпаются от одержимого (тест 05.10: «не разбудил спящего»). Игра будит их
/// только игроком - увидел или услышал (EAISetNearestEntityAsTarget.FindTargetPlayer), одержимый для
/// неё просто зомби. Будим сами: видит одержимого ближе SleeperWakeRange (пригнувшегося - ближе
/// SleeperWakeCrouched) или тот вплотную, ближе SleeperHearRange, хоть и за спиной.</summary>
public const float SleeperWakeRange = 8f;
public const float SleeperWakeCrouched = 3f;
public const float SleeperHearRange = 2f;
/// <summary>Приседание (указание 05.10): пока зажата кнопка приседания. Высота - доля роста зомби;
/// ниже 0.7 игра включает ходьбу пригнувшись (walkType 8), как у её зомби под низким потолком.</summary>
public static bool Crouching;
/// <summary>Удар по прицелу (тест 05.10: «не попадаю по другим зомби»). Удар зомби - луч ~1.65 м из
/// головы по взгляду (ItemActionMelee, EntityHuman.GetLookVector), а взгляд брался из наклона камеры,
/// которая сзади и выше: под прицелом враг, а луч из головы уходит ниже и мимо. На время замаха
/// взгляд зомби - на точку под прицелом, а если рядом враг почти по прицелу - на его грудь.</summary>
public const float MeleeAssistRange = 2.5f;
public const float MeleeAssistDeg = 25f;
static float _meleeLookUntil;
/// <summary>Ctrl в игре - приседание переключателем (ToggleCrouch), C - пока держишь (Crouch).</summary>
static bool _crouchToggled;
public const float CrouchHeight = 0.6f;
/// <summary>Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить
/// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора
/// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков
/// хватает, чтобы тело стояло на земле и зомби могли к нему подойти.</summary>
public const int BodyViewDim = 3;
static int _bodyViewDim = -1;
/// <summary>Полный радиус области игрока (до сжатия) - для систем, рисующих мир у одержимого
/// (SnakeAudioSwap: дальний ландшафт, Dynamic Mesh, детализация зданий). -1 - не сжата.</summary>
public static int FullViewDim => _bodyViewDim;
public const float ScreamRange = 50f;
public const float ScreamCooldown = 10f;
static float _nextScream;
static EntityPlayerLocal _player;
static float _yaw, _pitch;
/// <summary>Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») -
/// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера.</summary>
static float _bodyYaw;
public const float TurnSpeed = 540f;
static float _nextAttack;
static float _nextTaunt;
static float _sleeperCheck;
static ChunkManager.ChunkObserver _observer;
static readonly List<Animator> _frozen = new List<Animator>();
static readonly List<float> _frozenSpeed = new List<float>();
static bool _loggedCamera;
// Дальняя атака захваченного зомби (копия параметров его EAIRangedAttackTarget).
static bool _hasRanged;
static int _rItem, _rStartAnim;
static float _rRelease, _rDuration, _rCooldown;
static string _rSoundStart, _rSoundRelease;
enum RState { Idle, StartAnim, Release, Fire }
static RState _rState;
static float _rTime, _rNext;
static GameObject _hud;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
if (_hud == null)
{
_hud = new GameObject("NecroPossessionHud");
Object.DontDestroyOnLoad(_hud);
_hud.AddComponent<PossessionHud>();
}
}
public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
/// <summary>Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).</summary>
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
/// <summary>True - вселились (для звука «пиф-пафа»).</summary>
public static bool Enter(EntityPlayerLocal _player0, EntityAlive _zombie)
{
if (Active || SnakeMode.Active || MindPalace.Active || _player0 == null || _player0.IsDead() ||
_player0.AttachedToEntity != null || !CanPossess(_zombie) || _player0.world.IsRemote())
{
return false;
}
Active = true;
_player = _player0;
Zombie = _zombie;
_yaw = _player0.rotation.y;
_bodyYaw = _zombie.rotation.y;
_pitch = 0f;
_nextAttack = 0f;
_nextTaunt = 0f;
_nextScream = 0f;
_loggedCamera = false;
// Зомби - в руки игрока: цели, путь и шаги ИИ сброшены.
_zombie.SetAttackTarget(null, 0);
_zombie.SetRevengeTarget(null);
PathFinderThread.Instance.RemovePathsFor(_zombie.entityId);
_zombie.navigator?.clearPath();
_zombie.moveHelper?.Stop();
_zombie.ConditionalTriggerSleeperWakeUp();
_zombie.SetRotation(new Vector3(0f, _yaw, 0f));
// Тело цепенеет: вид от третьего лица, аниматоры модели на паузе.
_player0.SetFirstPersonView(false, true);
FreezeBody(_player0, true);
_observer = GameManager.Instance.AddChunkObserver(_zombie.position, true, GameUtils.GetViewDistance(), -1);
ChunkManager.ChunkObserver body = _player0.ChunkObserver;
if (body != null && body.viewDim > BodyViewDim)
{
_bodyViewDim = body.viewDim;
body.viewDim = BodyViewDim;
}
ReadRanged(_zombie);
Hint(_player0, _zombie);
Debug.Log("[NecromancerTome] Possession: вселение в " + _zombie.EntityName + " (" + _zombie.entityId + ")");
return true;
}
public static void Exit(string _why)
{
if (!Active)
{
return;
}
Active = false;
Crouching = false;
_meleeLookUntil = 0f;
_crouchToggled = false;
EntityAlive z = Zombie;
z?.SetLookPosition(Vector3.zero);
EntityPlayerLocal player = _player;
if (_rState != RState.Idle && z != null && !z.IsDead())
{
StopRanged(z);
}
_rState = RState.Idle;
_hasRanged = false;
Zombie = null;
_player = null;
Debug.Log("[NecromancerTome] Possession: выход - " + _why);
FreezeBody(player, false);
if (_bodyViewDim > 0 && player?.ChunkObserver != null)
{
player.ChunkObserver.viewDim = _bodyViewDim;
}
_bodyViewDim = -1;
if (player != null && !player.IsDead())
{
player.SetFirstPersonView(true, true);
}
if (_observer != null && GameManager.Instance?.World != null)
{
GameManager.Instance.RemoveChunkObserver(_observer);
}
_observer = null;
World world = GameManager.Instance?.World;
if (world == null || z == null)
{
return;
}
// Кто гнался за одержимым - отпускает его.
for (int i = 0; i < world.EntityAlives.Count; i++)
{
EntityAlive e = world.EntityAlives[i];
if (e != null && e != z && e.GetAttackTarget() == z)
{
e.SetAttackTarget(null, 0);
}
}
// Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»).
if (!z.IsDead() && player != null)
{
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
NecroFleeSnakeTask.ScareFrom(z, player, HandHolds.DenyMaxSeconds, HandHolds.DenySpeed, HandHolds.DenyFarDistance);
}
}
static void FreezeBody(EntityPlayerLocal _p, bool _freeze)
{
if (_freeze)
{
_frozen.Clear();
_frozenSpeed.Clear();
Transform model = _p?.emodel?.GetModelTransform();
if (model == null)
{
return;
}
foreach (Animator a in model.GetComponentsInChildren<Animator>(true))
{
_frozen.Add(a);
_frozenSpeed.Add(a.speed);
a.speed = 0f;
}
return;
}
for (int i = 0; i < _frozen.Count; i++)
{
if (_frozen[i] != null)
{
_frozen[i].speed = _frozenSpeed[i];
}
}
_frozen.Clear();
_frozenSpeed.Clear();
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
if (world == null)
{
if (Active || Zombie != null || _frozen.Count > 0)
{
Active = false;
Zombie = null;
_player = null;
_observer = null;
_bodyViewDim = -1;
_frozen.Clear();
_frozenSpeed.Clear();
}
return;
}
if (!Active)
{
return;
}
EntityAlive z = Zombie;
if (_player == null || _player.IsDead())
{
Exit("тело погибло");
return;
}
if (z == null || z.IsDead() || z.IsMarkedForUnload())
{
Exit("одержимый погиб");
return;
}
PlayerActionsLocal input = _player.playerInput;
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
{
Exit("R");
return;
}
Vector2 move = Vector2.zero;
bool run = false, jump = false, attack = false, ranged = false, rangedHeld = false, detonate = false;
if (input != null && !gui)
{
Look();
move = new Vector2(input.Move.X, input.Move.Y);
run = input.Run.IsPressed;
jump = input.Jump.WasPressed;
attack = input.Primary.WasPressed;
ranged = input.Secondary.WasPressed;
rangedHeld = input.Secondary.IsPressed;
// E на клавиатуре - в PlayerActionsPermanent.Activate; у PlayerActionsLocal.Activate только
// кнопка геймпада (тест 03.10: «подрыва нету»).
detonate = input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed;
}
if (input != null && !gui && input.ToggleCrouch.WasPressed)
{
_crouchToggled = !_crouchToggled;
}
Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
{
Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
cop.HandlePrimingDetonator();
}
Ranged(z, ranged, rangedHeld);
bool moving = move.sqrMagnitude > 0.01f;
float wantYaw = moving ? _yaw + Mathf.Atan2(move.x, move.y) * Mathf.Rad2Deg : _yaw;
_bodyYaw = Mathf.MoveTowardsAngle(_bodyYaw, wantYaw, TurnSpeed * Time.deltaTime);
z.SetRotation(new Vector3(0f, _bodyYaw, 0f));
z.rotation.x = -_pitch; // наклон головы - для луча удара
if (moving)
{
z.SetMoveForwardWithModifiers(run ? z.GetMoveSpeedAggro() : z.GetMoveSpeed(), 1f, 0f, false);
}
else
{
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
}
if (jump && z.onGround && !z.Jumping)
{
z.Jumping = true;
z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f);
}
if (ranged && IsScreamer(z))
{
Scream(world, z);
}
if (attack && Time.time >= _nextAttack)
{
z.SetLookPosition(MeleeAim(world, z));
if (z.Attack(false))
{
z.Attack(true);
float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
_nextAttack = Time.time + timeout;
_meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout);
}
}
if (_meleeLookUntil > 0f && _rState == RState.Idle)
{
if (Time.time < _meleeLookUntil)
{
z.SetLookPosition(MeleeAim(world, z));
}
else
{
_meleeLookUntil = 0f;
z.SetLookPosition(Vector3.zero);
}
}
_observer?.SetPosition(z.position);
// Спящие в зданиях просыпаются от одержимого, как от змеи (указание 05.10).
SnakeControl.Sleepers(world, _player, z.position, ref _sleeperCheck, Time.deltaTime);
Taunt(world, z);
}
static void ReadRanged(EntityAlive _z)
{
_hasRanged = false;
_rState = RState.Idle;
_rNext = 0f;
List<EAIRangedAttackTarget> tasks = _z.aiManager?.GetTasks<EAIRangedAttackTarget>();
if (tasks == null || tasks.Count == 0)
{
return;
}
EAIRangedAttackTarget t = tasks[0];
_hasRanged = true;
_rItem = t.itemActionType;
_rStartAnim = t.startAnimType;
_rRelease = t.releaseDelay;
_rDuration = t.attackDuration > 0f ? t.attackDuration : 3f;
// Вдвое быстрее ИИ (тест 03.10: «перезаряд медленный»).
_rCooldown = Mathf.Max(1f, t.baseCooldown * 0.5f);
_rSoundStart = t.soundStartName;
_rSoundRelease = t.soundReleaseName;
Debug.Log("[NecromancerTome] Possession: дальняя атака - оружие " + _rItem + ", замах " + _rStartAnim + ", длит. " + _rDuration + " с, откат " + _rCooldown + " с");
}
/// <summary>Подсказка при вселении: что умеет этот зомби.</summary>
static void Hint(EntityPlayerLocal _p, EntityAlive _z)
{
string text = Localization.Get("necroPossessHintBase");
if (_hasRanged)
{
text += ", " + Localization.Get("necroPossessHintRanged");
}
if (IsScreamer(_z))
{
text += ", " + Localization.Get("necroPossessHintScream");
}
if (_z.jumpMaxDistance >= 5f)
{
text += ", " + Localization.Get("necroPossessHintLeap");
}
if (_z is EntityZombieCop)
{
text += ", " + Localization.Get("necroPossessHintDetonate");
}
GameManager.ShowTooltip(_p, text);
}
/// <summary>Куда бить: грудь врага ближе MeleeAssistRange к голове и не дальше MeleeAssistDeg от
/// направления на прицел (ближайший к прицелу), иначе - точка под прицелом.</summary>
static Vector3 MeleeAim(World _world, EntityAlive _z)
{
Vector3 aim = AimPoint();
Vector3 head = _z.getHeadPosition();
Vector3 dir = (aim - head).normalized;
EntityAlive best = null;
float bestAngle = MeleeAssistDeg;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _z || e.IsDead() || e is EntityPlayer || SnakeControl.IsNecroEntity(e))
{
continue;
}
Vector3 to = e.getChestPosition() - head;
if (to.sqrMagnitude > MeleeAssistRange * MeleeAssistRange)
{
continue;
}
float angle = Vector3.Angle(dir, to);
if (angle < bestAngle)
{
bestAngle = angle;
best = e;
}
}
return best != null ? best.getChestPosition() : aim;
}
/// <summary>Точка под прицелом камеры (в мировых координатах), до 50 м.</summary>
static Vector3 AimPoint()
{
Transform cam = _player?.cameraTransform;
if (cam == null)
{
return Zombie.position + Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * 30f;
}
if (Physics.Raycast(cam.position, cam.forward, out RaycastHit hit, 50f, -5, QueryTriggerInteraction.Ignore) &&
hit.distance > CamBack + 0.5f)
{
return hit.point + Origin.position;
}
return cam.position + Origin.position + cam.forward * 50f;
}
/// <summary>ПКМ - дальняя атака зомби по шагам ванильной EAIRangedAttackTarget.</summary>
static void Ranged(EntityAlive _z, bool _pressed, bool _held)
{
if (!_hasRanged)
{
return;
}
float dt = Time.deltaTime;
switch (_rState)
{
case RState.Idle:
if (!_pressed)
{
return;
}
if (Time.time < _rNext || !_z.IsAttackValid())
{
Debug.Log("[NecromancerTome] Possession: дальняя атака не готова - откат " + Mathf.Max(0f, _rNext - Time.time).ToString("F1") + " с, атака " + (_z.IsAttackValid() ? "можно" : "нельзя"));
return;
}
RearmRanged(_z);
_z.emodel.avatarController.hitWeightMax = 0.5f;
_rTime = 0f;
if (!string.IsNullOrEmpty(_rSoundStart))
{
Audio.Manager.BroadcastPlay(_z, _rSoundStart);
}
if (_rStartAnim >= 0)
{
_z.StartAnimAction(_rStartAnim + 3000);
_rState = RState.StartAnim;
}
else
{
_rState = RState.Fire;
}
return;
case RState.StartAnim:
_z.SetLookPosition(AimPoint());
if (_z.GetAnimActionState() != AvatarController.ActionState.Ready)
{
return;
}
_z.ContinueAnimAction(_rStartAnim + 1 + 3000);
if (!string.IsNullOrEmpty(_rSoundRelease))
{
Audio.Manager.BroadcastPlay(_z, _rSoundRelease);
}
_rState = RState.Release;
_rTime = 0f;
return;
case RState.Release:
_z.SetLookPosition(AimPoint());
_rTime += dt;
if (_rTime >= _rRelease)
{
_rState = RState.Fire;
_rTime = 0f;
}
return;
case RState.Fire:
_z.SetLookPosition(AimPoint());
_rTime += dt;
_z.UseHoldingItem(_rItem, false);
// Доводим до конца по одному нажатию: у плевка сначала 1-3 предупреждения по 1.2 с
// (ItemActionVomit), и с отпущенной кнопкой он сбрасывался (тест 03.10: «если зажать
// ПКМ - плюёт»). Конец - оружие закончило или вышла длительность задачи.
if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration)
{
StopRanged(_z);
}
return;
}
}
/// <summary>Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
/// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
/// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).</summary>
static void RearmRanged(EntityAlive _z)
{
ItemInventoryData inv = _z.inventory?.holdingItemData;
if (inv == null || _rItem >= inv.actionData.Count)
{
return;
}
ItemValue iv = _z.inventory.holdingItemItemValue;
string before = "патронов " + (iv != null ? iv.Meta.ToString() : "?");
if (inv.actionData[_rItem] is ItemActionRanged.ItemActionDataRanged d && _z.inventory.holdingItem.Actions[_rItem] is ItemActionRanged ranged)
{
before += ", очередь " + d.curBurstCount + ", перезарядка " + d.isReloading;
ranged.ResetBurstShot(d);
d.bReleased = true;
d.bPressed = false;
d.isReloading = false;
d.isReloadRequested = false;
}
// Плевок: без лишних «позывов» - сразу последнее предупреждение (ItemActionVomit: 1-3 по 1.2 с).
if (inv.actionData[_rItem] is ItemActionVomit.ItemActionDataVomit dv && _z.inventory.holdingItem.Actions[_rItem] is ItemActionVomit vomit)
{
dv.numWarningsPlayed = Mathf.Max(0, Traverse.Create(vomit).Field("warningMax").GetValue<int>() - 1);
dv.warningTime = 0f;
}
if (iv != null && iv.Meta < 1)
{
iv.Meta = 1;
}
Debug.Log("[NecromancerTome] Possession: дальняя атака - взвод (" + before + ")");
}
static void StopRanged(EntityAlive _z)
{
_z.ShowHoldingItem(false);
_z.UseHoldingItem(_rItem, true);
_z.StartAnimAction(9999);
_z.SetLookPosition(Vector3.zero);
_z.emodel.avatarController.hitWeightMax = 1f;
_rState = RState.Idle;
_rNext = Time.time + _rCooldown;
}
static bool IsScreamer(EntityAlive _z)
{
return EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c) && c.entityClassName.StartsWith("zombieScreamer");
}
/// <summary>Крик одержимой крикуньи: её звук тревоги и все зомби в ScreamRange - на неё (танкование).</summary>
static void Scream(World _world, EntityAlive _z)
{
if (Time.time < _nextScream)
{
return;
}
_nextScream = Time.time + ScreamCooldown;
string snd = _z.GetSoundAlert();
if (!string.IsNullOrEmpty(snd))
{
Audio.Manager.BroadcastPlay(_z, snd);
}
int n = 0;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _z.position).sqrMagnitude > ScreamRange * ScreamRange)
{
continue;
}
e.ConditionalTriggerSleeperWakeUp();
AllowZombieTarget(e);
e.SetAttackTarget(_z, TauntTicks);
n++;
}
Debug.Log("[NecromancerTome] Possession: крик крикуньи - на зов идут " + n);
}
static void Look()
{
PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
_yaw += Input.GetAxis("Mouse X") * sens.x;
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f);
}
/// <summary>Раз в секунду: зомби, которые видят одержимого, бросаются на него (решение 03.10:
/// «зомби начинают считать этого зомби ещё одним игроком»). Ванильный подход к цели принимает
/// только свои классы (EntityPlayer...) - добавляем EntityZombie, как CharmPatch; это безопасно:
/// цель-зомби им ставим только мы (поиск цели EAISetNearestEntityAsTarget не трогаем).</summary>
static void Taunt(World _world, EntityAlive _z)
{
if (Time.time < _nextTaunt)
{
return;
}
_nextTaunt = Time.time + TauntPeriod;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.GetAttackTarget() == _z ||
e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null)
{
continue;
}
float d2 = (e.position - _z.position).sqrMagnitude;
if (d2 > TauntRange * TauntRange)
{
continue;
}
if (e.IsSleeping)
{
float wake = Crouching ? SleeperWakeCrouched : SleeperWakeRange;
if (d2 > SleeperHearRange * SleeperHearRange && (d2 > wake * wake || !e.CanSee(_z)))
{
continue;
}
e.ConditionalTriggerSleeperWakeUp();
Debug.Log("[NecromancerTome] Possession: проснулся " + e.EntityName + " (" + e.entityId + "), " + Mathf.Sqrt(d2).ToString("F1") + " м");
}
else if (!e.CanSee(_z))
{
continue;
}
AllowZombieTarget(e);
e.SetAttackTarget(_z, TauntTicks);
}
}
static void AllowZombieTarget(EntityAlive _e)
{
List<EAIApproachAndAttackTarget> tasks = _e.aiManager.GetTasks<EAIApproachAndAttackTarget>();
if (tasks == null)
{
return;
}
foreach (EAIApproachAndAttackTarget t in tasks)
{
bool has = false;
foreach (EAIApproachAndAttackTarget.TargetClass c in t.targetClasses)
{
has |= c.type == typeof(EntityZombie);
}
if (!has)
{
t.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = typeof(EntityZombie), chaseTimeMax = 0f });
}
}
}
/// <summary>Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.</summary>
public static void PlaceCamera(Transform _camera)
{
EntityAlive z = Zombie;
if (z == null)
{
return;
}
Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null;
Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f;
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
Vector3 eye = anchor + Vector3.up * CamUp - rot * Vector3.forward * CamBack;
Vector3 to = eye - anchor;
if (Physics.Raycast(anchor, to.normalized, out RaycastHit hit, to.magnitude + 0.2f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
{
eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f);
}
_camera.SetPositionAndRotation(eye, rot);
if (!_loggedCamera)
{
_loggedCamera = true;
Debug.Log("[NecromancerTome] Possession: камера за " + (head != null ? "головой " + head.name : "корнем (головы нет)"));
}
}
}
/// <summary>Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))]
public static class Patch_EntityAlive_updateTasks_Possession
{
public static bool Prefix(EntityAlive __instance)
{
return !Possession.IsPossessed(__instance);
}
}
/// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_Possession
{
public static bool Prefix(PlayerMoveController __instance)
{
if (!Possession.Active)
{
return true;
}
__instance.stopMoving();
return false;
}
}
/// <summary>Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
/// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
/// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.</summary>
public class PossessionHud : MonoBehaviour
{
const float Width = 420f, Height = 16f, Bottom = 150f;
static readonly Color Back = new Color(0f, 0f, 0f, 0.6f);
static readonly Color Fill = new Color(0.55f, 0.08f, 0.06f, 0.95f);
static GUIStyle _style;
void OnGUI()
{
EntityAlive z = Possession.Active ? Possession.Zombie : null;
if (z == null || Event.current.type != EventType.Repaint)
{
return;
}
float scale = Screen.height / 1080f;
float w = Width * scale, h = Height * scale;
Rect r = new Rect((Screen.width - w) / 2f, Screen.height - Bottom * scale - h, w, h);
float frac = Mathf.Clamp01(z.Health / (float)Mathf.Max(1, z.GetMaxHealth()));
Color prev = GUI.color;
GUI.color = Back;
GUI.DrawTexture(r, Texture2D.whiteTexture);
GUI.color = Fill;
GUI.DrawTexture(new Rect(r.x + 2f, r.y + 2f, (r.width - 4f) * frac, r.height - 4f), Texture2D.whiteTexture);
GUI.color = prev;
if (_style == null)
{
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.LowerCenter };
_style.normal.textColor = new Color(0.9f, 0.85f, 0.8f);
}
_style.fontSize = Mathf.RoundToInt(18f * scale);
string name = Localization.Get(z.EntityName);
GUI.Label(new Rect(r.x, r.y - 26f * scale, r.width, 24f * scale), name + " " + z.Health + " / " + z.GetMaxHealth(), _style);
}
}
/// <summary>Приседание одержимого: пока зажата кнопка, рост зомби опускается до CrouchHeight, как игра
/// опускает его под низким потолком (EntityAlive.CrouchHeightFixedUpdate, её же шаги). Отпустил - дальше
/// считает игра: встаёт, только если сверху свободно. Сама игра пригибает лишь под потолком над головой,
/// а проём впереди чует через moveHelper, который у одержимого остановлен.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.CrouchHeightFixedUpdate))]
public static class Patch_EntityAlive_CrouchHeightFixedUpdate_Possession
{
public static bool Prefix(EntityAlive __instance)
{
if (!Possession.Crouching || !Possession.IsPossessed(__instance) || __instance.crouchType == 0 ||
__instance.physicsBaseHeight <= 1.3f)
{
return true;
}
float h = Utils.FastMoveTowards(__instance.physicsHeight, __instance.physicsBaseHeight * Possession.CrouchHeight, 0.099999994f);
__instance.SetHeight(h);
if (h <= __instance.physicsBaseHeight * 0.7f)
{
__instance.crouchBendPerTarget = 0f;
if (__instance.walkType != 8 && __instance.walkType != 21)
{
int was = __instance.walkType;
__instance.SetWalkType(8);
__instance.walkTypeBeforeCrouch = was;
}
}
__instance.crouchBendPer = Mathf.MoveTowards(__instance.crouchBendPer, __instance.crouchBendPerTarget, 0.099999994f);
return false;
}
}
}
+77 -65
View File
@@ -269,97 +269,108 @@ namespace NecromancerTome
// `ToWorldPos()` (this feature's own position) instead of a dictionary-passed key.
// ------------------------------------------------------------------
// 01.10.2026: огня больше нет. Своя модель (бандл necroward) светится сама - кристалл и
// глазницы в карте эмиссии; включённая пирамида светится ярко, выключенная тускло
// (MaterialPropertyBlock на рендерерах модели блока, материал общий не трогается).
static readonly MaterialPropertyBlock glowBlock = new MaterialPropertyBlock();
Transform ModelTransform()
{
World world = GameManager.Instance?.World;
if (world == null) return null;
Vector3i pos = ToWorldPos();
Chunk chunk = world.GetChunkFromWorldPos(pos) as Chunk;
BlockEntityData be = chunk?.GetBlockEntity(pos);
return be != null && be.bHasTransform ? be.transform : null;
}
bool ApplyModelGlow(bool on)
{
Transform t = ModelTransform();
if (t == null) return false;
Color c = on ? new Color(2.2f, 2.2f, 2.2f, 1f) : new Color(0.25f, 0.25f, 0.25f, 1f);
foreach (Renderer r in t.GetComponentsInChildren<Renderer>(true))
{
r.GetPropertyBlock(glowBlock);
glowBlock.SetColor("_EmissionColor", c);
r.SetPropertyBlock(glowBlock);
}
return true;
}
public void SpawnGlow()
{
if (GameManager.Instance == null)
{
return;
}
Vector3i pos = ToWorldPos();
if (GameManager.Instance.HasBlockParticleEffect(pos))
{
return;
}
// FIXED 2026-09-02 (user report: "горение пирамидки почему-то смещено на куб вверх и
// вбок"): World.blockToTransformPos(Vector3i) ALREADY returns (x+0.5, y, z+0.5)
// (decompiled to confirm - horizontally centered, y left raw/un-centered) - adding
// another +0.5 on x/z on top of that (the original bug) double-centered it, landing a
// full extra block over on both horizontal axes. Only the vertical lift (how far above
// the block the flame sits) is actually ours to add. Height LOWERED again same day
// ("сделай ниже не 2/3 куба" - after the sideways offset was gone, still sat too high):
// 1.2 (0.2 above the full block top) -> 0.6, under 2/3 (0.667) of a block as asked.
// Height lowered twice same day: 1.2 -> 0.6 ("сделай ниже не 2/3 куба"), then -> 0.4
// (direct follow-up: "Снизь высоту пламени до +0.4").
Vector3 worldPos = World.blockToTransformPos(pos) + new Vector3(0f, 0.4f, 0f);
// WardTint (not Color.white) as the ParticleEffect's own _color: ParticleEffect.
// SpawnParticleEffect applies this directly to any non-ParticleSystem Renderer on the
// prefab (decompiled to confirm) - covers a sub-emitter/glow sprite ApplyGlowTint's own
// ParticleSystem-only loop wouldn't reach, belt-and-suspenders alongside it.
GameManager.Instance.SpawnBlockParticleEffect(pos, new ParticleEffect(GlowParticleName, worldPos, Quaternion.identity, 0f, WardTint));
pendingTint.Add(pos);
if (GameManager.Instance != null && !ApplyModelGlow(true)) glowPending = true;
}
public void RemoveGlow()
{
if (GameManager.Instance == null)
{
return;
}
if (GameManager.Instance == null) return;
// пирамиды, поставленные до 01.10, могли оставить огонь - снимаем, если есть
Vector3i pos = ToWorldPos();
if (GameManager.Instance.HasBlockParticleEffect(pos))
{
GameManager.Instance.RemoveBlockParticleEffect(pos);
}
if (GameManager.Instance.HasBlockParticleEffect(pos)) GameManager.Instance.RemoveBlockParticleEffect(pos);
pendingTint.Remove(pos);
if (!ApplyModelGlow(false)) glowPending = true;
}
/// <summary>Ring of glow points marking EffectRadius, keyed by y = -1000-i (real block
/// y-coordinates never go negative that far, so these keys can never collide with an
/// actual placed block's own glow key).</summary>
bool glowPending = true;
// Граница зоны (01.10: «вместо огня другие частицы, как круг у квеста на копание»):
// светящееся фиолетовое кольцо по земле - LineRenderer, точки опущены на рельеф лучом.
// Дочерний объект модели блока - поэтому сдвиги Origin и выгрузка чанка его не теряют.
GameObject ring;
public void SpawnZoneRing()
{
if (GameManager.Instance == null)
if (ring != null) return;
Transform t = ModelTransform();
if (t == null) return;
ring = new GameObject("NecroWardRing");
ring.transform.SetParent(t, false);
var lr = ring.AddComponent<LineRenderer>();
lr.useWorldSpace = false; lr.loop = true;
const int n = 96;
lr.positionCount = n;
lr.widthMultiplier = 0.14f;
var mat = new Material(Shader.Find("Sprites/Default"));
lr.material = mat;
Color col = new Color(0.75f, 0.25f, 1f, 0.85f);
lr.startColor = col; lr.endColor = col;
lr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; lr.receiveShadows = false;
Vector3 c = t.position;
for (int i = 0; i < n; i++)
{
return;
}
Vector3i basePos = ToWorldPos();
// Same double-centering bug as SpawnGlow's own fix above - blockToTransformPos already
// centers x/z, only the vertical offset (0.5, mid-block height) is ours to add.
Vector3 center = World.blockToTransformPos(basePos) + new Vector3(0f, 0.5f, 0f);
for (int i = 0; i < ZoneRingPointCount; i++)
{
float angle = i * (360f / ZoneRingPointCount) * Mathf.Deg2Rad;
Vector3 point = center + new Vector3(Mathf.Cos(angle) * EffectRadius, 0f, Mathf.Sin(angle) * EffectRadius);
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
if (GameManager.Instance.HasBlockParticleEffect(key))
{
continue;
}
// ZoneRingParticleName (not GlowParticleName) - see that constant's own comment:
// user asked for the boundary to read as "some particles", not literal fire.
GameManager.Instance.SpawnBlockParticleEffect(key, new ParticleEffect(ZoneRingParticleName, point, Quaternion.identity, 0f, WardTint));
pendingTint.Add(key);
float ang = i * Mathf.PI * 2f / n;
Vector3 p = c + new Vector3(Mathf.Cos(ang) * EffectRadius, 0f, Mathf.Sin(ang) * EffectRadius);
if (Physics.Raycast(p + Vector3.up * 12f, Vector3.down, out RaycastHit hit, 30f, 65536))
p.y = hit.point.y + 0.08f;
lr.SetPosition(i, t.InverseTransformPoint(p));
}
}
public void RemoveZoneRing()
{
if (GameManager.Instance == null)
{
return;
}
if (ring != null) { UnityEngine.Object.Destroy(ring); ring = null; }
if (GameManager.Instance == null) return;
// кольца из огоньков от версий до 01.10
Vector3i basePos = ToWorldPos();
for (int i = 0; i < ZoneRingPointCount; i++)
{
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
if (GameManager.Instance.HasBlockParticleEffect(key))
{
GameManager.Instance.RemoveBlockParticleEffect(key);
}
if (GameManager.Instance.HasBlockParticleEffect(key)) GameManager.Instance.RemoveBlockParticleEffect(key);
pendingTint.Remove(key);
}
}
/// <summary>Модель блока появляется позже, чем срабатывает OnAdded/загрузка, - поэтому
/// свечение и кольцо досоздаются из UpdateTick, когда модель уже есть.</summary>
void EnsureVisuals()
{
if (glowPending && ApplyModelGlow(EffectOn)) glowPending = false;
if (ZoneShown && ring == null) SpawnZoneRing();
}
/// <summary>Mirrors ParticlePatch.cs's own tint technique (same ParticleSystem.MainModule
/// fields, same reasoning: the particle prefab always instantiates at its authored color,
/// nothing in XML/the block-particle API can override that) - kept as its own copy here
@@ -463,6 +474,7 @@ namespace NecromancerTome
{
return;
}
EnsureVisuals();
for (int i = pendingTint.Count - 1; i >= 0; i--)
{
+447 -128
View File
@@ -40,8 +40,9 @@ namespace NecromancerTome
/// позади головы: голова впереди и чуть ниже, в поле зрения.
public const float EyeUp = 0.5f;
public const float EyeForward = -1.2f;
/// <summary>Сколько змея живёт в радиации края карты (указание 28.09: «таймер 30 сек»).</summary>
public const float RadiationTime = 30f;
/// <summary>Как часто змея проверяет радиацию края карты. Указание 02.10: без таймера, сразу
/// в тело; проверка раз в 2 с ради нагрузки (было 30 с отсчёта и проверка раз в 0.5 с).</summary>
public const float RadiationCheckPeriod = 2f;
/// <summary>Порог игры: buffRadiation03 вешается при _biomeradiation &gt; 2.5
/// (buffs.xml, buffStatusCheck01), значение - biomeProvider.GetRadiationAt.</summary>
public const float RadiationThreshold = 2.5f;
@@ -55,6 +56,18 @@ namespace NecromancerTome
/// <summary>Радиус проверочных сфер при шаге. Меньше полублока - в дверной проём и щель в
/// один блок змея пролезает.</summary>
public const float BodyRadius = 0.25f;
/// <summary>Точка тела для проверки «внутри текстуры»: над корнем змеи, ниже головы.</summary>
public const float BodyUp = 0.35f;
/// <summary>Как далеко вверх искать поверхность, под которой змея может быть зарыта.</summary>
public const float InsideProbe = 32f;
/// <summary>Полёт (указание 05.10): пока зажата правая кнопка. Скорость - вдвое ниже шага
/// зомби (MindControl.WalkSpeed), и в воздухе, и сквозь постройки; Shift не ускоряет.
/// Сквозь грунт (земля, песок, камень) - никак.</summary>
static bool _flying;
public const float FlySpeed = MindControl.WalkSpeed * 0.5f;
/// <summary>Не нашлось, куда выбросить змею из текстур, - пауза, а не поиск каждый кадр.</summary>
static float _ejectWait;
static Vector3[] _ejectOffsets;
/// <summary>Параметр Forward аниматора змеи при ползании и на Shift. Подобраны на глаз:
/// у животного с RootMotion полный ход даёт landMovementFactor * 2.5 = 0.25.</summary>
@@ -78,6 +91,11 @@ namespace NecromancerTome
public const float KillFreeze = 0.35f;
public const float BurstFollow = 1f;
static float _freeze;
/// <summary>Отброс взрывом Стирателя (02.10): горизонтальная скорость, гаснет сама.</summary>
static Vector3 _kick;
public const float KickSpeed = 14f;
public const float KickUp = 8f;
public const float KickDamping = 3f;
static int _killId = -1;
static Transform _burst;
static Vector3 _burstOffset;
@@ -92,6 +110,15 @@ namespace NecromancerTome
public const string SenseNavObject = "zombie";
static readonly System.Collections.Generic.HashSet<int> _sensed = new System.Collections.Generic.HashSet<int>();
static float _senseCheck;
/// <summary>Очередь испуга (указание 02.10: «пусть пугаются по очереди» - толпа, напуганная
/// разом, давала фризы): новые учуянные встают в очередь, пугается по одному раз в
/// FearInterval. Уже напуганным страх продлевается сразу, это дёшево.</summary>
static readonly System.Collections.Generic.Queue<int> _fearQueue = new System.Collections.Generic.Queue<int>();
static readonly System.Collections.Generic.HashSet<int> _fearQueued = new System.Collections.Generic.HashSet<int>();
static float _fearCheck;
public const string SnakeMapNavObject = "necroSnakeSpirit";
static EntityAlive _mapSnake;
public const float FearInterval = 0.25f;
/// <summary>Убитые змеёй: с них не падает мешок с добычей (указание 28.09: «в режиме змеи
/// лут не выпадал вообще»). Снимается в патче dropItemOnDeath.</summary>
public static readonly System.Collections.Generic.HashSet<int> NoLoot = new System.Collections.Generic.HashSet<int>();
@@ -99,24 +126,22 @@ namespace NecromancerTome
/// или лута, она для повышения уровня Некромантии»). Снимается в патче AddKillXP.</summary>
public static readonly System.Collections.Generic.HashSet<int> NoXp = new System.Collections.Generic.HashSet<int>();
/// <summary>Зомби, которые заметили змею (указание 28.09: «встречные зомби просто стоят,
/// скучно - пусть начинают ходить, если змея рядом»): не дальше StirRange идут к ней, как на
/// шум. Нападать всё равно не станут - змей зомби не трогают.</summary>
public const float StirRange = 20f;
static float _stirCheck;
public const string ToolbeltWindow = "toolbelt";
public static bool Controlling;
public static float AnimForward;
/// <summary>Секунды до развеивания; меньше нуля - змея не в радиации.</summary>
public static float Countdown = -1f;
static float _radiationCheck;
/// <summary>Свой наблюдатель чанков у змеи: мир подгружается вокруг наблюдателей, а у игрока
/// он стоит у тела (тест 28.09: «змея не может уйти далеко от тела»). Так же игра грузит
/// место под сброс припасов (AIAirDrop) - только здесь с видимой сеткой и дальностью
/// обзора из настроек.</summary>
static ChunkManager.ChunkObserver _observer;
/// <summary>Область тела сжата до Possession.BodyViewDim, как у одержимого (указание 05.10:
/// «делай как у одержимого»): две полные области - лишняя память. Полный радиус - для систем,
/// рисующих мир у змеи (SnakeAudioSwap). -1 - не сжата.</summary>
static int _bodyViewDim = -1;
public static int FullViewDim => _bodyViewDim;
public static EntityAlive Target;
static Vector3 _pos;
static float _vy;
@@ -144,18 +169,31 @@ namespace NecromancerTome
_loggedCamera = false;
_headForward = -1f;
_dashTarget = null;
_stirCheck = 0f;
_freeze = 0f;
_kick = Vector3.zero;
_flying = false;
_ejectWait = 0f;
_killId = -1;
_burst = null;
_sleeperCheck = 0f;
_senseCheck = 0f;
_fearQueue.Clear();
_fearQueued.Clear();
_fearCheck = 0f;
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10.
_snake.AddNavObject(SnakeMapNavObject, null, null);
_mapSnake = _snake;
Target = null;
Countdown = -1f;
_radiationCheck = 0f;
BodyDanger = false;
_bodyCheck = 0f;
_observer = GameManager.Instance.AddChunkObserver(_pos, true, GameUtils.GetViewDistance(), -1);
ChunkManager.ChunkObserver bodyObserver = _player.ChunkObserver;
if (bodyObserver != null && bodyObserver.viewDim > Possession.BodyViewDim)
{
_bodyViewDim = bodyObserver.viewDim;
bodyObserver.viewDim = Possession.BodyViewDim;
}
Controlling = true;
// Пояс прячется на весь режим (указание 28.09); обратно его откроет сама игра в OnHUD.
_player.PlayerUI?.windowManager.Close(ToolbeltWindow);
@@ -210,11 +248,23 @@ namespace NecromancerTome
public static void End()
{
ClearSense();
if (_mapSnake != null)
{
_mapSnake.RemoveNavObject(SnakeMapNavObject);
_mapSnake = null;
}
_fearQueue.Clear();
_fearQueued.Clear();
Controlling = false;
SnakeVision.Off(GameManager.Instance?.World?.GetPrimaryPlayer());
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
SnakeVision.Off(player);
if (_bodyViewDim > 0 && player?.ChunkObserver != null)
{
player.ChunkObserver.viewDim = _bodyViewDim;
}
_bodyViewDim = -1;
_dashTarget = null;
Target = null;
Countdown = -1f;
BodyDanger = false;
if (_observer != null)
{
@@ -239,6 +289,8 @@ namespace NecromancerTome
Vector2 move = Vector2.zero;
bool run = false;
bool jump = false;
bool jumpHeld = false;
bool downHeld = false;
bool attack = false;
World world = GameManager.Instance.World;
if (_freeze > 0f)
@@ -255,13 +307,25 @@ namespace NecromancerTome
move = new Vector2(input.Move.X, input.Move.Y);
run = input.Run.IsPressed;
jump = input.Jump.WasPressed;
// Указание 28.09: атака на левую кнопку, правая - выбраться из застревания.
jumpHeld = input.Jump.IsPressed;
downHeld = input.Crouch.IsPressed || input.ToggleCrouch.IsPressed;
// Указание 28.09: атака на левую кнопку. 05.10: E - аварийный выход из текстур
// (E в игре на PermanentActions.Activate, см. MindControl).
attack = input.Primary.WasPressed;
if (input.Secondary.WasPressed)
if ((input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed) && _dashTarget == null)
{
Unstick(world);
Eject(world, false, 0.5f);
}
}
// Полёт - пока зажата правая кнопка (указание 05.10); отпустил - змея падает.
bool fly = !gui && input != null && input.Secondary.IsPressed && _dashTarget == null;
if (fly != _flying)
{
_flying = fly;
_vy = 0f;
_airborne = !fly;
Debug.Log("[NecromancerTome] SnakeControl: полёт " + (fly ? "вкл" : "выкл"));
}
if (_dashTarget == null && attack && Target != null)
{
@@ -275,6 +339,10 @@ namespace NecromancerTome
if (_dashTarget != null)
{
Dash(_player);
if (_dashTarget == null)
{
KeepOut(world, dt);
}
_observer?.SetPosition(_pos);
_snake.SetPosition(_pos);
_snake.SetRotation(new Vector3(0f, _snakeYaw, 0f));
@@ -286,6 +354,12 @@ namespace NecromancerTome
}
Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
Vector3 dir = yawRot * Vector3.forward * move.y + yawRot * Vector3.right * move.x;
if (_flying)
{
// В полёте - куда смотрит камера, Пробел вверх, приседание вниз.
dir = Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * move.y + yawRot * Vector3.right * move.x +
Vector3.up * ((jumpHeld ? 1f : 0f) - (downHeld ? 1f : 0f));
}
if (dir.sqrMagnitude > 1f)
{
dir.Normalize();
@@ -293,21 +367,41 @@ namespace NecromancerTome
bool moving = dir.sqrMagnitude > 0.01f;
if (moving)
{
Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt;
float speed = _flying ? FlySpeed : run ? RunSpeed : WalkSpeed;
Vector3 delta = dir * speed * dt;
int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / MaxStep));
Vector3 d = delta / steps;
for (int i = 0; i < steps; i++)
{
// Упёрлись - пробуем скользить вдоль стены по одной из осей.
if (!TryStep(world, d) && !TryStep(world, new Vector3(d.x, 0f, 0f)) && !TryStep(world, new Vector3(0f, 0f, d.z)))
if (!TryStep(world, d) && !TryStep(world, new Vector3(d.x, 0f, 0f)) && !TryStep(world, new Vector3(0f, 0f, d.z)) &&
!(_flying && TryStep(world, new Vector3(0f, d.y, 0f))))
{
break;
}
}
}
if (_kick.sqrMagnitude > 0.01f)
{
Vector3 kd = _kick * dt;
int ks = Mathf.Max(1, Mathf.CeilToInt(kd.magnitude / MaxStep));
for (int i = 0; i < ks; i++)
{
if (!TryStep(world, kd / ks))
{
_kick = Vector3.zero;
break;
}
}
_kick *= Mathf.Exp(-KickDamping * dt);
}
// Вид от первого лица: змея смотрит туда же, куда игрок.
_snakeYaw = Mathf.LerpAngle(_snakeYaw, _yaw, dt * 12f);
Fall(dt, jump);
if (!_flying)
{
Fall(world, dt, jump);
}
KeepOut(world, dt);
_observer?.SetPosition(_pos);
_snake.SetPosition(_pos);
@@ -317,12 +411,37 @@ namespace NecromancerTome
_snake.moveDirection = Vector3.zero;
AnimForward = moving ? (run ? AnimRun : AnimWalk) : 0f;
CheckBody(world, _player, _snake, dt);
Stir(world, _snake, dt);
Sleepers(world, _player, dt);
Sleepers(world, _player, _pos, ref _sleeperCheck, dt);
Sense(world, _snake, dt);
FearQueue(world, _snake, dt);
return Radiation(world, dt);
}
static void FearQueue(World _world, EntityAlive _snake, float _dt)
{
_fearCheck -= _dt;
if (_fearCheck > 0f || _fearQueue.Count == 0)
{
return;
}
_fearCheck = FearInterval;
bool bloodMoon = _world.aiDirector?.BloodMoonComponent?.BloodMoonActive ?? false;
while (_fearQueue.Count > 0)
{
int id = _fearQueue.Dequeue();
_fearQueued.Remove(id);
EntityAlive e = _world.GetEntity(id) as EntityAlive;
// Ушёл из чутья, умер, стал своим, началась луна - пропускаем и берём следующего.
if (e == null || bloodMoon || _world.IsRemote() || !IsFoe(e, _snake) ||
(e.position - _pos).sqrMagnitude > SenseRange * SenseRange)
{
continue;
}
NecroFleeSnakeTask.Scare(e);
return;
}
}
/// <summary>Шаг рывка: по прямой к ногам цели, без земли и стен. Долетела - убийство: цель
/// помечается для чёрного разрыва (NecroBurst.Pending) и получает смертельный урон ОТ ИГРОКА -
/// тогда игра сама выдаёт опыт, а NecromancyKillCreditPatch засчитывает убийство в Некромантию.</summary>
@@ -349,6 +468,11 @@ namespace NecromancerTome
}
_pos = target.position - (flat.sqrMagnitude > 0.0001f ? flat.normalized * DashStop : Vector3.zero);
_dashTarget = null;
// Стиратель от разрыва взрывается; отброс змеи - из EraserPatch.Explode.
if (EraserPatch.IsEraser(target) && EraserPatch.Explode(target))
{
return;
}
_freeze = KillFreeze;
_killId = target.entityId;
NoLoot.Add(target.entityId);
@@ -365,6 +489,32 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] SnakeControl: удар по " + target.EntityName + ", " + (target.IsDead() ? "убит" : "ВЫЖИЛ"));
}
/// <summary>Из EraserPatch.Explode: змею ближе _range от взрыва отбрасывает прочь и вверх.
/// Не ранит - змея бессмертна.</summary>
public static void Knockback(Vector3 _from, float _range)
{
if (!Controlling)
{
return;
}
Vector3 away = _pos - _from;
if (away.sqrMagnitude > _range * _range)
{
return;
}
away.y = 0f;
if (away.sqrMagnitude < 0.01f)
{
away = Quaternion.Euler(0f, _yaw, 0f) * Vector3.back;
}
_dashTarget = null;
_kick = away.normalized * KickSpeed;
_vy = KickUp;
_airborne = true;
_freeze = 0.6f;
Debug.Log("[NecromancerTome] SnakeControl: отброшена взрывом Стирателя");
}
/// <summary>Из NecroBurst.OnBurst: наш разрыв - запоминаем частицы, чтобы вести их за змеёй.</summary>
public static void OnBurst(EntityAlive _victim, Transform _effect)
{
@@ -396,17 +546,18 @@ namespace NecromancerTome
/// <summary>Комнаты со спящими в зданиях просыпаются, когда в них входит ИГРОК:
/// EntityPlayer.CheckSleeperTriggers каждый тик зовёт World.CheckSleeperVolumeTouching и
/// CheckTriggerVolumeTrigger, а те берут player.position (тест 28.09: «зомби в локациях не
/// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи.</summary>
static void Sleepers(World _world, EntityPlayerLocal _player, float _dt)
/// спавнятся, если змея туда заходит»). Раз в 0,25 с зовём их же, подставив позицию змеи
/// (и одержимого - Possession, 05.10).</summary>
public static void Sleepers(World _world, EntityPlayerLocal _player, Vector3 _at, ref float _timer, float _dt)
{
_sleeperCheck -= _dt;
if (_sleeperCheck > 0f || _world.IsRemote())
_timer -= _dt;
if (_timer > 0f || _world.IsRemote())
{
return;
}
_sleeperCheck = 0.25f;
_timer = 0.25f;
Vector3 body = _player.position;
_player.position = _pos;
_player.position = _at;
try
{
_world.CheckSleeperVolumeTouching(_player);
@@ -426,6 +577,19 @@ namespace NecromancerTome
return;
}
_senseCheck = 1f;
var senseWatch = System.Diagnostics.Stopwatch.StartNew();
// Леденящий ужас (02.10): учуянный зомби сразу бежит. ИИ живёт на сервере. В кровавую
// луну ужаса нет (указание 02.10: иначе змеёй распугивают орду).
bool scare = !_world.IsRemote() && !(_world.aiDirector?.BloodMoonComponent?.BloodMoonActive ?? false);
NecroFleeSnakeTask.SnakePos = _pos;
// Сетка поиска пути (AstarManager) строится только вокруг игроков (76 блоков) и отмеченных
// мест; тело далеко - у змеи сетки нет, и путь бегства не находился (тест 02.10, лог
// «путь нет»). Отмечаем место змеи, как игра отмечает точки орды (AddLocation(pos, 40) в
// AIDirectorBloodMoonParty). Отметка живёт 4 с и сливается с соседней - не копится.
if (!_world.IsRemote())
{
AstarManager.Instance?.AddLocation(_pos, 76);
}
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
@@ -437,6 +601,12 @@ namespace NecromancerTome
bool foe = IsFoe(e, _snake);
if (foe && d2 < SenseRange * SenseRange)
{
// Только наземные: зомби и звери-зомби. Стервятникам пафайндер не нужен.
if (scare && (e is EntityZombie || e is EntityEnemyAnimal) && !NecroFleeSnakeTask.Refresh(e) &&
_fearQueued.Add(e.entityId))
{
_fearQueue.Enqueue(e.entityId);
}
if (_sensed.Add(e.entityId))
{
e.AddNavObject(SenseNavObject, null, null);
@@ -453,6 +623,10 @@ namespace NecromancerTome
}
// Исчезнувшие (разорванные, выгруженные) - просто забываем: метку игра снимет сама.
_sensed.RemoveWhere(id => _world.GetEntity(id) == null);
if (senseWatch.Elapsed.TotalMilliseconds > 3.0)
{
Debug.Log("[NecromancerTome] SnakeControl: долгое чутьё/ужас - " + senseWatch.Elapsed.TotalMilliseconds.ToString("F1") + " мс");
}
}
static void ClearSense()
@@ -470,37 +644,26 @@ namespace NecromancerTome
_sensed.Clear();
}
/// <summary>Раз в 2 с: спокойные зомби рядом со змеёй идут к ней, как на шум.</summary>
static void Stir(World _world, EntityAlive _snake, float _dt)
{
_stirCheck -= _dt;
if (_stirCheck > 0f)
{
return;
}
_stirCheck = 2f;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (!(e is EntityZombie) || !IsFoe(e, _snake) || e.IsSleeping || e.GetAttackTarget() != null ||
e.HasInvestigatePosition || (e.position - _pos).sqrMagnitude > StirRange * StirRange)
{
continue;
}
Vector3 spot = _pos + new Vector3(Random.Range(-3f, 3f), 0f, Random.Range(-3f, 3f));
e.SetInvestigatePosition(spot, 300, false);
}
}
/// <summary>Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под
/// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это
/// не враг»). Общее для тревоги у тела и для прицела.</summary>
static bool IsFoe(EntityAlive _e, EntityAlive _snake)
public static bool IsFoe(EntityAlive _e, EntityAlive _snake)
{
return _e != null && _e != _snake && _e is EntityEnemy && !_e.IsDead() &&
!IsNecroEntity(_e) &&
!GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
}
/// <summary>Питомцы и змея мода - тоже EntityEnemy (зомби-основа), и без этой проверки
/// охрана выбирала целью самого питомца: он ближе всех к хозяину (тест 30.09, лог
/// «pet 3838 идёт на 3838»). Все наши классы сущностей начинаются с necro.</summary>
public static bool IsNecroEntity(EntityAlive _e)
{
EntityClass cls;
return EntityClass.list.TryGetValue(_e.entityClass, out cls) && cls != null &&
cls.entityClassName != null && cls.entityClassName.StartsWith("necro");
}
/// <summary>Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу игрока.</summary>
static void CheckBody(World _world, EntityPlayerLocal _player, EntityAlive _snake, float _dt)
{
@@ -528,32 +691,17 @@ namespace NecromancerTome
}
}
/// <summary>Отсчёт в радиации края карты. Вышла из зоны - отсчёт сбрасывается.</summary>
/// <summary>Радиация края карты: змея в ней сразу развеивается, игрок в теле.</summary>
static bool Radiation(World _world, float _dt)
{
_radiationCheck -= _dt;
if (_radiationCheck <= 0f)
{
_radiationCheck = 0.5f;
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
bool inside = biomes != null && biomes.GetRadiationAt((int)_pos.x, (int)_pos.z) > RadiationThreshold;
if (inside && Countdown < 0f)
{
Countdown = RadiationTime;
Debug.Log("[NecromancerTome] SnakeControl: радиация, отсчёт " + RadiationTime + " с");
}
else if (!inside && Countdown >= 0f)
{
Countdown = -1f;
Debug.Log("[NecromancerTome] SnakeControl: вышла из радиации");
}
}
if (Countdown < 0f)
if (_radiationCheck > 0f)
{
return false;
}
Countdown -= _dt;
if (Countdown > 0f)
_radiationCheck = RadiationCheckPeriod;
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
if (biomes == null || biomes.GetRadiationAt((int)_pos.x, (int)_pos.z) <= RadiationThreshold)
{
return false;
}
@@ -581,6 +729,16 @@ namespace NecromancerTome
{
return false;
}
if (_flying)
{
// Сквозь постройки можно, в грунт - нет.
if (Inside(_world, c + Vector3.up * BodyUp, TerrainMask, out bool ground) && ground)
{
return false;
}
_pos = c;
return true;
}
// Один блок - ступенька, два - стена. Проверяем настоящую геометрию, а не тип блока:
// у блока есть только флаг «твёрдый», и открытая дверь для него такая же стена, как
// закрытая (тест 28.09: «змея не может пройти в открытые двери»).
@@ -599,11 +757,16 @@ namespace NecromancerTome
_vy = 0f;
}
}
// Ни в грунт, ни в блоки: обрыв выше подъёма или стена, которую шар изнутри не чует.
if (Inside(_world, c + Vector3.up * BodyUp, WallMask, out _))
{
return false;
}
_pos = c;
return true;
}
static void Fall(float _dt, bool _jump)
static void Fall(World _world, float _dt, bool _jump)
{
if (!Ground(_pos + Vector3.up * 0.3f, 300f, out float ground))
{
@@ -631,6 +794,12 @@ namespace NecromancerTome
_vy = 0f;
dy = 0f;
}
// Прыжок не должен заносить тело в текстуры (указание 05.10).
if (dy > 0f && Inside(_world, _pos + Vector3.up * (dy + BodyUp), WallMask, out _))
{
_vy = 0f;
dy = 0f;
}
_pos.y += dy;
if (_pos.y <= ground)
{
@@ -645,38 +814,115 @@ namespace NecromancerTome
return Physics.CheckSphere(_p - Origin.position, BodyRadius, WallMask, QueryTriggerInteraction.Ignore);
}
/// <summary>Правая кнопка: выбраться из текстур. Ближайшее свободное место - сначала выше
/// на месте, потом кольцами до 3 м и до 4 м вверх; там, где тело змеи ни во что не упирается.
/// Дальше змея сама падает на землю.</summary>
static void Unstick(World _world)
/// <summary>Каждый кадр: змея в грунте (в полёте) или в любой текстуре (на земле) -
/// выбрасываем на ближайшее свободное место (указание 05.10).</summary>
static void KeepOut(World _world, float _dt)
{
if (_dashTarget != null)
if (_ejectWait > 0f)
{
_ejectWait -= _dt;
return;
}
Vector3 body = _pos + Vector3.up * BodyUp;
if (!_world.IsChunkAreaCollidersLoaded(body))
{
return;
}
for (float r = 0f; r <= 3f; r += 1f)
bool ground;
bool stuck = _flying ? Inside(_world, body, TerrainMask, out ground) && ground : Inside(_world, body, WallMask, out ground);
if (stuck && !Eject(_world, _flying, 0f))
{
int dirs = r == 0f ? 1 : 8;
// На месте - минимум на полметра вверх: проверка может счесть место свободным, а
// змея всё равно не идёт (тест 28.09: дважды «выбралась, сдвиг 0.0 м»).
for (float h = r == 0f ? 0.5f : 0f; h <= 4f; h += 0.5f)
_ejectWait = 1f;
}
}
/// <summary>Ближайшее свободное место: до 4 м вбок, до 1 м вниз и до 6 м вверх; вниз
/// дороже, чем вверх. _groundOnly - мешает только грунт (полёт). E зовёт с _minShift 0.5:
/// проверка может счесть место свободным, а змея не идёт (тест 28.09: «сдвиг 0.0 м»).</summary>
static bool Eject(World _world, bool _groundOnly, float _minShift)
{
if (_ejectOffsets == null)
{
System.Collections.Generic.List<Vector3> list = new System.Collections.Generic.List<Vector3>();
for (float r = 0f; r <= 4f; r += 0.5f)
{
for (int i = 0; i < dirs; i++)
int dirs = r == 0f ? 1 : 8;
for (float h = -1f; h <= 6f; h += 0.5f)
{
Vector3 p = _pos + Quaternion.Euler(0f, _yaw + i * 45f, 0f) * Vector3.forward * r + Vector3.up * h;
if (!_world.IsChunkAreaCollidersLoaded(p) || Blocked(p + Vector3.up * 0.4f) || Blocked(p + Vector3.up * 1f))
for (int i = 0; i < dirs; i++)
{
continue;
list.Add(Quaternion.Euler(0f, i * 45f, 0f) * Vector3.forward * r + Vector3.up * h);
}
Debug.Log("[NecromancerTome] SnakeControl: выбралась, сдвиг " + (p - _pos).magnitude.ToString("F1") + " м");
_pos = p;
_vy = 0f;
_airborne = true;
return;
}
}
list.Sort((x, y) => EjectCost(x).CompareTo(EjectCost(y)));
_ejectOffsets = list.ToArray();
}
for (int i = 0; i < _ejectOffsets.Length; i++)
{
Vector3 o = _ejectOffsets[i];
if (o.magnitude < _minShift || !Free(_world, _pos + o, _groundOnly))
{
continue;
}
Debug.Log("[NecromancerTome] SnakeControl: выброшена из текстур" + (_groundOnly ? " (грунт)" : "") + ", сдвиг " + o.magnitude.ToString("F1") + " м");
_pos += o;
_vy = 0f;
_airborne = !_flying;
return true;
}
Debug.Log("[NecromancerTome] SnakeControl: выбраться некуда");
return false;
}
static float EjectCost(Vector3 _o)
{
return _o.magnitude + (_o.y < 0f ? -_o.y : 0f);
}
static bool Free(World _world, Vector3 _p, bool _groundOnly)
{
if (!_world.IsChunkAreaCollidersLoaded(_p))
{
return false;
}
bool ground;
if (_groundOnly)
{
return !(Inside(_world, _p + Vector3.up * BodyUp, TerrainMask, out ground) && ground);
}
return !Inside(_world, _p + Vector3.up * BodyUp, WallMask, out ground) &&
!Blocked(_p + Vector3.up * 0.4f) && !Blocked(_p + Vector3.up * 1f);
}
/// <summary>Точка внутри текстуры? Земля игры - поверхность, а не сплошная масса, и шар
/// (Blocked) изнутри её не чует. Поэтому так: луч вверх до первого потолка (поверхность
/// лицом вниз), потом луч от потолка вниз до точки. Попалась поверхность лицом вверх -
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
/// точке), а не постройка.</summary>
static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
{
_ground = false;
Vector3 o = _p - Origin.position;
float top = InsideProbe;
if (Physics.Raycast(o, Vector3.up, out RaycastHit ceil, InsideProbe, _mask, QueryTriggerInteraction.Ignore))
{
top = ceil.distance;
}
if (top < 0.05f ||
!Physics.Raycast(o + Vector3.up * (top - 0.02f), Vector3.down, out RaycastHit floor, top - 0.02f, _mask, QueryTriggerInteraction.Ignore))
{
return false;
}
_ground = IsGround(_world, floor.point + Origin.position + Vector3.down * 0.05f) || IsGround(_world, _p);
return true;
}
static bool IsGround(World _world, Vector3 _p)
{
BlockValue b = _world.GetBlock(World.worldToBlockPos(_p));
return !b.isair && b.Block.shape.IsTerrain();
}
static bool Solid(World _world, Vector3 _p)
@@ -790,6 +1036,11 @@ namespace NecromancerTome
{
public static bool Prefix(EntityPlayerLocal __instance)
{
if (MindControl.Controlling)
{
MindControl.PlaceCamera(__instance.cameraTransform);
return false;
}
if (!SnakeControl.Controlling)
{
return true;
@@ -837,6 +1088,14 @@ namespace NecromancerTome
{
SnakeControl.PlaceCamera(__instance.transform);
}
else if (Possession.Active)
{
Possession.PlaceCamera(__instance.transform);
}
else if (MindControl.Controlling)
{
MindControl.PlaceCamera(__instance.transform);
}
}
}
@@ -881,11 +1140,6 @@ namespace NecromancerTome
{
DrawBodyDanger();
}
if (SnakeControl.Countdown >= 0f)
{
DrawCountdown();
return;
}
Texture tex = _custom != null ? (Texture)_custom : _atlas.texture;
Rect uv = _custom != null
? new Rect(0f, 0f, 1f, 1f)
@@ -907,30 +1161,6 @@ namespace NecromancerTome
GUI.color = old;
}
GUIStyle _countdownStyle;
/// <summary>Вместо глаза - секунды до развеивания, красным, с тенью.</summary>
void DrawCountdown()
{
if (_countdownStyle == null)
{
_countdownStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontStyle = FontStyle.Bold
};
}
_countdownStyle.fontSize = Mathf.RoundToInt(44f * Screen.height / 1080f);
string text = Mathf.CeilToInt(SnakeControl.Countdown).ToString();
Rect rect = new Rect(Screen.width * 0.5f - 100f, Screen.height * 0.5f - 50f, 200f, 100f);
Color old = GUI.color;
GUI.color = new Color(0f, 0f, 0f, 0.8f);
GUI.Label(new Rect(rect.x + 2f, rect.y + 2f, rect.width, rect.height), text, _countdownStyle);
GUI.color = OnTarget;
GUI.Label(rect, text, _countdownStyle);
GUI.color = old;
}
GUIStyle _dangerStyle;
/// <summary>Надпись под прицелом, пока враг у тела.</summary>
@@ -1054,13 +1284,10 @@ namespace NecromancerTome
public static void Prefix(out Vector3 __state)
{
__state = Vector3.zero;
if (!SnakeControl.Controlling)
{
return;
}
World world = GameManager.Instance.World;
// И у одержимого зомби (03.10: «если одержимый отошёл подальше, новые не спавнятся возле него»).
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(world) : Possession.Active ? Possession.Zombie : null;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
EntityAlive snake = SnakeMode.Snake(world);
if (player == null || snake == null)
{
return;
@@ -1093,12 +1320,18 @@ namespace NecromancerTome
{
public static bool Prefix(EntityAlive __instance, ref int ___ticksNoPlayerAdjacent)
{
if (!SnakeControl.Controlling)
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(__instance.world as World) : Possession.Active ? Possession.Zombie : null;
if (snake == null)
{
return true;
}
EntityAlive snake = SnakeMode.Snake(__instance.world as World);
if (snake == null || snake == __instance || (__instance.position - snake.position).sqrMagnitude > 6400f)
// Одержимый сам не исчезает, как бы далеко от тела ни ушёл.
if (snake == __instance)
{
___ticksNoPlayerAdjacent = 0;
return !Possession.IsPossessed(__instance);
}
if ((__instance.position - snake.position).sqrMagnitude > 6400f)
{
return true;
}
@@ -1115,7 +1348,7 @@ namespace NecromancerTome
{
public static bool Prefix(GUIWindow _w)
{
return !(SnakeControl.Controlling && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
return !((SnakeControl.Controlling || MindControl.Controlling) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
}
}
@@ -1125,8 +1358,61 @@ namespace NecromancerTome
/// змеи. На время вызова переносим позицию игрока к змее.</summary>
public static class SnakeAudioSwap
{
/// <summary>Вложенность подмены: CheckLODs зовётся и изнутри WorldEnvironment.Update, где позиция уже
/// подменена. Подменяет и возвращает только внешний вызов, вложенные ничего не трогают.</summary>
static int _swapped, _swapFrame;
public static Vector3 Prefix()
{
if (_swapped > 0 && _swapFrame != Time.frameCount)
{
_swapped = 0; // подмена не вернулась (исключение посреди вызова) - не держим её дольше кадра
}
if (_swapped > 0)
{
_swapped++;
return Vector3.zero;
}
Vector3 body = PrefixOuter();
if (body != Vector3.zero)
{
_swapped = 1;
_swapFrame = Time.frameCount;
}
return body;
}
static Vector3 PrefixOuter()
{
if (MindControl.Controlling)
{
// Дух чертогов: звук, мир и компас - от него (MindControl.cs).
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p == null)
{
return Vector3.zero;
}
Vector3 bodyPos = p.position;
p.position = MindControl.FeetPos;
return bodyPos;
}
if (Possession.Active && Possession.Zombie != null)
{
// Одержимость (тест 03.10: «без звуков») - звуки мерятся от одержимого зомби, а не от тела.
EntityPlayerLocal pp = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pp == null)
{
return Vector3.zero;
}
Vector3 bodyAt = pp.position;
pp.position = Possession.Zombie.position;
// Область тела сжата (Possession.BodyViewDim) - этим системам радиус полный, они рисуют у зомби.
if (Possession.FullViewDim > 0 && pp.ChunkObserver != null)
{
pp.ChunkObserver.viewDim = Possession.FullViewDim;
}
return bodyAt;
}
if (!SnakeControl.Controlling)
{
return Vector3.zero;
@@ -1140,6 +1426,10 @@ namespace NecromancerTome
}
Vector3 body = player.position;
player.position = snake.position;
if (SnakeControl.FullViewDim > 0 && player.ChunkObserver != null)
{
player.ChunkObserver.viewDim = SnakeControl.FullViewDim;
}
return body;
}
@@ -1147,16 +1437,45 @@ namespace NecromancerTome
{
if (_body == Vector3.zero)
{
if (_swapped > 1)
{
_swapped--;
}
return;
}
_swapped = 0;
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player != null)
{
player.position = _body;
if (((Possession.Active && Possession.FullViewDim > 0) || (SnakeControl.Controlling && SnakeControl.FullViewDim > 0)) &&
player.ChunkObserver != null)
{
player.ChunkObserver.viewDim = Possession.BodyViewDim;
}
}
}
}
/// <summary>Полная модель блоков (двери, люки, мебель) ближе 41 м, дальше - упрощённая замена:
/// ChunkGameObject.CheckLODs меряет расстояние до ИГРОКА - тела. Его зовёт не только WorldEnvironment.Update
/// (там позиция уже подменена), но и сам чанк при появлении - тест 03.10: «змея и одержимый видят вместо
/// дверей деревянные блоки». Подменяем позицию и здесь: змея, одержимый, дух чертогов.</summary>
[HarmonyPatch(typeof(ChunkGameObject), nameof(ChunkGameObject.CheckLODs))]
public static class Patch_ChunkGameObject_CheckLODs_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
/// <summary>Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела), и вокруг змеи (05.10: «текстуры вокруг неё отображаются коряво»).</summary>
[HarmonyPatch(typeof(PrefabLODManager), nameof(PrefabLODManager.FrameUpdate))]
public static class Patch_PrefabLODManager_FrameUpdate_Possession
{
public static void Prefix(out Vector3 __state) { __state = Possession.Active || SnakeControl.Controlling ? SnakeAudioSwap.Prefix() : Vector3.zero; }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
[HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Vector3), typeof(string), typeof(int), typeof(bool), typeof(float) })]
public static class Patch_AudioManager_PlayAt_Snake
{
+20 -2
View File
@@ -72,7 +72,7 @@ namespace NecromancerTome
public static void Enter(EntityPlayerLocal _player)
{
if (Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
if (Active || MindPalace.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
{
return;
}
@@ -318,6 +318,10 @@ namespace NecromancerTome
{
Enter(_sender.xui.playerUI.entityPlayer);
}
else if (_commandIndex == 2)
{
MindPalace.Enter(_sender.xui.playerUI.entityPlayer);
}
}
}
@@ -326,7 +330,7 @@ namespace NecromancerTome
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
if (!__result && !SnakeMode.Active && SnakeMode.IsKnife(__instance.item))
if (!__result && !SnakeMode.Active && !MindPalace.Active && SnakeMode.IsKnife(__instance.item))
{
__result = true;
}
@@ -336,6 +340,9 @@ namespace NecromancerTome
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_SnakeKnife
{
/// <summary>Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается).</summary>
public const string MindPalaceIcon = "ui_game_symbol_book";
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (!SnakeMode.IsKnife(__instance.item))
@@ -355,6 +362,17 @@ namespace NecromancerTome
_xuiRadialWindow.CreateRadialEntry(1, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false);
}
// Чертоги разума (указание 03.10: «вход тоже через нож») - третья строка, тоже только ночью.
if (SnakeMode.IsNight())
{
_xuiRadialWindow.CreateRadialEntry(2, MindPalaceIcon, "UIAtlas", "",
Localization.Get("necroMindModePalace"), _highlighted: false);
}
else
{
_xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false);
}
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0);
return false;
+48 -1
View File
@@ -133,6 +133,11 @@ namespace NecromancerTome
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
/// разные стороны.</summary>
public bool UsesFollowTask;
/// <summary>Охраняет ли питомец хозяина сам (PetFollowPatch.Guard) - указание 30.09.2026:
/// «охраняют все, кроме крысы». Рой сюда не входит: он не спутник, а разовое заклинание
/// со своим поиском целей.</summary>
public bool Guards;
}
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
@@ -153,6 +158,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
Guards = true,
UsesFollowTask = true,
}
},
@@ -199,6 +205,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
Guards = true,
}
},
{
@@ -217,6 +224,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
Guards = true,
UsesFollowTask = true,
}
},
@@ -236,6 +244,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
Guards = true,
UsesFollowTask = true,
}
},
@@ -340,10 +349,48 @@ namespace NecromancerTome
}
return false;
}
RecallOtherCompanions(holdingEntity, __instance.entityToSpawn, tooltips);
summonAllowedThisCall = true;
return true;
}
/// <summary>ОДИН ПИТОМЕЦ ЗА РАЗ - указание 30.09.2026: «за один раз можно было призвать только
/// одного питомца. А то слишком жирно сразу и собаку, и крысу, и стервятника, и медведя».
/// Раньше предел был по одному НА ВИД, и все пятеро могли ходить вместе.
///
/// Сделано заменой, а не отказом: призыв нового сам отзывает прежнего. Игроку не надо
/// искать в поясе другую книгу, чтобы отозвать, и новых строк перевода не нужно - о прежнем
/// сообщает его же готовая надпись «… отозван».
///
/// Спутник - это вид с отзывом (RecalledKey). Рой Жуков не спутник: он разовое заклинание
/// без отзыва, сам никого не вытесняет, и его не вытесняют.</summary>
public static void RecallOtherCompanions(EntityAlive _owner, string _summoned, PetInfo _summonedInfo)
{
if (_summonedInfo.RecalledKey == null)
{
return;
}
foreach (KeyValuePair<string, PetInfo> entry in LimitedPets)
{
if (entry.Key == _summoned || entry.Value.RecalledKey == null)
{
continue;
}
int classId = EntityClass.GetId(entry.Key);
if (classId == -1)
{
continue;
}
List<OwnedEntityData> others = _owner.GetOwnedEntities(classId);
for (int i = others.Count - 1; i >= 0; i--)
{
Debug.Log("[NecromancerTome] SummonPatch: один питомец за раз - " + entry.Key + " " + others[i].Id +
" отозван ради " + _summoned);
RecallPet(_owner, others[i].Id, entry.Value.RecalledKey, null);
}
}
}
/// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
/// Последней строкой ваниль делает вот что:
///
@@ -634,7 +681,7 @@ namespace NecromancerTome
// Поэтому результат передаётся в Register, и тик повторяет попытку до успеха.
bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result);
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
petInfo.FlyingHomeRadius);
petInfo.FlyingHomeRadius, petInfo.Guards);
AttachFollowTask(owner, __result, petInfo);
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
+32
View File
@@ -0,0 +1,32 @@
using HarmonyLib;
namespace NecromancerTome
{
/// <summary>
/// Турели не стреляют по существам мода. Жалоба 01.10: «мои питомцы враги, их атакуют
/// турели». Питомцы, рой и змея сделаны на зомби-основе и для игры остаются EntityEnemy, а
/// обе турели (стационарная AutoTurretFireController и переносная MiniTurretFireController)
/// решают в shouldIgnoreTarget: EntityEnemy - стрелять, если включено «по зомби». Здесь
/// ответ «пропустить» для всех necro*-сущностей (SnakeControl.IsNecroEntity) - тот же признак,
/// по которому их не выбирает целью охрана.
/// </summary>
[HarmonyPatch(typeof(AutoTurretFireController), nameof(AutoTurretFireController.shouldIgnoreTarget))]
public static class Patch_AutoTurretFireController_shouldIgnoreTarget_Pets
{
public static void Postfix(Entity _target, ref bool __result)
{
if (!__result && _target is EntityAlive alive && SnakeControl.IsNecroEntity(alive))
__result = true;
}
}
[HarmonyPatch(typeof(MiniTurretFireController), nameof(MiniTurretFireController.shouldIgnoreTarget))]
public static class Patch_MiniTurretFireController_shouldIgnoreTarget_Pets
{
public static void Postfix(Entity _target, ref bool __result)
{
if (!__result && _target is EntityAlive alive && SnakeControl.IsNecroEntity(alive))
__result = true;
}
}
}
+1 -1
View File
@@ -4,6 +4,6 @@
<DisplayName value="Necromancer's Tome" />
<Description value="A dark necromancy progression for 7 Days to Die 3.3: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, undead companions that keep station with you and attack what you point at, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
<Author value="Alex Cube" />
<Version value="1.5.0" />
<Version value="1.6.1" />
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
</xml>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<prefab>
<property name="CopyAirBlocks" value="True" />
<property name="ExcludeDistantPOIMesh" value="False" />
<property name="ExcludePOICulling" value="False" />
<property name="DistantPOIYOffset" value="0" />
<property name="AllowDecorations" value="True" />
<property name="DifficultyTier" value="0" />
<property name="ShowQuestClearCount" value="1" />
<property name="TraderArea" value="False" />
<property name="PrefabSize" value="17, 8, 17" />
<property name="RotationToFaceNorth" value="2" />
<property name="Zoning" value="none" />
<property class="IndexedBlockOffsets">
<property class="POILight">
<property name="0" value="11, 3, 9" />
<property name="1" value="12, 3, 9" />
<property name="2" value="8, 4, 10" />
<property name="3" value="12, 4, 10" />
<property name="4" value="12, 4, 12" />
<property name="5" value="9, 3, 13" />
</property>
<property class="challenge_sinks">
<property name="0" value="11, 1, 3" />
<property name="1" value="13, 1, 4" />
</property>
<property class="TraderOnOff">
<property name="0" value="8, 1, 8" />
<property name="1" value="10, 1, 11" />
</property>
<property class="challenge_gore">
<property name="0" value="3, 2, 9" />
</property>
<property class="challenge_mech_parts">
<property name="0" value="6, 1, 13" />
</property>
</property>
<property class="Stats">
<property name="LightsVolume" value="5516.637" />
<property name="TotalVertices" value="91813" />
<property name="TotalTriangles" value="61958" />
<property class="BlockEntities">
<property name="Vertices" value="73400" />
<property name="Triangles" value="52089" />
</property>
<property class="ChunkMeshes0">
<property name="Vertices" value="18121" />
<property name="Triangles" value="9645" />
</property>
<property class="ChunkMeshes1">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
<property class="ChunkMeshes2">
<property name="Vertices" value="292" />
<property name="Triangles" value="224" />
</property>
<property class="ChunkMeshes3">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
<property class="ChunkMeshes4">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
<property class="ChunkMeshes5">
<property name="Vertices" value="0" />
<property name="Triangles" value="0" />
</property>
</property>
</prefab>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2010, j. 'mach' wust, with Reserved Font Name UnifrakturMaguntia.
Copyright (c) 2009, Peter Wiegel.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB