Add scene fade transitions, gacha pull reveal, and modal/hover animations

Menu <-> combat scene loads now fade through black instead of hard-cutting,
gacha pulls reveal reward cards with a staggered stagger-in and a rarity
flash instead of a static text block, starter selection and root-credit
exchange modals fade+scale in/out instead of popping via display:none, and
menu/collection/gacha rows now transition on hover/select instead of
snapping. All new motion respects the existing reduced-motion setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hiJi3irEg1RegCXRaQDcu
This commit is contained in:
Claude
2026-07-08 06:52:58 +00:00
parent 131eedb626
commit 7d2d0a1152
7 changed files with 329 additions and 20 deletions
+30 -4
View File
@@ -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.
+4 -1
View File
@@ -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;
+91 -9
View File
@@ -40,6 +40,9 @@ namespace KernelPanic.UI
private bool _hasOpened;
private string _resultBannerId;
private string _resultText;
private readonly List<string> _resultRewardLines = new();
private readonly List<int> _resultRewardStars = new();
private bool _resultRevealed;
public void Bind(VisualElement root, DistroDatabase database, FontAsset artFont, GachaService service, PlayerCollection collection, EntropyWallet entropyWallet, Func<IReadOnlyList<DistroDefinition>, 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<string> lines = new();
lines.Add($"git pull complete: spent {result.CurrencySpent} {GachaService.FormatCurrencyName(result.CurrencyType)}");
List<string> 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<DistroDefinition> 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)
+20 -3
View File
@@ -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<CardDefinition> BuildEquippedCardDefinitions(DistroDefinition unit, IReadOnlyList<string> equippedCardIds)
@@ -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<VisualElement> _cards = new();
private readonly List<Label> _names = new();
@@ -35,6 +37,8 @@ namespace KernelPanic.UI
private bool _showingError;
private bool _confirming;
private IVisualElementScheduledItem _closeSchedule;
private IVisualElementScheduledItem _modalOpenSchedule;
private IVisualElementScheduledItem _modalHideSchedule;
public bool IsActive => _active;
@@ -90,7 +94,7 @@ namespace KernelPanic.UI
_errorLabel.AddToClassList("hidden");
_options.RemoveFromClassList("hidden");
_confirmLabel.AddToClassList("hidden");
_modal.RemoveFromClassList("hidden");
OpenModal();
for (int i = 0; i < _cards.Count; i++)
{
@@ -165,6 +169,8 @@ namespace KernelPanic.UI
public void PauseSchedules()
{
_closeSchedule?.Pause();
_modalOpenSchedule?.Pause();
_modalHideSchedule?.Pause();
}
private bool TryGetStarters(out IReadOnlyList<DistroDefinition> starters, out string error)
@@ -204,10 +210,18 @@ namespace KernelPanic.UI
_confirmLabel.AddToClassList("hidden");
_errorLabel.text = $"starter selection unavailable: {error}. press enter to continue.";
_errorLabel.RemoveFromClassList("hidden");
_modal.RemoveFromClassList("hidden");
OpenModal();
_root.Focus();
}
private void OpenModal()
{
_modalHideSchedule?.Pause();
_modal.RemoveFromClassList("hidden");
_modalOpenSchedule?.Pause();
_modalOpenSchedule = _modal.schedule.Execute(() => _modal.AddToClassList(ModalOpenClassName)).StartingIn(0);
}
private void Select(int index)
{
if (_activeStarters.Count == 0)
@@ -292,7 +306,11 @@ namespace KernelPanic.UI
_active = false;
_confirming = false;
_showingError = false;
_modal.AddToClassList("hidden");
_modalOpenSchedule?.Pause();
_modal.RemoveFromClassList(ModalOpenClassName);
int delay = UIPreferences.ReducedMotion ? 0 : ModalTransitionMs;
_modalHideSchedule?.Pause();
_modalHideSchedule = _modal.schedule.Execute(() => _modal.AddToClassList("hidden")).StartingIn(delay);
_root.Focus();
}
}