Files
necromants-tome-7d2d-3-2/HarmonySrc/FinalSlides.cs
T
AlexCubeandClaude Opus 5 026e006c9c Убран мёртвый код проигрывания видео; заготовка своей модели ножа
PlayBlackPortalVideoLegacy и BlackPortalVideoPath ниоткуда не вызывались
с 2026-09-09, когда концовку заменили слайдами. Разборы Pause/PlayVideo и
синтаксиса @modfolder перенесены в NoteFlashbackPatch - единственное место,
где эти API ещё вызываются. Перекрёстные ссылки в FinalSlides поправлены,
устаревшее упоминание StayVideoPath/ReturnVideoPath убрано.

В items.xml у necroWpnBladeNecroKnife добавлена закомментированная строка
Meshfile под будущий бандл Resources/necroknife - включать вместе с самим
бандлом, не раньше. Там же снято старое сомнение про TintColor: ваниль сама
тинтит тот же boneShivPrefab, значит механизм на оружии работает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3pwyLpTrwz41qBjyjzNSA
2026-09-10 02:07:31 +03:00

363 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) внутри себя (см. комментарий в
/// FinishEnding). Как и вся остальная 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 и BlackPortal.webm тогда же
/// удалены из мода перед публикацией: все три были побайтовой копией ванильного
/// TFP_Intro.webm (заглушка для тестов), а раздавать чужой ассет игры в релизе нельзя.
/// Мёртвый код проигрывания видео (PortalStonePatch.PlayBlackPortalVideoLegacy и
/// константа с путём) убран 2026-09-10 - живой пример того же вызова, если видео
/// понадобится вернуть, остался в NoteFlashbackPatch.cs.
///
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
/// текст показывается сразу.</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);
}
}
}
}