Книга некроманта 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:
@@ -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 уже показывает подтверждение "да/нет". Шаблон
|
||||
/// <messagebox> (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user