3126 lines
116 KiB
C#
3126 lines
116 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using KernelPanic.Combat;
|
||
using KernelPanic.Core;
|
||
using KernelPanic.Data;
|
||
using KernelPanic.Meta;
|
||
using KernelPanic.Run;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
|
||
namespace KernelPanic.UI
|
||
{
|
||
/// <summary>
|
||
/// Runtime-built GameScene combat UI. Intent icons use a compact terminal vocabulary:
|
||
/// ! attack/status attack/segfault, # defend, + buff, * special, ? hidden/rootkit,
|
||
/// ~ reviving/race link, : split, @ countdown, . orphan.
|
||
/// </summary>
|
||
[RequireComponent(typeof(UIDocument))]
|
||
[RequireComponent(typeof(RunManager))]
|
||
[RequireComponent(typeof(CombatManager))]
|
||
public sealed class CombatSceneController : MonoBehaviour
|
||
{
|
||
private const string StyleResourcePath = "CombatScene";
|
||
private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars";
|
||
private const string SharedRarityStyleResourcePath = "RarityPresentation";
|
||
private const int MaxFeedbackElements = 42;
|
||
private const string FilledCycle = "●";
|
||
private const string EmptyCycle = "○";
|
||
|
||
[SerializeField] private DistroDatabase distroDatabase;
|
||
[SerializeField] private CardDatabase cardDatabase;
|
||
[SerializeField] private LanguageDeckDatabase languageDeckDatabase;
|
||
|
||
private UIDocument _document;
|
||
private RunManager _runManager;
|
||
private CombatManager _combatManager;
|
||
private SaveService _saveService;
|
||
private VisualElement _root;
|
||
private VisualElement _statusBar;
|
||
private Label _waveLabel;
|
||
private Label _phaseLabel;
|
||
private Label _entropyLabel;
|
||
private Label _bitsLabel;
|
||
private Label _bandwidthLabel;
|
||
private Label _rootCreditsLabel;
|
||
private Label _seedLabel;
|
||
private VisualElement _playerPanel;
|
||
private VisualElement _interpreterStrip;
|
||
private VisualElement _lazyStackPile;
|
||
private VisualElement _tokenArea;
|
||
private VisualElement _enemyRow;
|
||
private VisualElement _handRow;
|
||
private VisualElement _turnResourceGrid;
|
||
private Label _logLabel;
|
||
private Button _surrenderButton;
|
||
private Label _surrenderConfirmLabel;
|
||
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 Dictionary<VisualElement, int> _feedbackBeatVersions = new();
|
||
private readonly Dictionary<CombatantState, Rect> _lastCombatantFeedbackRects = new();
|
||
private readonly Dictionary<CombatantState, int> _heldDeathUptime = new();
|
||
private readonly HashSet<CombatantState> _deathBeatStartedCombatants = new();
|
||
private readonly HashSet<CombatantState> _visuallyRemovedCombatants = new();
|
||
private readonly HashSet<CardInstance> _knownHandCards = new();
|
||
private int _queueCascadeIndex;
|
||
private int _feedbackCascadeIndex;
|
||
private int _feedbackBeatVersion;
|
||
private int _combatBeatCursorMs;
|
||
private int _combatBeatCursorVersion;
|
||
private int _rootShakeVersion;
|
||
private int _hitStopVersion;
|
||
private int _lastHitStopFrame = -1;
|
||
private Label _archBtwCounterLabel;
|
||
private VisualElement _fedoraRiskFillElement;
|
||
private Label _fedoraRiskValueLabel;
|
||
private bool _pendingFedoraPayoff;
|
||
private int _rollingReleaseHitStopVersion;
|
||
private bool _surrenderConfirming;
|
||
private bool _runEndedBySurrender;
|
||
private bool _runEndOverlayPresented;
|
||
private bool _playerFeedbackAnchorTracked;
|
||
|
||
private void Awake()
|
||
{
|
||
_document = GetComponent<UIDocument>();
|
||
_runManager = GetComponent<RunManager>();
|
||
_combatManager = GetComponent<CombatManager>();
|
||
_saveService = new SaveService();
|
||
_root = _document.rootVisualElement;
|
||
LoadStyles();
|
||
ApplyTerminalFont();
|
||
BuildLayout();
|
||
|
||
_root.AddToClassList("scene-fade-hidden");
|
||
_root.schedule.Execute(() => _root.RemoveFromClassList("scene-fade-hidden")).StartingIn(0);
|
||
}
|
||
|
||
private void ApplyTerminalFont()
|
||
{
|
||
var font = TerminalFontResolver.Resolve(null);
|
||
if (font != null)
|
||
{
|
||
_root.style.unityFontDefinition = new StyleFontDefinition(font);
|
||
}
|
||
}
|
||
|
||
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.OverflowDamageTravel += HandleOverflowDamageTravel;
|
||
GameEvents.CombatantDefeated += HandleCombatantDefeated;
|
||
GameEvents.DeathSpawnedEnemy += HandleDeathSpawnedEnemy;
|
||
GameEvents.EnemyWouldAct += HandleEnemyWouldAct;
|
||
GameEvents.PlayerDamaged += HandlePlayerDamaged;
|
||
GameEvents.StatusApplied += HandleStatusApplied;
|
||
GameEvents.StatusExpired += HandleStatusEnded;
|
||
GameEvents.StatusCleansed += HandleStatusEnded;
|
||
GameEvents.UbuntuAptUpdatePeeked += HandleUbuntuAptUpdatePeeked;
|
||
GameEvents.FedoraBleedingEdgeTriggered += HandleFedoraBleedingEdgeTriggered;
|
||
GameEvents.ArchBtwTurnEnded += HandleArchBtwTurnEnded;
|
||
_root?.RegisterCallback<KeyDownEvent>(HandleKeyDown);
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
if (!TryBuildRunConfig(out RunConfig config))
|
||
{
|
||
HandleMissingRunConfig();
|
||
return;
|
||
}
|
||
|
||
_distroAccent = config.Distro == null ? Color.white : config.Distro.AccentColor;
|
||
_combatManager.SetGeneratedCardPool(BuildGeneratedCardPool());
|
||
_runManager.StartRun(config);
|
||
Refresh();
|
||
}
|
||
|
||
private IReadOnlyList<CardDefinition> BuildGeneratedCardPool()
|
||
{
|
||
List<CardDefinition> cards = new();
|
||
if (cardDatabase != null)
|
||
{
|
||
cards.AddRange(cardDatabase.AllCards);
|
||
return cards;
|
||
}
|
||
|
||
if (languageDeckDatabase == null)
|
||
{
|
||
return cards;
|
||
}
|
||
|
||
AddLanguageDeck(Language.Python, cards);
|
||
return cards;
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
Time.timeScale = 1f;
|
||
SettleRunRewardsIfNeeded();
|
||
_combatManager.StateChanged -= Refresh;
|
||
_combatManager.CombatLog -= HandleCombatLog;
|
||
_runManager.RepositoryChanged -= Refresh;
|
||
GameEvents.CardPlayed -= HandleCardPlayed;
|
||
GameEvents.CardResolved -= HandleCardResolved;
|
||
GameEvents.PhaseChanged -= HandlePhaseChanged;
|
||
GameEvents.DamageDealt -= HandleDamageDealt;
|
||
GameEvents.OverflowDamageTravel -= HandleOverflowDamageTravel;
|
||
GameEvents.CombatantDefeated -= HandleCombatantDefeated;
|
||
GameEvents.DeathSpawnedEnemy -= HandleDeathSpawnedEnemy;
|
||
GameEvents.EnemyWouldAct -= HandleEnemyWouldAct;
|
||
GameEvents.PlayerDamaged -= HandlePlayerDamaged;
|
||
GameEvents.StatusApplied -= HandleStatusApplied;
|
||
GameEvents.StatusExpired -= HandleStatusEnded;
|
||
GameEvents.StatusCleansed -= HandleStatusEnded;
|
||
GameEvents.UbuntuAptUpdatePeeked -= HandleUbuntuAptUpdatePeeked;
|
||
GameEvents.FedoraBleedingEdgeTriggered -= HandleFedoraBleedingEdgeTriggered;
|
||
GameEvents.ArchBtwTurnEnded -= HandleArchBtwTurnEnded;
|
||
_root?.UnregisterCallback<KeyDownEvent>(HandleKeyDown);
|
||
}
|
||
|
||
private void HandleKeyDown(KeyDownEvent evt)
|
||
{
|
||
if (!_surrenderConfirming)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (evt.keyCode == KeyCode.Y || evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
|
||
{
|
||
ConfirmSurrender();
|
||
evt.StopPropagation();
|
||
return;
|
||
}
|
||
|
||
if (evt.keyCode == KeyCode.N || evt.keyCode == KeyCode.Escape)
|
||
{
|
||
CancelSurrender();
|
||
evt.StopPropagation();
|
||
}
|
||
}
|
||
|
||
private bool TryBuildRunConfig(out RunConfig config)
|
||
{
|
||
if (RunContext.TryCreateRunConfig(languageDeckDatabase, out config))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (Application.isEditor && TryBuildEditorFallback(out config))
|
||
{
|
||
Debug.Log("GameScene started without RunContext; using Ubuntu Python/JavaScript dev fallback.");
|
||
return true;
|
||
}
|
||
|
||
config = null;
|
||
return false;
|
||
}
|
||
|
||
private bool TryBuildEditorFallback(out RunConfig config)
|
||
{
|
||
config = null;
|
||
DistroDefinition distro = distroDatabase == null ? null : distroDatabase.FindById("ubuntu");
|
||
if (distro == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
List<CardDefinition> startingDeck = new();
|
||
for (int i = 0; i < distro.ExclusiveCards.Count && startingDeck.Count < CardLoadout.MaxEquippedCards; i++)
|
||
{
|
||
if (distro.ExclusiveCards[i] != null && !distro.ExclusiveCards[i].IsToken && !distro.ExclusiveCards[i].IsRunOnly)
|
||
{
|
||
startingDeck.Add(distro.ExclusiveCards[i]);
|
||
}
|
||
}
|
||
|
||
AddLanguageDeck(Language.Python, startingDeck);
|
||
AddLanguageDeck(Language.JavaScript, startingDeck);
|
||
config = new RunConfig(distro, Language.Python, Language.JavaScript, startingDeck, Environment.TickCount, 1);
|
||
return true;
|
||
}
|
||
|
||
private void AddLanguageDeck(Language language, List<CardDefinition> target)
|
||
{
|
||
LanguageDeckDefinition deck = languageDeckDatabase == null ? null : languageDeckDatabase.FindByLanguage(language);
|
||
if (deck == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int entryIndex = 0; entryIndex < deck.Entries.Count; entryIndex++)
|
||
{
|
||
LanguageDeckDefinition.LanguageDeckEntry entry = deck.Entries[entryIndex];
|
||
for (int copy = 0; copy < entry.Count; copy++)
|
||
{
|
||
if (entry.Card != null && !entry.Card.IsRunOnly)
|
||
{
|
||
target.Add(entry.Card);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void HandleMissingRunConfig()
|
||
{
|
||
_root.Clear();
|
||
_root.AddToClassList("combat-root");
|
||
Label error = new("combat bootstrap failed: no RunContext and no editor fallback data");
|
||
error.AddToClassList("overlay-line");
|
||
_root.Add(error);
|
||
Debug.LogError("GameScene could not start: no RunContext and no Ubuntu editor fallback data.");
|
||
}
|
||
|
||
private void LoadStyles()
|
||
{
|
||
StyleSheet rarityStyleSheet = Resources.Load<StyleSheet>(SharedRarityStyleResourcePath);
|
||
if (rarityStyleSheet != null)
|
||
{
|
||
_root.styleSheets.Add(rarityStyleSheet);
|
||
}
|
||
|
||
StyleSheet styleSheet = Resources.Load<StyleSheet>(StyleResourcePath);
|
||
if (styleSheet != null)
|
||
{
|
||
_root.styleSheets.Add(styleSheet);
|
||
}
|
||
|
||
StyleSheet scrollbarStyleSheet = Resources.Load<StyleSheet>(SharedScrollbarStyleResourcePath);
|
||
if (scrollbarStyleSheet != null)
|
||
{
|
||
_root.styleSheets.Add(scrollbarStyleSheet);
|
||
}
|
||
}
|
||
|
||
private void BuildLayout()
|
||
{
|
||
_root.Clear();
|
||
_lastCombatantFeedbackRects.Clear();
|
||
_heldDeathUptime.Clear();
|
||
_deathBeatStartedCombatants.Clear();
|
||
_visuallyRemovedCombatants.Clear();
|
||
_playerFeedbackAnchorTracked = false;
|
||
_root.AddToClassList("combat-root");
|
||
_root.style.flexGrow = 1;
|
||
|
||
_statusBar = BuildStatusBar();
|
||
_root.Add(_statusBar);
|
||
|
||
VisualElement main = new();
|
||
main.AddToClassList("main-layout");
|
||
_root.Add(main);
|
||
|
||
_playerPanel = CreatePanel("player", "htop process monitor", 242);
|
||
main.Add(_playerPanel);
|
||
|
||
VisualElement fieldPanel = CreatePanel("field", "resolution tracks", 0);
|
||
fieldPanel.AddToClassList("field-panel");
|
||
main.Add(fieldPanel);
|
||
_interpreterStrip = AddTrackZone(fieldPanel, "interpreter queue", "FIFO queued scripts", "track-zone-queue");
|
||
_lazyStackPile = AddTrackZone(fieldPanel, "lazy stack", "LIFO delayed work", "track-zone-stack");
|
||
_tokenArea = AddTrackZone(fieldPanel, "goroutines", "token lane", "track-zone-token");
|
||
|
||
VisualElement enemiesPanel = CreatePanel("enemy processes", "intent telemetry", 438);
|
||
main.Add(enemiesPanel);
|
||
ScrollView enemyScroll = new(ScrollViewMode.Vertical);
|
||
enemyScroll.AddToClassList("enemy-scroll");
|
||
enemiesPanel.Add(enemyScroll);
|
||
_enemyRow = new();
|
||
_enemyRow.AddToClassList("enemy-row");
|
||
enemyScroll.Add(_enemyRow);
|
||
|
||
VisualElement bottom = new();
|
||
bottom.AddToClassList("bottom-layout");
|
||
_root.Add(bottom);
|
||
|
||
VisualElement handPanel = CreatePanel("hand", "executable cards", 0);
|
||
handPanel.AddToClassList("hand-panel");
|
||
bottom.Add(handPanel);
|
||
_handRow = new();
|
||
_handRow.AddToClassList("hand-row");
|
||
handPanel.Add(_handRow);
|
||
|
||
VisualElement commandPanel = CreatePanel("turn", "command", 240);
|
||
commandPanel.AddToClassList("turn-panel");
|
||
bottom.Add(commandPanel);
|
||
_turnResourceGrid = new();
|
||
_turnResourceGrid.AddToClassList("turn-resource-grid");
|
||
commandPanel.Add(_turnResourceGrid);
|
||
|
||
Button endTurn = new(() => _combatManager.EndPlayerTurn()) { text = "> end-turn" };
|
||
endTurn.AddToClassList("primary-action");
|
||
commandPanel.Add(endTurn);
|
||
|
||
_surrenderButton = new(HandleSurrenderClicked) { text = "> shutdown -r run" };
|
||
_surrenderButton.AddToClassList("secondary-action");
|
||
_surrenderButton.AddToClassList("surrender-action");
|
||
commandPanel.Add(_surrenderButton);
|
||
|
||
_surrenderConfirmLabel = new("end this run now? progress since the last wave clear is lost. [Y/n]");
|
||
_surrenderConfirmLabel.AddToClassList("surrender-confirm");
|
||
_surrenderConfirmLabel.AddToClassList("hidden");
|
||
commandPanel.Add(_surrenderConfirmLabel);
|
||
|
||
_logLabel = new();
|
||
_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;
|
||
_root.Add(_overlay);
|
||
}
|
||
|
||
private VisualElement BuildStatusBar()
|
||
{
|
||
VisualElement bar = new();
|
||
bar.AddToClassList("combat-status-bar");
|
||
|
||
_waveLabel = AddStatusReadout(bar, "wave", "--", false);
|
||
_phaseLabel = AddStatusReadout(bar, "phase", "--", true);
|
||
_bitsLabel = AddStatusReadout(bar, "Bits", "0", false);
|
||
_entropyLabel = AddStatusReadout(bar, "Entropy", "0", false);
|
||
_bandwidthLabel = AddStatusReadout(bar, "Bandwidth", "0", false);
|
||
_rootCreditsLabel = AddStatusReadout(bar, "Root Credits", "0", false);
|
||
|
||
VisualElement spacer = new();
|
||
spacer.AddToClassList("status-spacer");
|
||
bar.Add(spacer);
|
||
|
||
_seedLabel = new("--");
|
||
_seedLabel.AddToClassList("seed-value");
|
||
bar.Add(_seedLabel);
|
||
return bar;
|
||
}
|
||
|
||
private static Label AddStatusReadout(VisualElement parent, string labelText, string valueText, bool phase)
|
||
{
|
||
VisualElement cluster = new();
|
||
cluster.AddToClassList("status-cluster");
|
||
Label label = new(labelText);
|
||
label.AddToClassList("status-label");
|
||
Label value = new(valueText);
|
||
value.AddToClassList(phase ? "phase-pill" : "status-value");
|
||
cluster.Add(label);
|
||
cluster.Add(value);
|
||
parent.Add(cluster);
|
||
return value;
|
||
}
|
||
|
||
private VisualElement AddTrackZone(VisualElement parent, string title, string subtitle, string className)
|
||
{
|
||
VisualElement zone = new();
|
||
zone.AddToClassList("track-zone");
|
||
zone.AddToClassList(className);
|
||
|
||
VisualElement header = new();
|
||
header.AddToClassList("track-header");
|
||
Label titleLabel = new(title);
|
||
titleLabel.AddToClassList("track-title");
|
||
Label subtitleLabel = new(subtitle);
|
||
subtitleLabel.AddToClassList("track-subtitle");
|
||
header.Add(titleLabel);
|
||
header.Add(subtitleLabel);
|
||
zone.Add(header);
|
||
|
||
VisualElement contents = new();
|
||
contents.AddToClassList("track-contents");
|
||
zone.Add(contents);
|
||
parent.Add(zone);
|
||
return contents;
|
||
}
|
||
|
||
private void Refresh()
|
||
{
|
||
if (_combatManager.PlayerState == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
RefreshStatus();
|
||
RefreshPlayer();
|
||
RefreshTracks();
|
||
RefreshEnemies();
|
||
RefreshHand();
|
||
RefreshTurnPanel();
|
||
RefreshOverlay();
|
||
}
|
||
|
||
private void RefreshStatus()
|
||
{
|
||
SaveData data = _saveService.Load();
|
||
RunConfig config = _runManager.CurrentConfig;
|
||
_waveLabel.text = _runManager.CurrentWaveNumber.ToString();
|
||
_phaseLabel.text = PhaseText(_combatManager.CurrentPhase);
|
||
_bitsLabel.text = _runManager.Bits.ToString();
|
||
_entropyLabel.text = FormatWalletWithAccrual(data.entropyBalance, _runManager.AccruedEntropy);
|
||
_bandwidthLabel.text = FormatWalletWithAccrual(data.bandwidthBalance, _runManager.AccruedBandwidth);
|
||
_rootCreditsLabel.text = data.rootCredits.ToString();
|
||
_seedLabel.text = $"seed {config?.RunSeed ?? 0}";
|
||
}
|
||
|
||
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;
|
||
TrackCombatantFeedbackAnchor(state, _playerPanel);
|
||
DetectBeneficialResourceFeedback(state, _playerPanel);
|
||
ApplyStatusStateClasses(_playerPanel, state);
|
||
_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));
|
||
|
||
Label ram = new($"ram hand cap {_combatManager.HandController.UsedRam}/{_combatManager.HandController.RamCapacity}");
|
||
ram.AddToClassList("ram-note");
|
||
_playerPanel.Add(ram);
|
||
if (_combatManager.ActiveKernelRamPenalty > 0)
|
||
{
|
||
Label ramPressure = new($"! kernel panic: RAM -{_combatManager.ActiveKernelRamPenalty}");
|
||
ramPressure.AddToClassList("status-label");
|
||
ramPressure.AddToClassList("intent-attack");
|
||
_playerPanel.Add(ramPressure);
|
||
}
|
||
|
||
_playerPanel.Add(StatusBlock(state, true));
|
||
|
||
if (IsDistro("fedora"))
|
||
{
|
||
_playerPanel.Add(FedoraRiskMeter(_combatManager.FedoraCrashChance));
|
||
}
|
||
|
||
if (IsDistro("arch"))
|
||
{
|
||
_playerPanel.Add(ArchBtwCounter(state.ArchBtwStacks));
|
||
}
|
||
}
|
||
|
||
private bool IsDistro(string id)
|
||
{
|
||
return string.Equals(_runManager?.CurrentConfig?.Distro?.Id, id, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private void RefreshTracks()
|
||
{
|
||
_queueChipElements.Clear();
|
||
_stackChipElements.Clear();
|
||
FillCardStrip(_interpreterStrip, _combatManager.InterpreterQueue.Cards, "queue empty");
|
||
FillCardStrip(_lazyStackPile, _combatManager.LazyStack.Cards, "stack empty");
|
||
_tokenArea.Clear();
|
||
_tokenArea.Add(EmptyState("no goroutines or tokens"));
|
||
}
|
||
|
||
private void RefreshEnemies()
|
||
{
|
||
_enemyRow.Clear();
|
||
_combatantElements.Clear();
|
||
PruneVisuallyRemovedCombatants();
|
||
int enemyCount = _combatManager.Enemies.Count;
|
||
bool compactEnemies = enemyCount >= 5;
|
||
_enemyRow.EnableInClassList("enemy-row-compact", compactEnemies);
|
||
if (_combatManager.PlayerState != null)
|
||
{
|
||
_combatantElements[_combatManager.PlayerState] = _playerPanel;
|
||
}
|
||
|
||
for (int i = 0; i < _combatManager.Enemies.Count; i++)
|
||
{
|
||
int index = i;
|
||
EnemyInstance enemy = _combatManager.Enemies[i];
|
||
if (_visuallyRemovedCombatants.Contains(enemy.State))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Button card = new(() => _combatManager.SelectEnemy(index));
|
||
card.text = string.Empty;
|
||
card.AddToClassList("enemy-card");
|
||
card.EnableInClassList("enemy-card-compact", compactEnemies);
|
||
card.EnableInClassList("enemy-card-elite", enemy.HasEliteSignal);
|
||
bool deathPending = enemy.DefeatedPendingRemoval || (enemy.State.IsDefeated && !enemy.PendingRevive);
|
||
bool deathBeatStarted = _deathBeatStartedCombatants.Contains(enemy.State);
|
||
bool holdDeathDisplay = deathPending && !deathBeatStarted;
|
||
int displayedUptime = holdDeathDisplay
|
||
? HeldDeathUptime(enemy.State, enemy.MaxUptime)
|
||
: enemy.CurrentUptime;
|
||
card.EnableInClassList("enemy-card-defeated-hold", deathPending && deathBeatStarted);
|
||
card.SetEnabled(!enemy.State.IsDefeated || holdDeathDisplay);
|
||
bool highlighted = _combatManager.PendingTargetCard != null || _combatManager.SelectedEnemyIndex == index;
|
||
if (highlighted)
|
||
{
|
||
card.AddToClassList("enemy-card-targeted");
|
||
}
|
||
|
||
Label name = new(enemy.Name);
|
||
name.AddToClassList("enemy-name");
|
||
card.Add(name);
|
||
|
||
if (enemy.HasPendingMarker)
|
||
{
|
||
Label marker = new(enemy.PendingRevive ? "~ reviving" : "~ revived once");
|
||
marker.AddToClassList("status-label");
|
||
card.Add(marker);
|
||
}
|
||
|
||
if (enemy.HasSpecialSignal)
|
||
{
|
||
card.Add(EnemySignalPanel(enemy, _combatManager.Enemies));
|
||
}
|
||
|
||
_combatantElements[enemy.State] = card;
|
||
if (!deathPending)
|
||
{
|
||
DetectBeneficialResourceFeedback(enemy.State, card);
|
||
}
|
||
|
||
ApplyStatusStateClasses(card, enemy.State);
|
||
card.Add(MeterBlock("uptime", displayedUptime, enemy.MaxUptime, MeterTone.Uptime));
|
||
if (enemy.State.Shield > 0)
|
||
{
|
||
Label shield = new($"# shield {enemy.State.Shield}");
|
||
shield.AddToClassList("enemy-shield");
|
||
card.Add(shield);
|
||
}
|
||
|
||
card.Add(StatusPipRow(enemy.State));
|
||
if (!deathPending && (!enemy.SpawnedFromDeath || enemy.DeathSpawnIntentRevealed))
|
||
{
|
||
card.Add(IntentPanel(enemy.DisplayIntent));
|
||
}
|
||
_enemyRow.Add(card);
|
||
TrackCombatantFeedbackAnchor(enemy.State, card);
|
||
}
|
||
}
|
||
|
||
private int HeldDeathUptime(CombatantState state, int maxUptime)
|
||
{
|
||
if (state != null && _heldDeathUptime.TryGetValue(state, out int uptime))
|
||
{
|
||
return Mathf.Clamp(uptime, 0, Mathf.Max(1, maxUptime));
|
||
}
|
||
|
||
return Mathf.Clamp(maxUptime, 1, Mathf.Max(1, maxUptime));
|
||
}
|
||
|
||
private void PruneVisuallyRemovedCombatants()
|
||
{
|
||
bool IsRemoved(CombatantState state)
|
||
{
|
||
IReadOnlyList<EnemyInstance> enemies = _combatManager.Enemies;
|
||
for (int i = 0; i < enemies.Count; i++)
|
||
{
|
||
if (enemies[i].State == state)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
_visuallyRemovedCombatants.RemoveWhere(IsRemoved);
|
||
_deathBeatStartedCombatants.RemoveWhere(IsRemoved);
|
||
List<CombatantState> staleHeldStates = null;
|
||
foreach (CombatantState state in _heldDeathUptime.Keys)
|
||
{
|
||
if (!IsRemoved(state))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
staleHeldStates ??= new List<CombatantState>();
|
||
staleHeldStates.Add(state);
|
||
}
|
||
|
||
if (staleHeldStates == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < staleHeldStates.Count; i++)
|
||
{
|
||
_heldDeathUptime.Remove(staleHeldStates[i]);
|
||
}
|
||
}
|
||
|
||
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];
|
||
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);
|
||
}
|
||
}
|
||
|
||
private void RefreshTurnPanel()
|
||
{
|
||
_turnResourceGrid.Clear();
|
||
CombatantState state = _combatManager.PlayerState;
|
||
IReadOnlyList<CardInstance> hand = _combatManager.HandController.Cards;
|
||
_turnResourceGrid.Add(TurnStat("cycles", $"{state.Cycles}/{state.MaxCycles}"));
|
||
_turnResourceGrid.Add(TurnStat("hand", $"{_combatManager.HandController.UsedRam}/{_combatManager.HandController.RamCapacity}"));
|
||
_turnResourceGrid.Add(TurnStat("draw", _combatManager.DeckController.DrawPile.Count.ToString()));
|
||
_turnResourceGrid.Add(TurnStat("discard", _combatManager.DeckController.DiscardPile.Count.ToString()));
|
||
_turnResourceGrid.Add(TurnStat("exhaust", _combatManager.DeckController.ExhaustPile.Count.ToString()));
|
||
RefreshSurrenderCommand();
|
||
}
|
||
|
||
private void RefreshSurrenderCommand()
|
||
{
|
||
bool canSurrender = _combatManager.CanSurrenderRun;
|
||
if (!canSurrender)
|
||
{
|
||
_surrenderConfirming = false;
|
||
}
|
||
|
||
if (_surrenderButton != null)
|
||
{
|
||
_surrenderButton.text = _surrenderConfirming ? "> confirm shutdown" : "> shutdown -r run";
|
||
_surrenderButton.SetEnabled(canSurrender);
|
||
_surrenderButton.EnableInClassList("surrender-action-confirming", _surrenderConfirming);
|
||
}
|
||
|
||
if (_surrenderConfirmLabel != null)
|
||
{
|
||
_surrenderConfirmLabel.EnableInClassList("hidden", !_surrenderConfirming);
|
||
}
|
||
}
|
||
|
||
private void HandleSurrenderClicked()
|
||
{
|
||
if (!_combatManager.CanSurrenderRun)
|
||
{
|
||
_surrenderConfirming = false;
|
||
RefreshSurrenderCommand();
|
||
return;
|
||
}
|
||
|
||
if (!_surrenderConfirming)
|
||
{
|
||
_surrenderConfirming = true;
|
||
RefreshSurrenderCommand();
|
||
_root?.Focus();
|
||
return;
|
||
}
|
||
|
||
ConfirmSurrender();
|
||
}
|
||
|
||
private void ConfirmSurrender()
|
||
{
|
||
if (!_combatManager.CanSurrenderRun)
|
||
{
|
||
_surrenderConfirming = false;
|
||
RefreshSurrenderCommand();
|
||
return;
|
||
}
|
||
|
||
_surrenderConfirming = false;
|
||
_runEndedBySurrender = true;
|
||
_combatManager.SurrenderRun();
|
||
}
|
||
|
||
private void CancelSurrender()
|
||
{
|
||
if (!_surrenderConfirming)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_surrenderConfirming = false;
|
||
RefreshSurrenderCommand();
|
||
}
|
||
|
||
private void RefreshOverlay()
|
||
{
|
||
if (_overlay == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_overlay.Clear();
|
||
if (_combatManager.RunLost)
|
||
{
|
||
SettleRunRewardsIfNeeded();
|
||
_overlay.style.display = DisplayStyle.Flex;
|
||
_overlay.Add(BuildRunEndOverlay());
|
||
return;
|
||
}
|
||
|
||
_runEndOverlayPresented = false;
|
||
_runEndedBySurrender = false;
|
||
if (_combatManager.AwaitingWaveContinue)
|
||
{
|
||
if (!_runManager.RepositoryVisitActive)
|
||
{
|
||
_runManager.GenerateRepositoryOffers(cardDatabase, languageDeckDatabase);
|
||
return;
|
||
}
|
||
|
||
_overlay.style.display = DisplayStyle.Flex;
|
||
_overlay.Add(BuildRepositoryView());
|
||
return;
|
||
}
|
||
|
||
_overlay.style.display = DisplayStyle.None;
|
||
}
|
||
|
||
private VisualElement BuildRunEndOverlay()
|
||
{
|
||
VisualElement screen = new();
|
||
screen.AddToClassList("death-overlay");
|
||
|
||
if (!_runEndOverlayPresented)
|
||
{
|
||
_runEndOverlayPresented = true;
|
||
if (!UIPreferences.ReducedMotion)
|
||
{
|
||
VisualElement beat = new();
|
||
beat.AddToClassList("death-crash-beat");
|
||
screen.Add(beat);
|
||
screen.schedule.Execute(() => beat.AddToClassList("death-crash-beat-settled")).StartingIn(25);
|
||
screen.schedule.Execute(() => beat.RemoveFromHierarchy()).StartingIn(260);
|
||
}
|
||
}
|
||
|
||
VisualElement content = new();
|
||
content.AddToClassList("death-content");
|
||
|
||
content.Add(OverlayTitle("KERNEL PANIC"));
|
||
|
||
string cause = _runEndedBySurrender ? "fatal: shutdown signal accepted" : "fatal: player uptime reached 0";
|
||
Label causeLabel = OverlayLine(cause);
|
||
causeLabel.AddToClassList("death-cause");
|
||
content.Add(causeLabel);
|
||
|
||
VisualElement summary = new();
|
||
summary.AddToClassList("death-section");
|
||
summary.AddToClassList("death-summary");
|
||
summary.Add(DeathStat("wave reached", _runManager.CurrentWaveNumber.ToString()));
|
||
summary.Add(DeathStat("waves cleared", _runManager.WavesCleared.ToString()));
|
||
summary.Add(DeathStat("seed", (_runManager.CurrentConfig?.RunSeed ?? 0).ToString()));
|
||
content.Add(summary);
|
||
|
||
VisualElement rewards = new();
|
||
rewards.AddToClassList("death-section");
|
||
rewards.AddToClassList("death-rewards");
|
||
rewards.Add(RewardReadout("#", _runManager.AccruedBandwidth, "bandwidth", false));
|
||
rewards.Add(RewardReadout("*", _runManager.AccruedEntropy, "entropy", _runManager.AccruedEntropy <= 0));
|
||
content.Add(rewards);
|
||
|
||
Button returnButton = new(() => SceneLoader.LoadMainMenu(_root)) { text = "> return to menu" };
|
||
returnButton.AddToClassList("primary-action");
|
||
returnButton.AddToClassList("death-return-action");
|
||
content.Add(returnButton);
|
||
|
||
screen.Add(content);
|
||
return screen;
|
||
}
|
||
|
||
private void SettleRunRewardsIfNeeded()
|
||
{
|
||
if (_runManager == null || _saveService == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!_runManager.TrySettleRunRewards(out int bandwidth, out int entropy))
|
||
{
|
||
return;
|
||
}
|
||
|
||
SaveData data = _saveService.Load();
|
||
data.bandwidthBalance = Math.Max(0, data.bandwidthBalance) + bandwidth;
|
||
data.entropyBalance = Math.Max(0, data.entropyBalance) + entropy;
|
||
data.RecordRunStats(_runManager.CurrentConfig?.Distro?.Id, _runManager.CurrentWaveNumber);
|
||
_saveService.Save(data);
|
||
}
|
||
|
||
private VisualElement BuildRepositoryView()
|
||
{
|
||
VisualElement repo = new();
|
||
repo.AddToClassList("repository");
|
||
repo.AddToClassList("screen-frame");
|
||
repo.Add(BuildRepositoryHeader());
|
||
|
||
ScrollView body = new(ScrollViewMode.Vertical);
|
||
body.AddToClassList("repository-body");
|
||
body.AddToClassList("screen-frame-content");
|
||
|
||
VisualElement offers = new();
|
||
offers.AddToClassList("repository-offers");
|
||
if (_runManager.RepositoryOffers.Count == 0)
|
||
{
|
||
offers.Add(EmptyState("repository clean: no offers left this visit"));
|
||
}
|
||
else
|
||
{
|
||
for (int i = 0; i < _runManager.RepositoryOffers.Count; i++)
|
||
{
|
||
offers.Add(RepositoryOfferTile(_runManager.RepositoryOffers[i]));
|
||
}
|
||
}
|
||
|
||
body.Add(offers);
|
||
body.Add(RemoveCardBlock());
|
||
repo.Add(body);
|
||
|
||
VisualElement footer = new();
|
||
footer.AddToClassList("repository-footer");
|
||
footer.AddToClassList("screen-frame-footer");
|
||
Button reroll = new(() => _runManager.RerollRepositoryOffers(cardDatabase, languageDeckDatabase))
|
||
{
|
||
text = $"apt update {_runManager.RerollCost} bits"
|
||
};
|
||
reroll.AddToClassList("primary-action");
|
||
reroll.AddToClassList("repository-footer-action");
|
||
reroll.AddToClassList("repository-reroll-action");
|
||
reroll.EnableInClassList("repo-unaffordable", _runManager.Bits < _runManager.RerollCost);
|
||
reroll.SetEnabled(_runManager.Bits >= _runManager.RerollCost);
|
||
footer.Add(reroll);
|
||
|
||
Button leave = new(() => _combatManager.ContinueToNextWave()) { text = "> boot next-wave" };
|
||
leave.AddToClassList("primary-action");
|
||
leave.AddToClassList("repository-leave-action");
|
||
VisualElement leaveBlock = new();
|
||
leaveBlock.AddToClassList("repository-leave-block");
|
||
leaveBlock.Add(leave);
|
||
Label banked = new($"{_runManager.Bits} bits remaining");
|
||
banked.AddToClassList("repository-banked-hint");
|
||
banked.AddToClassList("screen-frame-hint");
|
||
leaveBlock.Add(banked);
|
||
footer.Add(leaveBlock);
|
||
repo.Add(footer);
|
||
return repo;
|
||
}
|
||
|
||
private VisualElement BuildRepositoryHeader()
|
||
{
|
||
VisualElement header = new();
|
||
header.AddToClassList("repository-header");
|
||
header.AddToClassList("screen-frame-header");
|
||
|
||
VisualElement copy = new();
|
||
copy.AddToClassList("repository-header-copy");
|
||
Label title = new("repository");
|
||
title.AddToClassList("repository-title");
|
||
title.AddToClassList("screen-frame-title");
|
||
Label subtitle = new($"wave {_runManager.CurrentWaveNumber - 1} cleared / {_runManager.RepositoryOffers.Count} packages indexed");
|
||
subtitle.AddToClassList("repository-subtitle");
|
||
copy.Add(title);
|
||
copy.Add(subtitle);
|
||
|
||
VisualElement wallet = new();
|
||
wallet.AddToClassList("repository-wallet");
|
||
Label walletLabel = new("available bits");
|
||
walletLabel.AddToClassList("status-label");
|
||
Label walletValue = new($"◆ {_runManager.Bits}");
|
||
walletValue.AddToClassList("repository-wallet-value");
|
||
wallet.Add(walletLabel);
|
||
wallet.Add(walletValue);
|
||
|
||
header.Add(copy);
|
||
header.Add(wallet);
|
||
return header;
|
||
}
|
||
|
||
private VisualElement RepositoryOfferTile(RepositoryOffer offer)
|
||
{
|
||
VisualElement tile = new();
|
||
tile.AddToClassList("repository-tile");
|
||
tile.AddToClassList(OfferTileClass(offer.Kind));
|
||
bool unavailable = offer.Sold || _runManager.Bits < offer.Price;
|
||
tile.EnableInClassList("repo-unaffordable", unavailable);
|
||
tile.EnableInClassList("repo-sold", offer.Sold);
|
||
|
||
VisualElement top = new();
|
||
top.AddToClassList("repository-tile-top");
|
||
Label kind = new($"{OfferKindIcon(offer.Kind)} {OfferKindText(offer.Kind)}");
|
||
kind.AddToClassList("repository-kind");
|
||
top.Add(kind);
|
||
top.Add(PriceBadge(offer.Price, unavailable));
|
||
tile.Add(top);
|
||
|
||
VisualElement content = offer.Kind switch
|
||
{
|
||
RepositoryOfferKind.NewCard => CardOfferContent(offer),
|
||
RepositoryOfferKind.CardUpgrade => UpgradeOfferContent(offer),
|
||
RepositoryOfferKind.StatUpgrade => StatOfferContent(offer),
|
||
_ => GenericOfferContent(offer)
|
||
};
|
||
tile.Add(content);
|
||
|
||
Button buy = new(() => _runManager.BuyOffer(offer, _combatManager.PlayerState))
|
||
{
|
||
text = offer.Sold ? "installed" : OfferActionText(offer.Kind)
|
||
};
|
||
buy.tooltip = OfferCommandText(offer);
|
||
buy.AddToClassList("repository-action");
|
||
buy.SetEnabled(!offer.Sold && _runManager.Bits >= offer.Price);
|
||
tile.Add(buy);
|
||
return tile;
|
||
}
|
||
|
||
private VisualElement CardOfferContent(RepositoryOffer offer)
|
||
{
|
||
VisualElement content = new();
|
||
content.AddToClassList("repository-card-content");
|
||
CardInstance preview = new(offer.CardDefinition);
|
||
VisualElement card = CardFaceView(preview);
|
||
card.AddToClassList("repository-card-preview");
|
||
content.Add(card);
|
||
|
||
if (offer.CardDefinition != null && offer.CardDefinition.IsRunOnly)
|
||
{
|
||
content.Add(Tag("run-only", "repository-run-only-tag"));
|
||
}
|
||
|
||
return content;
|
||
}
|
||
|
||
private VisualElement UpgradeOfferContent(RepositoryOffer offer)
|
||
{
|
||
VisualElement content = new();
|
||
content.AddToClassList("repository-upgrade-content");
|
||
if (offer.TargetCard != null)
|
||
{
|
||
VisualElement target = CardFaceView(offer.TargetCard);
|
||
target.AddToClassList("repository-upgrade-card");
|
||
content.Add(target);
|
||
}
|
||
|
||
VisualElement delta = new();
|
||
delta.AddToClassList("repository-delta");
|
||
Label arrow = new("▲");
|
||
arrow.AddToClassList("repository-delta-icon");
|
||
Label text = new(offer.Description);
|
||
text.AddToClassList("repository-delta-text");
|
||
delta.Add(arrow);
|
||
delta.Add(text);
|
||
content.Add(delta);
|
||
return content;
|
||
}
|
||
|
||
private VisualElement StatOfferContent(RepositoryOffer offer)
|
||
{
|
||
VisualElement content = new();
|
||
content.AddToClassList("repository-stat-content");
|
||
Label icon = new(StatOfferIcon(offer.StatUpgradeKind));
|
||
icon.AddToClassList("repository-stat-icon");
|
||
Label name = new(offer.DisplayName);
|
||
name.AddToClassList("repository-name");
|
||
Label description = new(offer.Description);
|
||
description.AddToClassList("repository-description");
|
||
content.Add(icon);
|
||
content.Add(name);
|
||
content.Add(description);
|
||
return content;
|
||
}
|
||
|
||
private static VisualElement GenericOfferContent(RepositoryOffer offer)
|
||
{
|
||
VisualElement content = new();
|
||
Label name = new(offer.DisplayName);
|
||
name.AddToClassList("repository-name");
|
||
Label description = new(offer.Description);
|
||
description.AddToClassList("repository-description");
|
||
content.Add(name);
|
||
content.Add(description);
|
||
return content;
|
||
}
|
||
|
||
private static VisualElement PriceBadge(int price, bool warning)
|
||
{
|
||
Label badge = new($"◆ {price}");
|
||
badge.AddToClassList("repository-price");
|
||
badge.EnableInClassList("repository-price-warning", warning);
|
||
return badge;
|
||
}
|
||
|
||
private VisualElement RemoveCardBlock()
|
||
{
|
||
VisualElement block = new();
|
||
block.AddToClassList("repository-remove");
|
||
Label title = new($"installed deck maintenance / remove for {CombatTuning.RemoveCardCost} bit");
|
||
title.AddToClassList("repository-name");
|
||
block.Add(title);
|
||
|
||
VisualElement rows = new();
|
||
rows.AddToClassList("repository-remove-list");
|
||
for (int i = 0; i < _runManager.RunDeck.Count; i++)
|
||
{
|
||
CardInstance card = _runManager.RunDeck[i];
|
||
Button remove = new(() => _runManager.RemoveCard(card))
|
||
{
|
||
text = $"apt remove {DisplayName(card.Definition)}"
|
||
};
|
||
remove.AddToClassList("repo-remove-button");
|
||
remove.EnableInClassList("repo-unaffordable", _runManager.Bits < CombatTuning.RemoveCardCost || _runManager.RunDeck.Count <= 1);
|
||
remove.SetEnabled(_runManager.Bits >= CombatTuning.RemoveCardCost && _runManager.RunDeck.Count > 1);
|
||
rows.Add(remove);
|
||
}
|
||
|
||
block.Add(rows);
|
||
return block;
|
||
}
|
||
|
||
private VisualElement CardFace(CardInstance card)
|
||
{
|
||
Button button = new(() =>
|
||
{
|
||
if (card.IsLocked)
|
||
{
|
||
UnlockCardWithFeedback(card);
|
||
return;
|
||
}
|
||
|
||
PlayCardWithFeedback(card);
|
||
});
|
||
button.text = string.Empty;
|
||
button.AddToClassList("hand-card");
|
||
if (_combatManager.PendingTargetCard == card)
|
||
{
|
||
button.AddToClassList("hand-card-selected");
|
||
}
|
||
|
||
if (GetDisplayCardCost(card) > _combatManager.PlayerState.Cycles)
|
||
{
|
||
button.AddToClassList("hand-card-unaffordable");
|
||
}
|
||
|
||
if (card.IsBroken || card.IsLocked)
|
||
{
|
||
button.AddToClassList("hand-card-unaffordable");
|
||
button.SetEnabled(!card.IsBroken);
|
||
}
|
||
|
||
PopulateCardFace(button, card);
|
||
return button;
|
||
}
|
||
|
||
private VisualElement CardFaceView(CardInstance card)
|
||
{
|
||
VisualElement face = new();
|
||
face.AddToClassList("hand-card");
|
||
PopulateCardFace(face, card);
|
||
return face;
|
||
}
|
||
|
||
private void PopulateCardFace(VisualElement target, CardInstance card)
|
||
{
|
||
string cardRarityClass = CardRarityClass(card.Definition.Rarity);
|
||
if (!string.IsNullOrEmpty(cardRarityClass))
|
||
{
|
||
target.AddToClassList(cardRarityClass);
|
||
}
|
||
|
||
VisualElement top = new();
|
||
top.AddToClassList("card-top-row");
|
||
Label cost = new(GetDisplayCardCost(card).ToString());
|
||
cost.AddToClassList("card-cost");
|
||
Label name = new($"{DisplayName(card.Definition)}{(card.IsUpgraded ? " +" : string.Empty)}");
|
||
name.AddToClassList("card-name");
|
||
top.Add(cost);
|
||
top.Add(name);
|
||
target.Add(top);
|
||
|
||
if (card.IsBroken || card.IsLocked)
|
||
{
|
||
Label state = new(card.IsBroken ? "segv corrupt" : $"locked: pay {EnemyArchetypeCatalog.DrmUnlockCycleCost}c");
|
||
state.AddToClassList("status-label");
|
||
target.Add(state);
|
||
}
|
||
|
||
VisualElement tags = new();
|
||
tags.AddToClassList("tag-row");
|
||
tags.Add(Tag(card.Definition.Language.ToString(), LanguageClass(card.Definition.Language)));
|
||
tags.Add(Tag(TrackText(card.Definition.ResolutionTrack), "track-tag"));
|
||
tags.Add(Tag(card.Definition.Rarity.ToString(), RarityTagClass(card.Definition.Rarity)));
|
||
target.Add(tags);
|
||
|
||
Label effect = new(CardEffectFactory.GetRulesText(card));
|
||
effect.AddToClassList("card-effect");
|
||
target.Add(effect);
|
||
}
|
||
|
||
private static VisualElement IntentPanel(EnemyIntent intent)
|
||
{
|
||
VisualElement panel = new();
|
||
panel.AddToClassList("intent-panel");
|
||
string className = IntentClass(intent.Kind);
|
||
panel.AddToClassList(className);
|
||
|
||
Label label = new("intent");
|
||
label.AddToClassList("intent-label");
|
||
Label value = new($"{IntentIcon(intent)} {intent.ValueText}");
|
||
value.AddToClassList("intent-value");
|
||
value.AddToClassList(className);
|
||
Label detail = new(IntentText(intent));
|
||
detail.AddToClassList("status-label");
|
||
panel.Add(label);
|
||
panel.Add(value);
|
||
panel.Add(detail);
|
||
return panel;
|
||
}
|
||
|
||
private static VisualElement EnemySignalPanel(EnemyInstance enemy, IReadOnlyList<EnemyInstance> enemies)
|
||
{
|
||
VisualElement row = new();
|
||
row.AddToClassList("status-row");
|
||
|
||
if (enemy.SpawnedFromDeath)
|
||
{
|
||
row.Add(Tag(". orphaned", "track-tag"));
|
||
}
|
||
|
||
if (enemy.HasEliteSignal)
|
||
{
|
||
row.Add(Tag("! elite", "intent-attack"));
|
||
}
|
||
|
||
if (enemy.HasRamPressureSignal)
|
||
{
|
||
row.Add(Tag($"# RAM -{EnemyArchetypeCatalog.KernelPanicRamPenalty}", "intent-attack"));
|
||
}
|
||
|
||
if (enemy.HasTelemetrySignal)
|
||
{
|
||
row.Add(Tag($"+ profile {enemy.TelemetryStacks}", "intent-special"));
|
||
}
|
||
|
||
if (enemy.HasCardLockerSignal)
|
||
{
|
||
row.Add(Tag($"$ lock {EnemyArchetypeCatalog.DrmUnlockCycleCost}c", "intent-special"));
|
||
}
|
||
|
||
if (enemy.HasSplitSignal)
|
||
{
|
||
string text = CountLivingArchetype(enemies, enemy.ArchetypeId) >= EnemyArchetypeCatalog.ForkBombTotalCap
|
||
? ": split capped"
|
||
: ": split next";
|
||
row.Add(Tag(text, "track-tag"));
|
||
}
|
||
|
||
if (enemy.HasSegfaultSignal)
|
||
{
|
||
row.Add(Tag($"! corrupt in {enemy.CountdownRemaining}", "track-tag"));
|
||
}
|
||
|
||
if (enemy.HasRaceSignal)
|
||
{
|
||
string text = HasLivingPair(enemies, enemy) ? "~ linked random" : "~ unlinked";
|
||
row.Add(Tag(text, "track-tag"));
|
||
}
|
||
|
||
if (enemy.HasRootkitSignal)
|
||
{
|
||
string text = HasOtherLivingEnemy(enemies, enemy) ? "? masked reduced" : "? exposed";
|
||
row.Add(Tag(text, "track-tag"));
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
private static int CountLivingArchetype(IReadOnlyList<EnemyInstance> enemies, string archetypeId)
|
||
{
|
||
int count = 0;
|
||
for (int i = 0; i < enemies.Count; i++)
|
||
{
|
||
EnemyInstance enemy = enemies[i];
|
||
if (enemy.ArchetypeId == archetypeId && !enemy.State.IsDefeated)
|
||
{
|
||
count++;
|
||
}
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
private static bool HasLivingPair(IReadOnlyList<EnemyInstance> enemies, EnemyInstance target)
|
||
{
|
||
if (target == null || target.PairId < 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
for (int i = 0; i < enemies.Count; i++)
|
||
{
|
||
EnemyInstance enemy = enemies[i];
|
||
if (enemy != target && enemy.PairId == target.PairId && !enemy.State.IsDefeated)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static bool HasOtherLivingEnemy(IReadOnlyList<EnemyInstance> enemies, EnemyInstance target)
|
||
{
|
||
for (int i = 0; i < enemies.Count; i++)
|
||
{
|
||
EnemyInstance enemy = enemies[i];
|
||
if (enemy != target && !enemy.State.IsDefeated)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static VisualElement MeterBlock(string name, int current, int max, MeterTone tone)
|
||
{
|
||
float ratio = max <= 0 ? 0f : Mathf.Clamp01((float)current / max);
|
||
VisualElement block = new();
|
||
block.AddToClassList("meter-block");
|
||
|
||
VisualElement head = new();
|
||
head.AddToClassList("meter-head");
|
||
Label label = new(name);
|
||
label.AddToClassList("metric-label");
|
||
Label value = new(max <= 0 ? current.ToString() : $"{current}/{max}");
|
||
value.AddToClassList(tone == MeterTone.Muted ? "metric-value-muted" : "metric-value");
|
||
head.Add(label);
|
||
head.Add(value);
|
||
block.Add(head);
|
||
|
||
VisualElement track = new();
|
||
track.AddToClassList("meter-track");
|
||
VisualElement fill = new();
|
||
fill.AddToClassList("meter-fill");
|
||
fill.AddToClassList(MeterFillClass(tone, ratio));
|
||
fill.style.width = Length.Percent(ratio * 100f);
|
||
track.Add(fill);
|
||
block.Add(track);
|
||
return block;
|
||
}
|
||
|
||
private static VisualElement CycleBlock(int current, int max)
|
||
{
|
||
VisualElement block = new();
|
||
block.AddToClassList("cycle-row");
|
||
VisualElement head = new();
|
||
head.AddToClassList("meter-head");
|
||
Label label = new("cycles");
|
||
label.AddToClassList("metric-label");
|
||
Label value = new($"{current}/{max}");
|
||
value.AddToClassList("metric-value");
|
||
head.Add(label);
|
||
head.Add(value);
|
||
block.Add(head);
|
||
|
||
Label pips = new(BuildPips(current, max));
|
||
pips.AddToClassList("cycle-pips");
|
||
block.Add(pips);
|
||
return block;
|
||
}
|
||
|
||
private VisualElement FedoraRiskMeter(float chancePercent)
|
||
{
|
||
float clamped = Mathf.Clamp(chancePercent, 0f, 100f);
|
||
VisualElement block = new();
|
||
block.AddToClassList("meter-block");
|
||
block.AddToClassList("fedora-risk-meter");
|
||
|
||
VisualElement head = new();
|
||
head.AddToClassList("meter-head");
|
||
Label label = new("crash risk");
|
||
label.AddToClassList("metric-label");
|
||
Label value = new($"{clamped:0.#}%");
|
||
value.AddToClassList("metric-value");
|
||
head.Add(label);
|
||
head.Add(value);
|
||
block.Add(head);
|
||
|
||
VisualElement track = new();
|
||
track.AddToClassList("meter-track");
|
||
VisualElement fill = new();
|
||
fill.AddToClassList("meter-fill");
|
||
fill.AddToClassList(FedoraRiskFillClass(clamped));
|
||
fill.style.width = Length.Percent(clamped);
|
||
track.Add(fill);
|
||
block.Add(track);
|
||
|
||
_fedoraRiskFillElement = fill;
|
||
_fedoraRiskValueLabel = value;
|
||
return block;
|
||
}
|
||
|
||
private static string FedoraRiskFillClass(float chancePercent)
|
||
{
|
||
if (chancePercent >= 50f)
|
||
{
|
||
return "meter-fill-danger";
|
||
}
|
||
|
||
return chancePercent >= 25f ? "meter-fill-warning" : "meter-fill";
|
||
}
|
||
|
||
private VisualElement ArchBtwCounter(int stacks)
|
||
{
|
||
VisualElement block = new();
|
||
block.AddToClassList("arch-btw-counter");
|
||
Label label = new($"btw ×{stacks}");
|
||
label.AddToClassList("arch-btw-value");
|
||
label.style.color = _distroAccent;
|
||
block.Add(label);
|
||
_archBtwCounterLabel = label;
|
||
return block;
|
||
}
|
||
|
||
private static VisualElement StatusBlock(CombatantState state, bool includeTooltips)
|
||
{
|
||
VisualElement block = new();
|
||
Label title = new("statuses");
|
||
title.AddToClassList("status-section-title");
|
||
block.Add(title);
|
||
if (!includeTooltips || state.Statuses.Count == 0)
|
||
{
|
||
block.Add(StatusPipRow(state));
|
||
return block;
|
||
}
|
||
|
||
VisualElement details = new();
|
||
details.AddToClassList("status-detail-list");
|
||
for (int i = 0; i < state.Statuses.Count; i++)
|
||
{
|
||
details.Add(StatusDetail(state.Statuses[i]));
|
||
}
|
||
|
||
block.Add(details);
|
||
return block;
|
||
}
|
||
|
||
private static VisualElement StatusDetail(StatusInstance status)
|
||
{
|
||
StatusDescriptor descriptor = StatusEffectController.GetDescriptor(status.Type);
|
||
VisualElement item = new();
|
||
item.AddToClassList("status-detail");
|
||
item.Add(StatusPip(status));
|
||
|
||
Label tooltip = new(descriptor.Tooltip);
|
||
tooltip.AddToClassList("status-tooltip");
|
||
item.Add(tooltip);
|
||
return item;
|
||
}
|
||
|
||
private static VisualElement StatusPipRow(CombatantState state)
|
||
{
|
||
VisualElement row = new();
|
||
row.AddToClassList("status-row");
|
||
if (state.Statuses.Count == 0)
|
||
{
|
||
row.Add(EmptyStatusPip());
|
||
return row;
|
||
}
|
||
|
||
for (int i = 0; i < state.Statuses.Count; i++)
|
||
{
|
||
row.Add(StatusPip(state.Statuses[i]));
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
private static VisualElement StatusPip(StatusInstance status)
|
||
{
|
||
StatusDescriptor descriptor = StatusEffectController.GetDescriptor(status.Type);
|
||
Label pip = new($"{descriptor.IconKey} x{status.Stacks} {DurationText(status.Duration)}");
|
||
pip.AddToClassList("status-pip");
|
||
pip.AddToClassList(descriptor.IsBeneficial ? "status-pip-beneficial" : "status-pip-harmful");
|
||
pip.tooltip = $"{descriptor.DisplayName}: {descriptor.Tooltip}";
|
||
return pip;
|
||
}
|
||
|
||
private static VisualElement EmptyStatusPip()
|
||
{
|
||
Label pip = new("none");
|
||
pip.AddToClassList("status-pip");
|
||
pip.AddToClassList("status-pip-empty");
|
||
return pip;
|
||
}
|
||
|
||
private void FillCardStrip(VisualElement target, IReadOnlyList<CardInstance> cards, string emptyText)
|
||
{
|
||
target.Clear();
|
||
if (cards.Count == 0)
|
||
{
|
||
target.Add(EmptyState(emptyText));
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < cards.Count; 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 bool UnlockCardWithFeedback(CardInstance card)
|
||
{
|
||
int cyclesBefore = _combatManager.PlayerState == null ? 0 : _combatManager.PlayerState.Cycles;
|
||
bool unlocked = _combatManager.TryUnlockCard(card);
|
||
if (unlocked)
|
||
{
|
||
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 (_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");
|
||
|
||
if (IsDistro("arch") && _archBtwCounterLabel != null && _combatManager.PlayerState != null)
|
||
{
|
||
_archBtwCounterLabel.text = $"btw ×{_combatManager.PlayerState.ArchBtwStacks}";
|
||
PlayElementBeat(_archBtwCounterLabel, "arch-btw-pop", 200);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
_feedbackCascadeIndex = 0;
|
||
_combatBeatCursorMs = 0;
|
||
_combatBeatCursorVersion++;
|
||
ScheduleFeedback(() => PlayElementBeat(_phaseLabel, "phase-pulse", 260), 0);
|
||
}
|
||
|
||
private void HandleDamageDealt(DamageDealtEvent payload)
|
||
{
|
||
bool isPlayer = payload.Target == _combatManager.PlayerState;
|
||
int magnitudeAmount = payload.UptimeDamage > 0 ? payload.UptimeDamage : payload.ShieldDamage > 0 ? payload.ShieldDamage : payload.Amount;
|
||
bool defeated = payload.Target != null && payload.Target.CurrentUptime <= 0;
|
||
HitMagnitudeTier tier = HitMagnitude.Classify(magnitudeAmount, payload.Target?.MaxUptime ?? 1, defeated);
|
||
bool shieldOnly = payload.WasFullyBlocked && payload.ShieldDamage > 0;
|
||
|
||
bool fedoraPayoff = _pendingFedoraPayoff && payload.Source == _combatManager.PlayerState && payload.UptimeDamage > 0;
|
||
_pendingFedoraPayoff = false;
|
||
if (fedoraPayoff && tier < HitMagnitudeTier.Major)
|
||
{
|
||
tier = HitMagnitudeTier.Major;
|
||
}
|
||
|
||
if (payload.ArchRollingReleaseSaveTriggered)
|
||
{
|
||
TriggerRollingReleaseSave();
|
||
}
|
||
|
||
ScheduleCombatBeat(beatIndex =>
|
||
{
|
||
string bypass = payload.TrueDamage ? "! " : string.Empty;
|
||
string text = shieldOnly
|
||
? $"{bypass}block {payload.ShieldDamage}"
|
||
: payload.WasFullyBlocked
|
||
? $"{bypass}blocked"
|
||
: $"{bypass}-{Mathf.RoundToInt(payload.UptimeDamage)}";
|
||
string tone = BuildDamageFloatClasses(tier, payload, shieldOnly);
|
||
string beatClass = fedoraPayoff ? "feedback-fedora-payoff" : BuildDamageBeatClass(tier, payload, shieldOnly);
|
||
if (fedoraPayoff)
|
||
{
|
||
tone += " float-fedora-payoff";
|
||
}
|
||
|
||
int beatDuration = fedoraPayoff ? Mathf.Max(360, HitBeatDuration(tier, payload.WasMitigated)) : HitBeatDuration(tier, payload.WasMitigated);
|
||
|
||
TriggerHitStop(tier);
|
||
TriggerRootShake(tier, isPlayer);
|
||
SpawnFloatingText(payload.Target, text, tone, beatIndex);
|
||
SpawnImpactMarker(payload.Target, payload.WasCritical, payload.WasMitigated, beatIndex, tier, shieldOnly);
|
||
if (TryGetCombatantElement(payload.Target, out VisualElement target))
|
||
{
|
||
PlayElementBeat(target, beatClass, beatDuration);
|
||
}
|
||
if (payload.ArchBtwBonusAmount > 0)
|
||
{
|
||
SpawnFloatingText(payload.Target, $"+{payload.ArchBtwBonusAmount} btw", "float-btw-bonus", beatIndex + 1);
|
||
}
|
||
|
||
if (isPlayer && payload.UptimeDamage > 0)
|
||
{
|
||
FlashDamageVignette(tier);
|
||
}
|
||
}, HitBeatDuration(tier, payload.WasMitigated));
|
||
}
|
||
|
||
private void HandleOverflowDamageTravel(OverflowDamageTravelEvent payload)
|
||
{
|
||
ScheduleCombatBeat(() => SpawnOverflowTravel(payload.From, payload.To, payload.Amount), CombatTuning.OverflowTravelMs);
|
||
}
|
||
|
||
private void TriggerRollingReleaseSave()
|
||
{
|
||
SpawnFloatingText(_playerPanel, "rolling release: recovered", "float-heal float-large");
|
||
PlayElementBeat(_damageVignette, "damage-vignette-rolling-release", 420);
|
||
if (UIPreferences.ReducedMotion)
|
||
{
|
||
return;
|
||
}
|
||
|
||
int version = ++_rollingReleaseHitStopVersion;
|
||
Time.timeScale = 0.04f;
|
||
ScheduleFeedback(() =>
|
||
{
|
||
if (_rollingReleaseHitStopVersion == version)
|
||
{
|
||
Time.timeScale = 1f;
|
||
}
|
||
}, 220);
|
||
}
|
||
|
||
private void HandleCombatantDefeated(CombatantDefeatedEvent payload)
|
||
{
|
||
HoldDeathUptime(payload.Combatant);
|
||
int completionMs = ScheduleCombatBeat(() =>
|
||
{
|
||
if (payload.Combatant != null && payload.Combatant != _combatManager.PlayerState)
|
||
{
|
||
_deathBeatStartedCombatants.Add(payload.Combatant);
|
||
RefreshEnemies();
|
||
}
|
||
|
||
SpawnFloatingText(payload.Combatant, "killed", "float-kill float-large", 0);
|
||
if (TryGetCombatantFeedbackRect(payload.Combatant, out Rect targetBounds))
|
||
{
|
||
SpawnDeathGhost(targetBounds);
|
||
}
|
||
|
||
if (TryGetCombatantElement(payload.Combatant, out VisualElement target))
|
||
{
|
||
if (target != _playerPanel)
|
||
{
|
||
target.AddToClassList("enemy-card-defeated-hold");
|
||
target.SetEnabled(false);
|
||
}
|
||
|
||
PlayElementBeat(target, "feedback-killed", 420);
|
||
}
|
||
}, CombatTuning.DeathBeatMs + CombatTuning.PostDeathPauseMs);
|
||
ScheduleFeedback(() => CompleteCombatantDeathVisual(payload.Combatant), completionMs);
|
||
}
|
||
|
||
private void HoldDeathUptime(CombatantState combatant)
|
||
{
|
||
if (combatant == null || combatant == _combatManager.PlayerState || _heldDeathUptime.ContainsKey(combatant))
|
||
{
|
||
return;
|
||
}
|
||
|
||
int held = _previousUptime.TryGetValue(combatant, out int previous)
|
||
? previous
|
||
: combatant.MaxUptime;
|
||
_heldDeathUptime[combatant] = Mathf.Clamp(held, 1, Mathf.Max(1, combatant.MaxUptime));
|
||
}
|
||
|
||
private void CompleteCombatantDeathVisual(CombatantState combatant)
|
||
{
|
||
if (ShouldHideCombatantAfterDeathVisual(combatant))
|
||
{
|
||
_visuallyRemovedCombatants.Add(combatant);
|
||
_heldDeathUptime.Remove(combatant);
|
||
_deathBeatStartedCombatants.Remove(combatant);
|
||
RefreshEnemies();
|
||
}
|
||
|
||
GameEvents.RaiseCombatantDeathVisualCompleted(new CombatantDeathVisualCompletedEvent(combatant));
|
||
}
|
||
|
||
private bool ShouldHideCombatantAfterDeathVisual(CombatantState combatant)
|
||
{
|
||
if (combatant == null || combatant == _combatManager.PlayerState)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
IReadOnlyList<EnemyInstance> enemies = _combatManager.Enemies;
|
||
for (int i = 0; i < enemies.Count; i++)
|
||
{
|
||
if (enemies[i].State == combatant)
|
||
{
|
||
return !enemies[i].PendingRevive;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private void HandleDeathSpawnedEnemy(DeathSpawnedEnemyEvent payload)
|
||
{
|
||
ScheduleFeedback(() =>
|
||
{
|
||
SpawnFloatingText(payload.Enemy?.State, "spawned", "float-status");
|
||
if (TryGetCombatantElement(payload.Enemy?.State, out VisualElement target))
|
||
{
|
||
PlayElementBeat(target, "feedback-spawned", CombatTuning.SpawnMaterializeMs);
|
||
}
|
||
}, 0);
|
||
}
|
||
|
||
private void HandleEnemyWouldAct(EnemyWouldActEvent payload)
|
||
{
|
||
ScheduleCombatBeat(() =>
|
||
{
|
||
if (TryGetCombatantElement(payload.Enemy?.State, out VisualElement enemy))
|
||
{
|
||
PlayElementBeat(enemy, "enemy-anticipating", 340);
|
||
}
|
||
});
|
||
}
|
||
|
||
private void HandlePlayerDamaged(PlayerDamagedEvent payload)
|
||
{
|
||
// DamageDealt carries richer mitigation data and owns the visual hit beat.
|
||
}
|
||
|
||
private void HandleStatusApplied(StatusAppliedEvent payload)
|
||
{
|
||
ScheduleCombatBeat(() =>
|
||
{
|
||
StatusDescriptor descriptor = StatusEffectController.GetDescriptor(payload.StatusType);
|
||
if (TryGetCombatantElement(payload.Target, out VisualElement target))
|
||
{
|
||
ApplyStatusStateClass(target, payload.StatusType, true);
|
||
PlayElementBeat(target, descriptor.IsBeneficial ? "feedback-boost" : "feedback-status", 220);
|
||
}
|
||
|
||
SpawnFloatingText(payload.Target, $"{descriptor.IconKey} x{payload.Stacks}", descriptor.IsBeneficial ? "float-heal" : "float-status");
|
||
});
|
||
}
|
||
|
||
private void HandleStatusEnded(StatusExpiredEvent payload)
|
||
{
|
||
ApplyStatusStateClass(CombatantElement(payload.Target), payload.StatusType, false);
|
||
}
|
||
|
||
private void HandleStatusEnded(StatusCleansedEvent payload)
|
||
{
|
||
ApplyStatusStateClass(CombatantElement(payload.Target), payload.StatusType, false);
|
||
}
|
||
|
||
private void HandleUbuntuAptUpdatePeeked(UbuntuAptUpdatePeekedEvent payload)
|
||
{
|
||
if (payload.Peeked == null || payload.Peeked.Count == 0 || payload.Chosen == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
int minCost = int.MaxValue;
|
||
for (int i = 0; i < payload.Peeked.Count; i++)
|
||
{
|
||
minCost = Mathf.Min(minCost, payload.Peeked[i].Cost);
|
||
}
|
||
|
||
if (UIPreferences.ReducedMotion)
|
||
{
|
||
string summary = string.Empty;
|
||
for (int i = 0; i < payload.Peeked.Count; i++)
|
||
{
|
||
PeekedCardInfo info = payload.Peeked[i];
|
||
summary += (i > 0 ? ", " : string.Empty) + $"{DisplayName(info.Card.Definition)} {info.Cost}c";
|
||
}
|
||
|
||
string prefix = payload.WasTie ? "apt update tie" : "apt update peeked";
|
||
SpawnStaticInfoLabel(_turnResourceGrid, $"{prefix}: {summary} -> added {DisplayName(payload.Chosen.Definition)}", 900);
|
||
return;
|
||
}
|
||
|
||
if (!TryGetElementWorldRect(_turnResourceGrid, out Rect turnResourceRect)
|
||
|| !TryGetFeedbackLocalRect(turnResourceRect, out Rect anchorRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
List<VisualElement> stageElements = new(payload.Peeked.Count);
|
||
for (int i = 0; i < payload.Peeked.Count; i++)
|
||
{
|
||
PruneFeedbackLayer();
|
||
VisualElement stageCard = CardFaceView(payload.Peeked[i].Card);
|
||
stageCard.AddToClassList("peek-card");
|
||
stageCard.style.position = Position.Absolute;
|
||
stageCard.style.left = anchorRect.x + (i * 96f);
|
||
stageCard.style.top = anchorRect.y - 128f;
|
||
stageCard.style.width = 88f;
|
||
stageCard.style.height = 108f;
|
||
stageCard.style.opacity = 0f;
|
||
_feedbackLayer.Add(stageCard);
|
||
stageElements.Add(stageCard);
|
||
}
|
||
|
||
ScheduleFeedback(() =>
|
||
{
|
||
for (int i = 0; i < stageElements.Count; i++)
|
||
{
|
||
stageElements[i].style.opacity = 1f;
|
||
}
|
||
}, 20);
|
||
|
||
ScheduleFeedback(() =>
|
||
{
|
||
for (int i = 0; i < payload.Peeked.Count; i++)
|
||
{
|
||
if (payload.Peeked[i].Cost != minCost)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
stageElements[i].AddToClassList("peek-card-marked");
|
||
stageElements[i].EnableInClassList("peek-card-tie", payload.WasTie);
|
||
}
|
||
}, 180);
|
||
|
||
ScheduleFeedback(() =>
|
||
{
|
||
for (int i = 0; i < payload.Peeked.Count; i++)
|
||
{
|
||
VisualElement stage = stageElements[i];
|
||
if (payload.Peeked[i].Card == payload.Chosen)
|
||
{
|
||
FlyCardGhost(payload.Chosen, stage, _handRow, "feedback-card-route");
|
||
stage.style.opacity = 0f;
|
||
ScheduleFeedback(() => stage.RemoveFromHierarchy(), 40);
|
||
}
|
||
else
|
||
{
|
||
stage.style.opacity = 0f;
|
||
ScheduleFeedback(() => stage.RemoveFromHierarchy(), 220);
|
||
}
|
||
}
|
||
}, 340);
|
||
}
|
||
|
||
private void HandleFedoraBleedingEdgeTriggered(FedoraBleedingEdgeEvent payload)
|
||
{
|
||
if (payload.Card == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
VisualElement anchor = _handCardElements.TryGetValue(payload.Card, out VisualElement cardElement)
|
||
? cardElement
|
||
: CombatantElement(_combatManager.PlayerState);
|
||
|
||
if (payload.Crashed)
|
||
{
|
||
SnapFedoraRiskMeter(payload.CrashChanceAfter);
|
||
}
|
||
|
||
if (UIPreferences.ReducedMotion)
|
||
{
|
||
string outcome = payload.Crashed ? "crashed - no effect" : $"hit +{payload.DamageMultiplierPercent - 100}%";
|
||
SpawnStaticInfoLabel(anchor, $"bleeding edge: -1c {outcome}", 700);
|
||
if (!payload.Crashed)
|
||
{
|
||
_pendingFedoraPayoff = true;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
VisualElement windup = CardFaceView(payload.Card);
|
||
windup.AddToClassList("feedback-fedora-windup");
|
||
windup.style.position = Position.Absolute;
|
||
if (!TryGetElementWorldRect(anchor, out Rect anchorWorldRect) || !TryGetFeedbackLocalRect(anchorWorldRect, out Rect anchorRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
windup.style.left = anchorRect.x;
|
||
windup.style.top = anchorRect.y;
|
||
windup.style.width = Mathf.Max(110f, anchorRect.width);
|
||
windup.style.height = Mathf.Max(94f, anchorRect.height);
|
||
PruneFeedbackLayer();
|
||
_feedbackLayer.Add(windup);
|
||
|
||
VisualElement badgeRow = new();
|
||
badgeRow.AddToClassList("tag-row");
|
||
badgeRow.Add(Tag("-1c", "fedora-badge"));
|
||
badgeRow.Add(Tag($"+{payload.DamageMultiplierPercent - 100}%", "fedora-badge"));
|
||
windup.Add(badgeRow);
|
||
|
||
ScheduleFeedback(() =>
|
||
{
|
||
if (payload.Crashed)
|
||
{
|
||
windup.AddToClassList("feedback-fedora-crash");
|
||
Label stamp = new("CRASHED");
|
||
stamp.AddToClassList("fedora-crash-stamp");
|
||
windup.Add(stamp);
|
||
SpawnFloatingText(anchor, "no effect", "float-crash");
|
||
ScheduleFeedback(() => windup.style.opacity = 0.15f, 60);
|
||
ScheduleFeedback(() => windup.style.opacity = 0.85f, 120);
|
||
ScheduleFeedback(() => windup.style.opacity = 0f, 220);
|
||
ScheduleFeedback(() => windup.RemoveFromHierarchy(), 320);
|
||
return;
|
||
}
|
||
|
||
_pendingFedoraPayoff = true;
|
||
windup.style.opacity = 0f;
|
||
ScheduleFeedback(() => windup.RemoveFromHierarchy(), 220);
|
||
}, 200);
|
||
}
|
||
|
||
private void SnapFedoraRiskMeter(float chanceAfter)
|
||
{
|
||
float clamped = Mathf.Clamp(chanceAfter, 0f, 100f);
|
||
if (_fedoraRiskValueLabel != null)
|
||
{
|
||
_fedoraRiskValueLabel.text = $"{clamped:0.#}%";
|
||
}
|
||
|
||
if (_fedoraRiskFillElement != null)
|
||
{
|
||
_fedoraRiskFillElement.style.width = Length.Percent(clamped);
|
||
_fedoraRiskFillElement.RemoveFromClassList("meter-fill-warning");
|
||
_fedoraRiskFillElement.RemoveFromClassList("meter-fill-danger");
|
||
_fedoraRiskFillElement.AddToClassList(FedoraRiskFillClass(clamped));
|
||
PlayElementBeat(_fedoraRiskFillElement, "fedora-risk-snap", 260);
|
||
}
|
||
}
|
||
|
||
private void HandleArchBtwTurnEnded(ArchBtwTurnEndedEvent payload)
|
||
{
|
||
if (_archBtwCounterLabel == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (payload.Persisted)
|
||
{
|
||
PlayElementBeat(_archBtwCounterLabel, "arch-btw-persist", 320);
|
||
return;
|
||
}
|
||
|
||
_archBtwCounterLabel.text = "btw ×0";
|
||
PlayElementBeat(_archBtwCounterLabel, "arch-btw-reset", 260);
|
||
}
|
||
|
||
private void SpawnStaticInfoLabel(VisualElement anchor, string text, int holdMs)
|
||
{
|
||
if (_feedbackLayer == null || !TryGetElementWorldRect(anchor, out Rect anchorRect) || !TryGetFeedbackLocalRect(anchorRect, out Rect localRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
PruneFeedbackLayer();
|
||
Label label = new(text);
|
||
label.AddToClassList("float-number");
|
||
label.AddToClassList("float-status");
|
||
label.AddToClassList("float-static");
|
||
label.style.left = localRect.center.x - 80f;
|
||
label.style.top = localRect.y - 26f;
|
||
_feedbackLayer.Add(label);
|
||
ScheduleFeedback(() => label.RemoveFromHierarchy(), holdMs);
|
||
}
|
||
|
||
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 static string BuildDamageFloatClasses(HitMagnitudeTier tier, DamageDealtEvent payload, bool shieldOnly)
|
||
{
|
||
string classes = shieldOnly || payload.WasFullyBlocked ? "float-shield" : "float-damage";
|
||
classes += " " + TierFloatClass(tier);
|
||
if (payload.TrueDamage)
|
||
{
|
||
classes += " float-true";
|
||
}
|
||
|
||
return classes;
|
||
}
|
||
|
||
private static string BuildDamageBeatClass(HitMagnitudeTier tier, DamageDealtEvent payload, bool shieldOnly)
|
||
{
|
||
if (shieldOnly || payload.WasFullyBlocked)
|
||
{
|
||
return tier >= HitMagnitudeTier.Major ? "feedback-clang-major" : "feedback-clang";
|
||
}
|
||
|
||
if (payload.WasCritical || tier == HitMagnitudeTier.Massive)
|
||
{
|
||
return "feedback-crit";
|
||
}
|
||
|
||
return tier switch
|
||
{
|
||
HitMagnitudeTier.Minor => "feedback-hit-minor",
|
||
HitMagnitudeTier.Moderate => "feedback-hit",
|
||
HitMagnitudeTier.Major => "feedback-hit-major",
|
||
_ => "feedback-crit"
|
||
};
|
||
}
|
||
|
||
private static string TierFloatClass(HitMagnitudeTier tier)
|
||
{
|
||
return tier switch
|
||
{
|
||
HitMagnitudeTier.Minor => "float-tier-minor",
|
||
HitMagnitudeTier.Moderate => "float-tier-moderate",
|
||
HitMagnitudeTier.Major => "float-tier-major",
|
||
HitMagnitudeTier.Massive => "float-tier-massive",
|
||
_ => "float-tier-moderate"
|
||
};
|
||
}
|
||
|
||
private static string TierImpactClass(HitMagnitudeTier tier)
|
||
{
|
||
return tier switch
|
||
{
|
||
HitMagnitudeTier.Minor => "impact-tier-minor",
|
||
HitMagnitudeTier.Moderate => "impact-tier-moderate",
|
||
HitMagnitudeTier.Major => "impact-tier-major",
|
||
HitMagnitudeTier.Massive => "impact-tier-massive",
|
||
_ => "impact-tier-moderate"
|
||
};
|
||
}
|
||
|
||
private static int HitBeatDuration(HitMagnitudeTier tier, bool mitigated)
|
||
{
|
||
if (mitigated && tier < HitMagnitudeTier.Major)
|
||
{
|
||
return 220;
|
||
}
|
||
|
||
return tier switch
|
||
{
|
||
HitMagnitudeTier.Minor => 120,
|
||
HitMagnitudeTier.Moderate => 220,
|
||
HitMagnitudeTier.Major => 340,
|
||
HitMagnitudeTier.Massive => 460,
|
||
_ => 220
|
||
};
|
||
}
|
||
|
||
private void TrackCombatantFeedbackAnchor(CombatantState state, VisualElement element)
|
||
{
|
||
if (state == null || element == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
TryCacheCombatantFeedbackRect(state, element);
|
||
if (element == _playerPanel)
|
||
{
|
||
if (_playerFeedbackAnchorTracked)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_playerFeedbackAnchorTracked = true;
|
||
}
|
||
|
||
element.RegisterCallback<GeometryChangedEvent>(_ => TryCacheCombatantFeedbackRect(state, element));
|
||
}
|
||
|
||
private bool TryCacheCombatantFeedbackRect(CombatantState state, VisualElement element)
|
||
{
|
||
if (state == null || !TryGetElementWorldRect(element, out Rect rect))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
_lastCombatantFeedbackRects[state] = rect;
|
||
return true;
|
||
}
|
||
|
||
private bool TryGetCombatantFeedbackRect(CombatantState state, out Rect rect)
|
||
{
|
||
if (state != null && _combatantElements.TryGetValue(state, out VisualElement element) && TryGetElementWorldRect(element, out rect))
|
||
{
|
||
_lastCombatantFeedbackRects[state] = rect;
|
||
return true;
|
||
}
|
||
|
||
if (state != null && _lastCombatantFeedbackRects.TryGetValue(state, out rect) && IsValidRect(rect))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
rect = default;
|
||
return false;
|
||
}
|
||
|
||
private bool TryGetCombatantElement(CombatantState state, out VisualElement element)
|
||
{
|
||
if (state != null && _combatantElements.TryGetValue(state, out element))
|
||
{
|
||
return element != null;
|
||
}
|
||
|
||
element = state == _combatManager.PlayerState ? _playerPanel : null;
|
||
return element != null;
|
||
}
|
||
|
||
private bool TryGetElementWorldRect(VisualElement element, out Rect rect)
|
||
{
|
||
rect = default;
|
||
if (element == null || element.panel == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
rect = element.worldBound;
|
||
return IsValidRect(rect);
|
||
}
|
||
|
||
private bool TryGetFeedbackLocalRect(Rect worldRect, out Rect localRect)
|
||
{
|
||
localRect = default;
|
||
if (_feedbackLayer == null || !IsValidRect(worldRect) || !TryGetElementWorldRect(_feedbackLayer, out Rect layerRect))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
localRect = new Rect(worldRect.x - layerRect.x, worldRect.y - layerRect.y, worldRect.width, worldRect.height);
|
||
return IsValidRect(localRect);
|
||
}
|
||
|
||
private static bool IsValidRect(Rect rect)
|
||
{
|
||
return rect.width > 0f
|
||
&& rect.height > 0f
|
||
&& IsFinite(rect.x)
|
||
&& IsFinite(rect.y)
|
||
&& IsFinite(rect.width)
|
||
&& IsFinite(rect.height)
|
||
&& IsFinite(rect.center.x)
|
||
&& IsFinite(rect.center.y);
|
||
}
|
||
|
||
private static bool IsFinite(float value)
|
||
{
|
||
return !float.IsNaN(value) && !float.IsInfinity(value);
|
||
}
|
||
|
||
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 null;
|
||
}
|
||
|
||
for (int i = 0; i < card.TargetSnapshot.Count; i++)
|
||
{
|
||
if (TryGetCombatantElement(card.TargetSnapshot[i], out VisualElement element))
|
||
{
|
||
return element;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void PlayElementBeat(VisualElement element, string className, int durationMs)
|
||
{
|
||
if (element == null || string.IsNullOrWhiteSpace(className))
|
||
{
|
||
return;
|
||
}
|
||
|
||
int version = ++_feedbackBeatVersion;
|
||
_feedbackBeatVersions[element] = version;
|
||
RemoveFeedbackBeatClasses(element);
|
||
ScheduleFeedback(() =>
|
||
{
|
||
if (!_feedbackBeatVersions.TryGetValue(element, out int currentVersion) || currentVersion != version)
|
||
{
|
||
return;
|
||
}
|
||
|
||
element.AddToClassList(className);
|
||
ScheduleFeedback(() =>
|
||
{
|
||
if (!_feedbackBeatVersions.TryGetValue(element, out int removeVersion) || removeVersion != version)
|
||
{
|
||
return;
|
||
}
|
||
|
||
element.RemoveFromClassList(className);
|
||
_feedbackBeatVersions.Remove(element);
|
||
}, UIPreferences.ReducedMotion ? 80 : durationMs);
|
||
}, 0);
|
||
}
|
||
|
||
private void SpawnFloatingText(VisualElement anchor, string text, string className, int cascadeOffset = 0)
|
||
{
|
||
if (!TryGetElementWorldRect(anchor, out Rect anchorRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
SpawnFloatingText(anchorRect, text, className, cascadeOffset);
|
||
}
|
||
|
||
private void SpawnFloatingText(CombatantState anchor, string text, string className, int cascadeOffset = 0)
|
||
{
|
||
if (!TryGetCombatantFeedbackRect(anchor, out Rect anchorRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
SpawnFloatingText(anchorRect, text, className, cascadeOffset);
|
||
}
|
||
|
||
private void SpawnFloatingText(Rect anchorRect, string text, string className, int cascadeOffset = 0)
|
||
{
|
||
if (_feedbackLayer == null || !TryGetFeedbackLocalRect(anchorRect, out Rect localRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
PruneFeedbackLayer();
|
||
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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
float offsetX = (((cascadeOffset % 5) - 2) * 14f);
|
||
float offsetY = ((cascadeOffset % 3) * 5f);
|
||
label.style.left = localRect.center.x - 30f + offsetX;
|
||
label.style.top = localRect.center.y - 16f + offsetY;
|
||
_feedbackLayer.Add(label);
|
||
|
||
if (UIPreferences.ReducedMotion)
|
||
{
|
||
label.AddToClassList("float-static");
|
||
ScheduleFeedback(() => label.RemoveFromHierarchy(), 420);
|
||
return;
|
||
}
|
||
|
||
ScheduleFeedback(() =>
|
||
{
|
||
label.style.top = localRect.center.y - 42f + offsetY;
|
||
label.style.opacity = 0f;
|
||
}, 20);
|
||
ScheduleFeedback(() => label.RemoveFromHierarchy(), 520);
|
||
}
|
||
|
||
private void SpawnImpactMarker(VisualElement anchor, bool critical, bool mitigated, int cascadeOffset, HitMagnitudeTier tier = HitMagnitudeTier.Moderate, bool shieldOnly = false)
|
||
{
|
||
if (!TryGetElementWorldRect(anchor, out Rect anchorRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
SpawnImpactMarker(anchorRect, critical, mitigated, cascadeOffset, tier, shieldOnly);
|
||
}
|
||
|
||
private void SpawnImpactMarker(CombatantState anchor, bool critical, bool mitigated, int cascadeOffset, HitMagnitudeTier tier = HitMagnitudeTier.Moderate, bool shieldOnly = false)
|
||
{
|
||
if (!TryGetCombatantFeedbackRect(anchor, out Rect anchorRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
SpawnImpactMarker(anchorRect, critical, mitigated, cascadeOffset, tier, shieldOnly);
|
||
}
|
||
|
||
private void SpawnImpactMarker(Rect anchorRect, bool critical, bool mitigated, int cascadeOffset, HitMagnitudeTier tier = HitMagnitudeTier.Moderate, bool shieldOnly = false)
|
||
{
|
||
if (_feedbackLayer == null || !TryGetFeedbackLocalRect(anchorRect, out Rect localRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
PruneFeedbackLayer();
|
||
Label marker = new(shieldOnly ? "shield" : critical ? "crit" : mitigated ? "clang" : "hit");
|
||
marker.AddToClassList("impact-marker");
|
||
marker.AddToClassList(shieldOnly || mitigated ? "impact-marker-clang" : critical ? "impact-marker-crit" : "impact-marker-hit");
|
||
marker.AddToClassList(TierImpactClass(tier));
|
||
|
||
float lane = (cascadeOffset % 4) * 18f;
|
||
marker.style.left = localRect.xMax - 54f;
|
||
marker.style.top = localRect.y + 8f + lane;
|
||
_feedbackLayer.Add(marker);
|
||
|
||
if (UIPreferences.ReducedMotion)
|
||
{
|
||
ScheduleFeedback(() => marker.RemoveFromHierarchy(), 260);
|
||
return;
|
||
}
|
||
|
||
ScheduleFeedback(() =>
|
||
{
|
||
marker.style.left = localRect.xMax - 34f;
|
||
marker.style.opacity = 0f;
|
||
}, 20);
|
||
ScheduleFeedback(() => marker.RemoveFromHierarchy(), 420);
|
||
}
|
||
|
||
private void TriggerHitStop(HitMagnitudeTier tier)
|
||
{
|
||
if (UIPreferences.ReducedMotion || tier < HitMagnitudeTier.Major || Time.frameCount == _lastHitStopFrame)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_lastHitStopFrame = Time.frameCount;
|
||
int version = ++_hitStopVersion;
|
||
float duration = tier == HitMagnitudeTier.Massive ? 0.16f : 0.09f;
|
||
Time.timeScale = 0.04f;
|
||
ScheduleFeedback(() =>
|
||
{
|
||
if (_hitStopVersion == version)
|
||
{
|
||
Time.timeScale = 1f;
|
||
}
|
||
}, Mathf.RoundToInt(duration * 1000f));
|
||
}
|
||
|
||
private void TriggerRootShake(HitMagnitudeTier tier, bool playerHit)
|
||
{
|
||
if (UIPreferences.ReducedMotion || tier < HitMagnitudeTier.Major || _root == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
int version = ++_rootShakeVersion;
|
||
_root.RemoveFromClassList("root-shake-major");
|
||
_root.RemoveFromClassList("root-shake-massive");
|
||
_root.RemoveFromClassList("root-shake-player");
|
||
_root.AddToClassList(tier == HitMagnitudeTier.Massive ? "root-shake-massive" : "root-shake-major");
|
||
_root.EnableInClassList("root-shake-player", playerHit);
|
||
ScheduleFeedback(() =>
|
||
{
|
||
if (_rootShakeVersion != version)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_root.RemoveFromClassList("root-shake-major");
|
||
_root.RemoveFromClassList("root-shake-massive");
|
||
_root.RemoveFromClassList("root-shake-player");
|
||
}, tier == HitMagnitudeTier.Massive ? 220 : 140);
|
||
}
|
||
|
||
private void PruneFeedbackLayer()
|
||
{
|
||
if (_feedbackLayer == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
while (_feedbackLayer.childCount >= MaxFeedbackElements)
|
||
{
|
||
_feedbackLayer.ElementAt(0).RemoveFromHierarchy();
|
||
}
|
||
}
|
||
|
||
private void FlyCardGhost(CardInstance card, VisualElement source, VisualElement destination, string className)
|
||
{
|
||
if (_feedbackLayer == null
|
||
|| card == null
|
||
|| !TryGetElementWorldRect(source, out Rect sourceWorldRect)
|
||
|| !TryGetElementWorldRect(destination, out Rect destinationWorldRect)
|
||
|| !TryGetFeedbackLocalRect(sourceWorldRect, out Rect sourceRect)
|
||
|| !TryGetFeedbackLocalRect(destinationWorldRect, out Rect destinationRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
VisualElement ghost = CardFaceView(card);
|
||
ghost.AddToClassList("feedback-card-ghost");
|
||
ghost.AddToClassList(className);
|
||
ghost.style.position = Position.Absolute;
|
||
ghost.style.left = sourceRect.x;
|
||
ghost.style.top = sourceRect.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 - (sourceRect.width * 0.5f);
|
||
ghost.style.top = destinationRect.center.y - (sourceRect.height * 0.5f);
|
||
ghost.style.opacity = 0f;
|
||
}, 20);
|
||
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 360);
|
||
}
|
||
|
||
private void SpawnDeathGhost(Rect sourceRect)
|
||
{
|
||
if (_feedbackLayer == null || !TryGetFeedbackLocalRect(sourceRect, out Rect localRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
float ghostWidth = Mathf.Min(sourceRect.width, 156f);
|
||
float ghostHeight = Mathf.Min(sourceRect.height, 132f);
|
||
VisualElement ghost = new();
|
||
ghost.AddToClassList("death-ghost");
|
||
ghost.style.position = Position.Absolute;
|
||
ghost.style.left = localRect.center.x - (ghostWidth * 0.5f);
|
||
ghost.style.top = localRect.center.y - (ghostHeight * 0.5f);
|
||
ghost.style.width = ghostWidth;
|
||
ghost.style.height = ghostHeight;
|
||
_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 SpawnOverflowTravel(CombatantState source, CombatantState destination, int amount)
|
||
{
|
||
if (_feedbackLayer == null
|
||
|| !TryGetCombatantFeedbackRect(source, out Rect sourceWorldRect)
|
||
|| !TryGetCombatantFeedbackRect(destination, out Rect destinationWorldRect)
|
||
|| !TryGetFeedbackLocalRect(sourceWorldRect, out Rect sourceRect)
|
||
|| !TryGetFeedbackLocalRect(destinationWorldRect, out Rect destinationRect))
|
||
{
|
||
return;
|
||
}
|
||
|
||
PruneFeedbackLayer();
|
||
Label spill = new($"> {Mathf.Max(0, amount)}");
|
||
spill.AddToClassList("overflow-travel");
|
||
spill.style.position = Position.Absolute;
|
||
spill.style.left = sourceRect.center.x - 20f;
|
||
spill.style.top = sourceRect.center.y - 12f;
|
||
_feedbackLayer.Add(spill);
|
||
|
||
if (UIPreferences.ReducedMotion)
|
||
{
|
||
ScheduleFeedback(() => spill.RemoveFromHierarchy(), CombatTuning.ReducedMotionOverflowTravelMs);
|
||
return;
|
||
}
|
||
|
||
ScheduleFeedback(() =>
|
||
{
|
||
spill.style.left = destinationRect.center.x - 20f;
|
||
spill.style.top = destinationRect.center.y - 12f;
|
||
spill.style.opacity = 0f;
|
||
}, 20);
|
||
ScheduleFeedback(() => spill.RemoveFromHierarchy(), CombatTuning.OverflowTravelMs + 80);
|
||
}
|
||
|
||
private void FlashDamageVignette(HitMagnitudeTier tier)
|
||
{
|
||
if (_damageVignette == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
string className = tier >= HitMagnitudeTier.Massive
|
||
? "damage-vignette-massive"
|
||
: tier >= HitMagnitudeTier.Major
|
||
? "damage-vignette-crit"
|
||
: "damage-vignette-on";
|
||
PlayElementBeat(_damageVignette, className, UIPreferences.ReducedMotion ? 120 : tier >= HitMagnitudeTier.Massive ? 360 : tier >= HitMagnitudeTier.Major ? 300 : 180);
|
||
}
|
||
|
||
private static void RemoveFeedbackBeatClasses(VisualElement element)
|
||
{
|
||
element.RemoveFromClassList("feedback-hit-minor");
|
||
element.RemoveFromClassList("feedback-hit");
|
||
element.RemoveFromClassList("feedback-hit-major");
|
||
element.RemoveFromClassList("feedback-crit");
|
||
element.RemoveFromClassList("feedback-clang");
|
||
element.RemoveFromClassList("feedback-clang-major");
|
||
element.RemoveFromClassList("feedback-block");
|
||
element.RemoveFromClassList("feedback-status");
|
||
element.RemoveFromClassList("feedback-boost");
|
||
element.RemoveFromClassList("feedback-killed");
|
||
element.RemoveFromClassList("enemy-anticipating");
|
||
element.RemoveFromClassList("enemy-acting");
|
||
element.RemoveFromClassList("phase-pulse");
|
||
element.RemoveFromClassList("cycles-spent-beat");
|
||
element.RemoveFromClassList("feedback-denied");
|
||
element.RemoveFromClassList("queue-chip-resolve");
|
||
element.RemoveFromClassList("stack-chip-resolve");
|
||
element.RemoveFromClassList("hand-card-drawn");
|
||
element.RemoveFromClassList("damage-vignette-on");
|
||
element.RemoveFromClassList("damage-vignette-crit");
|
||
element.RemoveFromClassList("damage-vignette-massive");
|
||
element.RemoveFromClassList("damage-vignette-rolling-release");
|
||
element.RemoveFromClassList("feedback-fedora-payoff");
|
||
element.RemoveFromClassList("feedback-spawned");
|
||
element.RemoveFromClassList("fedora-risk-snap");
|
||
element.RemoveFromClassList("arch-btw-pop");
|
||
element.RemoveFromClassList("arch-btw-reset");
|
||
element.RemoveFromClassList("arch-btw-persist");
|
||
}
|
||
|
||
private int ScheduleCombatBeat(Action action, int holdMs = 0)
|
||
{
|
||
return ScheduleCombatBeat(_ => action?.Invoke(), holdMs);
|
||
}
|
||
|
||
private int ScheduleCombatBeat(Action<int> action, int holdMs = 0)
|
||
{
|
||
if (action == null)
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
int beatIndex = _feedbackCascadeIndex++;
|
||
int delay = _combatBeatCursorMs;
|
||
int step = CombatBeatStepMs(holdMs);
|
||
_combatBeatCursorMs += step;
|
||
int version = ++_combatBeatCursorVersion;
|
||
ScheduleFeedback(() => action(beatIndex), delay);
|
||
ScheduleFeedback(() =>
|
||
{
|
||
if (_combatBeatCursorVersion == version)
|
||
{
|
||
_combatBeatCursorMs = 0;
|
||
}
|
||
}, delay + step + CombatTuning.CombatBeatSettleMs);
|
||
return delay + step;
|
||
}
|
||
|
||
private static int CombatBeatStepMs(int holdMs)
|
||
{
|
||
if (UIPreferences.ReducedMotion)
|
||
{
|
||
int compressed = holdMs <= 0 ? CombatTuning.ReducedMotionCombatBeatDefaultMs : holdMs / 4;
|
||
return Mathf.Clamp(compressed, CombatTuning.ReducedMotionCombatBeatMinMs, CombatTuning.ReducedMotionCombatBeatMaxMs);
|
||
}
|
||
|
||
return Mathf.Max(CombatTuning.CombatBeatDefaultMs, holdMs);
|
||
}
|
||
|
||
private void ScheduleFeedback(Action action, int delayMs)
|
||
{
|
||
if (action == null || _root == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_root.schedule.Execute(action).StartingIn(Mathf.Max(0, delayMs));
|
||
}
|
||
|
||
private static void ApplyStatusStateClasses(VisualElement element, CombatantState state)
|
||
{
|
||
if (element == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
RemoveStatusStateClasses(element);
|
||
if (state == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < state.Statuses.Count; i++)
|
||
{
|
||
ApplyStatusStateClass(element, state.Statuses[i].Type, true);
|
||
}
|
||
}
|
||
|
||
private static void ApplyStatusStateClass(VisualElement element, StatusType statusType, bool enabled)
|
||
{
|
||
string className = StatusStateClass(statusType);
|
||
if (element == null || string.IsNullOrEmpty(className))
|
||
{
|
||
return;
|
||
}
|
||
|
||
element.EnableInClassList(className, enabled);
|
||
}
|
||
|
||
private static void RemoveStatusStateClasses(VisualElement element)
|
||
{
|
||
ApplyStatusStateClass(element, StatusType.MemoryLeak, false);
|
||
ApplyStatusStateClass(element, StatusType.Segfault, false);
|
||
ApplyStatusStateClass(element, StatusType.RaceCondition, false);
|
||
ApplyStatusStateClass(element, StatusType.Deprecated, false);
|
||
ApplyStatusStateClass(element, StatusType.DependencyError, false);
|
||
ApplyStatusStateClass(element, StatusType.Deadlock, false);
|
||
ApplyStatusStateClass(element, StatusType.UnattendedUpgrades, false);
|
||
}
|
||
|
||
private void HandleCombatLog(string message)
|
||
{
|
||
if (_logLabel != null)
|
||
{
|
||
_logLabel.text = message ?? string.Empty;
|
||
}
|
||
}
|
||
|
||
private VisualElement CardChip(CardInstance card)
|
||
{
|
||
VisualElement chip = new();
|
||
chip.AddToClassList("card-chip");
|
||
chip.AddToClassList(LanguageClass(card.Definition.Language));
|
||
Label name = new(DisplayName(card.Definition));
|
||
name.AddToClassList("card-chip-name");
|
||
Label meta = new($"{GetDisplayCardCost(card)}c / {TrackText(card.Definition.ResolutionTrack)}");
|
||
meta.AddToClassList("card-chip-meta");
|
||
chip.Add(name);
|
||
chip.Add(meta);
|
||
return chip;
|
||
}
|
||
|
||
private static VisualElement EmptyState(string text)
|
||
{
|
||
Label label = new(text);
|
||
label.AddToClassList("empty-state");
|
||
return label;
|
||
}
|
||
|
||
private int GetDisplayCardCost(CardInstance card)
|
||
{
|
||
return _combatManager == null ? CombatManager.GetCardCost(card) : _combatManager.GetEffectiveCardCost(card);
|
||
}
|
||
|
||
private static VisualElement CreatePanel(string title, string subtitle, float width)
|
||
{
|
||
VisualElement panel = new();
|
||
panel.AddToClassList("panel");
|
||
if (width > 0f)
|
||
{
|
||
panel.style.width = width;
|
||
panel.style.minWidth = width;
|
||
}
|
||
|
||
panel.Add(PanelHeader(title, subtitle));
|
||
return panel;
|
||
}
|
||
|
||
private static VisualElement PanelHeader(string title, string subtitle)
|
||
{
|
||
VisualElement header = new();
|
||
header.AddToClassList("panel-header");
|
||
Label titleLabel = new(title);
|
||
titleLabel.AddToClassList("panel-title");
|
||
Label subtitleLabel = new(subtitle);
|
||
subtitleLabel.AddToClassList("panel-subtitle");
|
||
header.Add(titleLabel);
|
||
header.Add(subtitleLabel);
|
||
return header;
|
||
}
|
||
|
||
private void ApplyAccent(VisualElement element)
|
||
{
|
||
element.style.borderTopColor = _distroAccent;
|
||
}
|
||
|
||
private static VisualElement TurnStat(string labelText, string valueText)
|
||
{
|
||
VisualElement row = new();
|
||
row.AddToClassList("turn-stat-row");
|
||
Label label = new(labelText);
|
||
label.AddToClassList("turn-stat-label");
|
||
Label value = new(valueText);
|
||
value.AddToClassList("turn-stat-value");
|
||
row.Add(label);
|
||
row.Add(value);
|
||
return row;
|
||
}
|
||
|
||
private static Label OverlayTitle(string text)
|
||
{
|
||
Label label = new(text);
|
||
label.AddToClassList("overlay-title");
|
||
return label;
|
||
}
|
||
|
||
private static Label OverlayLine(string text)
|
||
{
|
||
Label label = new(text);
|
||
label.AddToClassList("overlay-line");
|
||
return label;
|
||
}
|
||
|
||
private static VisualElement DeathStat(string labelText, string valueText)
|
||
{
|
||
VisualElement row = new();
|
||
row.AddToClassList("death-stat-row");
|
||
Label label = new(labelText);
|
||
label.AddToClassList("death-stat-label");
|
||
Label value = new(valueText);
|
||
value.AddToClassList("death-stat-value");
|
||
row.Add(label);
|
||
row.Add(value);
|
||
return row;
|
||
}
|
||
|
||
private static VisualElement RewardReadout(string iconText, int amount, string currencyName, bool subdued)
|
||
{
|
||
VisualElement item = new();
|
||
item.AddToClassList("death-reward-item");
|
||
item.AddToClassList($"death-reward-{currencyName}");
|
||
item.EnableInClassList("death-reward-subdued", subdued);
|
||
|
||
Label icon = new(iconText);
|
||
icon.AddToClassList("death-reward-icon");
|
||
Label value = new($"+{amount}");
|
||
value.AddToClassList("death-reward-value");
|
||
Label name = new(currencyName);
|
||
name.AddToClassList("death-reward-name");
|
||
|
||
item.Add(icon);
|
||
item.Add(value);
|
||
item.Add(name);
|
||
return item;
|
||
}
|
||
|
||
private static VisualElement Tag(string text, string className)
|
||
{
|
||
Label tag = new(text);
|
||
tag.AddToClassList("tag");
|
||
if (!string.IsNullOrEmpty(className))
|
||
{
|
||
tag.AddToClassList(className);
|
||
}
|
||
|
||
return tag;
|
||
}
|
||
|
||
private static string BuildPips(int current, int max)
|
||
{
|
||
int safeMax = Mathf.Max(0, max);
|
||
int safeCurrent = Mathf.Clamp(current, 0, safeMax);
|
||
return string.Concat(Repeat(FilledCycle, safeCurrent), Repeat(EmptyCycle, safeMax - safeCurrent));
|
||
}
|
||
|
||
private static string Repeat(string value, int count)
|
||
{
|
||
if (count <= 0)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
string result = string.Empty;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
result += value;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static string DurationText(int duration)
|
||
{
|
||
return duration == -1 ? "inf" : $"{duration}t";
|
||
}
|
||
|
||
private static string PhaseText(TurnPhase phase)
|
||
{
|
||
return phase switch
|
||
{
|
||
TurnPhase.Boot => "boot",
|
||
TurnPhase.Allocate => "allocate",
|
||
TurnPhase.Execute => "execute",
|
||
TurnPhase.Interpret => "interpret",
|
||
TurnPhase.EnemyProcess => "enemy",
|
||
TurnPhase.GarbageCollection => "cleanup",
|
||
_ => phase.ToString()
|
||
};
|
||
}
|
||
|
||
private static string FormatWalletWithAccrual(int walletAmount, int accruedAmount)
|
||
{
|
||
return accruedAmount > 0 ? $"{walletAmount} (+{accruedAmount})" : walletAmount.ToString();
|
||
}
|
||
|
||
private static string TrackText(ResolutionTrack track)
|
||
{
|
||
return track switch
|
||
{
|
||
ResolutionTrack.Native => "Native",
|
||
ResolutionTrack.InterpreterQueue => "Queue",
|
||
ResolutionTrack.LazyStack => "Stack",
|
||
_ => track.ToString()
|
||
};
|
||
}
|
||
|
||
private static string IntentIcon(EnemyIntent intent)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(intent.IconKey))
|
||
{
|
||
return intent.IconKey;
|
||
}
|
||
|
||
return intent.Kind switch
|
||
{
|
||
EnemyIntentKind.Attack => "!",
|
||
EnemyIntentKind.StatusAttack => "!",
|
||
EnemyIntentKind.Defend => "#",
|
||
EnemyIntentKind.Buff => "+",
|
||
EnemyIntentKind.Special => "*",
|
||
_ => "?"
|
||
};
|
||
}
|
||
|
||
private static string IntentText(EnemyIntent intent)
|
||
{
|
||
return intent.Kind switch
|
||
{
|
||
EnemyIntentKind.Attack => "attack",
|
||
EnemyIntentKind.StatusAttack => $"{intent.DisplayLabel} x{intent.StatusStacks}",
|
||
EnemyIntentKind.Defend => "defend",
|
||
EnemyIntentKind.Buff => "buff",
|
||
EnemyIntentKind.Special => intent.DisplayLabel,
|
||
_ => intent.DisplayLabel
|
||
};
|
||
}
|
||
|
||
private static string IntentClass(EnemyIntentKind kind)
|
||
{
|
||
return kind switch
|
||
{
|
||
EnemyIntentKind.Attack => "intent-attack",
|
||
EnemyIntentKind.StatusAttack => "intent-attack",
|
||
EnemyIntentKind.Defend => "intent-defend",
|
||
_ => "intent-special"
|
||
};
|
||
}
|
||
|
||
private static string StatusStateClass(StatusType statusType)
|
||
{
|
||
return statusType switch
|
||
{
|
||
StatusType.MemoryLeak => "status-state-leaking",
|
||
StatusType.Segfault => "status-state-segfault",
|
||
StatusType.RaceCondition => "status-state-race",
|
||
StatusType.Deprecated => "status-state-deprecated",
|
||
StatusType.DependencyError => "status-state-dependency-error",
|
||
StatusType.Deadlock => "status-state-deadlock",
|
||
StatusType.UnattendedUpgrades => "status-state-upgrading",
|
||
_ => string.Empty
|
||
};
|
||
}
|
||
|
||
private static string OfferKindText(RepositoryOfferKind kind)
|
||
{
|
||
return kind switch
|
||
{
|
||
RepositoryOfferKind.NewCard => "card",
|
||
RepositoryOfferKind.CardUpgrade => "upgrade",
|
||
RepositoryOfferKind.StatUpgrade => "stat",
|
||
_ => "offer"
|
||
};
|
||
}
|
||
|
||
private static string OfferKindIcon(RepositoryOfferKind kind)
|
||
{
|
||
return kind switch
|
||
{
|
||
RepositoryOfferKind.NewCard => "+",
|
||
RepositoryOfferKind.CardUpgrade => "▲",
|
||
RepositoryOfferKind.StatUpgrade => "◆",
|
||
_ => "?"
|
||
};
|
||
}
|
||
|
||
private static string OfferTileClass(RepositoryOfferKind kind)
|
||
{
|
||
return kind switch
|
||
{
|
||
RepositoryOfferKind.NewCard => "repository-tile-card",
|
||
RepositoryOfferKind.CardUpgrade => "repository-tile-upgrade",
|
||
RepositoryOfferKind.StatUpgrade => "repository-tile-stat",
|
||
_ => "repository-tile-generic"
|
||
};
|
||
}
|
||
|
||
private static string StatOfferIcon(RunStatUpgradeKind kind)
|
||
{
|
||
return kind switch
|
||
{
|
||
RunStatUpgradeKind.Heal => "♥",
|
||
RunStatUpgradeKind.MaxCycles => "●",
|
||
RunStatUpgradeKind.MaxUptime => "▰",
|
||
RunStatUpgradeKind.Ram => "RAM",
|
||
_ => "◆"
|
||
};
|
||
}
|
||
|
||
private static string OfferCommandText(RepositoryOffer offer)
|
||
{
|
||
return offer.Kind switch
|
||
{
|
||
RepositoryOfferKind.CardUpgrade => $"apt upgrade {offer.CommandName}",
|
||
RepositoryOfferKind.StatUpgrade => $"apt install {offer.CommandName}",
|
||
_ => $"apt install {offer.CommandName}"
|
||
};
|
||
}
|
||
|
||
private static string OfferActionText(RepositoryOfferKind kind)
|
||
{
|
||
return kind switch
|
||
{
|
||
RepositoryOfferKind.CardUpgrade => "apt upgrade",
|
||
RepositoryOfferKind.StatUpgrade => "apt apply",
|
||
_ => "apt install"
|
||
};
|
||
}
|
||
|
||
private static string MeterFillClass(MeterTone tone, float ratio)
|
||
{
|
||
if (tone == MeterTone.Uptime)
|
||
{
|
||
if (ratio <= 0.25f)
|
||
{
|
||
return "meter-fill-danger";
|
||
}
|
||
|
||
if (ratio <= 0.5f)
|
||
{
|
||
return "meter-fill-warning";
|
||
}
|
||
}
|
||
|
||
return tone switch
|
||
{
|
||
MeterTone.Beneficial => "meter-fill-beneficial",
|
||
MeterTone.Muted => "metric-value-muted",
|
||
_ => "meter-fill"
|
||
};
|
||
}
|
||
|
||
private static string LanguageClass(Language language)
|
||
{
|
||
return language switch
|
||
{
|
||
Language.C => "language-c",
|
||
Language.CPlusPlus => "language-cpp",
|
||
Language.Rust => "language-rust",
|
||
Language.Python => "language-python",
|
||
Language.JavaScript => "language-javascript",
|
||
Language.TypeScript => "language-typescript",
|
||
Language.Haskell => "language-haskell",
|
||
Language.Assembly => "language-assembly",
|
||
Language.Java => "language-java",
|
||
Language.Go => "language-go",
|
||
Language.Ruby => "language-ruby",
|
||
Language.Php => "language-php",
|
||
_ => "track-tag"
|
||
};
|
||
}
|
||
|
||
private static string CardRarityClass(Rarity rarity)
|
||
{
|
||
return rarity switch
|
||
{
|
||
Rarity.Rare => "hand-card-rare",
|
||
Rarity.Epic => "hand-card-epic",
|
||
Rarity.Legendary => "hand-card-legendary",
|
||
_ => string.Empty
|
||
};
|
||
}
|
||
|
||
private static string RarityTagClass(Rarity rarity)
|
||
{
|
||
return rarity switch
|
||
{
|
||
Rarity.Rare => "rarity-rare",
|
||
Rarity.Epic => "rarity-epic",
|
||
Rarity.Legendary => "rarity-legendary",
|
||
_ => "track-tag"
|
||
};
|
||
}
|
||
|
||
private static string DisplayName(DistroDefinition distro)
|
||
{
|
||
return distro == null ? "--" : string.IsNullOrWhiteSpace(distro.DisplayName) ? distro.Id : distro.DisplayName;
|
||
}
|
||
|
||
private static string DisplayName(CardDefinition card)
|
||
{
|
||
return card == null ? "--" : string.IsNullOrWhiteSpace(card.DisplayName) ? card.Id : card.DisplayName;
|
||
}
|
||
|
||
private enum MeterTone
|
||
{
|
||
Uptime,
|
||
Beneficial,
|
||
Muted
|
||
}
|
||
}
|
||
}
|