Четыре страницы (английский и русский, как у остальных глав): вселение жестом «Пиф-паф» и управление; стихийные зомби - порождения выбросов некроэнергии; одержимость - выученное умение некроманта, доступное без артефактов, если позволяет плотность некроэнергии; чем ближе схема тела к телу некроманта и чем меньше выброс изменил зомби, тем больше возможностей; тело, гибель, мешок, леденящий ужас, кровавый прилив. Открыта сразу, после главы о ноже. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
421 lines
19 KiB
C#
421 lines
19 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using UnityEngine.Scripting;
|
||
using UnityEngine.Video;
|
||
|
||
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;
|
||
/// <summary>Иллюстрация справа от текста на всех страницах главы: картинка (путь вида
|
||
/// "@modfolder(NecromancerTome):Textures/x.png") или зацикленное видео без звука. Ни того,
|
||
/// ни другого - текст на всю ширину.</summary>
|
||
public string Image, Video;
|
||
/// <summary>Картинка полосой сверху во всю ширину, текст под ней (главы питомцев, указание 09.10:
|
||
/// «под картинкой - управление схематично, под ним - описание питомца»).</summary>
|
||
public bool ImageTop;
|
||
}
|
||
|
||
/// <summary>cvar «письмо архимага прочитано» - ставит записка noteDuke01 (Config/items.xml).</summary>
|
||
public const string LetterReadCVar = "necroTomeLetterRead";
|
||
|
||
public static readonly List<Chapter> Chapters = new List<Chapter>
|
||
{
|
||
// Глава 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
|
||
},
|
||
};
|
||
|
||
/// <summary>С какого дня видна глава о кровавом приливе. По замыслу - 5 (тогда же надпись на доске
|
||
/// чертогов, MindPalace.BoardLines); на время теста было 1, возвращено к релизу 1.6.0 (10.10).</summary>
|
||
public const int TideChapterDay = 5;
|
||
|
||
/// <summary>Глава питомца видна, когда игроку открыт рецепт его гримуара (указание 09.10, после проверки глав).
|
||
/// Крыса - учебная модель, её глава открыта сразу.</summary>
|
||
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
|
||
{
|
||
/// <summary>Кнопки глав в оглавлении (btnCh1..20 в windows.xml). Глав будет много (указание 10.10): до Rows - один
|
||
/// столбец по центру, больше - два столбца по Rows («я бы их сделал в две колонки»), ещё больше - страницы,
|
||
/// листаются теми же «Назад/Далее», что и главы.</summary>
|
||
const int MaxButtons = 20, Rows = 10;
|
||
/// <summary>Места кнопок (windows.xml: ширина 560, шаг строк 60 от y -130): столбец по центру окна 1300 и два столбца.</summary>
|
||
const int ColCenterX = 370, ColLeftX = 60, ColRightX = 680, RowTopY = -130, RowStep = 60;
|
||
|
||
XUiV_Label _title, _text, _page;
|
||
XUiV_Texture _img;
|
||
XUiV_Video _vid;
|
||
/// <summary>Ширина текста без иллюстрации и с ней (windows.xml: lblText 1180, картинка с x 680).</summary>
|
||
const int TextWidthFull = 1180, TextWidthHalf = 590;
|
||
/// <summary>Раскладка «картинка сверху»: лист-чертёж 1180x400 (как Textures/necroTomeRat.png), текст ниже.</summary>
|
||
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];
|
||
/// <summary>Глава по кнопке оглавления (только открытые главы, по порядку).</summary>
|
||
readonly int[] _buttonChapter = new int[MaxButtons];
|
||
int _chapter = -1, _pageIndex;
|
||
/// <summary>Страница оглавления; «Оглавление» из главы возвращает на неё.</summary>
|
||
int _tocPage;
|
||
readonly List<int> _open = new List<int>();
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/// <summary>Показать картинку или видео справа (или убрать обе). Видео задаётся только здесь,
|
||
/// а не в вёрстке: XUiV_Video сам запускает ролик при открытии окна, и в оглавлении он играл
|
||
/// бы невидимым. Пустой uri - XUiV_Video.startVideo сам освобождает свою текстуру.</summary>
|
||
/// <summary>Путь загруженной картинки (null - не загружена). Свой, а не _img.Path - см. ниже.</summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|
||
}
|
||
}
|