Книга некроманта 1.0 — первая публичная версия

Мод для 7 Days to Die 3.2: навык «Некромантия», растущий от счётчика убитых
зомби, тёмное оружие с шестью собственными модами, призывная нежить, пирамида
духов и сюжетный финал через Чёрный портал. Локализация на 13 языках.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaNro5hAGTzcQ7rJNN2tCX
This commit is contained in:
Alex Cube
2026-09-09 21:13:03 +03:00
co-authored by Claude Opus 5
commit e8f064f5ec
102 changed files with 7010 additions and 0 deletions
+360
View File
@@ -0,0 +1,360 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Финальная сцена Чёрного портального камня: шесть полноэкранных слайдов с текстом и
/// кнопками Назад/Дальше, а на последнем - выбор концовки. Заменяет собой прежнюю связку
/// "подтверждение -> сразу видео" (см. PortalStonePatch.ActivateBlackPortal, которая теперь
/// зовёт Begin вместо PlayVideo).
///
/// ЗАЧЕМ ЭТО ВООБЩЕ (BACKLOG.md, "концовка серией диалоговых окон вместо видео", запись
/// 2026-09-07): видео не локализуется - под английскую версию пришлось бы держать второй
/// файл. Текст слайдов идёт через Localization.Get + Localization.csv, то есть переводится
/// строкой, как всё остальное в моде.
///
/// НИ ОДНОГО НОВОГО UI-ПРИМИТИВА ЗДЕСЬ НЕ ИЗОБРЕТЕНО - вся сцена собрана из двух уже
/// работающих в этом моде кусков:
/// - Картинка: своё окно на слайд (Config/XUi_InGame/windows.xml, шесть штук
/// necroFinalSlide1..6) плюс свой атлас UIAtlases/NecroFinal. Механизм атласа тот же,
/// что у ItemIconAtlas/ItemIconAtlasGreyscale. Окна БЕЗ контроллера и без привязок -
/// спрайт в каждом прописан жёстко, поэтому листание это просто Close одного окна и
/// Open другого, см. ShowSlideWindow. Открываются НЕмодально: в
/// GUIWindowManager.openInternal модальное открытие зовёт CloseAllOpenModalWindows(), и
/// модальный messageBox снёс бы модальный слайд.
/// - Текст и кнопки: ванильный XUiC_MessageBoxWindowGroup.ShowCustom - ровно тот же вызов,
/// которым PortalStonePatch уже показывает подтверждение "да/нет". Шаблон
/// &lt;messagebox&gt; (Data/Config/XUi_Common/templates.xml:142) объявляет таблицу кнопок с
/// repeat_count="3", то есть три слота - их хватает и на Назад/Дальше, и на выбор из
/// двух вариантов с Назад в придачу.
///
/// ПОЧЕМУ ПЕРЕЛИСТЫВАНИЕ НЕ МОРГАЕТ: showMessage (декомпилировано) делает
/// "if (windowGroup.isShowing) OnOpen(); else windowManager.Open(...)" - то есть повторный
/// ShowCustom на уже открытой коробке просто обновляет её содержимое на месте, не закрывая
/// и не открывая окно заново.
///
/// ESC. _buttonOnExternalClose говорит, какую кнопку "нажать", если окно закрыли снаружи
/// (Esc). Ставим 2 ("Дальше") на слайдах 1-5 и 0 ("Назад") на шестом. Первое - чтобы Esc не
/// оставлял игрока в замершей паузе с картинкой на весь экран и без единого элемента
/// управления; второе - чтобы случайным Esc нельзя было ВЫБРАТЬ концовку. При -1 окно
/// закрылось бы, не нажав ничего, и сцена повисла бы намертво.
///
/// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
/// PortalStonePatch.ActivateBlackPortal). Как и вся остальная UI-часть этого мода, сцена
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
/// ограничение самой ванили, а не мода.
/// </summary>
public static class FinalSlides
{
public const int PageCount = 6;
/// <summary>Имена окон и групп в Config/XUi_InGame/windows.xml + xui.xml: к префиксу
/// приписывается номер страницы, 1..PageCount.</summary>
public const string SlideWindowPrefix = "necroFinalSlide";
/// <summary>Окно-заливка под текст эпилога: картинки к этому моменту кончились, текст
/// идёт по чёрному. Объявлено в Config/XUi_InGame/windows.xml + xui.xml.</summary>
public const string BlackWindow = "necroFinalBlack";
/// <summary>Имя Чёрного портального камня - его отбирают у игрока на концовке
/// "Вернуться" (см. ConsumeBlackStone). Совпадает с
/// Patch_ItemActionEat_ExecuteAction_PortalStones.BlackStoneName, продублировано здесь
/// строкой, чтобы этот класс не зависел от патча в другом файле.</summary>
public const string BlackStoneName = "thrownStonePortalBlack";
/// <summary>Имя спрайта с картинкой внутри окна слайда. ОБЯЗАНО совпадать с
/// name="slideArt" в Config/XUi_InGame/windows.xml - по нему ищется Transform, который
/// потом масштабируется наездом (см. RevealPage).</summary>
public const string SlideArtId = "slideArt";
/// <summary>Пауза между появлением картинки и появлением текстовой коробки поверх неё,
/// в секундах. Продиктовано 2026-09-09: "можно ли выводить диалоговое окно с задержкой в
/// 5 секунд, чтобы пользователь успевал увидеть картинку".
///
/// ВРЕМЯ СЧИТАЕТСЯ НЕМАСШТАБИРУЕМОЕ, И ЭТО НЕ ПРИДИРКА. Сцена стартует из Begin сразу
/// после GameManager.Instance.Pause(true), а тот выставляет Time.timeScale = 0. Обычный
/// WaitForSeconds и Time.deltaTime считают как раз по масштабированному времени, то есть
/// при timeScale = 0 не досчитали бы НИКОГДА: текст не появился бы вообще, и игрок
/// остался бы с картинкой и без единой кнопки. Поэтому везде ниже - unscaledDeltaTime.
/// (Ровно на этом уже обжигались в PyramidWardPatch, см. BACKLOG.md.)
///
/// Поставить 0, чтобы вернуть прежнее поведение "текст сразу, без наезда".</summary>
public const float SlideRevealSeconds = 5f;
/// <summary>Насколько картинка увеличивается за эти секунды: 0.10 = медленный наезд на
/// 10%. Продиктовано 2026-09-09. Ноль - наезда нет, картинка просто стоит.
///
/// Делается через localScale спрайта, а не через пересчёт якорей. Спрайт растянут по
/// #cam на все четыре стороны, то есть его РАЗМЕР каждый кадр пересчитывает сама NGUI по
/// якорям - трогать размер бессмысленно, его тут же перезапишут. А localScale к этому
/// отношения не имеет: XUiView выставляет его один раз при создании (Vector3.one) и
/// больше не трогает, так что наше значение держится. Масштабирование идёт от центра
/// виджета, поэтому кадр наезжает симметрично и ничего не перекашивает.
///
/// Пропорции при этом не плывут: картинка уже приведена к 16:9 чёрными полями по бокам
/// (см. UIAtlases/NecroFinal и комментарий в windows.xml), а наезд на 10% срезает по 4.5%
/// с каждой стороны - поля становятся уже, но не исчезают и не растягиваются.</summary>
public const float SlideZoomAmount = 0.10f;
/// <summary>Номер сейчас открытого слайда, 0 = ни одного. Статика, а не поле игрока:
/// сцена по построению одна на весь клиент и заканчивается выходом в главное меню.</summary>
private static int openSlide;
/// <summary>Слайды, которые игрок уже видел. Задержка и наезд нужны только при ПЕРВОМ
/// показе: на кнопку "Назад" картинка уже знакома, и повторное пятисекундное ожидание
/// читалось бы как зависание, а не как пауза на разглядывание.</summary>
private static readonly HashSet<int> revealedPages = new HashSet<int>();
public static void Begin(EntityPlayerLocal player)
{
Debug.Log("[NecromancerTome] FinalSlides: starting finale for owner=" + player.entityId);
GameManager.Instance.Pause(true);
openSlide = 0;
revealedPages.Clear();
ShowPage(player, 1);
}
private static void ShowPage(EntityPlayerLocal player, int page)
{
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
ShowSlideWindow(playerUI, page);
// Первый показ слайда - сначала голая картинка с медленным наездом, текстовая коробка
// приезжает через SlideRevealSeconds. Возврат на уже виденный слайд - сразу с текстом
// и без повторного наезда.
if (SlideRevealSeconds > 0f && revealedPages.Add(page))
{
GameManager.Instance.StartCoroutine(RevealPage(player, page));
return;
}
ShowText(player, page);
}
/// <summary>Пять секунд наезда, потом текст. Крутится покадрово, а не через
/// WaitForSecondsRealtime, потому что наезд всё равно надо обновлять каждый кадр - так
/// одна корутина делает обе вещи.
///
/// GameManager - обычный MonoBehaviour, и корутины на нём тикают из Update, то есть при
/// timeScale = 0 продолжают идти; замирает только само ожидание, если считать его
/// масштабированным временем (см. SlideRevealSeconds). Тот же способ запуска корутины
/// использует и ваниль - MinEventActionModifyStats.executeDelayed для Delay= у
/// triggered_effect.</summary>
private static IEnumerator RevealPage(EntityPlayerLocal player, int page)
{
Transform art = FindSlideArt(player, page);
if (art != null)
{
art.localScale = Vector3.one;
}
float elapsed = 0f;
while (elapsed < SlideRevealSeconds)
{
// Сцену успели закрыть (например, игрок вышел через Esc-меню, пока коробки на
// экране не было) - бросаем и наезд, и показ текста.
if (openSlide != page)
{
yield break;
}
elapsed += Time.unscaledDeltaTime;
if (art != null)
{
float k = 1f + SlideZoomAmount * Mathf.Clamp01(elapsed / SlideRevealSeconds);
art.localScale = new Vector3(k, k, 1f);
}
yield return null;
}
if (openSlide == page)
{
ShowText(player, page);
}
}
/// <summary>Ищет Transform картинки внутри окна слайда: группа по имени -> потомок по
/// id -> его view. GetChildById рекурсивный (декомпилировано), так что вложенность
/// значения не имеет. Возвращает null, если что-то из цепочки не нашлось - тогда наезда
/// просто не будет, а текст всё равно покажется: анимация не должна уметь сломать
/// сцену.</summary>
private static Transform FindSlideArt(EntityPlayerLocal player, int page)
{
XUiController group = LocalPlayerUI.GetUIForPlayer(player).xui.FindWindowGroupByName(SlideWindowPrefix + page);
XUiController art = (group != null) ? group.GetChildById(SlideArtId) : null;
if (art == null || art.ViewComponent == null)
{
Debug.LogWarning("[NecromancerTome] FinalSlides: no \"" + SlideArtId + "\" view on slide " + page + ", zoom skipped");
return null;
}
return art.ViewComponent.UiTransform;
}
private static void ShowText(EntityPlayerLocal player, int page)
{
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
bool lastPage = page >= PageCount;
XUiC_MessageBoxWindowGroup.ShowCustom(
playerUI.xui,
Localization.Get("necroFinalPage" + page + "Title"),
Localization.Get("necroFinalPage" + page + "Text"),
"",
delegate(XUiC_MessageBoxWindowGroup mb)
{
// Слот 0 - "Назад", кроме самого первого слайда, где назад некуда.
// Хоткея нарочно нет (null): Esc уже разобран через
// _buttonOnExternalClose ниже, и вешать его сюда же значило бы обработать
// одно нажатие дважды.
if (page > 1)
{
mb.Buttons[0].Set("necroFinalBtnBack", null, delegate { ShowPage(player, page - 1); });
}
if (!lastPage)
{
// DefaultConfirm вешает на кнопку хоткей Submit (Enter) - листать можно
// и с клавиатуры, не целясь мышью.
mb.Buttons[2].DefaultConfirm("necroFinalBtnNext", delegate { ShowPage(player, page + 1); });
}
else
{
mb.Buttons[1].Set("necroFinalBtnStay", null, delegate { ShowEpilogue(player, _stay: true); });
mb.Buttons[2].Set("necroFinalBtnReturn", null, delegate { ShowEpilogue(player, _stay: false); });
}
},
_openMainMenuOnClose: false,
_modal: true,
_buttonOnOutsideClick: -1,
// См. блок "ESC" в комментарии к классу.
_buttonOnExternalClose: lastPage ? 0 : 2);
}
/// <summary>Закрывает предыдущее окно-слайд и открывает нужное. Открытие немодальное -
/// иначе следующий же модальный ShowCustom закрыл бы картинку (GUIWindowManager
/// .openInternal -> CloseAllOpenModalWindows).</summary>
private static void ShowSlideWindow(LocalPlayerUI playerUI, int page)
{
if (openSlide == page)
{
return;
}
CloseSlideWindow(playerUI);
playerUI.windowManager.Open(SlideWindowPrefix + page, false);
openSlide = page;
}
private static void CloseSlideWindow(LocalPlayerUI playerUI)
{
if (openSlide > 0)
{
playerUI.windowManager.Close(SlideWindowPrefix + openSlide);
openSlide = 0;
}
}
/// <summary>Седьмой экран: чёрный фон и текст выбранной концовки, единственная кнопка -
/// "Конец".
///
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
/// из финала"). Файлы Video/FinalStay.webm и FinalReturn.webm остались лежать на месте, а
/// сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в
/// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая
/// код по кускам.
///
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
/// текст показывается сразу.</summary>
private static void ShowEpilogue(EntityPlayerLocal player, bool _stay)
{
string choice = _stay ? "stay" : "return";
Debug.Log("[NecromancerTome] FinalSlides: ending chosen (" + choice + ") by owner=" + player.entityId);
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
CloseSlideWindow(playerUI);
playerUI.windowManager.Open(BlackWindow, false);
string prefix = _stay ? "necroFinalStay" : "necroFinalReturn";
XUiC_MessageBoxWindowGroup.ShowCustom(
playerUI.xui,
Localization.Get(prefix + "Title"),
Localization.Get(prefix + "Text"),
"",
delegate(XUiC_MessageBoxWindowGroup mb)
{
mb.Buttons[0].DefaultConfirm("necroFinalBtnTheEnd", delegate { FinishEnding(player, _stay); });
},
_openMainMenuOnClose: false,
_modal: true,
_buttonOnOutsideClick: -1,
// Esc здесь равносилен "Конец": выбор уже сделан, отменять нечего, а оставить
// игрока на чёрном экране в замершей паузе нельзя.
_buttonOnExternalClose: 0);
}
/// <summary>Две концовки расходятся именно здесь, и только здесь.</summary>
private static void FinishEnding(EntityPlayerLocal player, bool _stay)
{
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
playerUI.windowManager.Close(BlackWindow);
if (_stay)
{
// "Остаться" - игра действительно кончилась. Disconnect() это ровно то, что зовёт
// кнопка "Выйти в главное меню" из игрового Esc-меню: она закрывает окна, сама
// снимает паузу (Pause(false) внутри), сохраняет и гасит локальный сервер.
Debug.Log("[NecromancerTome] FinalSlides: staying - exiting to main menu");
GameManager.Instance.Disconnect();
return;
}
// "Вернуться" - игрок продолжает играть в том же мире. Паузу здесь снимаем сами:
// Disconnect(), который делал это за нас, не вызывается.
Debug.Log("[NecromancerTome] FinalSlides: returning to the game");
ConsumeBlackStone(player);
GameManager.Instance.Pause(false);
}
/// <summary>Забирает у игрока один Чёрный портальный камень. Продиктовано 2026-09-09:
/// "портальный камень исчезает из инвентаря. Разумеется, его потом можно скрафтить
/// заново" - рецепт не трогаем, только предмет.
///
/// Ищем и в поясе (inventory), и в рюкзаке (bag): камень применяется из руки, то есть
/// лежит в поясе, но игрок мог за время сцены... вообще-то не мог - игра на паузе, а
/// сцена модальная. Проверяем оба всё равно, это дешевле, чем полагаться на догадку о
/// том, где предмет обязан оказаться.
///
/// DecItem у Bag и Inventory имеет одинаковую сигнатуру и возвращает, сколько СНЯТЬ НЕ
/// УДАЛОСЬ (декомпилировано) - поэтому остаток от первого вызова передаётся во второй.
/// Если камня не нашлось нигде, пишем варнинг и молча продолжаем: концовка не должна
/// падать из-за инвентаря.</summary>
private static void ConsumeBlackStone(EntityPlayerLocal player)
{
ItemValue stone = ItemClass.GetItem(BlackStoneName);
if (stone == null || stone.IsEmpty())
{
Debug.LogWarning("[NecromancerTome] FinalSlides: item \"" + BlackStoneName + "\" not found, nothing consumed");
return;
}
int left = 1;
if (player.inventory != null)
{
left = player.inventory.DecItem(stone, left);
}
if (left > 0 && player.bag != null)
{
left = player.bag.DecItem(stone, left);
}
if (left > 0)
{
Debug.LogWarning("[NecromancerTome] FinalSlides: no " + BlackStoneName + " found on owner=" + player.entityId + " to consume");
}
else
{
Debug.Log("[NecromancerTome] FinalSlides: consumed one " + BlackStoneName + " from owner=" + player.entityId);
}
}
}
}