From ac09d67677e3ec52319d1b51460627ffec5fe88e Mon Sep 17 00:00:00 2001 From: fretuks Date: Wed, 8 Jul 2026 10:29:49 +0200 Subject: [PATCH] Implement cutscene for gambling and make it dopamine-inducing --- .../.idea/workspace.xml | 130 +-- Assets/Resources/GamblingScene.uss | 390 ++++++++ Assets/Resources/RarityPresentation.uss | 24 + Assets/Scenes/GamblingScene.unity | 32 +- Assets/Scripts/Core/SceneLoader.cs | 6 + Assets/Scripts/Meta/GachaPullContext.cs | 189 ++++ Assets/Scripts/Meta/GachaTuning.cs | 4 +- Assets/Scripts/UI/CombatSceneController.cs | 7 + Assets/Scripts/UI/GachaScreenController.cs | 51 +- Assets/Scripts/UI/GamblingSceneController.cs | 848 ++++++++++++++++++ Assets/Scripts/UI/MainMenuController.cs | 109 ++- Assets/Scripts/UI/RarityPresentation.cs | 39 + ProjectSettings/EditorBuildSettings.asset | 3 + 13 files changed, 1761 insertions(+), 71 deletions(-) create mode 100644 Assets/Resources/GamblingScene.uss create mode 100644 Assets/Resources/RarityPresentation.uss create mode 100644 Assets/Scripts/Meta/GachaPullContext.cs create mode 100644 Assets/Scripts/UI/GamblingSceneController.cs create mode 100644 Assets/Scripts/UI/RarityPresentation.cs diff --git a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml index 747ca1c..246732b 100644 --- a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml +++ b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml @@ -11,9 +11,19 @@ - + + + + + + + + + + + - @@ -70,23 +80,23 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -94,37 +104,37 @@ - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + { @@ -213,7 +223,7 @@ - + - @@ -392,7 +418,9 @@ - diff --git a/Assets/Resources/GamblingScene.uss b/Assets/Resources/GamblingScene.uss new file mode 100644 index 0000000..2932425 --- /dev/null +++ b/Assets/Resources/GamblingScene.uss @@ -0,0 +1,390 @@ +:root { + --kp-bg: #030605; + --kp-bg-deep: #020403; + --kp-panel: #07110d; + --kp-panel-raised: #0b1812; + --kp-primary: #5cff91; + --kp-primary-dim: #2f7a4d; + --kp-muted: #7f9b8a; + --kp-border: #14592f; + --kp-hover: #102018; + --kp-amber: #ffbf3f; +} + +.gambling-root { + flex-grow: 1; + background-color: var(--kp-bg); + color: var(--kp-primary); + font-size: 16px; +} + +.hidden { + display: none; +} + +.gambling-shell { + flex-grow: 1; + padding: 18px 28px; + background-color: var(--kp-bg); +} + +.gambling-frame { + flex-grow: 1; + position: relative; + padding: 14px; + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); + background-color: var(--kp-bg-deep); +} + +.gambling-header { + flex-direction: row; + align-items: flex-start; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom-width: 1px; + border-bottom-color: var(--kp-border); +} + +.gambling-title-block { + flex-grow: 1; + min-width: 0; +} + +.gambling-command { + color: var(--kp-primary); + font-size: 20px; + -unity-font-style: bold; +} + +.gambling-cost { + color: var(--kp-muted); + margin-top: 4px; + white-space: normal; +} + +.gambling-skip { + color: var(--kp-muted); + min-width: 210px; + -unity-text-align: upper-right; +} + +.gambling-flourish { + min-height: 20px; + color: var(--kp-muted); + margin-bottom: 8px; + transition-property: color; + transition-duration: 160ms; +} + +.gambling-flourish-complete { + color: var(--kp-primary); +} + +.gambling-results { + height: 330px; + min-height: 330px; + max-height: 330px; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; +} + +.pull-card { + width: 19%; + height: 152px; + min-height: 152px; + margin-right: 1%; + margin-bottom: 10px; + padding: 9px; + background-color: var(--kp-panel); + border-left-width: 3px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); + opacity: 1; + translate: 0 0; + scale: 1 1; + transition-property: opacity, translate, scale, background-color, border-color; + transition-duration: 180ms; +} + +.pull-card-hidden { + opacity: 0; + translate: 0 10px; +} + +.pull-card-held { + color: var(--kp-muted); + background-color: var(--kp-bg); + border-color: var(--kp-primary-dim); + scale: 1 1; +} + +.pull-card-held-rare { + border-color: var(--kp-rarity-4); + background-color: var(--kp-rarity-4-bg); + border-left-width: 5px; + scale: 1 1; +} + +.pull-card-held-legendary { + border-color: var(--kp-rarity-5); + background-color: var(--kp-rarity-5-bg); + border-left-width: 6px; + scale: 1 1; +} + +.pull-card-flash { + background-color: var(--kp-rarity-5-bg); + scale: 1 1; +} + +.pull-card-revealed { + opacity: 1; +} + +.pull-card-rare-pop { + scale: 1 1; + border-color: var(--kp-rarity-4); + border-left-width: 6px; + background-color: #0a2522; +} + +.pull-card-legendary-pop { + scale: 1 1; + border-color: var(--kp-rarity-5); + border-left-width: 7px; + background-color: #2c1f07; +} + +.pull-card-glow-4 { + border-color: var(--kp-rarity-4); +} + +.pull-card-glow-5 { + border-color: var(--kp-rarity-5); + background-color: #1e1505; +} + +.pull-card-dimmed { + opacity: 0.34; + scale: 1 1; +} + +.pull-card-hidden.pull-card-dimmed { + opacity: 0; +} + +.pull-card.rarity-3 { + background-color: var(--kp-rarity-3-bg); + opacity: 0.82; +} + +.pull-card.rarity-4 { + background-color: var(--kp-rarity-4-bg); +} + +.pull-card.rarity-5 { + background-color: var(--kp-rarity-5-bg); + border-left-width: 5px; + scale: 1 1; +} + +.pull-card.rarity-5.pull-card-glow-5 { + border-left-width: 5px; + border-color: var(--kp-rarity-5); + background-color: #211705; +} + +.pull-card.rarity-5.pull-card-glow-on { + background-color: #2a1c05; + border-color: #ffd76f; + scale: 1 1; +} + +.pull-card-top { + flex-direction: row; + align-items: center; + height: 24px; + margin-bottom: 8px; +} + +.pull-index { + width: 28px; + min-width: 28px; + color: var(--kp-muted); + font-size: 12px; +} + +.pull-rarity { + flex-grow: 1; + min-width: 0; + -unity-font-style: bold; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pull-type-icon { + width: 22px; + min-width: 22px; + height: 22px; + color: var(--kp-muted); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); + -unity-text-align: middle-center; +} + +.pull-name { + flex-grow: 1; + min-width: 0; + color: var(--kp-primary); + -unity-font-style: bold; + font-size: 16px; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 8px; +} + +.pull-status { + align-self: flex-start; + min-height: 22px; + padding-left: 6px; + padding-right: 6px; + padding-top: 2px; + padding-bottom: 2px; + background-color: var(--kp-bg); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); + color: var(--kp-muted); + -unity-text-align: middle-center; +} + +.pull-status-new { + color: var(--kp-primary); + border-color: var(--kp-primary); +} + +.pull-status-pop { + background-color: var(--kp-primary); + color: var(--kp-bg); + scale: 1.08 1.08; + transition-property: scale, background-color, color; + transition-duration: 120ms; +} + +.effect-layer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; +} + +.spark { + position: absolute; + width: 8px; + height: 8px; + border-radius: 0; + opacity: 0; + scale: 1 1; + transition-property: left, top, opacity, scale; + transition-duration: 620ms; +} + +.spark-active { + opacity: 0; +} + +.gold-shimmer { + position: absolute; + left: 0; + top: 0; + width: 0; + height: 0; + opacity: 0; + background-color: #ffbf3f08; + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: #ffbf3f33; + scale: 1 1; + transition-property: opacity, scale; + transition-duration: 180ms; +} + +.gold-shimmer-on { + opacity: 0.85; + scale: 1 1; +} + +.summary-panel { + margin-top: 6px; + padding-top: 8px; + border-top-width: 1px; + border-top-color: var(--kp-border); +} + +.summary-totals { + color: var(--kp-muted); + margin-top: 0; + white-space: normal; + transition-property: color, scale; + transition-duration: 120ms; +} + +.summary-tick { + color: var(--kp-primary); + scale: 1.01 1.01; +} + +.continue-button { + height: 34px; + margin-top: 8px; + background-color: var(--kp-panel); + color: var(--kp-primary); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-primary); + opacity: 1; + transition-property: opacity, background-color, color; + transition-duration: 180ms; +} + +.continue-button-pending { + opacity: 0; +} + +.continue-button:hover { + background-color: var(--kp-primary); + color: var(--kp-bg); +} + +.reduced-motion .pull-card, +.reduced-motion .continue-button, +.reduced-motion .spark, +.reduced-motion .gold-shimmer, +.reduced-motion .summary-totals { + transition-duration: 0ms; +} + +.reduced-motion .pull-card-glow-4, +.reduced-motion .pull-card-glow-5 { + scale: 1 1; +} diff --git a/Assets/Resources/RarityPresentation.uss b/Assets/Resources/RarityPresentation.uss new file mode 100644 index 0000000..0c7117a --- /dev/null +++ b/Assets/Resources/RarityPresentation.uss @@ -0,0 +1,24 @@ +:root { + --kp-rarity-3: #7f9b8a; + --kp-rarity-3-bg: #07100c; + --kp-rarity-4: #5cfff2; + --kp-rarity-4-bg: #08201e; + --kp-rarity-5: #ffbf3f; + --kp-rarity-5-bg: #211705; +} + +.rarity-3 { + color: var(--kp-rarity-3); + border-color: var(--kp-rarity-3); +} + +.rarity-4 { + color: var(--kp-rarity-4); + border-color: var(--kp-rarity-4); +} + +.rarity-5 { + color: var(--kp-rarity-5); + border-color: var(--kp-rarity-5); + -unity-font-style: bold; +} diff --git a/Assets/Scenes/GamblingScene.unity b/Assets/Scenes/GamblingScene.unity index 8d7b8e7..4eff9ab 100644 --- a/Assets/Scenes/GamblingScene.unity +++ b/Assets/Scenes/GamblingScene.unity @@ -130,6 +130,8 @@ GameObject: - component: {fileID: 2124216809} - component: {fileID: 2124216808} - component: {fileID: 2124216807} + - component: {fileID: 2124216811} + - component: {fileID: 2124216810} m_Layer: 0 m_Name: Main Camera m_TagString: MainCamera @@ -155,7 +157,7 @@ Camera: m_Enabled: 1 serializedVersion: 2 m_ClearFlags: 1 - m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_BackGroundColor: {r: 0.011764706, g: 0.023529412, b: 0.019607844, a: 1} m_projectionMatrixMode: 1 m_GateFitMode: 2 m_FOVAxisMode: 0 @@ -211,6 +213,34 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &2124216810 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2124216806} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d5b4ca12d26e4e54962ab9f6658e72d2, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!114 &2124216811 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2124216806} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 19102, guid: 0000000000000000e000000000000000, type: 0} + m_Name: + m_EditorClassIdentifier: + m_PanelSettings: {fileID: 11400000, guid: 7d68bd7978394c8295debbd403a39d80, type: 2} + m_ParentUI: {fileID: 0} + sourceAsset: {fileID: 0} + m_SortingOrder: 0 --- !u!1660057539 &9223372036854775807 SceneRoots: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/Core/SceneLoader.cs b/Assets/Scripts/Core/SceneLoader.cs index ec9b7b4..b6fda90 100644 --- a/Assets/Scripts/Core/SceneLoader.cs +++ b/Assets/Scripts/Core/SceneLoader.cs @@ -11,6 +11,7 @@ namespace KernelPanic.Core { public const string MainMenuSceneName = "MainMenuScene"; public const string GameSceneName = "GameScene"; + public const string GamblingSceneName = "GamblingScene"; private const string CurtainClassName = "scene-fade-curtain"; private const string CurtainVisibleClassName = "scene-fade-visible"; @@ -26,6 +27,11 @@ namespace KernelPanic.Core FadeThenLoad(fadeRoot, GameSceneName); } + public static void LoadGambling(VisualElement fadeRoot) + { + FadeThenLoad(fadeRoot, GamblingSceneName); + } + private static void FadeThenLoad(VisualElement fadeRoot, string sceneName) { if (fadeRoot == null) diff --git a/Assets/Scripts/Meta/GachaPullContext.cs b/Assets/Scripts/Meta/GachaPullContext.cs new file mode 100644 index 0000000..d4e436d --- /dev/null +++ b/Assets/Scripts/Meta/GachaPullContext.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using KernelPanic.Data; +using KernelPanic.Core; + +namespace KernelPanic.Meta +{ + /// + /// Transient scene-transition handoff for gacha pull cutscenes. + /// + public static class GachaPullContext + { + private static PendingGachaPull pendingPull; + private static CompletedGachaPull completedPull; + + public static bool HasPendingPull => pendingPull != null; + public static bool HasCompletedPull => completedPull != null; + + public static void SetPending(string bannerId, int pullCount, int entropyTokenCount, DistroDatabase distroDatabase, string focusUnitId) + { + pendingPull = new PendingGachaPull(bannerId, pullCount, entropyTokenCount, distroDatabase, focusUnitId); + completedPull = null; + } + + public static bool TryConsumePending(out PendingGachaPull pull) + { + pull = pendingPull; + pendingPull = null; + return pull != null; + } + + public static void SetCompleted(CompletedGachaPull pull) + { + completedPull = pull; + } + + public static bool TryConsumeCompleted(out CompletedGachaPull pull) + { + pull = completedPull; + completedPull = null; + return pull != null; + } + } + + public sealed class PendingGachaPull + { + public PendingGachaPull(string bannerId, int pullCount, int entropyTokenCount, DistroDatabase distroDatabase, string focusUnitId) + { + BannerId = string.IsNullOrWhiteSpace(bannerId) ? GachaService.BeginnerBannerId : bannerId; + PullCount = Math.Max(1, pullCount); + EntropyTokenCount = Math.Max(0, entropyTokenCount); + DistroDatabase = distroDatabase; + FocusUnitId = focusUnitId; + } + + public string BannerId { get; } + public int PullCount { get; } + public int EntropyTokenCount { get; } + public DistroDatabase DistroDatabase { get; } + public string FocusUnitId { get; } + } + + public sealed class CompletedGachaPull + { + public CompletedGachaPull(string bannerId, string headerText, IReadOnlyList rewards) + { + BannerId = string.IsNullOrWhiteSpace(bannerId) ? GachaService.BeginnerBannerId : bannerId; + HeaderText = headerText; + Rewards = rewards ?? Array.Empty(); + } + + public string BannerId { get; } + public string HeaderText { get; } + public IReadOnlyList Rewards { get; } + + public IReadOnlyList RewardLines + { + get + { + List lines = new(); + for (int i = 0; i < Rewards.Count; i++) + { + CompletedGachaReward reward = Rewards[i]; + string status = string.IsNullOrWhiteSpace(reward.StatusText) ? string.Empty : $" {reward.StatusText}"; + string languages = string.IsNullOrWhiteSpace(reward.LanguageUnlockText) ? string.Empty : $" {reward.LanguageUnlockText}"; + lines.Add($"{reward.Index:00}: {reward.Stars}★ {reward.DisplayName} [{reward.TypeText}]{status}{languages}"); + } + + return lines; + } + } + + public IReadOnlyList RewardStars + { + get + { + List stars = new(); + for (int i = 0; i < Rewards.Count; i++) + { + stars.Add(Rewards[i].Stars); + } + + return stars; + } + } + } + + public sealed class CompletedGachaReward + { + public CompletedGachaReward(int index, int stars, string displayName, GachaRewardType rewardType, PullOutcomeKind outcomeKind, int mergesAwarded, IReadOnlyList languagesUnlocked, bool guaranteed, bool pityTriggered) + { + Index = Math.Max(1, index); + Stars = Math.Max(3, stars); + RewardType = rewardType; + DisplayName = NormalizeDisplayName(displayName, rewardType); + OutcomeKind = outcomeKind; + MergesAwarded = Math.Max(0, mergesAwarded); + LanguagesUnlocked = languagesUnlocked ?? Array.Empty(); + Guaranteed = guaranteed; + PityTriggered = pityTriggered; + } + + public int Index { get; } + public int Stars { get; } + public string DisplayName { get; } + public GachaRewardType RewardType { get; } + public PullOutcomeKind OutcomeKind { get; } + public int MergesAwarded { get; } + public IReadOnlyList LanguagesUnlocked { get; } + public bool Guaranteed { get; } + public bool PityTriggered { get; } + public bool IsCharacter => RewardType == GachaRewardType.Distro || RewardType == GachaRewardType.FutureStandardFiveStarDistro; + + public string TypeText => IsCharacter ? "character" : "equipment"; + + public string StatusText + { + get + { + return OutcomeKind switch + { + PullOutcomeKind.Granted => "NEW", + PullOutcomeKind.Dupe => $"→ {MergesAwarded} merges", + PullOutcomeKind.DupeOverflow => $"→ {MergesAwarded} merges", + _ => Guaranteed ? "guaranteed" : PityTriggered ? "pity" : string.Empty + }; + } + } + + public string LanguageUnlockText + { + get + { + if (LanguagesUnlocked.Count == 0) + { + return string.Empty; + } + + List names = new(); + for (int i = 0; i < LanguagesUnlocked.Count; i++) + { + names.Add(LanguagesUnlocked[i].ToString()); + } + + return $"unlocks {string.Join(", ", names)}"; + } + } + + private static string NormalizeDisplayName(string displayName, GachaRewardType rewardType) + { + if (string.IsNullOrWhiteSpace(displayName)) + { + return "unknown result"; + } + + if (rewardType != GachaRewardType.Equipment) + { + return displayName; + } + + string trimmed = displayName.Trim(); + return trimmed.StartsWith("3-star ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("4-star ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("5-star ", StringComparison.OrdinalIgnoreCase) + ? trimmed.Substring(7) + : trimmed; + } + } +} diff --git a/Assets/Scripts/Meta/GachaTuning.cs b/Assets/Scripts/Meta/GachaTuning.cs index a8186b4..493f874 100644 --- a/Assets/Scripts/Meta/GachaTuning.cs +++ b/Assets/Scripts/Meta/GachaTuning.cs @@ -6,8 +6,8 @@ namespace KernelPanic.Meta public static class GachaTuning { public const int MaxVersion = 5; - public const int MergesPerDupe = 20; - public const int MergesOnCharacterBonus = 10; + public const int MergesPerDupe = 50; + public const int MergesOnCharacterBonus = 25; public const float MergesMaxVersionOverflowMultiplier = 1.5f; public static int DupeConsolationBandwidth => 0; public static readonly int[] VersionCosts = { 50, 120, 250, 500 }; diff --git a/Assets/Scripts/UI/CombatSceneController.cs b/Assets/Scripts/UI/CombatSceneController.cs index f54e136..36667ac 100644 --- a/Assets/Scripts/UI/CombatSceneController.cs +++ b/Assets/Scripts/UI/CombatSceneController.cs @@ -21,6 +21,7 @@ namespace KernelPanic.UI { private const string StyleResourcePath = "CombatScene"; private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars"; + private const string SharedRarityStyleResourcePath = "RarityPresentation"; private const string FilledCycle = "●"; private const string EmptyCycle = "○"; @@ -218,6 +219,12 @@ namespace KernelPanic.UI private void LoadStyles() { + StyleSheet rarityStyleSheet = Resources.Load(SharedRarityStyleResourcePath); + if (rarityStyleSheet != null) + { + root.styleSheets.Add(rarityStyleSheet); + } + StyleSheet styleSheet = Resources.Load(StyleResourcePath); if (styleSheet != null) { diff --git a/Assets/Scripts/UI/GachaScreenController.cs b/Assets/Scripts/UI/GachaScreenController.cs index 160d1b0..5771edf 100644 --- a/Assets/Scripts/UI/GachaScreenController.cs +++ b/Assets/Scripts/UI/GachaScreenController.cs @@ -34,6 +34,7 @@ namespace KernelPanic.UI private Func, PullResolutionResult> _resolvePulledDistros; private Action _onChanged; private Action _requestRootCreditExchange; + private Action _requestPullCutscene; private int _selectedBannerIndex; private int _pendingPullCount; private int _pendingMissingTokens; @@ -44,7 +45,7 @@ namespace KernelPanic.UI 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) + public void Bind(VisualElement root, DistroDatabase database, FontAsset artFont, GachaService service, PlayerCollection collection, EntropyWallet entropyWallet, Func, PullResolutionResult> pullResolver, Action changedCallback, Action rootCreditExchangeCallback, Action pullCutsceneCallback) { _distroDatabase = database; _monospaceFont = artFont; @@ -54,6 +55,7 @@ namespace KernelPanic.UI _resolvePulledDistros = pullResolver; _onChanged = changedCallback; _requestRootCreditExchange = rootCreditExchangeCallback; + _requestPullCutscene = pullCutsceneCallback; _bannerList = root.Q("GachaBannerList"); _bannerDetail = root.Q("GachaBannerDetail"); } @@ -232,6 +234,12 @@ namespace KernelPanic.UI private void PullSelectedBanner(int pullCount, int entropyTokenCount) { string bannerId = _bannerIds[_selectedBannerIndex]; + if (_requestPullCutscene != null) + { + _requestPullCutscene.Invoke(bannerId, pullCount, entropyTokenCount); + return; + } + if (bannerId != GachaService.BeginnerBannerId) { _resultBannerId = bannerId; @@ -285,7 +293,7 @@ namespace KernelPanic.UI string suffix = reward.Guaranteed ? " guaranteed" : reward.PityTriggered ? " pity" : string.Empty; string outcomeText = FormatPullOutcome(outcome); - _resultRewardLines.Add($"{i + 1:00}: {reward.StarRating}-star {reward.DisplayName}{suffix}{outcomeText}"); + _resultRewardLines.Add($"{i + 1:00}: {reward.StarRating}★ {FormatRewardDisplayName(reward)}{suffix}{outcomeText}"); _resultRewardStars.Add(reward.StarRating); } @@ -296,6 +304,30 @@ namespace KernelPanic.UI Refresh(); } + public void SetCompletedPullResult(CompletedGachaPull result) + { + if (result == null) + { + return; + } + + _resultBannerId = result.BannerId; + _resultText = result.HeaderText; + _resultRewardLines.Clear(); + _resultRewardStars.Clear(); + for (int i = 0; i < result.RewardLines.Count; i++) + { + _resultRewardLines.Add(result.RewardLines[i]); + } + + for (int i = 0; i < result.RewardStars.Count; i++) + { + _resultRewardStars.Add(result.RewardStars[i]); + } + + _resultRevealed = true; + } + private VisualElement BuildPullResultReveal() { VisualElement container = new(); @@ -385,6 +417,21 @@ namespace KernelPanic.UI }; } + private static string FormatRewardDisplayName(GachaReward reward) + { + if (reward.RewardType != GachaRewardType.Equipment || string.IsNullOrWhiteSpace(reward.DisplayName)) + { + return reward.DisplayName; + } + + string trimmed = reward.DisplayName.Trim(); + return trimmed.StartsWith("3-star ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("4-star ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("5-star ", StringComparison.OrdinalIgnoreCase) + ? trimmed.Substring(7) + : trimmed; + } + private void RequestPullSelectedBanner(int pullCount) { string bannerId = _bannerIds[_selectedBannerIndex]; diff --git a/Assets/Scripts/UI/GamblingSceneController.cs b/Assets/Scripts/UI/GamblingSceneController.cs new file mode 100644 index 0000000..8b5a050 --- /dev/null +++ b/Assets/Scripts/UI/GamblingSceneController.cs @@ -0,0 +1,848 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using KernelPanic.Core; +using KernelPanic.Data; +using KernelPanic.Meta; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UIElements; + +namespace KernelPanic.UI +{ + /// + /// UI Toolkit gacha pull cutscene rendered by GamblingScene. + /// + [RequireComponent(typeof(UIDocument))] + public sealed class GamblingSceneController : MonoBehaviour + { + private const string GamblingStyleResourcePath = "GamblingScene"; + private const string RarityStyleResourcePath = "RarityPresentation"; + private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars"; + private const int FlourishMs = 420; + private const int CommonRevealMs = 150; + private const int RareRevealMs = 240; + private const int LegendaryRevealMs = 420; + private const int RareAnticipationMs = 520; + private const int LegendaryAnticipationMs = 820; + private const int RareSparkCount = 6; + private const int LegendarySparkCount = 18; + private const int SparkLifetimeMs = 520; + private const int PopClassMs = 280; + private const int LegendaryShimmerMs = 480; + private const int SummaryTickSteps = 8; + private const int SummaryTickMs = 220; + + private readonly List rewards = new(); + private readonly List cardViews = new(); + private UIDocument document; + private VisualElement root; + private Label commandLabel; + private Label costLabel; + private Label flourishLabel; + private Label skipHint; + private VisualElement resultGrid; + private VisualElement effectLayer; + private VisualElement goldShimmer; + private VisualElement summaryPanel; + private Label summaryTotalsLabel; + private Button continueButton; + private string completedHeader; + private bool skipRequested; + private bool revealComplete; + private bool failed; + + private void Awake() + { + document = GetComponent(); + root = document.rootVisualElement; + root.Clear(); + root.focusable = true; + root.AddToClassList("gambling-root"); + root.EnableInClassList("reduced-motion", UIPreferences.ReducedMotion); + LoadStyles(); + BuildLayout(); + root.RegisterCallback(HandleKeyDown); + root.RegisterCallback(HandlePointerDown); + } + + private void OnDestroy() + { + if (root == null) + { + return; + } + + root.UnregisterCallback(HandleKeyDown); + root.UnregisterCallback(HandlePointerDown); + } + + private void Start() + { + root.Focus(); + StartCoroutine(RunCutscene()); + } + + private void LoadStyles() + { + AddStyle(RarityStyleResourcePath); + AddStyle(GamblingStyleResourcePath); + AddStyle(SharedScrollbarStyleResourcePath); + } + + private void AddStyle(string resourcePath) + { + StyleSheet styleSheet = Resources.Load(resourcePath); + if (styleSheet != null) + { + root.styleSheets.Add(styleSheet); + } + } + + private void BuildLayout() + { + VisualElement shell = new(); + shell.AddToClassList("gambling-shell"); + root.Add(shell); + + VisualElement frame = new(); + frame.AddToClassList("gambling-frame"); + shell.Add(frame); + + VisualElement header = new(); + header.AddToClassList("gambling-header"); + frame.Add(header); + + VisualElement titleBlock = new(); + titleBlock.AddToClassList("gambling-title-block"); + header.Add(titleBlock); + + commandLabel = new("git pull"); + commandLabel.AddToClassList("gambling-command"); + titleBlock.Add(commandLabel); + + costLabel = new(); + costLabel.AddToClassList("gambling-cost"); + titleBlock.Add(costLabel); + + skipHint = new("[tap/key] reveal all"); + skipHint.AddToClassList("gambling-skip"); + header.Add(skipHint); + + flourishLabel = new(); + flourishLabel.AddToClassList("gambling-flourish"); + frame.Add(flourishLabel); + + resultGrid = new VisualElement(); + resultGrid.AddToClassList("gambling-results"); + frame.Add(resultGrid); + + goldShimmer = new VisualElement(); + goldShimmer.AddToClassList("gold-shimmer"); + goldShimmer.pickingMode = PickingMode.Ignore; + frame.Add(goldShimmer); + + effectLayer = new VisualElement(); + effectLayer.AddToClassList("effect-layer"); + effectLayer.pickingMode = PickingMode.Ignore; + frame.Add(effectLayer); + + summaryPanel = new VisualElement(); + summaryPanel.AddToClassList("summary-panel"); + summaryPanel.AddToClassList("hidden"); + frame.Add(summaryPanel); + + continueButton = new Button(ContinueToMenu) { text = "> continue" }; + continueButton.AddToClassList("continue-button"); + continueButton.AddToClassList("continue-button-pending"); + continueButton.SetEnabled(false); + frame.Add(continueButton); + } + + private IEnumerator RunCutscene() + { + if (!GachaPullContext.TryConsumePending(out PendingGachaPull pending)) + { + failed = true; + commandLabel.text = "git pull failed"; + costLabel.text = "no pending pull"; + ShowContinue(); + yield break; + } + + commandLabel.text = pending.PullCount == 10 ? "git pull --ten" : "git pull --single"; + int pullCost = pending.PullCount == 10 ? GachaService.BeginnerTenPullCost : GachaService.BeginnerSinglePullCost; + costLabel.text = pending.EntropyTokenCount > 0 + ? $"cost: {pullCost} Commits / entropy fallback: {pending.EntropyTokenCount * GachaService.EntropyPerPullToken}" + : $"cost: {pullCost} Commits"; + flourishLabel.text = "$ fetch-pack | verify | unpack"; + + CompletedGachaPull completed = ResolvePull(pending); + GachaPullContext.SetCompleted(completed); + completedHeader = completed.HeaderText; + rewards.Clear(); + rewards.AddRange(completed.Rewards); + + if (rewards.Count == 0) + { + failed = true; + costLabel.text = completed.HeaderText; + ShowContinue(); + yield break; + } + + BuildRows(); + if (UIPreferences.ReducedMotion) + { + RevealAll(); + yield break; + } + + yield return WaitOrSkip(FlourishMs); + flourishLabel.text = "$ unpacking sealed objects"; + for (int i = 0; i < rewards.Count; i++) + { + if (skipRequested) + { + RevealAll(); + yield break; + } + + CompletedGachaReward reward = rewards[i]; + PullCardView card = cardViews[i]; + int holdMs = GetAnticipationMs(reward.Stars); + if (holdMs > 0) + { + ApplyGridFocus(i, reward.Stars); + card.ShowHold(reward.Stars >= 5 ? "........ signed tag" : "... promoted object", reward.Stars); + yield return WaitOrSkip(holdMs); + if (skipRequested) + { + RevealAll(); + yield break; + } + + card.HideHold(); + } + + card.Reveal(allowFlash: reward.Stars >= 4, persistentMotion: true); + PlayRevealJuice(card, reward); + yield return WaitOrSkip(GetRevealMs(reward.Stars)); + ClearGridFocus(); + } + + CompleteReveal(); + } + + private CompletedGachaPull ResolvePull(PendingGachaPull pending) + { + if (pending.DistroDatabase == null) + { + return new CompletedGachaPull(pending.BannerId, "git pull failed: distro database unavailable", Array.Empty()); + } + + SaveService saveService = new(); + SaveData saveData = saveService.Load(); + saveData.EnsureLists(); + + EntropyWallet wallet = new(); + wallet.SetBalance(saveData.entropyBalance); + + PlayerCollection collection = BuildCollection(saveData, pending.DistroDatabase); + GachaService gacha = new(); + RestoreBannerPool(gacha, saveData, pending.DistroDatabase); + gacha.LoadProgress(saveData); + + GachaPullResult result = pending.BannerId == GachaService.BeginnerBannerId + ? gacha.PerformBeginnerPull(pending.PullCount, wallet, pending.EntropyTokenCount) + : GachaPullResult.Failed(pending.BannerId, "remote not implemented yet"); + + if (!result.Success) + { + return new CompletedGachaPull(pending.BannerId, $"git pull failed: {result.FailureReason}", Array.Empty()); + } + + List header = new() + { + $"spent {result.CurrencySpent} {GachaService.FormatCurrencyName(result.CurrencyType)}" + }; + if (result.EntropySpent > 0) + { + header.Add($"spent {result.EntropySpent} entropy"); + } + + List distroRewards = new(); + for (int i = 0; i < result.Rewards.Count; i++) + { + if (result.Rewards[i].Distro != null) + { + distroRewards.Add(result.Rewards[i].Distro); + } + } + + PullResolutionResult resolution = PullResolver.Resolve(distroRewards, new PullResolutionContext(saveData, collection, pending.DistroDatabase, pending.FocusUnitId)); + List completedRewards = new(); + int distroOutcomeIndex = 0; + for (int i = 0; i < result.Rewards.Count; i++) + { + GachaReward reward = result.Rewards[i]; + PullResolutionOutcome? outcome = null; + if (reward.Distro != null && resolution != null && distroOutcomeIndex < resolution.Outcomes.Count) + { + outcome = resolution.Outcomes[distroOutcomeIndex]; + distroOutcomeIndex++; + } + + PullOutcomeKind outcomeKind = outcome?.Kind ?? PullOutcomeKind.Invalid; + int merges = outcome?.MergesAwarded ?? 0; + IReadOnlyList languages = outcome?.LanguagesNewlyUnlocked ?? Array.Empty(); + completedRewards.Add(new CompletedGachaReward(i + 1, reward.StarRating, reward.DisplayName, reward.RewardType, outcomeKind, merges, languages, reward.Guaranteed, reward.PityTriggered)); + } + + WriteState(saveService, saveData, wallet, collection, gacha); + return new CompletedGachaPull(result.BannerId, string.Join(" / ", header), completedRewards); + } + + private void BuildRows() + { + resultGrid.Clear(); + cardViews.Clear(); + for (int i = 0; i < rewards.Count; i++) + { + PullCardView card = new(rewards[i]); + cardViews.Add(card); + resultGrid.Add(card.Root); + } + } + + private void RevealAll() + { + skipRequested = true; + ClearGridFocus(); + goldShimmer.RemoveFromClassList("gold-shimmer-on"); + for (int i = 0; i < cardViews.Count; i++) + { + cardViews[i].HideHold(); + cardViews[i].Reveal(allowFlash: false, persistentMotion: !UIPreferences.ReducedMotion); + } + + CompleteReveal(); + } + + private void CompleteReveal() + { + if (revealComplete) + { + return; + } + + revealComplete = true; + skipHint.text = "complete"; + flourishLabel.text = completedHeader; + flourishLabel.AddToClassList("gambling-flourish-complete"); + BuildSummary(animate: !skipRequested && !UIPreferences.ReducedMotion); + ShowContinue(); + } + + private void BuildSummary(bool animate) + { + summaryPanel.Clear(); + summaryPanel.RemoveFromClassList("hidden"); + summaryTotalsLabel = new Label { name = "PullSummaryTotals" }; + summaryTotalsLabel.AddToClassList("summary-totals"); + summaryPanel.Add(summaryTotalsLabel); + + SummaryTotals totals = CalculateTotals(); + if (!animate) + { + summaryTotalsLabel.text = FormatTotals(totals.Units, totals.Merges, totals.LanguagesText); + return; + } + + summaryTotalsLabel.AddToClassList("summary-tick"); + for (int step = 0; step <= SummaryTickSteps; step++) + { + int capturedStep = step; + int delay = Mathf.RoundToInt((SummaryTickMs / (float)SummaryTickSteps) * capturedStep); + summaryTotalsLabel.schedule.Execute(() => + { + float t = capturedStep / (float)SummaryTickSteps; + int units = Mathf.RoundToInt(Mathf.Lerp(0, totals.Units, t)); + int merges = Mathf.RoundToInt(Mathf.Lerp(0, totals.Merges, t)); + summaryTotalsLabel.text = FormatTotals(units, merges, capturedStep == SummaryTickSteps ? totals.LanguagesText : "..."); + if (capturedStep == SummaryTickSteps) + { + summaryTotalsLabel.RemoveFromClassList("summary-tick"); + } + }).StartingIn(delay); + } + } + + private SummaryTotals CalculateTotals() + { + int units = 0; + int merges = 0; + HashSet languages = new(); + for (int i = 0; i < rewards.Count; i++) + { + if (rewards[i].OutcomeKind == PullOutcomeKind.Granted && rewards[i].IsCharacter) + { + units++; + } + + merges += rewards[i].MergesAwarded; + for (int languageIndex = 0; languageIndex < rewards[i].LanguagesUnlocked.Count; languageIndex++) + { + languages.Add(rewards[i].LanguagesUnlocked[languageIndex]); + } + } + + return new SummaryTotals(units, merges, FormatLanguages(languages)); + } + + private static string FormatTotals(int units, int merges, string languagesText) + { + string unitText = units == 0 ? "none" : units.ToString(); + string mergeText = merges == 0 ? "none" : merges.ToString(); + return $"units gained: {unitText} merges: {mergeText} languages unlocked: {languagesText}"; + } + + private static string FormatLanguages(HashSet languages) + { + if (languages.Count == 0) + { + return "none"; + } + + List names = new(); + foreach (Language language in languages) + { + names.Add(language.ToString()); + } + + names.Sort(StringComparer.OrdinalIgnoreCase); + return string.Join(", ", names); + } + + private static PlayerCollection BuildCollection(SaveData saveData, DistroDatabase distroDatabase) + { + PlayerCollection collection = new(); + for (int i = 0; i < saveData.ownedUnits.Count; i++) + { + OwnedUnitSaveEntry entry = saveData.ownedUnits[i]; + DistroDefinition unit = entry == null ? null : distroDatabase.FindById(entry.id); + if (unit != null) + { + collection.AddSilently(unit, entry.version); + } + } + + return collection; + } + + private static void RestoreBannerPool(GachaService gacha, SaveData saveData, DistroDatabase distroDatabase) + { + for (int i = 0; i < saveData.bannerPoolIds.Count; i++) + { + DistroDefinition unit = distroDatabase.FindById(saveData.bannerPoolIds[i]); + if (unit != null) + { + gacha.AddToBannerPool(unit); + } + } + + if (saveData.starterChosen && gacha.BannerPool.Count == 0) + { + IReadOnlyList distros = distroDatabase.AllDistros; + for (int i = 0; i < distros.Count; i++) + { + gacha.AddToBannerPool(distros[i]); + } + } + } + + private static void WriteState(SaveService saveService, SaveData saveData, EntropyWallet wallet, PlayerCollection collection, GachaService gacha) + { + Dictionary mergeBalances = new(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < saveData.ownedUnits.Count; i++) + { + OwnedUnitSaveEntry entry = saveData.ownedUnits[i]; + if (entry != null && !string.IsNullOrWhiteSpace(entry.id)) + { + mergeBalances[entry.id] = Math.Max(0, entry.merges); + } + } + + saveData.entropyBalance = wallet.Balance; + saveData.ownedUnits.Clear(); + saveData.ownedUnitIds.Clear(); + saveData.bannerPoolIds.Clear(); + gacha.WriteProgress(saveData); + + for (int i = 0; i < collection.OwnedUnits.Count; i++) + { + DistroDefinition unit = collection.OwnedUnits[i]; + if (unit != null && !string.IsNullOrWhiteSpace(unit.Id)) + { + saveData.ownedUnits.Add(new OwnedUnitSaveEntry + { + id = unit.Id, + version = Mathf.Clamp(collection.GetVersion(unit.Id), 1, GachaTuning.MaxVersion), + merges = mergeBalances.TryGetValue(unit.Id, out int merges) ? merges : 0 + }); + } + } + + for (int i = 0; i < gacha.BannerPool.Count; i++) + { + DistroDefinition unit = gacha.BannerPool[i]; + if (unit != null && !string.IsNullOrWhiteSpace(unit.Id)) + { + saveData.bannerPoolIds.Add(unit.Id); + } + } + + saveService.Save(saveData); + } + + private IEnumerator WaitOrSkip(int milliseconds) + { + float elapsed = 0f; + float duration = Mathf.Max(0f, milliseconds / 1000f); + while (!skipRequested && elapsed < duration) + { + elapsed += Time.deltaTime; + yield return null; + } + } + + private static int GetAnticipationMs(int stars) + { + return stars >= 5 ? LegendaryAnticipationMs : stars >= 4 ? RareAnticipationMs : 0; + } + + private static int GetRevealMs(int stars) + { + return stars >= 5 ? LegendaryRevealMs : stars >= 4 ? RareRevealMs : CommonRevealMs; + } + + private void ApplyGridFocus(int focusedIndex, int stars) + { + if (UIPreferences.ReducedMotion || stars < 5) + { + return; + } + + for (int i = 0; i < cardViews.Count; i++) + { + cardViews[i].SetDimmed(i != focusedIndex); + } + } + + private void ClearGridFocus() + { + for (int i = 0; i < cardViews.Count; i++) + { + cardViews[i].SetDimmed(false); + } + } + + private void PlayRevealJuice(PullCardView card, CompletedGachaReward reward) + { + if (UIPreferences.ReducedMotion) + { + return; + } + + if (reward.Stars >= 5) + { + card.PlayTemporaryClass("pull-card-legendary-pop", PopClassMs); + PlayGoldShimmer(card.Root); + EmitSparks(card.Root, RarityPresentation.ForStars(reward.Stars).Color, LegendarySparkCount, 76f); + } + else if (reward.Stars >= 4) + { + card.PlayTemporaryClass("pull-card-rare-pop", PopClassMs); + EmitSparks(card.Root, RarityPresentation.ForStars(reward.Stars).Color, RareSparkCount, 44f); + } + + if (reward.OutcomeKind == PullOutcomeKind.Granted) + { + card.PulseStatus(PopClassMs); + } + } + + private void PlayGoldShimmer(VisualElement source) + { + if (source == null || goldShimmer?.parent == null) + { + return; + } + + Rect sourceBounds = source.worldBound; + Rect parentBounds = goldShimmer.parent.worldBound; + float left = Mathf.Max(0f, sourceBounds.x - parentBounds.x); + float top = Mathf.Max(0f, sourceBounds.y - parentBounds.y); + goldShimmer.style.left = left; + goldShimmer.style.top = top; + goldShimmer.style.width = sourceBounds.width; + goldShimmer.style.height = sourceBounds.height; + goldShimmer.AddToClassList("gold-shimmer-on"); + goldShimmer.schedule.Execute(() => goldShimmer.RemoveFromClassList("gold-shimmer-on")).StartingIn(LegendaryShimmerMs); + } + + private void EmitSparks(VisualElement source, Color color, int count, float radius) + { + if (effectLayer == null || source == null) + { + return; + } + + Rect sourceBounds = source.worldBound; + Rect layerBounds = effectLayer.worldBound; + float centerX = sourceBounds.center.x - layerBounds.x; + float centerY = sourceBounds.center.y - layerBounds.y; + float halfWidth = sourceBounds.width * 0.5f; + float halfHeight = sourceBounds.height * 0.5f; + for (int i = 0; i < count; i++) + { + VisualElement spark = CreateSpark(); + + float angle = (Mathf.PI * 2f * i / count) + UnityEngine.Random.Range(-0.18f, 0.18f); + float dirX = Mathf.Cos(angle); + float dirY = Mathf.Sin(angle); + float edgeDistanceX = Mathf.Approximately(dirX, 0f) ? float.MaxValue : halfWidth / Mathf.Abs(dirX); + float edgeDistanceY = Mathf.Approximately(dirY, 0f) ? float.MaxValue : halfHeight / Mathf.Abs(dirY); + float edgeDistance = Mathf.Min(edgeDistanceX, edgeDistanceY); + float startX = centerX + (dirX * (edgeDistance + 10f)) - 4f; + float startY = centerY + (dirY * (edgeDistance + 10f)) - 4f; + float distance = UnityEngine.Random.Range(radius * 0.55f, radius); + float targetX = startX + (dirX * distance); + float targetY = startY + (dirY * distance); + + spark.style.backgroundColor = color; + spark.style.left = startX; + spark.style.top = startY; + spark.style.opacity = 1f; + spark.style.scale = new Scale(new Vector2(1.35f, 1.35f)); + spark.RemoveFromClassList("spark-active"); + spark.schedule.Execute(() => + { + spark.AddToClassList("spark-active"); + spark.style.left = targetX; + spark.style.top = targetY; + spark.style.opacity = 0f; + spark.style.scale = new Scale(new Vector2(0.25f, 0.25f)); + }).StartingIn(35); + spark.schedule.Execute(() => + { + spark.RemoveFromHierarchy(); + }).StartingIn(SparkLifetimeMs); + } + } + + private VisualElement CreateSpark() + { + VisualElement spark = new(); + spark.pickingMode = PickingMode.Ignore; + spark.AddToClassList("spark"); + effectLayer.Add(spark); + return spark; + } + + private void HandleKeyDown(KeyDownEvent evt) + { + if (revealComplete) + { + ContinueToMenu(); + evt.StopPropagation(); + return; + } + + skipRequested = true; + evt.StopPropagation(); + } + + private void HandlePointerDown(PointerDownEvent evt) + { + if (revealComplete) + { + return; + } + + skipRequested = true; + evt.StopPropagation(); + } + + private void ShowContinue() + { + continueButton.SetEnabled(true); + continueButton.RemoveFromClassList("continue-button-pending"); + if (failed) + { + skipHint.text = "failed"; + } + } + + private void ContinueToMenu() + { + SceneManager.LoadSceneAsync(SceneLoader.MainMenuSceneName); + } + + private readonly struct SummaryTotals + { + public SummaryTotals(int units, int merges, string languagesText) + { + Units = units; + Merges = merges; + LanguagesText = languagesText; + } + + public int Units { get; } + public int Merges { get; } + public string LanguagesText { get; } + } + + private sealed class PullCardView + { + private readonly CompletedGachaReward reward; + private readonly Label rarityLabel; + private readonly Label nameLabel; + private readonly Label statusLabel; + private readonly Label typeIconLabel; + private IVisualElementScheduledItem glowPulse; + private bool glowOn; + + public PullCardView(CompletedGachaReward reward) + { + this.reward = reward; + RarityStyle rarity = RarityPresentation.ForStars(reward.Stars); + Root = new VisualElement(); + Root.AddToClassList("pull-card"); + Root.AddToClassList("pull-card-hidden"); + Root.AddToClassList(rarity.ClassName); + + VisualElement top = new(); + top.AddToClassList("pull-card-top"); + Root.Add(top); + + Label indexLabel = new($"{reward.Index:00}"); + indexLabel.AddToClassList("pull-index"); + top.Add(indexLabel); + + rarityLabel = new(rarity.Stars); + rarityLabel.AddToClassList("pull-rarity"); + rarityLabel.AddToClassList(rarity.ClassName); + top.Add(rarityLabel); + + typeIconLabel = new(reward.IsCharacter ? "@" : "#"); + typeIconLabel.tooltip = reward.TypeText; + typeIconLabel.AddToClassList("pull-type-icon"); + top.Add(typeIconLabel); + + nameLabel = new(reward.DisplayName); + nameLabel.AddToClassList("pull-name"); + Root.Add(nameLabel); + + statusLabel = new(reward.StatusText); + statusLabel.AddToClassList("pull-status"); + statusLabel.EnableInClassList("pull-status-new", reward.OutcomeKind == PullOutcomeKind.Granted); + statusLabel.visible = !string.IsNullOrWhiteSpace(reward.StatusText); + if (statusLabel.visible) + { + Root.Add(statusLabel); + } + } + + public VisualElement Root { get; } + + public void ShowHold(string holdText, int stars) + { + Root.RemoveFromClassList("pull-card-hidden"); + Root.AddToClassList("pull-card-held"); + Root.EnableInClassList("pull-card-held-rare", stars == 4); + Root.EnableInClassList("pull-card-held-legendary", stars >= 5); + rarityLabel.text = "..."; + nameLabel.text = holdText; + statusLabel.text = ""; + statusLabel.visible = false; + typeIconLabel.visible = false; + } + + public void HideHold() + { + Root.RemoveFromClassList("pull-card-held"); + Root.RemoveFromClassList("pull-card-held-rare"); + Root.RemoveFromClassList("pull-card-held-legendary"); + } + + public void Reveal(bool allowFlash, bool persistentMotion) + { + RarityStyle rarity = RarityPresentation.ForStars(reward.Stars); + Root.RemoveFromClassList("pull-card-hidden"); + Root.RemoveFromClassList("pull-card-held"); + Root.RemoveFromClassList("pull-card-held-rare"); + Root.RemoveFromClassList("pull-card-held-legendary"); + Root.AddToClassList("pull-card-revealed"); + Root.EnableInClassList("pull-card-glow-4", persistentMotion && reward.Stars == 4); + Root.EnableInClassList("pull-card-glow-5", persistentMotion && reward.Stars >= 5); + SetPersistentGlow(persistentMotion && reward.Stars >= 5); + rarityLabel.text = reward.Stars >= 5 ? $"{rarity.Badge} {rarity.Stars}" : rarity.Stars; + nameLabel.text = reward.DisplayName; + typeIconLabel.text = reward.IsCharacter ? "@" : "#"; + typeIconLabel.visible = true; + statusLabel.text = reward.StatusText; + statusLabel.visible = !string.IsNullOrWhiteSpace(reward.StatusText); + if (allowFlash) + { + Root.AddToClassList("pull-card-flash"); + Root.schedule.Execute(() => Root.RemoveFromClassList("pull-card-flash")).StartingIn(PopClassMs); + } + } + + public void SetDimmed(bool dimmed) + { + Root.EnableInClassList("pull-card-dimmed", dimmed); + } + + public void PlayTemporaryClass(string className, int durationMs) + { + Root.AddToClassList(className); + Root.schedule.Execute(() => Root.RemoveFromClassList(className)).StartingIn(durationMs); + } + + public void PulseStatus(int durationMs) + { + if (!statusLabel.visible) + { + return; + } + + statusLabel.AddToClassList("pull-status-pop"); + statusLabel.schedule.Execute(() => statusLabel.RemoveFromClassList("pull-status-pop")).StartingIn(durationMs); + } + + private void SetPersistentGlow(bool enabled) + { + if (!enabled) + { + glowPulse?.Pause(); + Root.RemoveFromClassList("pull-card-glow-on"); + return; + } + + if (glowPulse != null) + { + glowPulse.Resume(); + return; + } + + glowPulse = Root.schedule.Execute(() => + { + glowOn = !glowOn; + Root.EnableInClassList("pull-card-glow-on", glowOn); + }).Every(900); + } + } + } +} diff --git a/Assets/Scripts/UI/MainMenuController.cs b/Assets/Scripts/UI/MainMenuController.cs index 24d3b2e..0a652f0 100644 --- a/Assets/Scripts/UI/MainMenuController.cs +++ b/Assets/Scripts/UI/MainMenuController.cs @@ -72,6 +72,7 @@ namespace KernelPanic.UI private const string ModalOpenClassName = "modal-open"; private const int ModalTransitionMs = 160; private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars"; + private const string SharedRarityStyleResourcePath = "RarityPresentation"; private const float BootIntroSeconds = 1.5f; private static bool _bootIntroPlayed; @@ -98,6 +99,7 @@ namespace KernelPanic.UI private readonly List _packageRows = new(); private readonly List _loadoutRows = new(); private readonly List _selectedRunLanguages = new(); + private readonly List _screenHistory = new(); private UIDocument _document; private VisualElement _root; private VisualElement _shellRoot; @@ -168,6 +170,7 @@ namespace KernelPanic.UI private Action _rootCreditExchangePlusHundredClicked; private IVisualElementScheduledItem _blinkSchedule; private IVisualElementScheduledItem _bootIntroSchedule; + private MenuScreen _activeScreen = MenuScreen.Main; public void Initialize(EntropyWallet initializedWallet) { @@ -178,11 +181,7 @@ namespace KernelPanic.UI private void Awake() { _document = GetComponent(); - _saveService = new SaveService(); - _gachaService = new GachaService(); - _playerCollection = new PlayerCollection(); // TODO: Replace with persistent player-collection service composition. - _cardLoadout = new CardLoadout(_playerCollection.OwnedUnits); - Initialize(new EntropyWallet()); // TODO: Replace with persistent wallet service composition. + ComposeMetaServices(); BindElements(); LoadSharedStyles(); BindCommandEntries(); @@ -192,13 +191,22 @@ namespace KernelPanic.UI FontAsset resolvedFont = ResolveMonospaceFont(); featuredUnitPanel.Bind(_root, resolvedFont); collectionScreen.Bind(_root, resolvedFont, languageDeckDatabase, cardDatabase, _playerCollection, GetMergesBalance, UpgradeCollectionUnit); - gachaScreen.Bind(_root, distroDatabase, resolvedFont, _gachaService, _playerCollection, _wallet, ResolvePulledDistros, HandleMetaStateChanged, OpenRootCreditExchange); + gachaScreen.Bind(_root, distroDatabase, resolvedFont, _gachaService, _playerCollection, _wallet, ResolvePulledDistros, HandleMetaStateChanged, OpenRootCreditExchange, StartGachaPullCutscene); LoadMetaState(); _playerCollection.Changed += HandleMetaStateChanged; _gachaService.Changed += HandleMetaStateChanged; ApplyOptionalFont(resolvedFont); } + private void ComposeMetaServices() + { + _saveService = new SaveService(); + _wallet = new EntropyWallet(); + _gachaService = new GachaService(); + _playerCollection = new PlayerCollection(); + _cardLoadout = new CardLoadout(_playerCollection.OwnedUnits); + } + private void OnEnable() { RegisterCallbacks(); @@ -212,7 +220,16 @@ namespace KernelPanic.UI collectionScreen.RefreshUnits(_playerCollection.OwnedUnits); gachaScreen.Refresh(); SelectCommand(0); - ShowMainMenu(); + if (GachaPullContext.TryConsumeCompleted(out CompletedGachaPull completedPull)) + { + gachaScreen.SetCompletedPullResult(completedPull); + ShowGacha(); + } + else + { + ShowMainMenu(); + } + StartAmbientSchedules(); PlayBootIntroIfNeeded(); } @@ -277,6 +294,12 @@ namespace KernelPanic.UI private void LoadSharedStyles() { + StyleSheet rarityStyleSheet = Resources.Load(SharedRarityStyleResourcePath); + if (rarityStyleSheet != null) + { + _root.styleSheets.Add(rarityStyleSheet); + } + StyleSheet scrollbarStyleSheet = Resources.Load(SharedScrollbarStyleResourcePath); if (scrollbarStyleSheet != null) { @@ -765,6 +788,13 @@ namespace KernelPanic.UI return PullResolver.Resolve(pulledDistros, context); } + private void StartGachaPullCutscene(string bannerId, int pullCount, int entropyTokenCount) + { + SaveCurrentState(); + GachaPullContext.SetPending(bannerId, pullCount, entropyTokenCount, distroDatabase, _playerCollection.FeaturedUnit?.Id); + SceneLoader.LoadGambling(_root); + } + private int GetMergesBalance(DistroDefinition unit) { _saveData ??= SaveData.CreateDefault(); @@ -992,7 +1022,7 @@ namespace KernelPanic.UI if (evt.keyCode == KeyCode.Escape && IsSubScreenVisible()) { - ShowMainMenu(); + NavigateBack(); evt.StopPropagation(); return; } @@ -1669,7 +1699,7 @@ namespace KernelPanic.UI private void HandleStartRunClicked() { RefreshRunSetup(); - ShowPanel(_runSetupPanel); + ShowScreen(MenuScreen.RunSetup); } private void HandleQuitClicked() @@ -1686,14 +1716,14 @@ namespace KernelPanic.UI _cardLoadout.ClearAll(); _selectedRunLanguages.Clear(); _runSetupNotice = null; - ShowPanel(_mainMenuPanel); + ShowScreen(MenuScreen.Main, false); _root.Focus(); } private void ShowCollection() { ShowCollectionUnits(); - ShowPanel(_collectionPanel); + ShowScreen(MenuScreen.Collection); } private void HandleCollectionBack() @@ -1745,16 +1775,56 @@ namespace KernelPanic.UI private void ShowGacha() { gachaScreen.Open(); - ShowPanel(_gachaPanel); + ShowScreen(MenuScreen.Gacha); } private void ShowSettings() { - ShowPanel(_settingsPanel); + ShowScreen(MenuScreen.Settings); } - // TODO: Replace this direct toggle with a screen-stack/router when menu flows need history or transitions. - private void ShowPanel(VisualElement activePanel) + private void NavigateBack() + { + if (_screenHistory.Count == 0) + { + ShowMainMenu(); + return; + } + + MenuScreen previous = _screenHistory[_screenHistory.Count - 1]; + _screenHistory.RemoveAt(_screenHistory.Count - 1); + ShowScreen(previous, false); + } + + private void ShowScreen(MenuScreen screen, bool addToHistory = true) + { + if (screen == MenuScreen.Main) + { + _screenHistory.Clear(); + } + else if (addToHistory && _activeScreen != screen) + { + _screenHistory.Add(_activeScreen); + } + + _activeScreen = screen; + VisualElement activePanel = PanelForScreen(screen); + ApplyScreenVisibility(activePanel); + } + + private VisualElement PanelForScreen(MenuScreen screen) + { + return screen switch + { + MenuScreen.Collection => _collectionPanel, + MenuScreen.RunSetup => _runSetupPanel, + MenuScreen.Gacha => _gachaPanel, + MenuScreen.Settings => _settingsPanel, + _ => _mainMenuPanel + }; + } + + private void ApplyScreenVisibility(VisualElement activePanel) { _mainMenuPanel.EnableInClassList(HiddenClassName, _mainMenuPanel != activePanel); _collectionPanel.EnableInClassList(HiddenClassName, _collectionPanel != activePanel); @@ -1777,6 +1847,15 @@ namespace KernelPanic.UI RefreshCurrencyReadouts(); } + private enum MenuScreen + { + Main, + Collection, + RunSetup, + Gacha, + Settings + } + private sealed class CommandMenuEntry { private readonly Label _cursor; diff --git a/Assets/Scripts/UI/RarityPresentation.cs b/Assets/Scripts/UI/RarityPresentation.cs new file mode 100644 index 0000000..47e3e99 --- /dev/null +++ b/Assets/Scripts/UI/RarityPresentation.cs @@ -0,0 +1,39 @@ +using UnityEngine; + +namespace KernelPanic.UI +{ + /// + /// Shared rarity presentation tokens for pull, shop, collection, and drop UIs. + /// USS classes are defined in Resources/RarityPresentation.uss. + /// + public static class RarityPresentation + { + public static RarityStyle ForStars(int stars) + { + return stars switch + { + >= 5 => new RarityStyle(5, "5★", "★★★★★", "rarity-5", new Color(1f, 0.75f, 0.25f)), + 4 => new RarityStyle(4, "4★", "★★★★", "rarity-4", new Color(0.35f, 1f, 0.95f)), + _ => new RarityStyle(3, "3★", "★★★", "rarity-3", new Color(0.5f, 0.62f, 0.54f)) + }; + } + } + + public readonly struct RarityStyle + { + public RarityStyle(int tier, string badge, string stars, string className, Color color) + { + Tier = tier; + Badge = badge; + Stars = stars; + ClassName = className; + Color = color; + } + + public int Tier { get; } + public string Badge { get; } + public string Stars { get; } + public string ClassName { get; } + public Color Color { get; } + } +} diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset index f65bd4a..fd4e5ef 100644 --- a/ProjectSettings/EditorBuildSettings.asset +++ b/ProjectSettings/EditorBuildSettings.asset @@ -11,5 +11,8 @@ EditorBuildSettings: - enabled: 1 path: Assets/Scenes/GameScene.unity guid: e4259fa62d854e27af3cc2a10fb210c8 + - enabled: 1 + path: Assets/Scenes/GamblingScene.unity + guid: 761d33a16bc244544a2f119e1bd042bd m_configObjects: com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}