Implement animations, pipeline for duplicates and merging.

This commit is contained in:
2026-07-07 14:10:31 +02:00
parent 30db1e1108
commit d504e0b916
22 changed files with 1947 additions and 117 deletions
+115 -4
View File
@@ -18,11 +18,14 @@ namespace KernelPanic.UI
private readonly List<VisualElement> _rows = new();
private VisualElement _list;
private ScrollView _detailScroll;
private VisualElement _detail;
private FontAsset _monospaceFont;
private LanguageDeckDatabase _languageDeckDatabase;
private CardDatabase _cardDatabase;
private PlayerCollection _playerCollection;
private Func<int> _getMergesBalance;
private Func<DistroDefinition, VersionUpgradeResult> _tryUpgradeUnit;
private IReadOnlyList<DistroDefinition> _units = Array.Empty<DistroDefinition>();
private IReadOnlyList<LanguageCatalogEntry> _languages = LanguageCatalog.All;
private IReadOnlyList<CardEntry> _cards = Array.Empty<CardEntry>();
@@ -35,14 +38,17 @@ namespace KernelPanic.UI
public event Action ViewChanged;
public void Bind(VisualElement root, FontAsset artFont, LanguageDeckDatabase deckDatabase, CardDatabase cardsDatabase, PlayerCollection collection)
public void Bind(VisualElement root, FontAsset artFont, LanguageDeckDatabase deckDatabase, CardDatabase cardsDatabase, PlayerCollection collection, Func<int> getMergesBalance = null, Func<DistroDefinition, VersionUpgradeResult> tryUpgradeUnit = null)
{
_monospaceFont = artFont;
_languageDeckDatabase = deckDatabase;
_cardDatabase = cardsDatabase;
_playerCollection = collection;
_getMergesBalance = getMergesBalance;
_tryUpgradeUnit = tryUpgradeUnit;
_list = root.Q<VisualElement>("CollectionList");
_detail = root.Q<VisualElement>("CollectionDetail");
_detailScroll = root.Q<ScrollView>("CollectionDetail");
_detail = _detailScroll?.contentContainer;
}
public bool IsCardSubview => _mode == CollectionMode.CardSubview;
@@ -282,7 +288,79 @@ namespace KernelPanic.UI
readout.Add(details);
_detail.Add(readout);
_detail.Add(BuildSubviewCommand($"> cat ~/units/{unit.Id}/cards", "exclusive packages", HasListableCards(unit), () => OpenUnitCards(unit), "no packages installed"));
AddVersionUpgradePanel(_detail, unit);
_detail.Add(BuildSubviewCommand($"cat ~/units/{unit.Id}/cards", "exclusive packages", HasListableCards(unit), () => OpenUnitCards(unit), "no packages installed"));
}
private void AddVersionUpgradePanel(VisualElement target, DistroDefinition unit)
{
int version = _playerCollection == null ? 1 : Mathf.Clamp(_playerCollection.GetVersion(unit.Id), 1, GachaTuning.MaxVersion);
string release = DistroVersionCatalog.GetReleaseLabel(unit.Id, version);
int merges = _getMergesBalance?.Invoke() ?? 0;
VisualElement panel = new();
panel.AddToClassList("version-upgrade-panel");
VisualElement summary = new();
summary.AddToClassList("version-upgrade-summary");
Label current = new($"{DistroPresentation.DisplayName(unit)} {release} v{version}/{GachaTuning.MaxVersion}");
current.AddToClassList("version-upgrade-current");
summary.Add(current);
Label wallet = new(version < GachaTuning.MaxVersion ? $"merges {merges} / {GachaTuning.GetVersionUpgradeCost(version + 1)}" : $"merges {merges}");
wallet.AddToClassList("version-upgrade-wallet");
summary.Add(wallet);
panel.Add(summary);
if (version < GachaTuning.MaxVersion)
{
int targetVersion = version + 1;
int cost = GachaTuning.GetVersionUpgradeCost(targetVersion);
Label next = new($"next {DistroVersionCatalog.GetReleaseLabel(unit.Id, targetVersion)} - {DistroVersionCatalog.GetEffectSummary(unit.Id, targetVersion)}");
next.AddToClassList("version-upgrade-next");
panel.Add(next);
bool affordable = merges >= cost;
wallet.EnableInClassList(affordable ? "version-upgrade-wallet-affordable" : "version-upgrade-wallet-warning", true);
panel.Add(BuildCompactUpgradeCommand($"> apt full-upgrade ({cost} merges)", affordable ? "ready" : "insufficient merges", affordable && _tryUpgradeUnit != null, () => UpgradeUnit(unit)));
}
else
{
Label next = new("latest release installed");
next.AddToClassList("version-upgrade-next");
panel.Add(next);
panel.Add(BuildCompactUpgradeCommand("> apt full-upgrade", "latest release", false, null));
}
VisualElement path = new();
path.AddToClassList("version-path");
for (int i = 1; i <= GachaTuning.MaxVersion; i++)
{
string className = i <= version ? "version-path-owned" : i == version + 1 ? "version-path-next" : "version-path-locked";
Label step = new($"v{i} {DistroVersionCatalog.GetReleaseLabel(unit.Id, i)}");
step.AddToClassList("version-path-step");
step.AddToClassList(className);
path.Add(step);
}
panel.Add(path);
target.Add(panel);
}
private void UpgradeUnit(DistroDefinition unit)
{
if (unit == null || _tryUpgradeUnit == null)
{
return;
}
VersionUpgradeResult result = _tryUpgradeUnit(unit);
RenderUnitDetail(unit);
if (!result.Success)
{
_detail.Add(BuildDetailLine("upgrade", FormatUpgradeFailure(result.FailureReason)));
}
}
private void RenderLanguageDetail(LanguageCatalogEntry language)
@@ -310,7 +388,7 @@ namespace KernelPanic.UI
return;
}
_detail.Add(BuildSubviewCommand($"> cat ~/lang/{GetLanguageId(language.Language)}/deck", "starter deck", true, () => OpenLanguageDeck(language), null));
_detail.Add(BuildSubviewCommand($"cat ~/lang/{GetLanguageId(language.Language)}/deck", "starter deck", true, () => OpenLanguageDeck(language), null));
}
private void OpenUnitCards(DistroDefinition unit)
@@ -463,6 +541,28 @@ namespace KernelPanic.UI
return row;
}
private static VisualElement BuildCompactUpgradeCommand(string command, string state, bool enabled, Action action)
{
VisualElement row = new();
row.AddToClassList("version-upgrade-command");
row.EnableInClassList("disabled-row", !enabled);
Label commandLabel = new(command);
commandLabel.AddToClassList("version-upgrade-command-text");
Label stateLabel = new(state);
stateLabel.AddToClassList("version-upgrade-command-state");
row.Add(commandLabel);
row.Add(stateLabel);
if (enabled)
{
row.RegisterCallback<ClickEvent>(_ => action?.Invoke());
}
return row;
}
private static void AddPassiveDetails(VisualElement target, DistroDefinition unit)
{
if (unit.Passive == null)
@@ -600,6 +700,17 @@ namespace KernelPanic.UI
return card == null ? "--" : $"{card.Language} / {card.CycleCost}c";
}
private static string FormatUpgradeFailure(VersionUpgradeFailureReason reason)
{
return reason switch
{
VersionUpgradeFailureReason.NotOwned => "not owned",
VersionUpgradeFailureReason.MaxVersion => "latest release",
VersionUpgradeFailureReason.InsufficientMerges => "insufficient merges",
_ => "upgrade failed"
};
}
private static string GetLanguageId(Language language)
{
return language switch
+417 -4
View File
@@ -20,6 +20,7 @@ namespace KernelPanic.UI
public sealed class CombatSceneController : MonoBehaviour
{
private const string StyleResourcePath = "CombatScene";
private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars";
private const string FilledCycle = "●";
private const string EmptyCycle = "○";
@@ -48,8 +49,18 @@ namespace KernelPanic.UI
private VisualElement handRow;
private VisualElement turnResourceGrid;
private Label logLabel;
private VisualElement feedbackLayer;
private VisualElement damageVignette;
private VisualElement overlay;
private Color distroAccent;
private readonly Dictionary<CombatantState, VisualElement> combatantElements = new();
private readonly Dictionary<CardInstance, VisualElement> handCardElements = new();
private readonly Dictionary<CardInstance, VisualElement> queueChipElements = new();
private readonly Dictionary<CardInstance, VisualElement> stackChipElements = new();
private readonly Dictionary<CombatantState, int> previousUptime = new();
private readonly Dictionary<CombatantState, int> previousShield = new();
private readonly HashSet<CardInstance> knownHandCards = new();
private int queueCascadeIndex;
private void Awake()
{
@@ -64,9 +75,18 @@ namespace KernelPanic.UI
private void OnEnable()
{
root?.EnableInClassList("reduced-motion", UIPreferences.ReducedMotion);
combatManager.StateChanged += Refresh;
combatManager.CombatLog += HandleCombatLog;
runManager.RepositoryChanged += Refresh;
GameEvents.CardPlayed += HandleCardPlayed;
GameEvents.CardResolved += HandleCardResolved;
GameEvents.PhaseChanged += HandlePhaseChanged;
GameEvents.DamageDealt += HandleDamageDealt;
GameEvents.CombatantDefeated += HandleCombatantDefeated;
GameEvents.EnemyWouldAct += HandleEnemyWouldAct;
GameEvents.PlayerDamaged += HandlePlayerDamaged;
GameEvents.StatusApplied += HandleStatusApplied;
}
private void Start()
@@ -101,6 +121,14 @@ namespace KernelPanic.UI
combatManager.StateChanged -= Refresh;
combatManager.CombatLog -= HandleCombatLog;
runManager.RepositoryChanged -= Refresh;
GameEvents.CardPlayed -= HandleCardPlayed;
GameEvents.CardResolved -= HandleCardResolved;
GameEvents.PhaseChanged -= HandlePhaseChanged;
GameEvents.DamageDealt -= HandleDamageDealt;
GameEvents.CombatantDefeated -= HandleCombatantDefeated;
GameEvents.EnemyWouldAct -= HandleEnemyWouldAct;
GameEvents.PlayerDamaged -= HandlePlayerDamaged;
GameEvents.StatusApplied -= HandleStatusApplied;
}
private bool TryBuildRunConfig(out RunConfig config)
@@ -140,7 +168,7 @@ namespace KernelPanic.UI
AddLanguageDeck(Language.Python, startingDeck);
AddLanguageDeck(Language.JavaScript, startingDeck);
config = new RunConfig(distro, Language.Python, Language.JavaScript, startingDeck, Environment.TickCount);
config = new RunConfig(distro, Language.Python, Language.JavaScript, startingDeck, Environment.TickCount, 1);
return true;
}
@@ -182,6 +210,12 @@ namespace KernelPanic.UI
{
root.styleSheets.Add(styleSheet);
}
StyleSheet scrollbarStyleSheet = Resources.Load<StyleSheet>(SharedScrollbarStyleResourcePath);
if (scrollbarStyleSheet != null)
{
root.styleSheets.Add(scrollbarStyleSheet);
}
}
private void BuildLayout()
@@ -239,6 +273,16 @@ namespace KernelPanic.UI
logLabel.AddToClassList("log-line");
commandPanel.Add(logLabel);
feedbackLayer = new();
feedbackLayer.AddToClassList("feedback-layer");
feedbackLayer.pickingMode = PickingMode.Ignore;
root.Add(feedbackLayer);
damageVignette = new();
damageVignette.AddToClassList("damage-vignette");
damageVignette.pickingMode = PickingMode.Ignore;
root.Add(damageVignette);
overlay = new();
overlay.AddToClassList("overlay");
overlay.style.display = DisplayStyle.None;
@@ -336,10 +380,13 @@ namespace KernelPanic.UI
private void RefreshPlayer()
{
playerPanel.Clear();
combatantElements.Remove(combatManager.PlayerState);
combatantElements[combatManager.PlayerState] = playerPanel;
playerPanel.Add(PanelHeader(DisplayName(runManager.CurrentConfig.Distro), "htop"));
ApplyAccent(playerPanel);
CombatantState state = combatManager.PlayerState;
DetectBeneficialResourceFeedback(state, playerPanel);
playerPanel.Add(MeterBlock("uptime", state.CurrentUptime, state.MaxUptime, MeterTone.Uptime));
playerPanel.Add(MeterBlock("shield", state.Shield, Mathf.Max(1, state.Shield), state.Shield > 0 ? MeterTone.Beneficial : MeterTone.Muted));
playerPanel.Add(CycleBlock(state.Cycles, state.MaxCycles));
@@ -352,6 +399,8 @@ namespace KernelPanic.UI
private void RefreshTracks()
{
queueChipElements.Clear();
stackChipElements.Clear();
FillCardStrip(interpreterStrip, combatManager.InterpreterQueue.Cards, "queue empty");
FillCardStrip(lazyStackPile, combatManager.LazyStack.Cards, "stack empty");
tokenArea.Clear();
@@ -361,6 +410,12 @@ namespace KernelPanic.UI
private void RefreshEnemies()
{
enemyRow.Clear();
combatantElements.Clear();
if (combatManager.PlayerState != null)
{
combatantElements[combatManager.PlayerState] = playerPanel;
}
for (int i = 0; i < combatManager.Enemies.Count; i++)
{
int index = i;
@@ -377,6 +432,8 @@ namespace KernelPanic.UI
Label name = new(enemy.Name);
name.AddToClassList("enemy-name");
card.Add(name);
combatantElements[enemy.State] = card;
DetectBeneficialResourceFeedback(enemy.State, card);
card.Add(MeterBlock("uptime", enemy.CurrentUptime, enemy.MaxUptime, MeterTone.Uptime));
if (enemy.State.Shield > 0)
{
@@ -394,11 +451,27 @@ namespace KernelPanic.UI
private void RefreshHand()
{
handRow.Clear();
handCardElements.Clear();
HashSet<CardInstance> currentHandCards = new();
IReadOnlyList<CardInstance> hand = combatManager.HandController.Cards;
for (int i = 0; i < hand.Count; i++)
{
CardInstance card = hand[i];
handRow.Add(CardFace(card));
currentHandCards.Add(card);
VisualElement face = CardFace(card);
handCardElements[card] = face;
if (!knownHandCards.Contains(card))
{
PlayElementBeat(face, "hand-card-drawn", 260);
}
handRow.Add(face);
}
knownHandCards.Clear();
foreach (CardInstance card in currentHandCards)
{
knownHandCards.Add(card);
}
}
@@ -694,7 +767,7 @@ namespace KernelPanic.UI
private VisualElement CardFace(CardInstance card)
{
Button button = new(() => combatManager.PlayCard(card));
Button button = new(() => PlayCardWithFeedback(card));
button.text = string.Empty;
button.AddToClassList("hand-card");
if (combatManager.PendingTargetCard == card)
@@ -885,10 +958,350 @@ namespace KernelPanic.UI
for (int i = 0; i < cards.Count; i++)
{
target.Add(CardChip(cards[i]));
VisualElement chip = CardChip(cards[i]);
if (target == interpreterStrip)
{
queueChipElements[cards[i]] = chip;
}
else if (target == lazyStackPile)
{
stackChipElements[cards[i]] = chip;
}
target.Add(chip);
}
}
private bool PlayCardWithFeedback(CardInstance card)
{
int cyclesBefore = combatManager.PlayerState == null ? 0 : combatManager.PlayerState.Cycles;
bool played = combatManager.PlayCard(card);
if (played)
{
int spent = Mathf.Max(0, cyclesBefore - (combatManager.PlayerState == null ? cyclesBefore : combatManager.PlayerState.Cycles));
if (spent > 0)
{
PlayElementBeat(turnResourceGrid, "cycles-spent-beat", 220);
}
return true;
}
if (combatManager.PendingTargetCard != card && handCardElements.TryGetValue(card, out VisualElement face))
{
PlayElementBeat(face, "feedback-denied", 220);
}
return false;
}
private void HandleCardPlayed(CardPlayedEvent payload)
{
if (payload.Card == null)
{
return;
}
VisualElement source = handCardElements.TryGetValue(payload.Card, out VisualElement cardElement) ? cardElement : null;
VisualElement destination = payload.Track switch
{
ResolutionTrack.InterpreterQueue => interpreterStrip,
ResolutionTrack.LazyStack => lazyStackPile,
_ => FirstTargetElement(payload.Card)
};
FlyCardGhost(payload.Card, source, destination, payload.Track == ResolutionTrack.Native ? "feedback-card-strike" : "feedback-card-route");
}
private void HandleCardResolved(CardResolvedEvent payload)
{
if (payload.Card == null)
{
return;
}
if (payload.Track == ResolutionTrack.InterpreterQueue)
{
int delay = UIPreferences.ReducedMotion ? 0 : Mathf.Min(queueCascadeIndex * 120, 600);
queueCascadeIndex++;
ScheduleFeedback(() =>
{
VisualElement chip = queueChipElements.TryGetValue(payload.Card, out VisualElement queuedChip) ? queuedChip : interpreterStrip;
PlayElementBeat(chip, "queue-chip-resolve", 260);
FlyCardGhost(payload.Card, chip, turnResourceGrid, "feedback-card-discard");
}, delay);
return;
}
if (payload.Track == ResolutionTrack.LazyStack)
{
VisualElement chip = stackChipElements.TryGetValue(payload.Card, out VisualElement stackChip) ? stackChip : lazyStackPile;
PlayElementBeat(chip, "stack-chip-resolve", 320);
FlyCardGhost(payload.Card, chip, turnResourceGrid, "feedback-card-discard");
}
}
private void HandlePhaseChanged(PhaseChangedEvent payload)
{
if (payload.NextPhase == TurnPhase.Interpret)
{
queueCascadeIndex = 0;
}
ScheduleFeedback(() => PlayElementBeat(phaseLabel, "phase-pulse", 260), 0);
}
private void HandleDamageDealt(DamageDealtEvent payload)
{
ScheduleFeedback(() =>
{
bool isPlayer = payload.Target == combatManager.PlayerState;
VisualElement target = CombatantElement(payload.Target);
string text = payload.Amount <= 0 ? "blocked" : $"-{Mathf.RoundToInt(payload.Amount)}";
string tone = payload.Amount <= 0 ? "float-muted" : "float-damage";
if (payload.Amount >= 8)
{
tone += " float-large";
}
SpawnFloatingText(target, text, tone);
PlayElementBeat(target, payload.Amount <= 0 ? "feedback-block" : "feedback-hit", 240);
if (isPlayer && payload.Amount > 0)
{
FlashDamageVignette();
}
}, 0);
}
private void HandleCombatantDefeated(CombatantDefeatedEvent payload)
{
ScheduleFeedback(() =>
{
VisualElement target = CombatantElement(payload.Combatant);
SpawnFloatingText(target, "killed", "float-kill float-large");
SpawnDeathGhost(target);
PlayElementBeat(target, "feedback-killed", 260);
}, 0);
}
private void HandleEnemyWouldAct(EnemyWouldActEvent payload)
{
ScheduleFeedback(() => PlayElementBeat(CombatantElement(payload.Enemy?.State), "enemy-acting", 260), 0);
}
private void HandlePlayerDamaged(PlayerDamagedEvent payload)
{
if (payload.Amount > 0)
{
ScheduleFeedback(FlashDamageVignette, 0);
}
}
private void HandleStatusApplied(StatusAppliedEvent payload)
{
ScheduleFeedback(() =>
{
StatusDescriptor descriptor = StatusEffectController.GetDescriptor(payload.StatusType);
SpawnFloatingText(CombatantElement(payload.Target), $"{descriptor.IconKey} x{payload.Stacks}", descriptor.IsBeneficial ? "float-heal" : "float-status");
PlayElementBeat(CombatantElement(payload.Target), descriptor.IsBeneficial ? "feedback-boost" : "feedback-status", 220);
}, 0);
}
private void DetectBeneficialResourceFeedback(CombatantState state, VisualElement element)
{
if (state == null || element == null)
{
return;
}
if (previousUptime.TryGetValue(state, out int oldUptime) && state.CurrentUptime > oldUptime)
{
SpawnFloatingText(element, $"+{state.CurrentUptime - oldUptime}", "float-heal");
PlayElementBeat(element, "feedback-boost", 220);
}
if (previousShield.TryGetValue(state, out int oldShield) && state.Shield > oldShield)
{
SpawnFloatingText(element, $"+{state.Shield - oldShield} shield", "float-heal");
PlayElementBeat(element, "feedback-block", 220);
}
previousUptime[state] = state.CurrentUptime;
previousShield[state] = state.Shield;
}
private VisualElement CombatantElement(CombatantState state)
{
if (state != null && combatantElements.TryGetValue(state, out VisualElement element))
{
return element;
}
return state == combatManager.PlayerState ? playerPanel : enemyRow;
}
private VisualElement FirstTargetElement(CardInstance card)
{
if (card?.TargetSnapshot == null)
{
return enemyRow;
}
for (int i = 0; i < card.TargetSnapshot.Count; i++)
{
VisualElement element = CombatantElement(card.TargetSnapshot[i]);
if (element != null)
{
return element;
}
}
return enemyRow;
}
private void PlayElementBeat(VisualElement element, string className, int durationMs)
{
if (element == null || string.IsNullOrWhiteSpace(className))
{
return;
}
element.RemoveFromClassList(className);
element.AddToClassList(className);
ScheduleFeedback(() => element.RemoveFromClassList(className), UIPreferences.ReducedMotion ? 80 : durationMs);
}
private void SpawnFloatingText(VisualElement anchor, string text, string className)
{
if (feedbackLayer == null || anchor == null)
{
return;
}
Rect anchorRect = anchor.worldBound;
Rect rootRect = root.worldBound;
Label label = new(text);
label.AddToClassList("float-number");
if (!string.IsNullOrWhiteSpace(className))
{
string[] classes = className.Split(' ');
for (int i = 0; i < classes.Length; i++)
{
if (!string.IsNullOrWhiteSpace(classes[i]))
{
label.AddToClassList(classes[i]);
}
}
}
label.style.left = anchorRect.center.x - rootRect.x - 30f;
label.style.top = anchorRect.center.y - rootRect.y - 16f;
feedbackLayer.Add(label);
if (UIPreferences.ReducedMotion)
{
label.AddToClassList("float-static");
ScheduleFeedback(() => label.RemoveFromHierarchy(), 420);
return;
}
ScheduleFeedback(() =>
{
label.style.top = anchorRect.center.y - rootRect.y - 42f;
label.style.opacity = 0f;
}, 20);
ScheduleFeedback(() => label.RemoveFromHierarchy(), 520);
}
private void FlyCardGhost(CardInstance card, VisualElement source, VisualElement destination, string className)
{
if (feedbackLayer == null || card == null)
{
return;
}
Rect rootRect = root.worldBound;
Rect sourceRect = source == null ? new Rect(rootRect.center.x - 70f, rootRect.center.y - 50f, 140f, 110f) : source.worldBound;
Rect destinationRect = destination == null ? sourceRect : destination.worldBound;
VisualElement ghost = CardFaceView(card);
ghost.AddToClassList("feedback-card-ghost");
ghost.AddToClassList(className);
ghost.style.position = Position.Absolute;
ghost.style.left = sourceRect.x - rootRect.x;
ghost.style.top = sourceRect.y - rootRect.y;
ghost.style.width = Mathf.Max(110f, sourceRect.width);
ghost.style.height = Mathf.Max(94f, sourceRect.height);
feedbackLayer.Add(ghost);
if (UIPreferences.ReducedMotion)
{
ghost.AddToClassList("feedback-card-instant");
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 140);
return;
}
ScheduleFeedback(() =>
{
ghost.style.left = destinationRect.center.x - rootRect.x - (sourceRect.width * 0.5f);
ghost.style.top = destinationRect.center.y - rootRect.y - (sourceRect.height * 0.5f);
ghost.style.opacity = 0f;
}, 20);
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 360);
}
private void SpawnDeathGhost(VisualElement source)
{
if (feedbackLayer == null || source == null)
{
return;
}
Rect rootRect = root.worldBound;
Rect sourceRect = source.worldBound;
VisualElement ghost = new();
ghost.AddToClassList("death-ghost");
ghost.style.position = Position.Absolute;
ghost.style.left = sourceRect.x - rootRect.x;
ghost.style.top = sourceRect.y - rootRect.y;
ghost.style.width = sourceRect.width;
ghost.style.height = sourceRect.height;
feedbackLayer.Add(ghost);
if (UIPreferences.ReducedMotion)
{
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 180);
return;
}
ScheduleFeedback(() =>
{
ghost.style.opacity = 0f;
ghost.style.scale = new Scale(new Vector3(0.92f, 0.92f, 1f));
}, 20);
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 360);
}
private void FlashDamageVignette()
{
if (damageVignette == null)
{
return;
}
PlayElementBeat(damageVignette, "damage-vignette-on", UIPreferences.ReducedMotion ? 120 : 240);
}
private void ScheduleFeedback(Action action, int delayMs)
{
if (action == null || root == null)
{
return;
}
root.schedule.Execute(action).StartingIn(Mathf.Max(0, delayMs));
}
private void HandleCombatLog(string message)
{
if (logLabel != null)
+37 -6
View File
@@ -31,6 +31,7 @@ namespace KernelPanic.UI
private GachaService _gachaService;
private PlayerCollection _playerCollection;
private EntropyWallet _wallet;
private Func<IReadOnlyList<DistroDefinition>, PullResolutionResult> _resolvePulledDistros;
private Action _onChanged;
private Action _requestRootCreditExchange;
private int _selectedBannerIndex;
@@ -40,13 +41,14 @@ namespace KernelPanic.UI
private string _resultBannerId;
private string _resultText;
public void Bind(VisualElement root, DistroDatabase database, FontAsset artFont, GachaService service, PlayerCollection collection, EntropyWallet entropyWallet, Action changedCallback, Action rootCreditExchangeCallback)
public void Bind(VisualElement root, DistroDatabase database, FontAsset artFont, GachaService service, PlayerCollection collection, EntropyWallet entropyWallet, Func<IReadOnlyList<DistroDefinition>, PullResolutionResult> pullResolver, Action changedCallback, Action rootCreditExchangeCallback)
{
_distroDatabase = database;
_monospaceFont = artFont;
_gachaService = service;
_playerCollection = collection;
_wallet = entropyWallet;
_resolvePulledDistros = pullResolver;
_onChanged = changedCallback;
_requestRootCreditExchange = rootCreditExchangeCallback;
_bannerList = root.Q<VisualElement>("GachaBannerList");
@@ -252,18 +254,30 @@ namespace KernelPanic.UI
lines.Add($"entropy fallback: spent {result.EntropySpent} entropy");
}
List<DistroDefinition> 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 = _resolvePulledDistros?.Invoke(distroRewards);
int distroOutcomeIndex = 0;
for (int i = 0; i < result.Rewards.Count; i++)
{
GachaReward reward = result.Rewards[i];
bool duplicate = reward.Distro != null && _playerCollection.GetOwnedCount(reward.Distro.Id) > 0;
if (reward.Distro != null)
PullResolutionOutcome? outcome = null;
if (reward.Distro != null && resolution != null && distroOutcomeIndex < resolution.Outcomes.Count)
{
_playerCollection.Add(reward.Distro);
outcome = resolution.Outcomes[distroOutcomeIndex];
distroOutcomeIndex++;
}
string suffix = reward.Guaranteed ? " guaranteed" : reward.PityTriggered ? " pity" : string.Empty;
string duplicateText = duplicate ? " duplicate" : string.Empty;
lines.Add($"{i + 1:00}: {reward.StarRating}-star {reward.DisplayName}{suffix}{duplicateText}");
string outcomeText = FormatPullOutcome(outcome);
lines.Add($"{i + 1:00}: {reward.StarRating}-star {reward.DisplayName}{suffix}{outcomeText}");
}
_resultBannerId = bannerId;
@@ -272,6 +286,23 @@ namespace KernelPanic.UI
Refresh();
}
private static string FormatPullOutcome(PullResolutionOutcome? outcome)
{
if (!outcome.HasValue)
{
return string.Empty;
}
PullResolutionOutcome value = outcome.Value;
return value.Kind switch
{
PullOutcomeKind.Granted => " granted",
PullOutcomeKind.Dupe => $" duplicate +{value.MergesAwarded} merges",
PullOutcomeKind.DupeOverflow => $" max-version duplicate +{value.MergesAwarded} merges",
_ => string.Empty
};
}
private void RequestPullSelectedBanner(int pullCount)
{
string bannerId = _bannerIds[_selectedBannerIndex];
+58 -9
View File
@@ -23,6 +23,7 @@ namespace KernelPanic.UI
private const string HiddenClassName = "hidden";
private const string SelectedClassName = "selected";
private const string CursorOnClassName = "cursor-on";
private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars";
private const float BootIntroSeconds = 1.5f;
private static bool _bootIntroPlayed;
@@ -133,13 +134,14 @@ namespace KernelPanic.UI
_cardLoadout = new CardLoadout(_playerCollection.OwnedUnits);
Initialize(new EntropyWallet()); // TODO: Replace with persistent wallet service composition.
BindElements();
LoadSharedStyles();
BindCommandEntries();
RegisterCommandEntryCallbacks();
featuredUnitPanel.Bind(_root, monospaceFont);
collectionScreen.Bind(_root, monospaceFont, languageDeckDatabase, cardDatabase, _playerCollection);
collectionScreen.Bind(_root, monospaceFont, languageDeckDatabase, cardDatabase, _playerCollection, GetMergesBalance, UpgradeCollectionUnit);
BindScreenFrames();
starterSelection.Bind(_root, distroDatabase, HandleStarterConfirmed);
gachaScreen.Bind(_root, distroDatabase, monospaceFont, _gachaService, _playerCollection, _wallet, HandleMetaStateChanged, OpenRootCreditExchange);
gachaScreen.Bind(_root, distroDatabase, monospaceFont, _gachaService, _playerCollection, _wallet, ResolvePulledDistros, HandleMetaStateChanged, OpenRootCreditExchange);
LoadMetaState();
_playerCollection.Changed += HandleMetaStateChanged;
_gachaService.Changed += HandleMetaStateChanged;
@@ -221,6 +223,15 @@ namespace KernelPanic.UI
_collectionLanguagesButton = _root.Q<Button>("CollectionLanguagesButton");
}
private void LoadSharedStyles()
{
StyleSheet scrollbarStyleSheet = Resources.Load<StyleSheet>(SharedScrollbarStyleResourcePath);
if (scrollbarStyleSheet != null)
{
_root.styleSheets.Add(scrollbarStyleSheet);
}
}
private void BindScreenFrames()
{
_runSetupFrame.Bind(_runSetupPanel, "$ ./start_run --configure", "[esc] back [arrows] navigate [enter] select [b] boot run", ShowMainMenu);
@@ -554,12 +565,13 @@ namespace KernelPanic.UI
_saveData.EnsureLists();
_wallet.SetBalance(_saveData.entropyBalance);
for (int i = 0; i < _saveData.ownedUnitIds.Count; i++)
for (int i = 0; i < _saveData.ownedUnits.Count; i++)
{
DistroDefinition unit = ResolveSavedDistro(_saveData.ownedUnitIds[i]);
OwnedUnitSaveEntry entry = _saveData.ownedUnits[i];
DistroDefinition unit = ResolveSavedDistro(entry?.id);
if (unit != null)
{
_playerCollection.Add(unit);
_playerCollection.Add(unit, entry.version);
}
}
@@ -624,7 +636,7 @@ namespace KernelPanic.UI
private void HandleStarterConfirmed(DistroDefinition picked, IReadOnlyList<DistroDefinition> remaining)
{
_playerCollection.Add(picked);
_playerCollection.Add(picked, 1);
AddAllFourStarDistrosToBeginnerBannerPool();
_gachaService.SetBeginnerGuaranteedDistros(remaining);
_saveData.starterChosen = true;
@@ -639,6 +651,7 @@ namespace KernelPanic.UI
_saveData ??= SaveData.CreateDefault();
_saveData.EnsureLists();
_saveData.entropyBalance = _wallet == null ? 0 : _wallet.Balance;
_saveData.ownedUnits.Clear();
_saveData.ownedUnitIds.Clear();
_saveData.bannerPoolIds.Clear();
_gachaService.WriteProgress(_saveData);
@@ -648,7 +661,11 @@ namespace KernelPanic.UI
DistroDefinition unit = _playerCollection.OwnedUnits[i];
if (unit != null && !string.IsNullOrWhiteSpace(unit.Id))
{
_saveData.ownedUnitIds.Add(unit.Id);
_saveData.ownedUnits.Add(new OwnedUnitSaveEntry
{
id = unit.Id,
version = Mathf.Clamp(_playerCollection.GetVersion(unit.Id), 1, GachaTuning.MaxVersion)
});
}
}
@@ -664,6 +681,38 @@ namespace KernelPanic.UI
_saveService.Save(_saveData);
}
private PullResolutionResult ResolvePulledDistros(IReadOnlyList<DistroDefinition> pulledDistros)
{
PullResolutionContext context = new(_saveData, _playerCollection, distroDatabase, _playerCollection.FeaturedUnit?.Id);
return PullResolver.Resolve(pulledDistros, context);
}
private int GetMergesBalance()
{
_saveData ??= SaveData.CreateDefault();
_saveData.EnsureLists();
return _saveData.merges;
}
private VersionUpgradeResult UpgradeCollectionUnit(DistroDefinition unit)
{
if (unit == null)
{
return VersionUpgradeResult.Failed(null, VersionUpgradeFailureReason.NotOwned);
}
VersionUpgradeResult result = VersionUpgrader.TryUpgrade(unit.Id, _saveData, _playerCollection);
if (result.Success)
{
SaveCurrentState();
featuredUnitPanel.Refresh(_playerCollection.OwnedUnits, _playerCollection.FeaturedUnit);
collectionScreen.RefreshUnits(_playerCollection.OwnedUnits);
RefreshCurrencyReadouts();
}
return result;
}
private void SaveLastRunLoadout(DistroDefinition unit, IReadOnlyList<string> cardIds)
{
_saveData ??= SaveData.CreateDefault();
@@ -708,7 +757,7 @@ namespace KernelPanic.UI
_rootCreditsToEntropyButton?.SetEnabled(true);
bool showPullTokens = IsGachaVisible();
_pullTokensLabel.text = showPullTokens
? $"bandwidth={_gachaService.PullTokens} compute-credits={_gachaService.LimitedPullTokens}"
? $"bandwidth={_gachaService.PullTokens} compute-credits={_gachaService.LimitedPullTokens} merges={_saveData.merges}"
: string.Empty;
_pullTokensLabel.EnableInClassList(HiddenClassName, !showPullTokens);
}
@@ -1452,7 +1501,7 @@ namespace KernelPanic.UI
}
SaveLastRunLoadout(unit, equippedCardIds);
RunContext.Set(unit, BuildEquippedCardDefinitions(unit, equippedCardIds), _selectedRunLanguages[0], _selectedRunLanguages[1]);
RunContext.Set(unit, BuildEquippedCardDefinitions(unit, equippedCardIds), _selectedRunLanguages[0], _selectedRunLanguages[1], _playerCollection.GetVersion(unit.Id));
SceneLoader.LoadGame();
}