Compare commits
3
Commits
v1.0.0
..
ab1b3eadaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1b3eadaf | ||
|
|
026e006c9c | ||
|
|
6e1f42f053 |
@@ -122,3 +122,4 @@ necroFinalStayText,XUi,Menu,,,,"You took the army of the dead under your command
|
||||
necroFinalReturnTitle,XUi,Menu,,,,The Quiet Harbour,,Der stille Hafen,El puerto tranquilo,Le havre tranquille,Il porto tranquillo,静かな港,고요한 항구,Cicha przystań,O porto tranquilo,Тихая гавань,Sessiz Liman,静港,靜港
|
||||
necroFinalReturnText,XUi,Menu,,,,"You ordered the lich to lead the dead away into the forests, so that people could come out of their shelters and start surviving on their own, the way you once survived. If they manage, good. If they do not... then such is their fate. And you went back: to the world where you had already settled in and found your quiet harbour.",,"Du hast dem Lich befohlen, die Toten fort in die Wälder zu führen – damit die Menschen aus ihren Verstecken treten und selbst zu überleben beginnen können, so wie du einst überlebt hast. Schaffen sie es, gut. Schaffen sie es nicht ... dann ist das eben ihr Schicksal. Und du bist zurückgegangen: in die Welt, in der du dich bereits eingerichtet und deinen stillen Hafen gefunden hast.","Ordenaste al liche que se llevara a los muertos a los bosques, para que la gente pudiera salir de sus refugios y empezar a sobrevivir por su cuenta, igual que sobreviviste tú en su día. Si lo consiguen, bien. Si no... será su destino. Y tú regresaste: al mundo donde ya te habías asentado y habías encontrado tu puerto tranquilo.","Tu as ordonné à la liche d'emmener les morts au loin dans les forêts – pour que les gens puissent sortir de leurs abris et commencer à survivre seuls, comme tu as survécu autrefois. S'ils y parviennent, tant mieux. Sinon... c'est leur destin. Et toi, tu es reparti : vers le monde où tu t'étais déjà installé et où tu avais trouvé ton havre tranquille.","Hai ordinato al lich di condurre i morti via nelle foreste, perché la gente potesse uscire dai rifugi e iniziare a sopravvivere da sé, come un tempo sei sopravvissuto tu. Se ce la fanno, bene. Se non ce la fanno... allora è il loro destino. E tu sei tornato indietro: nel mondo dove ti eri già sistemato e avevi trovato il tuo porto tranquillo.",あなたはリッチに命じ、不死者をすべて森へ連れ去らせた。人々が避難所から出て、かつてのあなたと同じように、自らの力で生き延び始められるように。やり遂げるならそれでいい。できなければ……それが彼らの定めというだけだ。そしてあなたは戻っていった。すでに身を落ち着け、静かな港を見つけたあの世界へ。,"당신은 리치에게 명하여 언데드를 모두 숲으로 데려가게 했다. 사람들이 피난처에서 나와, 한때 당신이 그랬듯 스스로 살아남기 시작할 수 있도록. 해낸다면 좋은 일이다. 해내지 못한다면... 그것이 그들의 운명일 뿐이다. 그리고 당신은 돌아갔다. 이미 자리를 잡고 자신만의 고요한 항구를 찾아낸 그 세계로.","Rozkazałeś liczowi wyprowadzić umarłych w lasy – żeby ludzie mogli wyjść ze schronów i zacząć przetrwać o własnych siłach, tak jak kiedyś przetrwałeś ty. Poradzą sobie – dobrze. Nie poradzą... cóż, taki ich los. A ty wróciłeś: do świata, w którym zdążyłeś się urządzić i znaleźć swoją cichą przystań.","Você ordenou ao lich que levasse os mortos embora para as florestas - para que as pessoas pudessem sair dos abrigos e começar a sobreviver por conta própria, como você mesmo sobreviveu um dia. Se conseguirem, ótimo. Se não... então é o destino delas. E você voltou: para o mundo onde já havia se estabelecido e encontrado o seu porto tranquilo.","Вы приказали личу увести нежить в леса — чтобы люди вышли из убежищ и начали выживать сами, как когда-то выживали вы. Справятся — хорошо. Не справятся... значит, такова их судьба. А вы вернулись обратно: в мир, где успели обустроиться и найти свою тихую гавань.","Liche, ölüleri alıp ormanlara götürmesini emrettin - insanlar sığınaklarından çıkıp, bir zamanlar senin hayatta kaldığın gibi kendi başlarına hayatta kalmaya başlayabilsinler diye. Başarırlarsa ne âlâ. Başaramazlarsa... demek ki kaderleri buymuş. Sen ise geri döndün: çoktan yerleşip kendi sessiz limanını bulduğun o dünyaya.",你命令巫妖将亡灵尽数带往森林深处——好让人们走出避难所,像你当年那样,靠自己活下去。若他们撑得住,那很好;若撑不住……那便是他们的命。而你回去了:回到那个你早已安顿下来、寻得自己静港的世界。,你命令巫妖將亡靈盡數帶往森林深處——好讓人們走出避難所,像你當年那樣,靠自己活下去。若他們撐得住,那很好;若撐不住……那便是他們的命。而你回去了:回到那個你早已安頓下來、尋得自己靜港的世界。
|
||||
necroFinalBtnTheEnd,XUi,Menu,,,,The End,,Ende,Fin,Fin,Fine,おわり,끝,Koniec,Fim,Конец,Son,全剧终,全劇終
|
||||
txName_NecroAsh,painting,Texture,,,,Necrotic Ash,,Nekrotische Asche,Ceniza necrótica,Cendre nécrotique,Cenere necrotica,,,,,Некротический прах,,,
|
||||
|
||||
|
+95
-2
@@ -66,10 +66,103 @@
|
||||
<property class="TEFeaturePyramidWard" />
|
||||
</property>
|
||||
<property name="Material" value="Msteel_shapes" />
|
||||
<!-- ВЕРНУТО НА Shape="New" 2026-09-10 по решению пользователя: «пока верни пирамиду,
|
||||
которая была у нас изначально».
|
||||
|
||||
Своя модель со своей текстурой требует Shape="ModelEntity", и на этом пути осталось
|
||||
два невылеченных дефекта - разбор и все замеры в BACKLOG.md, раздел про пирамиду:
|
||||
- нет столкновений: при "New" их считает воксельная система блока, а "ModelEntity"
|
||||
берёт от модели, и одного BoxCollider на корне в слое 16 не хватило;
|
||||
- дальние экземпляры рисуются фиолетовым (маджента = не найден шейдер), ближние
|
||||
при этом верны; добавленный LODGroup положения не исправил.
|
||||
|
||||
Всё, что сделано для того пути, сохранено и работает: генератор текстуры
|
||||
_private/tools/make_pyramid_textures.py, сборка _private/Extracted/ShapesUnityProject,
|
||||
бандл Resources/necropyramid. Чтобы вернуться к нему, достаточно заменить Shape на
|
||||
ModelEntity и раскомментировать строку Model ниже. -->
|
||||
<property name="Shape" value="New" />
|
||||
<!-- Своя модель отключена вместе с возвратом на Shape="New" (см. комментарий выше).
|
||||
Строка сохранена: она рабочая, ею подключается наш бандл. -->
|
||||
<property name="Model" value="@:Shapes/pyramid.fbx" />
|
||||
<property name="Texture" value="356" />
|
||||
<property name="TintColor" value="8A4142" />
|
||||
<!-- <property name="Model" value="#@modfolder(NecromancerTome):Resources/necropyramid?necroPyramidPrefab.prefab" /> -->
|
||||
<!-- ModelOffset - ПОЛОЖЕНИЕ МОДЕЛИ, и задавать его надо именно здесь.
|
||||
|
||||
Меш формы из shapes-бандла сделан под воксельную систему: его границы лежат не вокруг
|
||||
начала координат, а в углу (центр -0.50, 0.13, -0.50 при размере 0.51 x 0.26 x 0.50).
|
||||
Как ModelEntity такая модель вылезает из своего куба - что и наблюдалось: «поднялась,
|
||||
вершиной упирается в верхний угол».
|
||||
|
||||
Сначала это лечили сдвигом детей ВНУТРИ префаба. Модель встала верно, но по блоку
|
||||
по-прежнему нельзя было попасть и не работало наведение по E: о сдвиге знал только
|
||||
префаб, а игра держала свой объём для попаданий там, где модель была бы без сдвига.
|
||||
Игрок целился в одно, а попадал мимо.
|
||||
|
||||
Ваниль двигает модель именно этим свойством (у верстака стоит "0,.5,0"), и его игра
|
||||
учитывает целиком - и в отрисовке, и в попаданиях. Значение посчитано скриптом сборки
|
||||
по фактическим границам меша: по X и Z в центр куба, по Y основанием на нижнюю грань.
|
||||
Если меш заменят, скрипт напечатает новое значение в лог. -->
|
||||
<!-- ModelOffset нужен только при ModelEntity; при "New" положение задаёт сама форма.
|
||||
Значение верное, посчитано скриптом сборки - сохранено для возврата.
|
||||
<property name="ModelOffset" value="0.5,-0.495,0.5" /> -->
|
||||
<!-- ПОКРАСКА ВМЕСТО СВОЕЙ ТЕКСТУРЫ, 2026-09-10. Мысль пользователя: «в игре есть
|
||||
кисточка, позволяющая перекрашивать блоки, можем ли мы наложить текстуру через
|
||||
покраску, только заранее?» Можем - именно это свойство Texture и делает.
|
||||
|
||||
Data/Config/painting.xml сопоставляет краски из кисточки с номерами текстур атласа
|
||||
(поле TextureId), а Texture у блока задаёт краску заранее. Доступно 156 красок;
|
||||
выписаны с названиями в _private/Extracted/paints.txt.
|
||||
|
||||
Было 356 (txName_Steel_wall, «оцинкованная стальная стена») - выбор достался от
|
||||
steelShapes вместе с парой Material/Texture и к праху отношения не имел.
|
||||
Пробовали 11 (txName_Gravel, «гравий») - в игре прочиталось как земля.
|
||||
Сейчас 552 (txName_GraniteBlack, «гранит чёрный»): тёмный камень ближе к
|
||||
спрессованному праху, чем крупная осыпь гравия.
|
||||
|
||||
СВОЮ текстуру сюда подставить НЕЛЬЗЯ, проверено по коду игры: у записи краски есть
|
||||
только TextureId, PaintCost, Group, SortIndex - поля с путём к своей картинке нет.
|
||||
TextureId это индекс в уже собранном атласе, а сам атлас лежит в
|
||||
blocktextureatlases_assets_all.bundle среди Addressables игры (рядом с
|
||||
TerrainTextures), и крючка для подмены его модом не нашлось. Своя текстура
|
||||
возможна только через путь ModelEntity со своим бандлом - см. BACKLOG.md.
|
||||
|
||||
Подбирать краску удобнее всего кисточкой прямо в игре: доступны все 156, перезапуск
|
||||
не нужен. Найденный номер прописывается сюда.
|
||||
|
||||
СЕЙЧАС ЭТИ ДВА СВОЙСТВА НЕ РАБОТАЮТ. Блок снова на Shape="ModelEntity" со своей
|
||||
моделью (2026-09-10, после того как префаб довели до вида ванильного): при
|
||||
ModelEntity поверхность даёт материал префаба, а не атлас, и Texture с TintColor
|
||||
не участвуют. Оставлены нетронутыми, чтобы возврат к покраске был ровно двумя
|
||||
правками - Shape на "New" и переключить строку Model ниже.
|
||||
|
||||
TintColor - множитель поверх краски. Взят пепельно-серый с уходом в фиолет, в тон
|
||||
остальному мод-набору. Прежний 8A4142 давал ржаво-красный.
|
||||
|
||||
Texture принимает и ШЕСТЬ значений через запятую - по одному на грань (в ванили есть
|
||||
например value="195,570,570,570,570,570"). Если захочется основание отличать от
|
||||
скатов - делается здесь же, без всякого бандла.
|
||||
|
||||
Хорошие запасные варианты из того же списка: 606 concrete_broken (потрескавшийся
|
||||
бетон), 552 GraniteBlack (чёрный гранит), 443 Rust_black (чёрная ржавчина),
|
||||
385 Green_rusty_metal (зелёная ржавчина, в тон вкраплениям). -->
|
||||
<!-- СВОЯ КРАСКА, 2026-09-10. Это уже НЕ ванильная краска из атласа: текстуру пирамиды
|
||||
добавляет в атлас наш собственный Harmony-патч, HarmonySrc/CustomBlockPaintPatch.cs.
|
||||
Блок при этом остаётся обычным Shape="New" - со всеми работающими столкновениями,
|
||||
наведением по E и правильной посадкой, - а поверхность у него своя.
|
||||
|
||||
608 - это НОМЕР ЗАПИСИ В uvMapping, а не номер краски. Патч печатает его в лог при
|
||||
каждом запуске: «uvMapping entry 608 points at slice 407». Краска дополнительно
|
||||
регистрируется под именем txName_NecroAsh («Некротический прах») и занимает слот 13,
|
||||
но блоку нужен именно номер записи.
|
||||
|
||||
ХРУПКОЕ МЕСТО: 608 - это длина uvMapping ванильной игры, то есть наша запись просто
|
||||
дописывается в конец. Число верно, пока атлас игры не изменился; после обновления игры
|
||||
оно может сдвинуться. Патч печатает фактический номер в лог, поэтому расхождение видно
|
||||
сразу - сверяться при обновлениях.
|
||||
|
||||
TintColor убран намеренно: цвет несёт сама текстура, а тинт - множитель, он бы её
|
||||
исказил. Тот же вывод, что уже сделан по ножу и по банке крови.
|
||||
<property name="TintColor" value="C8BCD2" /> -->
|
||||
<property name="Texture" value="608" />
|
||||
<!-- Real generated art (exch/pyramidOfSpirit.png, copied to
|
||||
UIAtlases/ItemIconAtlas/) added 2026-09-02, per direct instruction - no
|
||||
CustomIconTint alongside it (same lesson as every other real icon in this mod,
|
||||
|
||||
+88
-3
@@ -893,8 +893,61 @@
|
||||
whether melee weapons with ShowQuality="true" (inherited from the base knife)
|
||||
render TintColor the same way. If it's still not black after this deploy, that's
|
||||
the next thing to dig into (possibly needs ShowQuality or a cosmetic-slot
|
||||
workaround), not a guess to make blind right now. -->
|
||||
<property name="TintColor" value="0, 0, 0"/>
|
||||
workaround), not a guess to make blind right now.
|
||||
|
||||
СОМНЕНИЕ СНЯТО 2026-09-10 по самой ванили: meleeWpnBladeT0BoneKnife задаёт себе
|
||||
TintColor 107, 107, 71 (Data/Config/items.xml:2419) на том же самом
|
||||
boneShivPrefab, то есть слот тинта у этого меша рабочий и на оружии с
|
||||
ShowQuality тоже применяется. Копать тут больше нечего; осталось только
|
||||
посмотреть глазами, достаточно ли чёрный получается клинок. -->
|
||||
<!-- TintColor ОТКЛЮЧЁН 2026-09-10 вместе с переходом на свою текстуру.
|
||||
|
||||
TintColor - это МНОЖИТЕЛЬ цвета меша, а 0,0,0 множит в чистый чёрный. Он был нужен,
|
||||
пока нож носил ванильную бежевую кость: другого способа затемнить клинок не было.
|
||||
Теперь цвет несёт своя текстура necroKnife_d.png, и тот же множитель погасил бы
|
||||
всё нарисованное в ноль - вместе с пурпурным свечением в альбедо.
|
||||
|
||||
Иконка от этого не зависит: она отдельный арт (CustomIcon выше), CustomIconTint
|
||||
здесь никогда не стоял. И на иконке клинок именно КОСТЯНОЙ, а не чёрный, так что
|
||||
чёрный множитель ей вдобавок противоречил.
|
||||
|
||||
Побочно это снимает неоднозначность теста трубы: красный клинок мог быть виден
|
||||
только за счёт эмиссии, если тинт всё-таки давил альбедо нашего меша. Без тинта
|
||||
вопроса больше нет.
|
||||
<property name="TintColor" value="0, 0, 0"/> -->
|
||||
|
||||
<!-- СВОЯ МОДЕЛЬ. ВКЛЮЧЕНА 2026-09-10 вместе с бандлом Resources/necroknife (2.19 МБ,
|
||||
собран Unity 2022.3.62f2 в batch-режиме; внутри necroKnifePrefab.prefab, три меша
|
||||
boneShiv_LOD0/1/2, текстуры boneShiv_d/_n и материал necroKnife.mat - бандл
|
||||
самодостаточный, проверено по составу).
|
||||
|
||||
ВНИМАНИЕ: в бандле сейчас ТЕСТОВЫЙ вид - ярко-красный материал с эмиссией. Это
|
||||
проверка трубы, а не финальная модель. Красный выбран потому, что ванильный нож
|
||||
бежевый и по нему не отличить, загрузился наш бандл или подставилась ваниль;
|
||||
эмиссия - потому что TintColor ниже стоит 0,0,0, и если игра множит тинт на
|
||||
материал нашего меша, красный альбедо ушёл бы в чёрный и тест ничего бы не показал.
|
||||
Настоящая текстура рисуется поверх _private/Extracted/boneShiv_d.png после того,
|
||||
как труба подтвердится.
|
||||
|
||||
Синтаксис: "#" - грузить из бандла, "@modfolder(NecromancerTome):" - путь от
|
||||
корня этого мода, "?" отделяет путь к префабу ВНУТРИ бандла. Форма собрана из
|
||||
двух подтверждённых кусков: ваниль пишет "#Entities/Trees?SnakeweedPrefab.prefab"
|
||||
(Data/Config/blocks.xml), а поддержка "@modfolder:" лежит в Assembly-CSharp.
|
||||
ФОРМА ПОДТВЕРЖДЕНА ЖИВЫМ ПРИМЕРОМ 2026-09-10: с этой строкой тестовый красный
|
||||
нож появился в руке. Значит и запись работает, и бандл из папки мода читается,
|
||||
и Standard-шейдер из Unity 2022.3.62f2 игра отрисовывает. Больше не гипотеза -
|
||||
этой же формой можно подключать любые следующие свои модели.
|
||||
|
||||
necroknife - имя AssetBundle, заданное префабу в Unity; necroKnifePrefab.prefab -
|
||||
имя префаба ВНУТРИ бандла. Оба должны совпасть с тем, что реально собралось, иначе
|
||||
предмет останется без модели молча. Пересборка - меню NecromancerTome -> Ctrl+Shift+B
|
||||
либо batch: Unity.exe -batchmode -nographics -quit -projectPath <проект>
|
||||
-executeMethod NecroKnifeSetup.SetupAndBuild. Игру после каждой пересборки
|
||||
перезапускать: моды и их бандлы читаются только при старте.
|
||||
|
||||
Геометрия остаётся ванильной, меняются материал и текстура, поэтому HoldType,
|
||||
посадка в руке и анимации от базового ножа продолжают подходить без правок. -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroknife?necroKnifePrefab.prefab"/>
|
||||
|
||||
<!-- MOD SLOTS ADDED 2026-09-07 (user request: "добавь в нож слоты для
|
||||
модификаций... модификации там будут особые, именно для ножа
|
||||
@@ -1235,7 +1288,39 @@
|
||||
same reasoning as every other hand-drawn icon in this mod (Dog/Insect summon
|
||||
books, etc.) - don't recolor finished art. -->
|
||||
<property name="CustomIcon" value="NecromantsBlood"/>
|
||||
<property name="TintColor" value="60, 0, 10"/>
|
||||
|
||||
<!-- СВОЯ БАНКА С КРОВЬЮ, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое
|
||||
заменяем на кровавый цвет, с фиолетовыми оттенками»).
|
||||
|
||||
Заодно чинится расхождение текста и модели: описание предмета
|
||||
(resourceNecromancerBloodDesc) с самого начала говорит «Банка, наполненная кровью
|
||||
самого некроманта», а наследуемый medicalBloodBag показывает
|
||||
@:Other/Items/Misc/sackPrefab.prefab - обычный мешок. Банка вернее и по механике:
|
||||
рецепт и так требует пустую банку (recipes.xml, NecromancerBloodPatch.cs).
|
||||
|
||||
ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Сначала пробовали дёшево, без бандла:
|
||||
ванильный префаб чая плюс TintColor. Проверка 2026-09-10 показала, что банка
|
||||
осталась чаем из золотарника - тинт предмета на этот меш НЕ ПОДЕЙСТВОВАЛ ВООБЩЕ.
|
||||
У шейдера Game_EntityTintMaskSSS выигрывает собственный _Color материала (у чая
|
||||
жёлтый, 166,133,37), и свойство предмета его не перебивает. Поэтому TintColor здесь
|
||||
не задаётся совсем: он ничего не даёт и только вводил бы в заблуждение.
|
||||
|
||||
И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная и
|
||||
густая, с плёнкой на стекле. Поэтому жидкость ПЕРЕРИСОВАНА по яркости, а не
|
||||
перекрашена множителем - генератор _private/tools/make_necroblood_textures.py.
|
||||
|
||||
HoldType 3 - хват банки вместо 45 (мешок), Material Mglass - стекло вместо ткани
|
||||
(звук удара и осколки при разбитии). Без них банка держалась бы как мешок.
|
||||
|
||||
ЧТО СМОТРЕТЬ ГЛАЗАМИ. Материал собран на встроенном Standard в режиме Fade: родной
|
||||
шейдер переиспользовать нельзя, AssetRipper выгрузил шейдеры заглушками. У Standard
|
||||
альфа текстуры - это прозрачность, поэтому она задана осознанно: стекло
|
||||
полупрозрачное, жидкость плотная. Банка должна читаться как стекло с густой кровью,
|
||||
а не как матовый сосуд. -->
|
||||
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroBloodPrefab.prefab"/>
|
||||
<property name="HoldType" value="3"/>
|
||||
<property name="Material" value="Mglass"/>
|
||||
|
||||
<property name="EconomicValue" value="0"/>
|
||||
</item>
|
||||
</append>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// DIAGNOSTIC - measures the game's opaque block texture atlas and logs what it finds. Adds
|
||||
/// nothing and changes nothing.
|
||||
///
|
||||
/// KEPT ON PURPOSE, though it started as throwaway reconnaissance for CustomBlockPaintPatch.
|
||||
/// That patch appends our paint to the end of the atlas, so the block's Texture number in
|
||||
/// blocks.xml (608 today) is simply "however many entries vanilla had". A game update that
|
||||
/// grows the atlas moves it. This probe prints the real numbers on every load, which is what
|
||||
/// turns that drift from a silent wrong texture into one line in the log.
|
||||
///
|
||||
/// WHY THIS EXISTS. The Pyramid of Spirits should ship with its own surface, but vanilla has
|
||||
/// no way to add one: a block's Texture property is an INDEX into a prebuilt atlas, and the
|
||||
/// atlas itself lives in blocktextureatlases_assets_all.bundle. Confirmed by reading the
|
||||
/// game's own strings - a paint entry carries only TextureId/PaintCost/Group/SortIndex, never
|
||||
/// a path to an image. So the only way in is to extend the atlas at runtime from a Harmony
|
||||
/// patch.
|
||||
///
|
||||
/// Extending it means building a bigger Texture2DArray, copying every existing slice across
|
||||
/// and appending ours. That REQUIRES knowing the array's exact width, height, format and
|
||||
/// mipmap count - a slice that disagrees on any of those cannot be copied in. None of it can
|
||||
/// be known statically, hence this probe: measure first, write the real patch second.
|
||||
///
|
||||
/// TWO LESSONS FROM THE FIRST ATTEMPT, both paid for in a broken load:
|
||||
///
|
||||
/// 1. A THROWING POSTFIX BREAKS THE GAME'S LOADING. The first version dereferenced
|
||||
/// BlockTextureData.list without checking it, threw, and the log answered with
|
||||
/// "XML loader: Executing post load step on 'materials.xml' failed". A probe must be
|
||||
/// incapable of harm, so everything here is wrapped and nothing is allowed to escape.
|
||||
///
|
||||
/// 2. THIS RUNS BEFORE THE PAINT TABLE EXISTS. ReloadTextureArrays fires during
|
||||
/// MeshDescription.Init, and the log shows painting.xml loading well after it - so
|
||||
/// BlockTextureData.list is still null at that point. Hence the probe reports several
|
||||
/// times instead of once: the early call shows the atlas as loaded, later calls show it
|
||||
/// once the rest of the game has caught up.
|
||||
///
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(MeshDescription), "ReloadTextureArrays")]
|
||||
public static class BlockAtlasProbePatch
|
||||
{
|
||||
const int MaxReports = 4;
|
||||
static int reports;
|
||||
|
||||
static void Postfix()
|
||||
{
|
||||
if (reports >= MaxReports) return;
|
||||
reports++;
|
||||
// Never let a measurement break a load: the game calls this from inside its own
|
||||
// XML post-load step, and an escaping exception aborts that step.
|
||||
try
|
||||
{
|
||||
LogOpaqueAtlas(reports);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] BlockAtlasProbe: measurement #" + reports +
|
||||
" failed harmlessly: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void LogOpaqueAtlas(int report)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[NecromancerTome] BlockAtlasProbe #" + report + ": opaque block atlas");
|
||||
|
||||
if (MeshDescription.meshes == null)
|
||||
{
|
||||
sb.AppendLine(" MeshDescription.meshes is null - too early");
|
||||
Debug.Log(sb.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
|
||||
if (mesh == null)
|
||||
{
|
||||
sb.AppendLine(" MESH_OPAQUE is null - too early");
|
||||
Debug.Log(sb.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
var atlas = mesh.textureAtlas as TextureAtlasBlocks;
|
||||
if (atlas == null)
|
||||
{
|
||||
sb.AppendLine(" textureAtlas is " + (mesh.textureAtlas == null
|
||||
? "null" : mesh.textureAtlas.GetType().Name) + ", expected TextureAtlasBlocks");
|
||||
Debug.Log(sb.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine(" uvMapping entries: " +
|
||||
(atlas.uvMapping == null ? "null" : atlas.uvMapping.Length.ToString()));
|
||||
Describe(sb, "diffuse ", atlas.diffuseTexture);
|
||||
Describe(sb, "normal ", atlas.normalTexture);
|
||||
Describe(sb, "specular", atlas.specularTexture);
|
||||
|
||||
// A new paint needs an unused index in BlockTextureData.list. The table is filled
|
||||
// from painting.xml, which loads AFTER the textures - so on the early call this is
|
||||
// still null, and that is expected rather than a fault.
|
||||
if (BlockTextureData.list == null)
|
||||
{
|
||||
sb.AppendLine(" paint table: not built yet (painting.xml loads later)");
|
||||
}
|
||||
else
|
||||
{
|
||||
int used = 0, free = 0;
|
||||
for (int i = 0; i < BlockTextureData.list.Length; i++)
|
||||
{
|
||||
if (BlockTextureData.list[i] == null) free++; else used++;
|
||||
}
|
||||
sb.AppendLine(" paint slots: " + used + " used, " + free + " free, " +
|
||||
BlockTextureData.list.Length + " total");
|
||||
}
|
||||
|
||||
Debug.Log(sb.ToString());
|
||||
}
|
||||
|
||||
static void Describe(StringBuilder sb, string label, Texture texture)
|
||||
{
|
||||
if (texture == null)
|
||||
{
|
||||
sb.AppendLine(" " + label + ": null");
|
||||
return;
|
||||
}
|
||||
|
||||
var arr = texture as Texture2DArray;
|
||||
if (arr == null)
|
||||
{
|
||||
sb.AppendLine(" " + label + ": " + texture.GetType().Name +
|
||||
" (expected Texture2DArray) " + texture.width + "x" + texture.height);
|
||||
return;
|
||||
}
|
||||
|
||||
// depth = how many slices are already in the array; ours would become index `depth`,
|
||||
// and every number below has to be matched exactly by our own texture.
|
||||
sb.AppendLine(" " + label + ": " + arr.width + "x" + arr.height +
|
||||
" slices=" + arr.depth +
|
||||
" format=" + arr.format +
|
||||
" graphicsFormat=" + arr.graphicsFormat +
|
||||
" mips=" + arr.mipmapCount +
|
||||
" readable=" + arr.isReadable);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the mod's own paint to the game's opaque block texture atlas, so the Pyramid of
|
||||
/// Spirits can ship with a surface that does not exist in vanilla.
|
||||
///
|
||||
/// WHY A PATCH IS THE ONLY WAY. A block's Texture property is an INDEX into a prebuilt
|
||||
/// atlas; a paint entry in painting.xml carries TextureId/PaintCost/Group/SortIndex and
|
||||
/// never a path to an image, and the atlas itself is compiled into
|
||||
/// blocktextureatlases_assets_all.bundle. Nothing in the XML layer can introduce new image
|
||||
/// data, so the array has to be extended at runtime.
|
||||
///
|
||||
/// WHY THIS HOOK. CreateBlockTextures is the coroutine that reads painting.xml. Hooking its
|
||||
/// completion is deliberate and was learned the hard way: an earlier probe ran from
|
||||
/// MeshDescription.ReloadTextureArrays and found BlockTextureData.list still null, because
|
||||
/// the texture arrays load BEFORE painting.xml. By the time this coroutine finishes, both the
|
||||
/// arrays and the paint table exist.
|
||||
///
|
||||
/// THE NUMBERS THIS RELIES ON were measured in-game rather than assumed (BlockAtlasProbe,
|
||||
/// 2026-09-10): the opaque atlas holds 407 slices of 512x512 with a full 10-level mip chain,
|
||||
/// diffuse as DXT1 and normal/specular as DXT5, and all three arrays are non-readable. Two
|
||||
/// consequences drive the code below - our textures must match those numbers exactly, and
|
||||
/// every copy must go through the GPU, since a non-readable array cannot be read back.
|
||||
///
|
||||
/// SAFETY. Everything is wrapped: this runs inside the game's own XML loading, and an
|
||||
/// escaping exception aborts that step - which is exactly how a careless earlier version
|
||||
/// produced "XML loader: Executing post load step on 'materials.xml' failed". If anything
|
||||
/// here fails, the mod logs it and leaves the game exactly as it was.
|
||||
/// </summary>
|
||||
public static class CustomBlockPaintPatch
|
||||
{
|
||||
/// <summary>Bundle we ship the paint textures in, relative to the mod folder.</summary>
|
||||
const string BundlePath = "Resources/necroatlas";
|
||||
|
||||
const string DiffuseAsset = "Assets/NecroAtlas/atlas_necroPyramid_d.png";
|
||||
const string NormalAsset = "Assets/NecroAtlas/atlas_necroPyramid_n.png";
|
||||
const string SpecularAsset = "Assets/NecroAtlas/atlas_necroPyramid_m.png";
|
||||
|
||||
/// <summary>Name the paint is registered under; blocks.xml refers to the resulting id.</summary>
|
||||
public const string PaintName = "txName_NecroAsh";
|
||||
|
||||
/// <summary>Paint id handed out by the game once registration succeeds, -1 while unset.
|
||||
/// Logged on success so it can be written into blocks.xml.</summary>
|
||||
public static int AssignedPaintId = -1;
|
||||
|
||||
static bool alreadyRan;
|
||||
|
||||
/// <summary>
|
||||
/// Patch the coroutine's MoveNext. A coroutine compiles into a hidden state-machine
|
||||
/// class, so the method that actually runs is MoveNext, not CreateBlockTextures itself -
|
||||
/// AccessTools.EnumeratorMoveNext resolves it for us.
|
||||
/// </summary>
|
||||
[HarmonyPatch]
|
||||
static class CreateBlockTexturesHook
|
||||
{
|
||||
static IEnumerable<MethodBase> TargetMethods()
|
||||
{
|
||||
MethodBase coroutine = AccessTools.Method(
|
||||
typeof(BlockTexturesFromXML), "CreateBlockTextures");
|
||||
if (coroutine == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
|
||||
"BlockTexturesFromXML.CreateBlockTextures not found - paint not added");
|
||||
yield break;
|
||||
}
|
||||
MethodBase moveNext = AccessTools.EnumeratorMoveNext(coroutine);
|
||||
if (moveNext == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
|
||||
"could not resolve the coroutine's MoveNext - paint not added");
|
||||
yield break;
|
||||
}
|
||||
yield return moveNext;
|
||||
}
|
||||
|
||||
// __result == false means the enumerator is done: the XML has been read in full.
|
||||
static void Postfix(bool __result)
|
||||
{
|
||||
if (__result || alreadyRan) return;
|
||||
alreadyRan = true;
|
||||
try
|
||||
{
|
||||
AddPaint();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: failed, game left " +
|
||||
"untouched: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void AddPaint()
|
||||
{
|
||||
if (GameManager.IsDedicatedServer)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] CustomBlockPaint: dedicated server, textures skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
|
||||
var atlas = mesh == null ? null : mesh.textureAtlas as TextureAtlasBlocks;
|
||||
if (atlas == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: opaque atlas unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
AssetBundle bundle = LoadBundle();
|
||||
if (bundle == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var diffuse = bundle.LoadAsset<Texture2D>(DiffuseAsset);
|
||||
var normal = bundle.LoadAsset<Texture2D>(NormalAsset);
|
||||
var specular = bundle.LoadAsset<Texture2D>(SpecularAsset);
|
||||
if (diffuse == null || normal == null || specular == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle is missing one " +
|
||||
"of the three textures - nothing added");
|
||||
return;
|
||||
}
|
||||
|
||||
Describe("our diffuse ", diffuse);
|
||||
Describe("our normal ", normal);
|
||||
Describe("our specular", specular);
|
||||
|
||||
int slice = Append(ref atlas.diffuseTexture, diffuse, "diffuse");
|
||||
Append(ref atlas.normalTexture, normal, "normal");
|
||||
Append(ref atlas.specularTexture, specular, "specular");
|
||||
if (slice < 0) return;
|
||||
|
||||
mesh.TexDiffuse = atlas.diffuseTexture;
|
||||
mesh.TexNormal = atlas.normalTexture;
|
||||
mesh.TexSpecular = atlas.specularTexture;
|
||||
mesh.ReloadTextureArrays(false);
|
||||
|
||||
int textureId = RegisterUvMapping(atlas, slice);
|
||||
RegisterPaint(textureId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Keep the loaded textures alive: only the bundle wrapper is released.
|
||||
bundle.Unload(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Give the new slice an entry in uvMapping and return its index - that index is what a
|
||||
/// block's Texture property in blocks.xml actually refers to.
|
||||
///
|
||||
/// The entry is CLONED from an existing plain opaque paint rather than built field by
|
||||
/// field. UVRectTiling carries more than a slice number (tiling, block size, material
|
||||
/// flags), and copying a known-good neighbour keeps every one of those correct without
|
||||
/// guessing at fields we have never inspected. Only the slice index is changed.
|
||||
/// </summary>
|
||||
static int RegisterUvMapping(TextureAtlasBlocks atlas, int slice)
|
||||
{
|
||||
// 356 is txName_Steel_wall - an ordinary full-block opaque paint, which is exactly
|
||||
// the shape of entry we want.
|
||||
const int TemplateTextureId = 356;
|
||||
if (atlas.uvMapping == null || atlas.uvMapping.Length <= TemplateTextureId)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: uvMapping too small to " +
|
||||
"clone a template from - paint not registered");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int textureId = atlas.uvMapping.Length;
|
||||
Array.Resize(ref atlas.uvMapping, textureId + 1);
|
||||
UVRectTiling tile = atlas.uvMapping[TemplateTextureId];
|
||||
// Только индекс слоя: имени у UVRectTiling нет, оно живёт в BlockTextureData.
|
||||
tile.index = slice;
|
||||
atlas.uvMapping[textureId] = tile;
|
||||
|
||||
Debug.Log("[NecromancerTome] CustomBlockPaint: uvMapping entry " + textureId +
|
||||
" points at slice " + slice + " (cloned from " + TemplateTextureId + ")");
|
||||
return textureId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register the paint itself, so it has a name, shows up in the paint brush, and can be
|
||||
/// referred to by name. The block only needs the texture id, but a nameless texture with
|
||||
/// no paint entry would be invisible to the rest of the game.
|
||||
/// </summary>
|
||||
static void RegisterPaint(int textureId)
|
||||
{
|
||||
if (textureId < 0) return;
|
||||
if (BlockTextureData.list == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: paint table missing - " +
|
||||
"texture added but not named");
|
||||
return;
|
||||
}
|
||||
|
||||
int free = -1;
|
||||
for (int i = 0; i < BlockTextureData.list.Length; i++)
|
||||
{
|
||||
if (BlockTextureData.list[i] == null) { free = i; break; }
|
||||
}
|
||||
if (free < 0)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: no free paint slot - " +
|
||||
"texture added but not named");
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new BlockTextureData
|
||||
{
|
||||
ID = free,
|
||||
Name = PaintName,
|
||||
LocalizedName = Localization.Get(PaintName),
|
||||
TextureID = (ushort)textureId,
|
||||
Group = "txGroupMasonry",
|
||||
PaintCost = 1,
|
||||
SortIndex = 0,
|
||||
Hidden = false,
|
||||
};
|
||||
data.Init();
|
||||
|
||||
AssignedPaintId = free;
|
||||
Debug.Log("[NecromancerTome] CustomBlockPaint: paint registered, slot " + free +
|
||||
", texture id " + textureId + " -> put Texture=\"" + textureId +
|
||||
"\" on the block in blocks.xml");
|
||||
}
|
||||
|
||||
static AssetBundle LoadBundle()
|
||||
{
|
||||
if (ModEntry.Instance == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: mod path unknown");
|
||||
return null;
|
||||
}
|
||||
|
||||
string path = Path.Combine(ModEntry.Instance.Path, BundlePath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle not found at " + path);
|
||||
return null;
|
||||
}
|
||||
|
||||
AssetBundle bundle = AssetBundle.LoadFromFile(path);
|
||||
if (bundle == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle failed to load: " + path);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild a texture array one slice larger and put our texture in the new last slot.
|
||||
/// Returns the new slice index, or -1 if the arrays disagree on anything that makes a
|
||||
/// copy impossible.
|
||||
/// </summary>
|
||||
static int Append(ref Texture target, Texture2D ours, string label)
|
||||
{
|
||||
var src = target as Texture2DArray;
|
||||
if (src == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label +
|
||||
" is not a Texture2DArray - skipped");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ours.width != src.width || ours.height != src.height)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " size mismatch, " +
|
||||
"atlas is " + src.width + "x" + src.height + " but ours is " +
|
||||
ours.width + "x" + ours.height + " - skipped");
|
||||
return -1;
|
||||
}
|
||||
if (ours.graphicsFormat != src.graphicsFormat)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " format mismatch, " +
|
||||
"atlas is " + src.graphicsFormat + " but ours is " + ours.graphicsFormat +
|
||||
" - skipped");
|
||||
return -1;
|
||||
}
|
||||
if (ours.mipmapCount != src.mipmapCount)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " mip mismatch, " +
|
||||
"atlas has " + src.mipmapCount + " but ours has " + ours.mipmapCount +
|
||||
" - skipped");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int slice = src.depth;
|
||||
var grown = new Texture2DArray(src.width, src.height, slice + 1,
|
||||
src.graphicsFormat, UnityEngine.Experimental.Rendering.TextureCreationFlags.MipChain,
|
||||
src.mipmapCount);
|
||||
grown.name = src.name + "+necro";
|
||||
grown.wrapMode = src.wrapMode;
|
||||
grown.filterMode = src.filterMode;
|
||||
grown.anisoLevel = src.anisoLevel;
|
||||
|
||||
// GPU-side copy: the game's arrays are non-readable, so nothing can be pulled back
|
||||
// to the CPU. CopyTexture moves whole slices with their mip chains.
|
||||
for (int i = 0; i < slice; i++) Graphics.CopyTexture(src, i, grown, i);
|
||||
Graphics.CopyTexture(ours, 0, grown, slice);
|
||||
|
||||
target = grown;
|
||||
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " grown from " + slice +
|
||||
" to " + (slice + 1) + " slices");
|
||||
return slice;
|
||||
}
|
||||
|
||||
static void Describe(string label, Texture2D tex)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " " +
|
||||
tex.width + "x" + tex.height + " format=" + tex.format +
|
||||
" graphicsFormat=" + tex.graphicsFormat + " mips=" + tex.mipmapCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ namespace NecromancerTome
|
||||
/// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
|
||||
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
|
||||
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
|
||||
/// PortalStonePatch.ActivateBlackPortal). Как и вся остальная UI-часть этого мода, сцена
|
||||
/// FinishEnding). Как и вся остальная UI-часть этого мода, сцена
|
||||
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
|
||||
/// ограничение самой ванили, а не мода.
|
||||
/// </summary>
|
||||
@@ -258,14 +258,12 @@ namespace NecromancerTome
|
||||
/// "Конец".
|
||||
///
|
||||
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
|
||||
/// из финала"). Сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в
|
||||
/// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая
|
||||
/// код по кускам.
|
||||
///
|
||||
/// Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm УДАЛЕНЫ ИЗ МОДА
|
||||
/// 2026-09-09 перед публикацией: все три были побайтовой копией ванильного
|
||||
/// из финала"). Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm тогда же
|
||||
/// удалены из мода перед публикацией: все три были побайтовой копией ванильного
|
||||
/// TFP_Intro.webm (заглушка для тестов), а раздавать чужой ассет игры в релизе нельзя.
|
||||
/// Настоящее видео класть под тем же именем.
|
||||
/// Мёртвый код проигрывания видео (PortalStonePatch.PlayBlackPortalVideoLegacy и
|
||||
/// константа с путём) убран 2026-09-10 - живой пример того же вызова, если видео
|
||||
/// понадобится вернуть, остался в NoteFlashbackPatch.cs.
|
||||
///
|
||||
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
|
||||
/// текст показывается сразу.</summary>
|
||||
|
||||
@@ -10,8 +10,14 @@ namespace NecromancerTome
|
||||
/// </summary>
|
||||
public class ModEntry : IModApi
|
||||
{
|
||||
/// <summary>The mod's own folder, kept from InitMod so patches can find files we ship
|
||||
/// (currently Resources/necroatlas for the custom block paint). Nothing else knows where
|
||||
/// the mod lives - the game hands it over exactly once, right here.</summary>
|
||||
public static Mod Instance;
|
||||
|
||||
public void InitMod(Mod _modInstance)
|
||||
{
|
||||
Instance = _modInstance;
|
||||
var harmony = new Harmony("necromancertome.harmony");
|
||||
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||
PetFollowPatch.Init();
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- AssetBundle.LoadFromFile for CustomBlockPaintPatch.cs (2026-09-10) - the mod ships its
|
||||
own block paint textures in Resources/necroatlas, and Unity keeps bundle loading in its
|
||||
own module rather than CoreModule. -->
|
||||
<Reference Include="UnityEngine.AssetBundleModule">
|
||||
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AssetBundleModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- PlayerActionsLocal.Secondary (PlayerAction) for PortalStonePatch.cs's power-attack
|
||||
channel-cancel (2026-08-29) - the game's own input layer, not something this mod
|
||||
previously needed to touch directly. -->
|
||||
|
||||
@@ -7,10 +7,21 @@ namespace NecromancerTome
|
||||
/// <summary>
|
||||
/// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент
|
||||
/// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is
|
||||
/// opened, pause the game and play a flashback). Reuses the exact pause+video pipeline
|
||||
/// already built and tested for the Black Portal Stone (see PortalStonePatch.cs's
|
||||
/// ActivateBlackPortal - GameManager.Instance.Pause/XUiC_VideoPlayer.PlayVideo, both APIs
|
||||
/// decompiled there already, same reasoning applies unchanged here).
|
||||
/// opened, pause the game and play a flashback). Built on the pause+video pipeline first
|
||||
/// written for the Black Portal Stone; since the finale switched to text slides
|
||||
/// (FinalSlides) and its dead video code was removed 2026-09-10, this patch is the only
|
||||
/// place in the mod that still calls either API, so both write-ups live here now:
|
||||
/// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets
|
||||
/// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check
|
||||
/// baked into vanilla itself, not a limitation added by this mod) - a deliberate,
|
||||
/// documented no-op in multiplayer rather than something silently broken.
|
||||
/// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same
|
||||
/// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use.
|
||||
/// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it
|
||||
/// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/
|
||||
/// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived
|
||||
/// already auto-closes on a bad file on its own, this is just a second, player-facing
|
||||
/// way out.
|
||||
///
|
||||
/// FINDING THE RIGHT PATCH POINT: noteDuke01 has no custom C# class of its own - it's a
|
||||
/// plain Class="Eat" item (items.xml) whose entire "reading" experience is a vanilla trick:
|
||||
@@ -42,8 +53,9 @@ namespace NecromancerTome
|
||||
/// flashback -> read text -> confirm", rather than overlapping the video with the text box.
|
||||
///
|
||||
/// VIDEO FILE: Video/DukeNoteFlashback.mp4 - the user's real flashback clip (delivered
|
||||
/// 2026-08-30 as exch/flashbback.mp4), kept as .mp4 rather than renamed to .webm like the
|
||||
/// Black Portal placeholder: Unity's VideoPlayer component (confirmed by decompiling
|
||||
/// 2026-08-30 as exch/flashbback.mp4), and the only video the mod still ships. Kept as
|
||||
/// .mp4 rather than renamed to .webm like the since-deleted Black Portal placeholder:
|
||||
/// Unity's VideoPlayer component (confirmed by decompiling
|
||||
/// XUiV_Video - it wraps a plain UnityEngine.Video.VideoPlayer) natively decodes MP4/H.264 on
|
||||
/// Windows via Media Foundation, and re-labeling an actual MP4 container as .webm would just
|
||||
/// make it fail to decode (VP8/VP9 container expected, not H.264) - not decompiled/proven
|
||||
@@ -54,6 +66,11 @@ namespace NecromancerTome
|
||||
[HarmonyPatch(typeof(XUiC_MessageBoxWindowGroup), "ShowOkCancel")]
|
||||
public static class Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback
|
||||
{
|
||||
/// <summary>"@modfolder(NecromancerTome):..." is the exact mod-relative path syntax
|
||||
/// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to
|
||||
/// confirm - looks for "@modfolder(<mod name>):" and substitutes the mod's real
|
||||
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
|
||||
/// DisplayName).</summary>
|
||||
public const string NoteFlashbackVideoPath = "@modfolder(NecromancerTome):Video/DukeNoteFlashback.mp4";
|
||||
|
||||
/// <summary>Guards the re-entrant call this patch makes to the very method it patches
|
||||
|
||||
@@ -159,45 +159,23 @@ namespace NecromancerTome
|
||||
TeleportToBedroll(player);
|
||||
}
|
||||
|
||||
/// <summary>Black portal confirmation + fullscreen video, user request 2026-08-30
|
||||
/// ("диалоговое окно... вы уверены... Если Да, то игра останавливается и проигрывается
|
||||
/// видео"). Real APIs, both decompiled directly:
|
||||
/// - XUiC_MessageBoxWindowGroup.ShowCustom(xui, title, text, icon, setupCallback, ...) -
|
||||
/// the same generic Yes/No popup vanilla itself uses (its own delete-item/disconnect
|
||||
/// confirmations, etc). ShowOkCancel/ShowConfirmCancel exist too but hardcode their
|
||||
/// button caption keys ("xuiOk"/"xuiCancel"/"btnConfirm") - ShowCustom's
|
||||
/// _setupCallback is the only variant that lets the two buttons be captioned
|
||||
/// "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys, confirmed
|
||||
/// against Data/Config/Localization.csv), matching the user's literal "да/нет"
|
||||
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/
|
||||
/// ShowConfirmCancel themselves use internally - Buttons[1] is left unused, same as
|
||||
/// vanilla's own 2-button dialogs.
|
||||
/// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets
|
||||
/// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check
|
||||
/// baked into vanilla itself, not a limitation added by this mod) - a deliberate,
|
||||
/// documented no-op in multiplayer rather than something silently broken.
|
||||
/// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same
|
||||
/// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use.
|
||||
/// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it
|
||||
/// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/
|
||||
/// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived
|
||||
/// already auto-closes on a bad file on its own, this is just a second, player-facing
|
||||
/// way out.
|
||||
/// <summary>Black portal confirmation dialog, user request 2026-08-30
|
||||
/// ("диалоговое окно... вы уверены"). XUiC_MessageBoxWindowGroup.ShowCustom(xui, title,
|
||||
/// text, icon, setupCallback, ...) - the same generic Yes/No popup vanilla itself uses (its
|
||||
/// own delete-item/disconnect confirmations, etc), decompiled directly. ShowOkCancel/
|
||||
/// ShowConfirmCancel exist too but hardcode their button caption keys ("xuiOk"/"xuiCancel"/
|
||||
/// "btnConfirm") - ShowCustom's _setupCallback is the only variant that lets the two buttons
|
||||
/// be captioned "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys,
|
||||
/// confirmed against Data/Config/Localization.csv), matching the user's literal "да/нет"
|
||||
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/ShowConfirmCancel
|
||||
/// themselves use internally - Buttons[1] is left unused, same as vanilla's own 2-button
|
||||
/// dialogs.
|
||||
///
|
||||
/// VIDEO FILE: Video/BlackPortal.webm NO LONGER SHIPS WITH THE MOD. It used to be a
|
||||
/// byte-for-byte copy of vanilla's own TFP_Intro.webm (from
|
||||
/// 7DaysToDie_Data/StreamingAssets/Video/), placed there 2026-08-30 as a test stand-in
|
||||
/// ("Пока файл видео замени заглушкой") - deleted 2026-09-09 before the public release,
|
||||
/// since redistributing a game asset is not ours to do. This path is dead until a real
|
||||
/// video is dropped in under the same name; the method below is legacy anyway (the
|
||||
/// finale plays text epilogues now, see FinalSlides).
|
||||
/// "@modfolder(NecromancerTome):..." is the exact mod-relative path syntax
|
||||
/// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to
|
||||
/// confirm - looks for "@modfolder(<mod name>):" and substitutes the mod's real
|
||||
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
|
||||
/// DisplayName).</summary>
|
||||
public const string BlackPortalVideoPath = "@modfolder(NecromancerTome):Video/BlackPortal.webm";
|
||||
|
||||
/// Второй половины прежнего сценария - паузы и полноэкранного видео - здесь больше нет:
|
||||
/// после "Да" управление уходит в FinalSlides (см. ActivateBlackPortal ниже), пауза живёт
|
||||
/// там, а видео из финала убрано 2026-09-09. Разбор GameManager.Instance.Pause и
|
||||
/// XUiC_VideoPlayer.PlayVideo переехал в NoteFlashbackPatch.cs - единственное место в моде,
|
||||
/// где обе эти ванильные API ещё вызываются.</summary>
|
||||
public static void ShowBlackPortalConfirmation(EntityPlayerLocal player)
|
||||
{
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
@@ -220,55 +198,18 @@ namespace NecromancerTome
|
||||
}
|
||||
|
||||
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
|
||||
/// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом
|
||||
/// (Video/BlackPortal.webm), теперь запускается финальная сцена из шести слайдов с текстом
|
||||
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
|
||||
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
|
||||
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
|
||||
/// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора
|
||||
/// концовки на последнем слайде.
|
||||
///
|
||||
/// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и
|
||||
/// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath /
|
||||
/// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не
|
||||
/// используется и оставлена только как документация к разбору "@modfolder(...)" и
|
||||
/// XUiC_VideoPlayer.PlayVideo, на который FinalSlides ссылается.</summary>
|
||||
/// теперь живёт внутри FinalSlides. Видео из концовки убрано целиком 2026-09-09 -
|
||||
/// ни здесь, ни в FinalSlides его больше нет.</summary>
|
||||
public static void ActivateBlackPortal(EntityPlayerLocal player)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
|
||||
FinalSlides.Begin(player);
|
||||
}
|
||||
|
||||
/// <summary>Прежняя концовка "сразу видео, потом главное меню". Больше ниоткуда не
|
||||
/// вызывается (см. ActivateBlackPortal выше) - оставлена целиком, потому что весь разбор
|
||||
/// Pause/PlayVideo/Disconnect в её комментариях остаётся актуальным и на неё ссылается
|
||||
/// FinalSlides. Удалять при следующей уборке, если так и не понадобится.</summary>
|
||||
public static void PlayBlackPortalVideoLegacy(EntityPlayerLocal player)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", pausing + playing video");
|
||||
GameManager.Instance.Pause(true);
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
VideoData videoData = new VideoData { url = BlackPortalVideoPath };
|
||||
XUiC_VideoPlayer.PlayVideo(playerUI.xui, videoData, true, delegate(bool skipped)
|
||||
{
|
||||
// EXIT TO MAIN MENU after the video, user request 2026-08-30 ("После видео нужно
|
||||
// выходить из игры в главное меню") - fires whether the video played to the end
|
||||
// or was skipped (Cancel key / a bad file), same as any other "the video is over"
|
||||
// outcome. GameManager.Instance.Disconnect() is not a guess - it's the EXACT same
|
||||
// call the real in-game ESC menu's own "Exit to Main Menu" button uses
|
||||
// (decompiled XUiC_InGameMenuWindow.exitGame/BtnExit_OnPressed to confirm: it's a
|
||||
// thin wrapper straight to this method). Handles everything a clean exit needs by
|
||||
// itself - closes modal windows, un-pauses (calls Pause(false) internally, so no
|
||||
// separate unpause call needed here), saves/shuts down the local server, and
|
||||
// returns to XUiC_MainMenu - not reinventing any of that by hand. Replaces the
|
||||
// earlier "thrownStonePortalBlackNotBound" tooltip placeholder entirely: with a
|
||||
// real exit-to-menu ending, staying in-game and showing a tooltip no longer makes
|
||||
// sense (BACKLOG.md item 6's "destination not decided" placeholder is now this
|
||||
// exit itself, not a tooltip).
|
||||
Debug.Log("[NecromancerTome] PortalStonePatch: black portal video finished (skipped=" + skipped + "), exiting to main menu");
|
||||
GameManager.Instance.Disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>BedrollPos comes from EntityPlayer.PersistentPlayerData (decompiled - reads
|
||||
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
|
||||
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
<DisplayName value="Necromancer's Tome" />
|
||||
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, summonable undead pets, 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.0.0" />
|
||||
<Version value="2.0.0" />
|
||||
<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.
@@ -3,6 +3,7 @@
|
||||
**Версия 1.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
|
||||
|
||||
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||
- Репозиторий: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
|
||||
- YouTube-канал автора: https://www.youtube.com/@alexcube
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -64,7 +64,8 @@
|
||||
<h2>Ссылки</h2>
|
||||
|
||||
<ul>
|
||||
<li>Исходники и загрузка: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
||||
<li>Скачать на Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
|
||||
<li>Исходники: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
||||
<li>YouTube-канал автора: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -138,6 +139,7 @@ Built for single-player. In multiplayer the vanilla pause does not apply, so the
|
||||
<h3>Links</h3>
|
||||
|
||||
<ul>
|
||||
<li>Source and download: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
||||
<li>Download on Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
|
||||
<li>Source: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
|
||||
<li>The author's YouTube channel: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user