using System.Collections; using System.Collections.Generic; using UnityEngine; namespace NecromancerTome { /// /// Финальная сцена Чёрного портального камня: шесть полноэкранных слайдов с текстом и /// кнопками Назад/Дальше, а на последнем - выбор концовки. Заменяет собой прежнюю связку /// "подтверждение -> сразу видео" (см. 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 вообще работает только в одиночной игре, это /// ограничение самой ванили, а не мода. /// public static class FinalSlides { public const int PageCount = 6; /// Имена окон и групп в Config/XUi_InGame/windows.xml + xui.xml: к префиксу /// приписывается номер страницы, 1..PageCount. public const string SlideWindowPrefix = "necroFinalSlide"; /// Окно-заливка под текст эпилога: картинки к этому моменту кончились, текст /// идёт по чёрному. Объявлено в Config/XUi_InGame/windows.xml + xui.xml. public const string BlackWindow = "necroFinalBlack"; /// Имя Чёрного портального камня - его отбирают у игрока на концовке /// "Вернуться" (см. ConsumeBlackStone). Совпадает с /// Patch_ItemActionEat_ExecuteAction_PortalStones.BlackStoneName, продублировано здесь /// строкой, чтобы этот класс не зависел от патча в другом файле. public const string BlackStoneName = "thrownStonePortalBlack"; /// Имя спрайта с картинкой внутри окна слайда. ОБЯЗАНО совпадать с /// name="slideArt" в Config/XUi_InGame/windows.xml - по нему ищется Transform, который /// потом масштабируется наездом (см. RevealPage). public const string SlideArtId = "slideArt"; /// Пауза между появлением картинки и появлением текстовой коробки поверх неё, /// в секундах. Продиктовано 2026-09-09: "можно ли выводить диалоговое окно с задержкой в /// 5 секунд, чтобы пользователь успевал увидеть картинку". /// /// ВРЕМЯ СЧИТАЕТСЯ НЕМАСШТАБИРУЕМОЕ, И ЭТО НЕ ПРИДИРКА. Сцена стартует из Begin сразу /// после GameManager.Instance.Pause(true), а тот выставляет Time.timeScale = 0. Обычный /// WaitForSeconds и Time.deltaTime считают как раз по масштабированному времени, то есть /// при timeScale = 0 не досчитали бы НИКОГДА: текст не появился бы вообще, и игрок /// остался бы с картинкой и без единой кнопки. Поэтому везде ниже - unscaledDeltaTime. /// (Ровно на этом уже обжигались в PyramidWardPatch, см. BACKLOG.md.) /// /// Поставить 0, чтобы вернуть прежнее поведение "текст сразу, без наезда". public const float SlideRevealSeconds = 5f; /// Насколько картинка увеличивается за эти секунды: 0.10 = медленный наезд на /// 10%. Продиктовано 2026-09-09. Ноль - наезда нет, картинка просто стоит. /// /// Делается через localScale спрайта, а не через пересчёт якорей. Спрайт растянут по /// #cam на все четыре стороны, то есть его РАЗМЕР каждый кадр пересчитывает сама NGUI по /// якорям - трогать размер бессмысленно, его тут же перезапишут. А localScale к этому /// отношения не имеет: XUiView выставляет его один раз при создании (Vector3.one) и /// больше не трогает, так что наше значение держится. Масштабирование идёт от центра /// виджета, поэтому кадр наезжает симметрично и ничего не перекашивает. /// /// Пропорции при этом не плывут: картинка уже приведена к 16:9 чёрными полями по бокам /// (см. UIAtlases/NecroFinal и комментарий в windows.xml), а наезд на 10% срезает по 4.5% /// с каждой стороны - поля становятся уже, но не исчезают и не растягиваются. public const float SlideZoomAmount = 0.10f; /// Номер сейчас открытого слайда, 0 = ни одного. Статика, а не поле игрока: /// сцена по построению одна на весь клиент и заканчивается выходом в главное меню. private static int openSlide; /// Слайды, которые игрок уже видел. Задержка и наезд нужны только при ПЕРВОМ /// показе: на кнопку "Назад" картинка уже знакома, и повторное пятисекундное ожидание /// читалось бы как зависание, а не как пауза на разглядывание. private static readonly HashSet revealedPages = new HashSet(); 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); } /// Пять секунд наезда, потом текст. Крутится покадрово, а не через /// WaitForSecondsRealtime, потому что наезд всё равно надо обновлять каждый кадр - так /// одна корутина делает обе вещи. /// /// GameManager - обычный MonoBehaviour, и корутины на нём тикают из Update, то есть при /// timeScale = 0 продолжают идти; замирает только само ожидание, если считать его /// масштабированным временем (см. SlideRevealSeconds). Тот же способ запуска корутины /// использует и ваниль - MinEventActionModifyStats.executeDelayed для Delay= у /// triggered_effect. 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); } } /// Ищет Transform картинки внутри окна слайда: группа по имени -> потомок по /// id -> его view. GetChildById рекурсивный (декомпилировано), так что вложенность /// значения не имеет. Возвращает null, если что-то из цепочки не нашлось - тогда наезда /// просто не будет, а текст всё равно покажется: анимация не должна уметь сломать /// сцену. 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); } /// Закрывает предыдущее окно-слайд и открывает нужное. Открытие немодальное - /// иначе следующий же модальный ShowCustom закрыл бы картинку (GUIWindowManager /// .openInternal -> CloseAllOpenModalWindows). 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; } } /// Седьмой экран: чёрный фон и текст выбранной концовки, единственная кнопка - /// "Конец". /// /// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы /// из финала"). Файлы Video/FinalStay.webm и FinalReturn.webm остались лежать на месте, а /// сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в /// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая /// код по кускам. /// /// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем, /// текст показывается сразу. 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); } /// Две концовки расходятся именно здесь, и только здесь. 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); } /// Забирает у игрока один Чёрный портальный камень. Продиктовано 2026-09-09: /// "портальный камень исчезает из инвентаря. Разумеется, его потом можно скрафтить /// заново" - рецепт не трогаем, только предмет. /// /// Ищем и в поясе (inventory), и в рюкзаке (bag): камень применяется из руки, то есть /// лежит в поясе, но игрок мог за время сцены... вообще-то не мог - игра на паузе, а /// сцена модальная. Проверяем оба всё равно, это дешевле, чем полагаться на догадку о /// том, где предмет обязан оказаться. /// /// DecItem у Bag и Inventory имеет одинаковую сигнатуру и возвращает, сколько СНЯТЬ НЕ /// УДАЛОСЬ (декомпилировано) - поэтому остаток от первого вызова передаётся во второй. /// Если камня не нашлось нигде, пишем варнинг и молча продолжаем: концовка не должна /// падать из-за инвентаря. 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); } } } }