diff --git a/Assets/Resources/CombatScene.uss b/Assets/Resources/CombatScene.uss index e3a6a4b..87d4d56 100644 --- a/Assets/Resources/CombatScene.uss +++ b/Assets/Resources/CombatScene.uss @@ -46,6 +46,37 @@ background-color: var(--kp-bg); color: var(--kp-text); font-size: 13px; + opacity: 1; + transition-property: opacity; + transition-duration: 220ms; +} + +.combat-root.scene-fade-hidden { + opacity: 0; +} + +.combat-root.reduced-motion { + transition-duration: 0ms; +} + +.scene-fade-curtain { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: rgb(0, 0, 0); + opacity: 0; + transition-property: opacity; + transition-duration: 200ms; +} + +.scene-fade-curtain.scene-fade-visible { + opacity: 1; +} + +.reduced-motion .scene-fade-curtain { + transition-duration: 0ms; } .combat-status-bar { diff --git a/Assets/Scripts/Core/SceneLoader.cs b/Assets/Scripts/Core/SceneLoader.cs index 0f36ef2..ec9b7b4 100644 --- a/Assets/Scripts/Core/SceneLoader.cs +++ b/Assets/Scripts/Core/SceneLoader.cs @@ -1,4 +1,6 @@ +using KernelPanic.UI; using UnityEngine.SceneManagement; +using UnityEngine.UIElements; namespace KernelPanic.Core { @@ -10,14 +12,38 @@ namespace KernelPanic.Core public const string MainMenuSceneName = "MainMenuScene"; public const string GameSceneName = "GameScene"; - public static void LoadMainMenu() + private const string CurtainClassName = "scene-fade-curtain"; + private const string CurtainVisibleClassName = "scene-fade-visible"; + private const int FadeOutMs = 200; + + public static void LoadMainMenu(VisualElement fadeRoot) { - LoadScene(MainMenuSceneName); + FadeThenLoad(fadeRoot, MainMenuSceneName); } - public static void LoadGame() + public static void LoadGame(VisualElement fadeRoot) { - LoadScene(GameSceneName); + FadeThenLoad(fadeRoot, GameSceneName); + } + + private static void FadeThenLoad(VisualElement fadeRoot, string sceneName) + { + if (fadeRoot == null) + { + LoadScene(sceneName); + return; + } + + VisualElement curtain = new() { pickingMode = PickingMode.Ignore }; + curtain.AddToClassList(CurtainClassName); + fadeRoot.Add(curtain); + + // Two scheduled steps: add the class one tick late so the initial opacity:0 is + // actually rendered first and the transition has something to animate from. + curtain.schedule.Execute(() => curtain.AddToClassList(CurtainVisibleClassName)).StartingIn(0); + + int delay = UIPreferences.ReducedMotion ? 0 : FadeOutMs; + curtain.schedule.Execute(() => LoadScene(sceneName)).StartingIn(delay); } // Static for now because menu navigation has no instance state; a loading-screen service can replace this hook later. diff --git a/Assets/Scripts/UI/CombatSceneController.cs b/Assets/Scripts/UI/CombatSceneController.cs index ec63e92..be5fab0 100644 --- a/Assets/Scripts/UI/CombatSceneController.cs +++ b/Assets/Scripts/UI/CombatSceneController.cs @@ -72,6 +72,9 @@ namespace KernelPanic.UI LoadStyles(); ApplyTerminalFont(); BuildLayout(); + + root.AddToClassList("scene-fade-hidden"); + root.schedule.Execute(() => root.RemoveFromClassList("scene-fade-hidden")).StartingIn(0); } private void ApplyTerminalFont() @@ -515,7 +518,7 @@ namespace KernelPanic.UI overlay.Add(OverlayLine($"waves cleared: {runManager.WavesCleared}")); overlay.Add(OverlayLine($"+{runManager.AccruedBandwidth} bandwidth")); overlay.Add(OverlayLine($"+{runManager.AccruedEntropy} entropy")); - Button returnButton = new(SceneLoader.LoadMainMenu) { text = "> return to menu" }; + Button returnButton = new(() => SceneLoader.LoadMainMenu(root)) { text = "> return to menu" }; returnButton.AddToClassList("primary-action"); overlay.Add(returnButton); return; diff --git a/Assets/Scripts/UI/GachaScreenController.cs b/Assets/Scripts/UI/GachaScreenController.cs index 187465f..3f261f8 100644 --- a/Assets/Scripts/UI/GachaScreenController.cs +++ b/Assets/Scripts/UI/GachaScreenController.cs @@ -40,6 +40,9 @@ namespace KernelPanic.UI private bool _hasOpened; private string _resultBannerId; private string _resultText; + private readonly List _resultRewardLines = new(); + private readonly List _resultRewardStars = new(); + private bool _resultRevealed; public void Bind(VisualElement root, DistroDatabase database, FontAsset artFont, GachaService service, PlayerCollection collection, EntropyWallet entropyWallet, Func, PullResolutionResult> pullResolver, Action changedCallback, Action rootCreditExchangeCallback) { @@ -194,11 +197,10 @@ namespace KernelPanic.UI _bannerDetail.Add(BuildEntropyPrompt()); } - if (_resultBannerId == GachaService.BeginnerBannerId && !string.IsNullOrWhiteSpace(_resultText)) + if (_resultBannerId == GachaService.BeginnerBannerId && + (_resultRewardLines.Count > 0 || !string.IsNullOrWhiteSpace(_resultText))) { - Label result = new(_resultText); - result.AddToClassList("gacha-result"); - _bannerDetail.Add(result); + _bannerDetail.Add(BuildPullResultReveal()); } } @@ -234,6 +236,8 @@ namespace KernelPanic.UI { _resultBannerId = bannerId; _resultText = $"git pull origin {bannerId}: remote not implemented yet"; + _resultRewardLines.Clear(); + _resultRewardStars.Clear(); RenderSelectedBanner(); return; } @@ -243,15 +247,17 @@ namespace KernelPanic.UI { _resultBannerId = bannerId; _resultText = $"git pull failed: {result.FailureReason}"; + _resultRewardLines.Clear(); + _resultRewardStars.Clear(); RenderSelectedBanner(); return; } - List lines = new(); - lines.Add($"git pull complete: spent {result.CurrencySpent} {GachaService.FormatCurrencyName(result.CurrencyType)}"); + List header = new(); + header.Add($"git pull complete: spent {result.CurrencySpent} {GachaService.FormatCurrencyName(result.CurrencyType)}"); if (result.EntropySpent > 0) { - lines.Add($"entropy fallback: spent {result.EntropySpent} entropy"); + header.Add($"entropy fallback: spent {result.EntropySpent} entropy"); } List distroRewards = new(); @@ -265,6 +271,8 @@ namespace KernelPanic.UI PullResolutionResult resolution = _resolvePulledDistros?.Invoke(distroRewards); int distroOutcomeIndex = 0; + _resultRewardLines.Clear(); + _resultRewardStars.Clear(); for (int i = 0; i < result.Rewards.Count; i++) { GachaReward reward = result.Rewards[i]; @@ -277,15 +285,89 @@ namespace KernelPanic.UI string suffix = reward.Guaranteed ? " guaranteed" : reward.PityTriggered ? " pity" : string.Empty; string outcomeText = FormatPullOutcome(outcome); - lines.Add($"{i + 1:00}: {reward.StarRating}-star {reward.DisplayName}{suffix}{outcomeText}"); + _resultRewardLines.Add($"{i + 1:00}: {reward.StarRating}-star {reward.DisplayName}{suffix}{outcomeText}"); + _resultRewardStars.Add(reward.StarRating); } _resultBannerId = bannerId; - _resultText = string.Join("\n", lines); + _resultText = string.Join("\n", header); + _resultRevealed = false; _onChanged?.Invoke(); Refresh(); } + private VisualElement BuildPullResultReveal() + { + VisualElement container = new(); + container.AddToClassList("gacha-result-block"); + + if (!string.IsNullOrWhiteSpace(_resultText)) + { + Label header = new(_resultText); + header.AddToClassList("gacha-result"); + container.Add(header); + } + + if (_resultRewardLines.Count == 0) + { + return container; + } + + VisualElement list = new(); + list.AddToClassList("gacha-result-list"); + container.Add(list); + + bool animate = !_resultRevealed && !UIPreferences.ReducedMotion; + for (int i = 0; i < _resultRewardLines.Count; i++) + { + int stars = _resultRewardStars[i]; + VisualElement card = new(); + card.AddToClassList("gacha-result-card"); + card.AddToClassList(RarityClassForStars(stars)); + card.Add(new Label(_resultRewardLines[i])); + + if (!animate) + { + card.AddToClassList("gacha-result-card-shown"); + if (stars >= 5) + { + card.AddToClassList("gacha-result-card-flash"); + } + + list.Add(card); + continue; + } + + card.AddToClassList("gacha-result-card-hidden"); + list.Add(card); + // Scheduled off _bannerDetail (already attached to the panel) rather than the + // freshly created card, which has no panel to tick a scheduler on until this + // whole tree is attached by the caller. + _bannerDetail.schedule.Execute(() => + { + card.RemoveFromClassList("gacha-result-card-hidden"); + card.AddToClassList("gacha-result-card-shown"); + if (stars >= 5) + { + card.AddToClassList("gacha-result-card-flash"); + } + }).StartingIn(90 * i + 60); + } + + _resultRevealed = true; + return container; + } + + private static string RarityClassForStars(int stars) + { + return stars switch + { + >= 5 => "gacha-result-card-legendary", + 4 => "gacha-result-card-rare", + _ => "gacha-result-card-common" + }; + } + private static string FormatPullOutcome(PullResolutionOutcome? outcome) { if (!outcome.HasValue) diff --git a/Assets/Scripts/UI/MainMenuController.cs b/Assets/Scripts/UI/MainMenuController.cs index 54f9e10..70a3dad 100644 --- a/Assets/Scripts/UI/MainMenuController.cs +++ b/Assets/Scripts/UI/MainMenuController.cs @@ -69,6 +69,8 @@ namespace KernelPanic.UI private const string HiddenClassName = "hidden"; private const string SelectedClassName = "selected"; private const string CursorOnClassName = "cursor-on"; + private const string ModalOpenClassName = "modal-open"; + private const int ModalTransitionMs = 160; private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars"; private const float BootIntroSeconds = 1.5f; private static bool _bootIntroPlayed; @@ -157,6 +159,8 @@ namespace KernelPanic.UI private bool _suppressNextClick; private bool _warnedUnresolvedSaveId; private bool _rootCreditExchangeOpen; + private IVisualElementScheduledItem _rootCreditExchangeOpenSchedule; + private IVisualElementScheduledItem _rootCreditExchangeHideSchedule; private string _bootIntroCopy; private Action _rootCreditExchangeMinusHundredClicked; private Action _rootCreditExchangeMinusOneClicked; @@ -199,6 +203,7 @@ namespace KernelPanic.UI { RegisterCallbacks(); _root.Focus(); + _root.EnableInClassList("reduced-motion", UIPreferences.ReducedMotion); _shellRoot.EnableInClassList("reduced-motion", UIPreferences.ReducedMotion); RefreshStaticText(); RefreshCurrencyReadouts(); @@ -473,7 +478,11 @@ namespace KernelPanic.UI } _rootCreditExchangeOpen = true; + _rootCreditExchangeHideSchedule?.Pause(); _rootCreditExchangeModal.RemoveFromClassList(HiddenClassName); + _rootCreditExchangeOpenSchedule?.Pause(); + _rootCreditExchangeOpenSchedule = _rootCreditExchangeModal.schedule + .Execute(() => _rootCreditExchangeModal.AddToClassList(ModalOpenClassName)).StartingIn(0); _rootCreditExchangeMessageLabel?.AddToClassList(HiddenClassName); SetRootCreditExchangeAmount(Math.Min(100, _gachaService.RootCredits)); RefreshRootCreditExchange(); @@ -483,7 +492,12 @@ namespace KernelPanic.UI private void CloseRootCreditExchange() { _rootCreditExchangeOpen = false; - _rootCreditExchangeModal?.AddToClassList(HiddenClassName); + _rootCreditExchangeOpenSchedule?.Pause(); + _rootCreditExchangeModal?.RemoveFromClassList(ModalOpenClassName); + int delay = UIPreferences.ReducedMotion ? 0 : ModalTransitionMs; + _rootCreditExchangeHideSchedule?.Pause(); + _rootCreditExchangeHideSchedule = _rootCreditExchangeModal?.schedule + .Execute(() => _rootCreditExchangeModal.AddToClassList(HiddenClassName)).StartingIn(delay); _root.Focus(); } @@ -878,6 +892,10 @@ namespace KernelPanic.UI private void PlayBootIntroIfNeeded() { + // Always start the shell hidden so returning to the menu (starter chosen, boot intro + // already played this session) still gets a fade-in instead of popping in instantly. + _shellRoot.AddToClassList("boot-hidden"); + if (_bootIntroPlayed || UIPreferences.ReducedMotion) { CompleteBootIntro(); @@ -889,7 +907,6 @@ namespace KernelPanic.UI _bootIntroCharacterCount = 0; _bootIntroLogLabel.text = string.Empty; _bootIntroPanel.RemoveFromClassList(HiddenClassName); - _shellRoot.AddToClassList("boot-hidden"); _bootIntroSchedule = _root.schedule.Execute(UpdateBootIntro).Every(16); } @@ -1580,7 +1597,7 @@ namespace KernelPanic.UI SaveLastRunLoadout(unit, equippedCardIds); RunContext.Set(unit, BuildEquippedCardDefinitions(unit, equippedCardIds), _selectedRunLanguages[0], _selectedRunLanguages[1], _playerCollection.GetVersion(unit.Id)); - SceneLoader.LoadGame(); + SceneLoader.LoadGame(_root); } private IReadOnlyList BuildEquippedCardDefinitions(DistroDefinition unit, IReadOnlyList equippedCardIds) diff --git a/Assets/Scripts/UI/StarterSelectionController.cs b/Assets/Scripts/UI/StarterSelectionController.cs index a26cc6c..70de3fe 100644 --- a/Assets/Scripts/UI/StarterSelectionController.cs +++ b/Assets/Scripts/UI/StarterSelectionController.cs @@ -15,6 +15,8 @@ namespace KernelPanic.UI public sealed class StarterSelectionController { private const int StarterCount = 3; + private const string ModalOpenClassName = "modal-open"; + private const int ModalTransitionMs = 160; private readonly List _cards = new(); private readonly List