using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Scripting; using UnityEngine.Video; namespace NecromancerTome { /// /// Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»). Окно necroTome /// (Config/XUi_InGame/windows.xml): оглавление - кнопки глав (до восьми), глава - страницы /// с «Назад/Далее», «Оглавление» возвращает к списку, «Закрыть» и Esc закрывают. /// /// СОДЕРЖАНИЕ - список Chapters ниже: ключ названия, ключи страниц (Localization.csv, /// necroTome*), условие открытия. Закрытая глава в оглавлении не показывается. Условия брать /// из того, что игра уже хранит (уровень некромантии, рецепты, испытания), своё хранение - только /// для чистых событий, по cvar на событие. /// public static class NecroTomeBook { public const string WindowGroup = "necroTome"; public class Chapter { public string TitleKey; public string[] PageKeys; public Func Unlocked; /// Иллюстрация справа от текста на всех страницах главы: картинка (путь вида /// "@modfolder(NecromancerTome):Textures/x.png") или зацикленное видео без звука. Ни того, /// ни другого - текст на всю ширину. public string Image, Video; /// Картинка полосой сверху во всю ширину, текст под ней (главы питомцев, указание 09.10: /// «под картинкой - управление схематично, под ним - описание питомца»). public bool ImageTop; } /// cvar «письмо архимага прочитано» - ставит записка noteDuke01 (Config/items.xml). public const string LetterReadCVar = "necroTomeLetterRead"; public static readonly List Chapters = new List { // Глава 1 (указание 06.10): письмо архимага - та самая стартовая записка, текст берётся из её // же ключа. Открывается ТОЛЬКО когда записку прочли («появляется в книге, когда прочитано // письмо», 06.10): cvar ставит сама записка по «ОК» (Config/items.xml). Запасного условия по // уровню нет - в старых сейвах, где записку прочли до 1.6.0, главы не будет. new Chapter { TitleKey = "necroTomeLetterTitle", PageKeys = new[] { "noteDuke01Desc" }, Video = Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback.NoteFlashbackVideoPath, Unlocked = _p => _p.Buffs.GetCustomVar(LetterReadCVar) > 0f }, // Глава 2 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи. new Chapter { TitleKey = "necroTomeCh1Title", PageKeys = new[] { "necroTomeCh1P1", "necroTomeCh1P2", "necroTomeCh1P3", "necroTomeCh1P4" }, Unlocked = _p => true }, // Нож некроманта (указание 09.10): лист-чертёж ножа (тот же рецепт, что у крысы), управление ножом и духом // змеи, описание. Перед питомцами - «основной инструмент», с него всё начинается. new Chapter { TitleKey = "necroTomeKnifeTitle", PageKeys = new[] { "necroTomeKnifeP1", "necroTomeKnifeP2", "necroTomeKnifeP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeKnife.png", ImageTop = true, Unlocked = _p => true }, // Одержимость (указание 13.10): вселение в мёртвое; «чем ближе схема тела к телу некроманта и чем меньше изменён // стихийный зомби, тем больше возможностей» - стихийные зомби порождены выбросами некроэнергии. Страница 1 - // управление, дальше - суть. Вселение жестом «Пиф-паф» доступно сразу - глава открыта сразу. new Chapter { TitleKey = "necroTomePossessTitle", PageKeys = new[] { "necroTomePossessP1", "necroTomePossessP2", "necroTomePossessP3", "necroTomePossessP4" }, Unlocked = _p => true }, // Главы питомцев (указание 09.10): лист-чертёж модели (три вида из Blender, подписи на эсперанто) сверху, на первой // странице - управление, дальше - описание. Крыса - учебная модель, глава открыта сразу; прочие - с рецептом гримуара. new Chapter { TitleKey = "necroTomeRatTitle", PageKeys = new[] { "necroTomeRatP1", "necroTomeRatP2", "necroTomeRatP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeRat.png", ImageTop = true, Unlocked = _p => true }, new Chapter { TitleKey = "necroTomeDogTitle", PageKeys = new[] { "necroTomeDogP1", "necroTomeDogP2", "necroTomeDogP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeDog.png", ImageTop = true, Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieDog") }, new Chapter { TitleKey = "necroTomeVultureTitle", PageKeys = new[] { "necroTomeVultureP1", "necroTomeVultureP2", "necroTomeVultureP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeVulture.png", ImageTop = true, Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieGriffin") }, new Chapter { TitleKey = "necroTomeWolfTitle", PageKeys = new[] { "necroTomeWolfP1", "necroTomeWolfP2", "necroTomeWolfP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeWolf.png", ImageTop = true, Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieWolf") }, new Chapter { TitleKey = "necroTomeBearTitle", PageKeys = new[] { "necroTomeBearP1", "necroTomeBearP2", "necroTomeBearP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeBear.png", ImageTop = true, Unlocked = _p => RecipeUnlocked(_p, "bookSummonZombieBear") }, // Кровавый прилив (указание 10.10): предупреждение - в ночь кровавой луны дух не выходит из тела // (SnakeMode.IsBloodTide). Лист-схема: луна, горы, некропыль из земли; подписи на эсперанто. new Chapter { TitleKey = "necroTomeTideTitle", PageKeys = new[] { "necroTomeTideP1", "necroTomeTideP2", "necroTomeTideP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeTide.png", ImageTop = true, Unlocked = _p => GameUtils.WorldTimeToDays(_p.world.worldTime) >= TideChapterDay }, // Урны некротехнологов (18.10, после всех урн - «нужно будет сделать иллюстрацию для книги некроманта и рисунки»): // лист Fig. VIII - пять урн эскизом с ремёслами на эсперанто (BlenderWork/make_urn_sheet.py). Открывается вместе с // рецептом урны. new Chapter { TitleKey = "necroTomeUrnsTitle", PageKeys = new[] { "necroTomeUrnsP1", "necroTomeUrnsP2", "necroTomeUrnsP3" }, Image = "@modfolder(NecromancerTome):Textures/necroTomeUrns.png", ImageTop = true, Unlocked = _p => RecipeUnlocked(_p, "necroCinerarium") }, }; /// С какого дня видна глава о кровавом приливе. По замыслу - 5 (тогда же надпись на доске /// чертогов, MindPalace.BoardLines); на время теста было 1, возвращено к релизу 1.6.0 (10.10). public const int TideChapterDay = 5; /// Глава питомца видна, когда игроку открыт рецепт его гримуара (указание 09.10, после проверки глав). /// Крыса - учебная модель, её глава открыта сразу. static bool RecipeUnlocked(EntityPlayerLocal _p, string _item) { Recipe r = CraftingManager.GetRecipe(_item); return r != null && r.IsUnlocked(_p); } public static void Open(EntityPlayerLocal _player) { LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player); ui?.windowManager.Open(WindowGroup, true); } } [Preserve] public class XUiC_NecroTome : XUiController { /// Кнопки глав в оглавлении (btnCh1..20 в windows.xml). Глав будет много (указание 10.10): до Rows - один /// столбец по центру, больше - два столбца по Rows («я бы их сделал в две колонки»), ещё больше - страницы, /// листаются теми же «Назад/Далее», что и главы. const int MaxButtons = 20, Rows = 10; /// Места кнопок (windows.xml: ширина 560, шаг строк 60 от y -130): столбец по центру окна 1300 и два столбца. const int ColCenterX = 370, ColLeftX = 60, ColRightX = 680, RowTopY = -130, RowStep = 60; XUiV_Label _title, _text, _page; XUiV_Texture _img; XUiV_Video _vid; /// Ширина текста без иллюстрации и с ней (windows.xml: lblText 1180, картинка с x 680). const int TextWidthFull = 1180, TextWidthHalf = 590; /// Раскладка «картинка сверху»: лист-чертёж 1180x400 (как Textures/necroTomeRat.png), текст ниже. static readonly Vector2i ImgSidePos = new Vector2i(680, -104), ImgSideSize = new Vector2i(560, 640); static readonly Vector2i ImgTopPos = new Vector2i(60, -104), ImgTopSize = new Vector2i(1180, 400); static readonly Vector2i TextPos = new Vector2i(60, -104), TextTopPos = new Vector2i(60, -518); const int TextHeightFull = 640, TextHeightTop = 245; XUiC_SimpleButton _toc, _prev, _next, _close; readonly XUiC_SimpleButton[] _chapterButtons = new XUiC_SimpleButton[MaxButtons]; /// Глава по кнопке оглавления (только открытые главы, по порядку). readonly int[] _buttonChapter = new int[MaxButtons]; int _chapter = -1, _pageIndex; /// Страница оглавления; «Оглавление» из главы возвращает на неё. int _tocPage; readonly List _open = new List(); 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; _img = GetChildById("imgPage")?.ViewComponent as XUiV_Texture; _vid = GetChildById("vidPage")?.ViewComponent as XUiV_Video; _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(_tocPage); 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(0); } void ShowToc(int _p) { _chapter = -1; SetLabel(_title, Localization.Get("necroTomeToc")); SetLabel(_text, ""); SetMedia(null, null); EntityPlayerLocal player = xui.playerUI.entityPlayer; // Открытые главы по порядку; на странице - MaxButtons из них. _open.Clear(); for (int c = 0; c < NecroTomeBook.Chapters.Count; c++) { NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c]; if (ch.Unlocked == null || ch.Unlocked(player)) { _open.Add(c); } } bool twoCols = _open.Count > Rows; int perPage = twoCols ? MaxButtons : Rows; int pages = Mathf.Max(1, (_open.Count + perPage - 1) / perPage); _tocPage = Mathf.Clamp(_p, 0, pages - 1); int b = 0; for (int i = _tocPage * perPage; i < _open.Count && b < perPage; i++) { int c = _open[i]; NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c]; _buttonChapter[b] = c; if (_chapterButtons[b] != null) { // Сначала левый столбец сверху вниз, потом правый. int x = !twoCols ? ColCenterX : b < Rows ? ColLeftX : ColRightX; _chapterButtons[b].ViewComponent.Position = new Vector2i(x, RowTopY - RowStep * (b % Rows)); _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); // Листать оглавление - только если страниц больше одной. bool many = pages > 1; SetLabel(_page, many ? (_tocPage + 1) + " / " + pages : ""); SetVisible(_prev, many); SetVisible(_next, many); if (_prev != null) _prev.Enabled = _tocPage > 0; if (_next != null) _next.Enabled = _tocPage < pages - 1; } /// Открыть главу по ключу заголовка (команда necrotome <ключ> [страница] - проверка без мыши). public void ShowChapterKey(string _titleKey, int _page) { int i = NecroTomeBook.Chapters.FindIndex(c => c.TitleKey == _titleKey); if (i >= 0) { ShowChapter(i, _page); } } void ShowChapter(int _c, int _p) { if (_c < 0 || _c >= NecroTomeBook.Chapters.Count) { 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); SetMedia(ch.Image, ch.Video, ch.ImageTop); 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; } public override void OnClose() { SetMedia(null, null); base.OnClose(); } /// Показать картинку или видео справа (или убрать обе). Видео задаётся только здесь, /// а не в вёрстке: XUiV_Video сам запускает ролик при открытии окна, и в оглавлении он играл /// бы невидимым. Пустой uri - XUiV_Video.startVideo сам освобождает свою текстуру. /// Путь загруженной картинки (null - не загружена). Свой, а не _img.Path - см. ниже. string _imgPath; void ReleaseImage() { if (_img == null || _imgPath == null) { return; } Texture t = _img.Texture; _img.Path = null; // TextureUris = пусто, Texture = null - без выгрузки (autounload выключен) if (t != null) { UnityEngine.Object.Destroy(t); } _imgPath = null; } void SetMedia(string _image, string _video, bool _top = false) { bool img = !string.IsNullOrEmpty(_image), vid = !img && !string.IsNullOrEmpty(_video); if (_img != null) { // Без чтения _img.Path: у пустой картинки (в вёрстке texture не задан) его геттер берёт // textureUris[0] из пустого массива - IndexOutOfRange (тест 09.10). Сеттер сам сравнивает // с текущим путём и не перезагружает тот же файл. // Утечка (проверка 10.10): картинка из файла мода - новая текстура (XUiV_Texture.FetchWwwTexture, клон), // и при смене пути старая сама не удаляется (autounload в вёрстке не включён - в этом режиме игра на // загрузке стирает путь новой картинки). Каждая смена главы оставляла ~2 МБ. Удаляем сами: при смене // картинки, у главы без картинки и при закрытии окна (SetMedia(null, null)). if (_imgPath != _image) { ReleaseImage(); } if (img && _imgPath == null) { _img.Path = _image; _imgPath = _image; } _img.IsVisible = img; } if (_vid != null) { string uri = vid ? _video : ""; if (_vid.VideoUri != uri) { _vid.VideoUri = uri; } if (_vid.videoPlayer != null) { _vid.videoPlayer.audioOutputMode = VideoAudioOutputMode.None; // Проверка на утечки 06.10: XUiV_Video гасит ролик при смене uri, только если тот уже // играет; ушли в оглавление во время подготовки - догрузился бы и играл невидимым до // закрытия окна. Останавливаем сами (Stop освобождает декодер). if (!vid) { _vid.videoPlayer.Stop(); } } _vid.IsVisible = vid; } if (_img != null) { _img.Position = _top ? ImgTopPos : ImgSidePos; _img.Size = _top ? ImgTopSize : ImgSideSize; } if (_text != null) { bool top = _top && img; _text.Position = top ? TextTopPos : TextPos; _text.Size = new Vector2i(top || !(img || vid) ? TextWidthFull : TextWidthHalf, top ? TextHeightTop : TextHeightFull); } } void Turn(int _d) { if (_chapter >= 0) { ShowChapter(_chapter, _pageIndex + _d); } else { ShowToc(_tocPage + _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; } } } /// necrotome - открыть книгу (проверка окна без чертогов, в том числе через почтовый ящик). [Preserve] public class ConsoleCmdNecroTome : ConsoleCmdAbstract { public override string[] getCommands() => new[] { "necrotome" }; public override string getDescription() => "NecromancerTome: открыть книгу с оглавлением"; public override void Execute(List _params, CommandSenderInfo _senderInfo) { EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer(); if (player != null) { NecroTomeBook.Open(player); if (_params.Count > 0) { XUiC_NecroTome w = LocalPlayerUI.GetUIForPlayer(player)?.xui?.GetWindowByType(); w?.ShowChapterKey(_params[0], _params.Count > 1 ? int.Parse(_params[1]) : 0); } } } } }