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 @@
-
+
+
+
+
+
+
+
+
+
+
+
@@ -21,7 +31,7 @@
-
+
@@ -70,23 +80,23 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -94,37 +104,37 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
@@ -213,7 +223,7 @@
-
+
@@ -359,7 +369,23 @@
1783427430046
-
+
+
+ 1783492556269
+
+
+
+ 1783492556270
+
+
+
+ 1783493370994
+
+
+
+ 1783493370994
+
+
@@ -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}