diff --git a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml index 141e6b8..49bff27 100644 --- a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml +++ b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml @@ -11,14 +11,15 @@ + + - + - @@ -28,7 +29,7 @@ - @@ -52,8 +53,10 @@ } } + + @@ -104,23 +107,23 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -131,34 +134,34 @@ - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + { @@ -252,7 +255,11 @@ - + + + + + - @@ -510,8 +533,6 @@ diff --git a/Assets/Resources/CombatScene.uss b/Assets/Resources/CombatScene.uss index 95c6075..7db13d1 100644 --- a/Assets/Resources/CombatScene.uss +++ b/Assets/Resources/CombatScene.uss @@ -286,21 +286,33 @@ flex-wrap: wrap; } +.status-detail-list { + flex-direction: column; +} + +.status-detail { + margin-bottom: 7px; +} + .status-pip { flex-direction: row; align-items: center; + align-self: flex-start; + min-height: 18px; margin-right: 5px; margin-bottom: 5px; padding-left: 5px; padding-right: 5px; - padding-top: 2px; - padding-bottom: 2px; + padding-top: 1px; + padding-bottom: 1px; background-color: var(--kp-bg-deep); border-left-width: 1px; border-right-width: 1px; border-top-width: 1px; border-bottom-width: 1px; border-color: var(--kp-border); + font-size: 11px; + -unity-text-align: middle-center; } .status-pip-harmful { @@ -321,6 +333,7 @@ color: var(--kp-text-dim); font-size: 11px; white-space: normal; + min-height: 14px; margin-bottom: 4px; } @@ -523,6 +536,25 @@ border-left-width: 4px; } +.enemy-card-defeated-hold { + opacity: 0.34; + background-color: #120303; + border-color: var(--kp-kill); +} + +.enemy-card-defeated-hold .enemy-name { + color: var(--kp-danger); +} + +.enemy-exited-label { + color: var(--kp-text-dim); + font-size: 11px; + margin-top: 8px; + padding-top: 6px; + border-top-width: 1px; + border-color: var(--kp-border-empty); +} + .enemy-name { color: var(--kp-text); -unity-font-style: bold; @@ -800,6 +832,44 @@ background-color: var(--kp-action-hover); } +.secondary-action { + height: 34px; + margin-top: 8px; + background-color: var(--kp-bg-deep); + color: var(--kp-text-dim); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border-strong); + -unity-font-style: bold; +} + +.secondary-action:hover { + color: var(--kp-text); + border-color: var(--kp-warning); +} + +.surrender-action { + margin-top: 10px; +} + +.surrender-action-confirming { + color: var(--kp-warning); + border-color: var(--kp-warning); +} + +.surrender-confirm { + color: var(--kp-warning); + white-space: normal; + font-size: 11px; + margin-top: 6px; +} + +.hidden { + display: none; +} + .log-line { color: var(--kp-text-dim); white-space: normal; @@ -813,9 +883,13 @@ right: 0; top: 0; bottom: 0; - background-color: var(--kp-overlay); - justify-content: center; - align-items: center; + background-color: var(--kp-bg); + justify-content: flex-start; + align-items: stretch; + padding-left: 12px; + padding-right: 12px; + padding-top: 12px; + padding-bottom: 12px; } .overlay-title { @@ -830,10 +904,196 @@ margin-bottom: 6px; } +.death-overlay { + flex-grow: 1; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border-subtle); +} + +.death-crash-beat { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + opacity: 0.18; + background-color: #160805; + border-left-width: 6px; + border-right-width: 6px; + border-top-width: 2px; + border-bottom-width: 2px; + border-color: var(--kp-danger); + translate: 0 -8px; + transition-property: opacity, translate; + transition-duration: 220ms; +} + +.death-crash-beat-settled { + opacity: 0; + translate: 0 0; +} + +.death-content { + width: 72%; + max-width: 760px; + min-width: 540px; + padding-left: 26px; + padding-right: 26px; + padding-top: 24px; + padding-bottom: 24px; + border-left-width: 3px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-accent); + background-color: rgba(3, 6, 5, 0.92); +} + +.death-content .overlay-title { + font-size: 46px; + color: var(--kp-warning); + margin-bottom: 12px; + -unity-text-align: middle-center; +} + +.death-cause { + color: var(--kp-warning); + font-size: 16px; + -unity-font-style: bold; + -unity-text-align: middle-center; + margin-bottom: 24px; + padding-bottom: 18px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.death-section { + padding-top: 14px; + padding-bottom: 14px; + border-bottom-width: 1px; + border-color: var(--kp-border-subtle); +} + +.death-summary { + align-items: center; + margin-bottom: 8px; +} + +.death-stat-row { + flex-direction: row; + min-width: 330px; + margin-bottom: 5px; +} + +.death-stat-label { + width: 150px; + color: var(--kp-text-dim); + font-size: 12px; +} + +.death-stat-value { + color: var(--kp-text); + font-size: 13px; + -unity-font-style: bold; +} + +.death-rewards { + flex-direction: row; + justify-content: center; + margin-top: 8px; + padding-top: 18px; + padding-bottom: 20px; + border-color: var(--kp-border); +} + +.death-reward-item { + flex-direction: row; + align-items: center; + min-width: 220px; + margin-left: 10px; + margin-right: 10px; + padding-left: 14px; + padding-right: 14px; + padding-top: 10px; + padding-bottom: 10px; + background-color: var(--kp-panel-raised); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-warning); +} + +.death-reward-bandwidth { + border-color: var(--kp-beneficial); +} + +.death-reward-icon { + width: 30px; + color: var(--kp-warning); + -unity-font-style: bold; + font-size: 20px; +} + +.death-reward-bandwidth .death-reward-icon { + color: var(--kp-beneficial); +} + +.death-reward-value { + color: var(--kp-warning); + -unity-font-style: bold; + font-size: 34px; + margin-right: 8px; +} + +.death-reward-bandwidth .death-reward-value { + color: var(--kp-beneficial); +} + +.death-reward-name { + color: var(--kp-text); + font-size: 13px; + -unity-font-style: bold; +} + +.death-reward-subdued { + opacity: 0.62; + border-color: var(--kp-border); +} + +.death-reward-subdued .death-reward-value { + color: var(--kp-text-dim); +} + +.death-reward-subdued .death-reward-icon, +.death-reward-subdued .death-reward-name { + color: var(--kp-text-dim); +} + +.death-return-action { + margin-top: 30px; + height: 48px; + font-size: 14px; +} + +.reduced-motion .death-crash-beat { + display: none; + transition-duration: 0ms; +} + .repository { - width: 900px; - height: 86%; - padding: 14px; + flex-grow: 1; + width: 100%; + height: 100%; + padding: 16px; background-color: var(--kp-panel); border-left-width: 2px; border-right-width: 1px; @@ -845,16 +1105,31 @@ .repository-header { flex-direction: row; align-items: center; - margin-bottom: 8px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom-width: 1px; + border-color: var(--kp-border); } .repository-header-copy { flex-grow: 1; } +.repository-title { + color: var(--kp-warning); + -unity-font-style: bold; + font-size: 30px; + margin-bottom: 2px; +} + +.repository-subtitle { + color: var(--kp-text); + font-size: 13px; +} + .repository-wallet { - min-width: 132px; - padding: 8px; + min-width: 150px; + padding: 10px; background-color: var(--kp-bg-deep); border-left-width: 2px; border-right-width: 1px; @@ -866,20 +1141,21 @@ .repository-wallet-value { color: var(--kp-warning); -unity-font-style: bold; - font-size: 22px; + font-size: 24px; + margin-top: 4px; } .repository-body { flex-grow: 1; min-height: 0; - margin-top: 8px; - margin-bottom: 10px; + margin-bottom: 12px; + padding-right: 4px; } .repository-offers { flex-direction: row; flex-wrap: wrap; - margin-bottom: 10px; + margin-bottom: 12px; } .repository-row { @@ -894,11 +1170,11 @@ } .repository-tile { - width: 270px; - min-height: 244px; - margin-right: 10px; - margin-bottom: 10px; - padding: 8px; + width: 280px; + height: 332px; + margin-right: 12px; + margin-bottom: 12px; + padding: 10px; background-color: var(--kp-panel-raised); border-left-width: 2px; border-right-width: 1px; @@ -917,23 +1193,25 @@ .repository-tile-stat { border-left-color: var(--kp-warning); - min-height: 190px; } .repository-tile-top { flex-direction: row; align-items: center; - margin-bottom: 7px; + margin-bottom: 8px; + min-height: 24px; } .repository-kind { color: var(--kp-text-dim); font-size: 11px; flex-grow: 1; + -unity-font-style: bold; } .repository-price { - min-width: 46px; + min-width: 48px; + height: 24px; padding-left: 7px; padding-right: 7px; padding-top: 3px; @@ -958,6 +1236,7 @@ color: var(--kp-text); -unity-font-style: bold; margin-bottom: 4px; + white-space: normal; } .repository-description { @@ -967,8 +1246,8 @@ } .repository-action { - height: 28px; - margin-top: 7px; + height: 34px; + margin-top: 8px; background-color: var(--kp-bg-deep); color: var(--kp-text); border-left-width: 1px; @@ -977,6 +1256,12 @@ border-bottom-width: 1px; border-color: var(--kp-accent); -unity-font-style: bold; + font-size: 12px; +} + +.repository-reroll-action { + border-color: var(--kp-border-strong); + color: var(--kp-text-dim); } .repository-card-content { @@ -985,8 +1270,9 @@ .repository-card-preview { width: 100%; - min-height: 154px; + height: 176px; margin-right: 0; + margin-bottom: 6px; } .repository-run-only-tag { @@ -1000,7 +1286,7 @@ .repository-upgrade-card { width: 100%; - min-height: 126px; + height: 176px; margin-right: 0; opacity: 0.72; } @@ -1016,6 +1302,8 @@ border-top-width: 1px; border-bottom-width: 1px; border-color: var(--kp-beneficial); + min-height: 44px; + flex-shrink: 0; } .repository-delta-icon { @@ -1045,21 +1333,27 @@ .repository-footer { flex-direction: row; - min-height: 44px; + min-height: 56px; flex-shrink: 0; + padding-top: 10px; + border-top-width: 1px; + border-color: var(--kp-border); } .repository-footer-action { - width: 210px; - margin-right: 8px; + width: 190px; + height: 38px; + margin-right: 16px; } .repository-leave-action { flex-grow: 1; + height: 38px; } .repository-leave-block { flex-grow: 1; + margin-left: 2px; } .repository-banked-hint { @@ -1071,7 +1365,7 @@ .repository-remove { margin-bottom: 2px; - padding: 8px; + padding: 10px; background-color: var(--kp-bg-deep); border-left-width: 1px; border-right-width: 1px; @@ -1086,7 +1380,7 @@ } .repo-remove-button { - height: 24px; + height: 28px; margin-right: 6px; margin-bottom: 6px; background-color: var(--kp-bg-deep); @@ -1099,11 +1393,11 @@ } .repo-unaffordable { - opacity: 0.45; + opacity: 0.5; } .repo-sold { - opacity: 0.72; + opacity: 0.66; border-color: var(--kp-text-dim); } @@ -1284,6 +1578,24 @@ border-color: var(--kp-kill); } +.overflow-travel { + min-width: 42px; + height: 20px; + padding-left: 5px; + padding-right: 5px; + color: var(--kp-warning); + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-warning); + -unity-font-style: bold; + -unity-text-align: middle-center; + transition-property: left, top, opacity; + transition-duration: 180ms; +} + .feedback-card-ghost { opacity: 0.9; transition-property: left, top, opacity; @@ -1395,6 +1707,15 @@ transition-duration: 220ms; } +.feedback-spawned { + opacity: 1; + scale: 1.06 1.06; + background-color: #061820; + border-color: var(--kp-accent); + transition-property: opacity, scale, background-color, border-color; + transition-duration: 220ms; +} + .death-ghost { background-color: #240606; border-left-width: 2px; @@ -1490,8 +1811,148 @@ transition-duration: 130ms; } +.peek-card { + position: absolute; + opacity: 0; + transition-property: opacity, scale, border-color; + transition-duration: 160ms; +} + +.peek-card-marked { + border-color: var(--kp-beneficial); + scale: 1.05 1.05; +} + +.peek-card-marked.peek-card-tie { + border-color: var(--kp-warning); +} + +.fedora-badge { + border-color: var(--kp-accent); + color: var(--kp-accent); +} + +.fedora-risk-meter { + margin-top: 4px; +} + +.fedora-risk-snap { + opacity: 0.4; + scale: 1.15 1; + transition-property: opacity, scale; + transition-duration: 130ms; +} + +.feedback-fedora-windup { + position: absolute; + opacity: 0.94; + border-color: var(--kp-accent); + background-color: var(--kp-action-hover); + scale: 1.04 1.04; + transition-property: opacity, scale, border-color, background-color; + transition-duration: 200ms; +} + +.feedback-fedora-crash { + border-color: var(--kp-danger); + background-color: #2d0707; +} + +.fedora-crash-stamp { + position: absolute; + right: 4px; + top: 4px; + color: var(--kp-danger); + -unity-font-style: bold; + font-size: 12px; + padding-left: 4px; + padding-right: 4px; + padding-top: 1px; + padding-bottom: 1px; + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-danger); + background-color: rgba(45, 7, 7, 0.85); +} + +.float-crash { + color: var(--kp-danger); +} + +.feedback-fedora-payoff { + background-color: #3a1400; + border-color: var(--kp-accent); + translate: 10px 0; + scale: 1.1 0.88; + transition-property: scale, translate, background-color, border-color; + transition-duration: 190ms; +} + +.feedback-fedora-payoff .meter-fill { + background-color: var(--kp-accent); +} + +.float-fedora-payoff { + color: var(--kp-accent); + font-size: 27px; +} + +.arch-btw-counter { + margin-top: 4px; + padding-left: 6px; + padding-right: 6px; + padding-top: 2px; + padding-bottom: 2px; + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); + align-self: flex-start; +} + +.arch-btw-value { + -unity-font-style: bold; + font-size: 13px; + transition-property: scale, opacity, color, border-color; + transition-duration: 140ms; +} + +.arch-btw-pop { + scale: 1.25 1.25; +} + +.arch-btw-reset { + color: var(--kp-text-dim); + opacity: 0.5; + scale: 0.9 0.9; +} + +.arch-btw-persist { + border-left-width: 2px; + border-right-width: 2px; + border-top-width: 2px; + border-bottom-width: 2px; + border-color: var(--kp-beneficial); + scale: 1.08 1.08; +} + +.float-btw-bonus { + color: var(--kp-beneficial); + font-size: 12px; +} + +.damage-vignette-rolling-release { + opacity: 0.9; + border-color: var(--kp-beneficial); + background-color: rgba(87, 216, 255, 0.1); +} + .reduced-motion .float-number, .reduced-motion .impact-marker, +.reduced-motion .overflow-travel, .reduced-motion .feedback-card-ghost, .reduced-motion .damage-vignette, .reduced-motion .root-shake-major, @@ -1512,7 +1973,14 @@ .reduced-motion .hand-card-drawn, .reduced-motion .queue-chip-resolve, .reduced-motion .stack-chip-resolve, -.reduced-motion .death-ghost { +.reduced-motion .death-ghost, +.reduced-motion .peek-card, +.reduced-motion .feedback-fedora-windup, +.reduced-motion .feedback-fedora-crash, +.reduced-motion .feedback-fedora-payoff, +.reduced-motion .feedback-spawned, +.reduced-motion .fedora-risk-snap, +.reduced-motion .arch-btw-value { transition-duration: 0ms; } @@ -1526,7 +1994,14 @@ .reduced-motion .enemy-anticipating, .reduced-motion .enemy-acting, .reduced-motion .root-shake-major, -.reduced-motion .root-shake-massive { +.reduced-motion .root-shake-massive, +.reduced-motion .peek-card, +.reduced-motion .peek-card-marked, +.reduced-motion .feedback-fedora-windup, +.reduced-motion .feedback-fedora-payoff, +.reduced-motion .feedback-spawned, +.reduced-motion .fedora-risk-snap, +.reduced-motion .arch-btw-value { translate: 0 0; scale: 1 1; } diff --git a/Assets/Scripts/Combat/CardEffects.cs b/Assets/Scripts/Combat/CardEffects.cs index 6840ea3..5414857 100644 --- a/Assets/Scripts/Combat/CardEffects.cs +++ b/Assets/Scripts/Combat/CardEffects.cs @@ -72,6 +72,11 @@ namespace KernelPanic.Combat GrantRustOverkillShield(context, result, survivable); } + if (result.TargetDefeated) + { + context.CombatManager.QueueDefeatedCombatantForRemoval(target); + } + if (overkillSpills) { dealtDamage |= SpillOverkillDamage(context, target, result, survivable); @@ -118,8 +123,15 @@ namespace KernelPanic.Combat } int survivable = GetSurvivableDamage(spillTarget, _trueDamage); + GameEvents.RaiseOverflowDamageTravel(new OverflowDamageTravelEvent(context.Source, firstTarget, spillTarget, remaining, _language)); DamageResult spillResult = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, spillTarget, remaining, _language, _trueDamage, false, applySourceModifiers: false)); dealtDamage |= spillResult.IncomingAmount > 0; + if (spillResult.TargetDefeated) + { + context.CombatManager.QueueDefeatedCombatantForRemoval(spillTarget); + } + + firstTarget = spillTarget; remaining = UnityEngine.Mathf.Max(0, spillResult.IncomingAmount - survivable); } diff --git a/Assets/Scripts/Combat/CombatManager.cs b/Assets/Scripts/Combat/CombatManager.cs index a82e534..8bc57aa 100644 --- a/Assets/Scripts/Combat/CombatManager.cs +++ b/Assets/Scripts/Combat/CombatManager.cs @@ -28,6 +28,10 @@ namespace KernelPanic.Combat private readonly HashSet _loggedEffectTodos = new(); private readonly List _generatedCardPool = new(); private readonly List _delayedEffects = new(); + private readonly HashSet _enemiesPendingExit = new(); + private readonly Queue _enemyExitQueue = new(); + private readonly Dictionary> _pendingDeathSpawns = new(); + private readonly HashSet _completedDeathVisuals = new(); private RunManager _runManager; private RunConfig _runConfig; private HandController _handController; @@ -39,7 +43,7 @@ namespace KernelPanic.Combat private bool _skipNextAllocateDraw; private bool _ubuntuEmptyHandRefillUsed; private int _fedoraCardsDiscountedThisTurn; - private int _fedoraCrashChance = CombatTuning.FedoraBleedingEdgeBaseCrashChance; + private float _fedoraCrashChance = CombatTuning.FedoraBleedingEdgeBaseCrashChance; private int _cardsPlayedThisTurn; private int _cardsPlayedThisWave; private int _cCardsPlayedThisTurn; @@ -55,6 +59,9 @@ namespace KernelPanic.Combat private int _nextTurnCycleBonus; private int _nextRacePairId = 1; private int _activeKernelRamPenalty; + private int _deathSpawnSequencesActive; + private bool _enemyExitQueueDraining; + private EnemyInstance _enemyExitAwaitingVisual; private Coroutine _phaseCoroutine; public TurnPhase CurrentPhase => currentPhase; @@ -66,6 +73,7 @@ namespace KernelPanic.Combat public LazyStack LazyStack => _lazyStack; public NativeTrack NativeTrack => _nativeTrack; public DamagePipeline DamagePipeline => _damagePipeline; + public float FedoraCrashChance => _fedoraCrashChance; public IReadOnlyList Enemies => _enemies; public int SelectedEnemyIndex => _selectedEnemyIndex; public CardInstance PendingTargetCard => _pendingTargetCard; @@ -73,6 +81,12 @@ namespace KernelPanic.Combat public bool RunLost => _runLost; public int CurrentWaveNumber => _runManager == null ? 1 : _runManager.CurrentWaveNumber; public int ActiveKernelRamPenalty => _activeKernelRamPenalty; + public bool CanSurrenderRun => currentPhase == TurnPhase.Execute + && !_awaitingWaveContinue + && !_runLost + && !VisualResolutionPending + && _playerState != null + && !_playerState.IsDefeated; public event Action StateChanged; public event Action CombatLog; @@ -86,11 +100,13 @@ namespace KernelPanic.Combat private void OnEnable() { GameEvents.DamageDealt += HandlePackageDamageDealt; + GameEvents.CombatantDeathVisualCompleted += HandleCombatantDeathVisualCompleted; } private void OnDisable() { GameEvents.DamageDealt -= HandlePackageDamageDealt; + GameEvents.CombatantDeathVisualCompleted -= HandleCombatantDeathVisualCompleted; } public void StartCombat() @@ -131,6 +147,31 @@ namespace KernelPanic.Combat } } + public void SurrenderRun() + { + if (!CanSurrenderRun) + { + return; + } + + _pendingTargetCard = null; + _selectedEnemyIndex = -1; + _damagePipeline.DefeatCombatant(_playerState, null, Language.C); + CheckLoss(); + StateChanged?.Invoke(); + } + + public void QueueDefeatedCombatantForRemoval(CombatantState combatant) + { + EnemyInstance enemy = EnemyForState(combatant); + if (enemy == null) + { + return; + } + + QueueDefeatedEnemyForRemoval(enemy); + } + public void ContinueToNextWave() { if (!_awaitingWaveContinue || _runLost) @@ -156,7 +197,7 @@ namespace KernelPanic.Combat return; } - if (index < 0 || index >= _enemies.Count) + if (index < 0 || index >= _enemies.Count || _enemies[index].State.IsDefeated) { _selectedEnemyIndex = -1; StateChanged?.Invoke(); @@ -264,7 +305,8 @@ namespace KernelPanic.Combat _rawhideBonusCharges--; } - if (!IsCrashImmune(card) && RandomRoll.RollRange(1, 100, new RollContext(_playerState)) <= _fedoraCrashChance) + float fedoraCrashChanceBeforeRoll = _fedoraCrashChance; + if (!IsCrashImmune(card) && RandomRoll.RollRange(1, 1000, new RollContext(_playerState)) <= Mathf.RoundToInt(_fedoraCrashChance * 10f)) { _fedoraCrashChance = CombatTuning.FedoraBleedingEdgeBaseCrashChance; if (IsDistro("fedora") && _runConfig.DistroVersion >= 4) @@ -276,6 +318,7 @@ namespace KernelPanic.Combat _pendingTargetCard = null; _selectedEnemyIndex = -1; Log($"{GetCardName(card)} crashed under bleeding edge"); + GameEvents.RaiseFedoraBleedingEdgeTriggered(new FedoraBleedingEdgeEvent(card, true, firstFedoraBonusThisTurn, 0, fedoraCrashChanceBeforeRoll, _fedoraCrashChance)); StateChanged?.Invoke(); return true; } @@ -286,6 +329,7 @@ namespace KernelPanic.Combat card.MarkFedoraNonCrashBonus(); _playerState.DamageMultiplierPercent = _runConfig.DistroVersion >= 2 ? 175 : 150; ApplyFedoraGrowth(card, rawhideChargeUsed, firstFedoraBonusThisTurn); + GameEvents.RaiseFedoraBleedingEdgeTriggered(new FedoraBleedingEdgeEvent(card, false, firstFedoraBonusThisTurn, _playerState.DamageMultiplierPercent, fedoraCrashChanceBeforeRoll, _fedoraCrashChance)); } if (card.Definition.Language == Language.C && _cCardsPlayedThisTurn == 0 && HasPackageEffect(PackageEffectKind.FirstCThisTurnDamageMultiplier, out PackageEffectData cPackageEffect)) @@ -669,6 +713,13 @@ namespace KernelPanic.Combat _queuedRepeatCharges = 0; _nextTurnCycleBonus = 0; _delayedEffects.Clear(); + _enemiesPendingExit.Clear(); + _enemyExitQueue.Clear(); + _pendingDeathSpawns.Clear(); + _completedDeathVisuals.Clear(); + _deathSpawnSequencesActive = 0; + _enemyExitQueueDraining = false; + _enemyExitAwaitingVisual = null; RandomRoll.Seed(_runConfig.RunSeed); _playerState = new CombatantState(_runManager.EffectiveMaxUptime(), _runManager.EffectiveRam(), _runManager.EffectiveMaxCycles()); ApplyVersionState(_playerState); @@ -726,6 +777,13 @@ namespace KernelPanic.Combat _deckController.Initialize(startingCards); _interpreterQueue.Clear(); _enemies.Clear(); + _enemiesPendingExit.Clear(); + _enemyExitQueue.Clear(); + _pendingDeathSpawns.Clear(); + _completedDeathVisuals.Clear(); + _deathSpawnSequencesActive = 0; + _enemyExitQueueDraining = false; + _enemyExitAwaitingVisual = null; SpawnStructuralEnemies(); RecalculateKernelRamPressure(); PickEnemyIntents(); @@ -877,7 +935,13 @@ namespace KernelPanic.Combat return; } + int archBtwStacksBeforeReset = _playerState?.ArchBtwStacks ?? 0; ResetArchBtwAtEndOfTurn(); + if (IsDistro("arch") && archBtwStacksBeforeReset > 0) + { + GameEvents.RaiseArchBtwTurnEnded(new ArchBtwTurnEndedEvent(archBtwStacksBeforeReset, _playerState.ArchBtwStacks > 0)); + } + RemoveDefeatedEnemies(); CheckWinOrLoss(); StateChanged?.Invoke(); @@ -952,7 +1016,7 @@ namespace KernelPanic.Combat } int lookCount = _runConfig.DistroVersion >= 2 ? 3 : 2; - if (!_deckController.TryDrawCheapestFromTop(lookCount, out CardInstance card)) + if (!_deckController.TryDrawCheapestFromTop(lookCount, out CardInstance card, out IReadOnlyList peeked, out bool wasTie)) { return; } @@ -965,6 +1029,7 @@ namespace KernelPanic.Combat if (_handController.Add(card)) { Log($"apt update: staged {GetCardName(card)} from top {lookCount}"); + GameEvents.RaiseUbuntuAptUpdatePeeked(new UbuntuAptUpdatePeekedEvent(peeked, card, wasTie, lookCount)); } else { @@ -1836,17 +1901,7 @@ namespace KernelPanic.Combat { if (_enemies[i].State.IsDefeated) { - EnemyInstance enemy = _enemies[i]; - if (enemy.PendingRevive) - { - continue; - } - - Log($"{enemy.Name} exited"); - _runManager?.AddBits(CombatTuning.BitsPerKill); - HandleEnemyDeath(enemy); - _enemies.RemoveAt(i); - RecalculateKernelRamPressure(); + QueueDefeatedEnemyForRemoval(_enemies[i]); } } @@ -1854,15 +1909,42 @@ namespace KernelPanic.Combat { _selectedEnemyIndex = -1; } + + if (_enemyExitQueue.Count > 0 && !_enemyExitQueueDraining) + { + StartCoroutine(DrainEnemyExitQueue()); + } } - private void HandleEnemyDeath(EnemyInstance enemy) + private void QueueDefeatedEnemyForRemoval(EnemyInstance enemy) { - if (enemy == null) + if (enemy == null || enemy.State == null || !enemy.State.IsDefeated || enemy.PendingRevive || _enemiesPendingExit.Contains(enemy)) { return; } + Log($"{enemy.Name} exited"); + _runManager?.AddBits(CombatTuning.BitsPerKill); + List deathSpawns = HandleEnemyDeath(enemy); + enemy.MarkDefeatedPendingRemoval(); + _enemiesPendingExit.Add(enemy); + _pendingDeathSpawns[enemy] = deathSpawns; + _enemyExitQueue.Enqueue(enemy); + + if (!_enemyExitQueueDraining) + { + StartCoroutine(DrainEnemyExitQueue()); + } + } + + private List HandleEnemyDeath(EnemyInstance enemy) + { + List deathSpawns = new(); + if (enemy == null) + { + return deathSpawns; + } + if (enemy.HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath)) { ApplySegfaultDeathPayload(enemy); @@ -1875,7 +1957,148 @@ namespace KernelPanic.Combat if (enemy.HasBehavior(EnemyBehaviorFlags.LeavesOrphan)) { - SpawnOrphanFromDeath(enemy); + deathSpawns.Add(CreateOrphanDeathSpawn(enemy)); + } + + return deathSpawns; + } + + private void HandleCombatantDeathVisualCompleted(CombatantDeathVisualCompletedEvent payload) + { + EnemyInstance enemy = EnemyForState(payload.Combatant); + if (enemy == null) + { + return; + } + + _completedDeathVisuals.Add(enemy); + } + + private EnemyInstance EnemyForState(CombatantState state) + { + if (state == null) + { + return null; + } + + for (int i = 0; i < _enemies.Count; i++) + { + if (_enemies[i].State == state) + { + return _enemies[i]; + } + } + + return null; + } + + private IEnumerator DrainEnemyExitQueue() + { + if (_enemyExitQueueDraining) + { + yield break; + } + + _enemyExitQueueDraining = true; + float elapsedMs = 0f; + while (_enemyExitQueue.Count > 0) + { + bool removedAny = false; + int pendingCount = _enemyExitQueue.Count; + for (int i = 0; i < pendingCount; i++) + { + EnemyInstance enemy = _enemyExitQueue.Dequeue(); + bool visualComplete = _completedDeathVisuals.Contains(enemy); + bool timedOut = elapsedMs >= CombatTuning.VisualQueueMaxDrainMs; + if (!visualComplete && !timedOut) + { + _enemyExitQueue.Enqueue(enemy); + continue; + } + + _enemyExitAwaitingVisual = enemy; + _completedDeathVisuals.Remove(enemy); + yield return CompleteEnemyExit(enemy); + _enemyExitAwaitingVisual = null; + removedAny = true; + } + + if (removedAny) + { + elapsedMs = 0f; + continue; + } + + elapsedMs += Time.unscaledDeltaTime * 1000f; + yield return null; + } + + _enemyExitQueueDraining = false; + CheckWinOrLoss(); + StateChanged?.Invoke(); + if (!IsCombatPaused && currentPhase != TurnPhase.Execute) + { + yield return Wait(CombatTuning.PhaseTransitionDelaySeconds); + AdvancePhase(); + } + } + + private IEnumerator CompleteEnemyExit(EnemyInstance enemy) + { + if (enemy == null) + { + yield break; + } + + _pendingDeathSpawns.TryGetValue(enemy, out IReadOnlyList deathSpawns); + _pendingDeathSpawns.Remove(enemy); + + int removedIndex = _enemies.IndexOf(enemy); + if (removedIndex >= 0) + { + enemy.ClearDefeatedPendingRemoval(); + _enemies.RemoveAt(removedIndex); + if (_selectedEnemyIndex >= _enemies.Count) + { + _selectedEnemyIndex = -1; + } + + RecalculateKernelRamPressure(); + StateChanged?.Invoke(); + } + + _enemiesPendingExit.Remove(enemy); + + if (deathSpawns != null) + { + _deathSpawnSequencesActive++; + for (int i = 0; i < deathSpawns.Count; i++) + { + yield return WaitSequencedMs(CombatTuning.SpawnDelayMs, CombatTuning.ReducedMotionSpawnDelayMs); + EnemyInstance spawned = SpawnEnemy(deathSpawns[i].ArchetypeId, deathSpawns[i].UptimeBonus); + spawned.MarkSpawnedFromDeath(); + spawned.PickNextIntent(); + Log(deathSpawns[i].LogMessage); + StateChanged?.Invoke(); + GameEvents.RaiseDeathSpawnedEnemy(new DeathSpawnedEnemyEvent(deathSpawns[i].Source, spawned)); + + yield return WaitSequencedMs( + CombatTuning.SpawnMaterializeMs + CombatTuning.SpawnTelegraphDelayMs, + CombatTuning.ReducedMotionSpawnMaterializeMs + CombatTuning.ReducedMotionSpawnTelegraphDelayMs); + spawned.RevealDeathSpawnIntent(); + StateChanged?.Invoke(); + } + + _deathSpawnSequencesActive = Mathf.Max(0, _deathSpawnSequencesActive - 1); + } + } + + private static IEnumerator WaitSequencedMs(int normalMs, int reducedMotionMs) + { + int durationMs = UIPreferences.ReducedMotion ? reducedMotionMs : normalMs; + if (durationMs > 0) + { + yield return new WaitForSeconds(durationMs / 1000f); } } @@ -2004,11 +2227,9 @@ namespace KernelPanic.Combat Log($"{survivor.Name} race link broke; survivor enraged"); } - private void SpawnOrphanFromDeath(EnemyInstance source) + private static DeathSpawnRequest CreateOrphanDeathSpawn(EnemyInstance source) { - EnemyInstance orphan = SpawnEnemy("orphan_process", 0); - orphan.MarkSpawnedFromDeath(); - Log($"{source.Name} left an orphan process"); + return new DeathSpawnRequest(source?.State, "orphan_process", 0, $"{source?.Name ?? "enemy"} left an orphan process"); } private void SpawnStructuralEnemies() @@ -2473,11 +2694,16 @@ namespace KernelPanic.Combat private bool CheckWin() { - if (_awaitingWaveContinue || _runLost || _enemies.Count > 0) + if (_awaitingWaveContinue || _runLost) { return _awaitingWaveContinue; } + if (LivingEnemyStates().Count > 0 || VisualResolutionPending) + { + return false; + } + _awaitingWaveContinue = true; _selectedEnemyIndex = -1; _pendingTargetCard = null; @@ -2512,7 +2738,13 @@ namespace KernelPanic.Combat return string.IsNullOrWhiteSpace(card.Definition.DisplayName) ? card.Definition.Id : card.Definition.DisplayName; } - private bool IsCombatPaused => _awaitingWaveContinue || _runLost; + private bool VisualResolutionPending => _enemiesPendingExit.Count > 0 + || _enemyExitQueue.Count > 0 + || _enemyExitQueueDraining + || _enemyExitAwaitingVisual != null + || _deathSpawnSequencesActive > 0; + + private bool IsCombatPaused => _awaitingWaveContinue || _runLost || VisualResolutionPending; private enum DelayedCombatEffectKind { @@ -2543,5 +2775,21 @@ namespace KernelPanic.Combat return new DelayedCombatEffect(DelayedCombatEffectKind.TimeshiftRestore, uptime, garbageCollectionsRemaining); } } + + private readonly struct DeathSpawnRequest + { + public DeathSpawnRequest(CombatantState source, string archetypeId, int uptimeBonus, string logMessage) + { + Source = source; + ArchetypeId = archetypeId; + UptimeBonus = uptimeBonus; + LogMessage = logMessage; + } + + public CombatantState Source { get; } + public string ArchetypeId { get; } + public int UptimeBonus { get; } + public string LogMessage { get; } + } } } diff --git a/Assets/Scripts/Combat/CombatTuning.cs b/Assets/Scripts/Combat/CombatTuning.cs index 52c7a04..4c62841 100644 --- a/Assets/Scripts/Combat/CombatTuning.cs +++ b/Assets/Scripts/Combat/CombatTuning.cs @@ -41,9 +41,9 @@ namespace KernelPanic.Combat public const int StatUpgradeHealPercent = 30; public const int StatUpgradeRam = 1; public const int UpgradeMagnitudeBonus = 2; - public const int FedoraBleedingEdgeBaseCrashChance = 5; - public const int FedoraBleedingEdgeCrashChanceStep = 5; - public const int FedoraBleedingEdgeMaxCrashChance = 90; + public const float FedoraBleedingEdgeBaseCrashChance = 5f; + public const float FedoraBleedingEdgeCrashChanceStep = 2.5f; + public const float FedoraBleedingEdgeMaxCrashChance = 90f; public const int HitMagnitudeModeratePercent = 10; public const int HitMagnitudeMajorPercent = 25; public const int HitMagnitudeMassivePercent = 45; @@ -66,6 +66,25 @@ namespace KernelPanic.Combat public const float QueueCardResolveDelaySeconds = 0.45f; public const float EnemyTelegraphDelaySeconds = 0.35f; public const float EnemyActionDelaySeconds = 0.45f; + + public const int DeathBeatMs = 420; + public const int PostDeathPauseMs = 180; + public const int OverflowTravelMs = 180; + public const int SpawnDelayMs = 220; + public const int SpawnMaterializeMs = 220; + public const int SpawnTelegraphDelayMs = 120; + public const int CombatBeatDefaultMs = 120; + public const int CombatBeatSettleMs = 60; + public const int VisualQueueMaxDrainMs = 2400; + public const int ReducedMotionDeathBeatMs = 90; + public const int ReducedMotionPostDeathPauseMs = 45; + public const int ReducedMotionOverflowTravelMs = 55; + public const int ReducedMotionSpawnDelayMs = 60; + public const int ReducedMotionSpawnMaterializeMs = 70; + public const int ReducedMotionSpawnTelegraphDelayMs = 35; + public const int ReducedMotionCombatBeatDefaultMs = 45; + public const int ReducedMotionCombatBeatMinMs = 35; + public const int ReducedMotionCombatBeatMaxMs = 90; } public enum HitMagnitudeTier diff --git a/Assets/Scripts/Combat/DamagePipeline.cs b/Assets/Scripts/Combat/DamagePipeline.cs index eebb554..b01394c 100644 --- a/Assets/Scripts/Combat/DamagePipeline.cs +++ b/Assets/Scripts/Combat/DamagePipeline.cs @@ -61,6 +61,21 @@ namespace KernelPanic.Combat { public System.Func ResistanceResolver { get; set; } + public DamageResult DefeatCombatant(CombatantState target, CombatantState source, Language language) + { + if (target == null || target.IsDefeated) + { + return new DamageResult(0, false); + } + + int uptimeDamage = Mathf.Max(0, target.CurrentUptime); + target.CurrentUptime = 0; + GameEvents.RaiseDamageDealt(new DamageDealtEvent(source, target, uptimeDamage, language, uptimeDamage, 0, false, 0, uptimeDamage, true)); + target.IsDefeated = true; + GameEvents.RaiseCombatantDefeated(new CombatantDefeatedEvent(target)); + return new DamageResult(uptimeDamage, true, false, 0, 0, uptimeDamage, uptimeDamage); + } + public DamageResult DealDamage(DamageRequest request) { if (request.Target == null || request.Target.IsDefeated) @@ -70,9 +85,10 @@ namespace KernelPanic.Combat int amount = Mathf.Max(0, request.Amount); bool wasCritical = false; + int archBtwBonus = 0; if (request.ApplySourceModifiers) { - amount = ApplyFlatAdditions(amount, request); + amount = ApplyFlatAdditions(amount, request, out archBtwBonus); amount = ApplyMultipliers(amount, request); amount = ApplyCrit(amount, request, out wasCritical); } @@ -81,7 +97,8 @@ namespace KernelPanic.Combat int finalAmount = ApplyShieldAndUptime(amount, request, out int absorbedAmount, out int shieldDamage, out int uptimeDamage); ApplyArchRollingReleaseSave(request); - GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language, amount, absorbedAmount, wasCritical, shieldDamage, uptimeDamage, request.TrueDamage)); + bool rollingReleaseSaveTriggered = request.Target != null && request.Target.ArchRollingReleaseRecoveredThisHit; + GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language, amount, absorbedAmount, wasCritical, shieldDamage, uptimeDamage, request.TrueDamage, archBtwBonus, rollingReleaseSaveTriggered)); bool defeated = false; if (request.Target.CurrentUptime <= 0 && !request.Target.IsDefeated) @@ -113,8 +130,9 @@ namespace KernelPanic.Combat return Mathf.RoundToInt(amount * (multiplier / 100f) * (currentCardMultiplier / 100f)); } - private static int ApplyFlatAdditions(int amount, DamageRequest request) + private static int ApplyFlatAdditions(int amount, DamageRequest request, out int archBtwBonus) { + archBtwBonus = 0; if (amount == int.MaxValue) { return amount; @@ -127,7 +145,8 @@ namespace KernelPanic.Combat if (request.Source != null && (request.Language == Language.C || request.Language == Language.Rust)) { - amount += Mathf.Max(0, request.Source.ArchBtwStacks) * Mathf.Max(1, request.Source.ArchBtwDamagePerStack); + archBtwBonus = Mathf.Max(0, request.Source.ArchBtwStacks) * Mathf.Max(1, request.Source.ArchBtwDamagePerStack); + amount += archBtwBonus; } return amount; diff --git a/Assets/Scripts/Combat/DeckController.cs b/Assets/Scripts/Combat/DeckController.cs index 03d3554..318bfd8 100644 --- a/Assets/Scripts/Combat/DeckController.cs +++ b/Assets/Scripts/Combat/DeckController.cs @@ -3,6 +3,21 @@ using System.Linq; namespace KernelPanic.Combat { + /// + /// Describes one card inspected by a top-of-deck peek (e.g. Ubuntu's apt update), for UI display. + /// + public readonly struct PeekedCardInfo + { + public PeekedCardInfo(CardInstance card, int cost) + { + Card = card; + Cost = cost; + } + + public CardInstance Card { get; } + public int Cost { get; } + } + /// /// Manages draw, discard, and exhaust piles for a combat deck. /// @@ -55,9 +70,11 @@ namespace KernelPanic.Combat return drawn; } - public bool TryDrawCheapestFromTop(int lookCount, out CardInstance card) + public bool TryDrawCheapestFromTop(int lookCount, out CardInstance card, out IReadOnlyList peeked, out bool wasTie) { card = null; + peeked = System.Array.Empty(); + wasTie = false; if (lookCount <= 0) { return false; @@ -76,17 +93,27 @@ namespace KernelPanic.Combat int inspectCount = System.Math.Min(lookCount, _drawPile.Count); int selectedIndex = _drawPile.Count - 1; int selectedCost = CombatManager.GetCardCost(_drawPile[selectedIndex]); + List inspected = new(inspectCount) { new PeekedCardInfo(_drawPile[selectedIndex], selectedCost) }; + int tieCount = 1; for (int offset = 1; offset < inspectCount; offset++) { int index = _drawPile.Count - 1 - offset; int cost = CombatManager.GetCardCost(_drawPile[index]); + inspected.Add(new PeekedCardInfo(_drawPile[index], cost)); if (cost < selectedCost) { selectedCost = cost; selectedIndex = index; + tieCount = 1; + } + else if (cost == selectedCost) + { + tieCount++; } } + peeked = inspected; + wasTie = tieCount > 1; card = _drawPile[selectedIndex]; _drawPile.RemoveAt(selectedIndex); return card != null; diff --git a/Assets/Scripts/Combat/EnemyInstance.cs b/Assets/Scripts/Combat/EnemyInstance.cs index 22e7736..5efa480 100644 --- a/Assets/Scripts/Combat/EnemyInstance.cs +++ b/Assets/Scripts/Combat/EnemyInstance.cs @@ -44,10 +44,12 @@ namespace KernelPanic.Combat public bool PendingRevive { get; private set; } public bool Reaped { get; private set; } public bool LethalHitThisTurn { get; private set; } + public bool DefeatedPendingRemoval { get; private set; } public int PairId { get; private set; } = -1; public int RaceEnrageStacks { get; private set; } public int TelemetryStacks { get; private set; } public bool SpawnedFromDeath { get; private set; } + public bool DeathSpawnIntentRevealed { get; private set; } = true; public bool HasPendingMarker => PendingRevive || HasRevived; public bool HasSpecialSignal => HasSplitSignal || HasSegfaultSignal || HasRaceSignal || HasRootkitSignal || HasEliteSignal || HasRamPressureSignal || HasTelemetrySignal || HasCardLockerSignal || SpawnedFromDeath; public bool HasSplitSignal => HasBehavior(EnemyBehaviorFlags.Split); @@ -133,6 +135,12 @@ namespace KernelPanic.Combat public void MarkSpawnedFromDeath() { SpawnedFromDeath = true; + DeathSpawnIntentRevealed = false; + } + + public void RevealDeathSpawnIntent() + { + DeathSpawnIntentRevealed = true; } public void EnrageRaceSurvivor() @@ -162,8 +170,19 @@ namespace KernelPanic.Combat LethalHitThisTurn = true; } + public void MarkDefeatedPendingRemoval() + { + DefeatedPendingRemoval = true; + } + + public void ClearDefeatedPendingRemoval() + { + DefeatedPendingRemoval = false; + } + public void MarkPendingRevive() { + DefeatedPendingRemoval = false; PendingRevive = true; State.IsDefeated = false; State.CurrentUptime = 0; diff --git a/Assets/Scripts/Core/GameEvents.cs b/Assets/Scripts/Core/GameEvents.cs index f41a16e..882f9d7 100644 --- a/Assets/Scripts/Core/GameEvents.cs +++ b/Assets/Scripts/Core/GameEvents.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using KernelPanic.Combat; namespace KernelPanic.Core @@ -12,7 +13,10 @@ namespace KernelPanic.Core public static event Action CardResolved; public static event Action PhaseChanged; public static event Action DamageDealt; + public static event Action OverflowDamageTravel; public static event Action CombatantDefeated; + public static event Action CombatantDeathVisualCompleted; + public static event Action DeathSpawnedEnemy; public static event Action EnemyWouldAct; public static event Action EnemyActed; public static event Action PlayerDamaged; @@ -24,6 +28,9 @@ namespace KernelPanic.Core public static event Action StatusExpired; public static event Action StatusCleansed; public static event Action RunEnded; + public static event Action UbuntuAptUpdatePeeked; + public static event Action FedoraBleedingEdgeTriggered; + public static event Action ArchBtwTurnEnded; public static void RaiseCardPlayed(CardPlayedEvent payload) { @@ -45,11 +52,26 @@ namespace KernelPanic.Core DamageDealt?.Invoke(payload); } + public static void RaiseOverflowDamageTravel(OverflowDamageTravelEvent payload) + { + OverflowDamageTravel?.Invoke(payload); + } + public static void RaiseCombatantDefeated(CombatantDefeatedEvent payload) { CombatantDefeated?.Invoke(payload); } + public static void RaiseCombatantDeathVisualCompleted(CombatantDeathVisualCompletedEvent payload) + { + CombatantDeathVisualCompleted?.Invoke(payload); + } + + public static void RaiseDeathSpawnedEnemy(DeathSpawnedEnemyEvent payload) + { + DeathSpawnedEnemy?.Invoke(payload); + } + public static void RaiseEnemyWouldAct(EnemyWouldActEvent payload) { EnemyWouldAct?.Invoke(payload); @@ -104,6 +126,21 @@ namespace KernelPanic.Core { RunEnded?.Invoke(payload); } + + public static void RaiseUbuntuAptUpdatePeeked(UbuntuAptUpdatePeekedEvent payload) + { + UbuntuAptUpdatePeeked?.Invoke(payload); + } + + public static void RaiseFedoraBleedingEdgeTriggered(FedoraBleedingEdgeEvent payload) + { + FedoraBleedingEdgeTriggered?.Invoke(payload); + } + + public static void RaiseArchBtwTurnEnded(ArchBtwTurnEndedEvent payload) + { + ArchBtwTurnEnded?.Invoke(payload); + } } /// @@ -171,7 +208,7 @@ namespace KernelPanic.Core { } - public DamageDealtEvent(CombatantState source, CombatantState target, int amount, Language language, int incomingAmount, int absorbedAmount, bool wasCritical, int shieldDamage, int uptimeDamage, bool trueDamage) + public DamageDealtEvent(CombatantState source, CombatantState target, int amount, Language language, int incomingAmount, int absorbedAmount, bool wasCritical, int shieldDamage, int uptimeDamage, bool trueDamage, int archBtwBonusAmount = 0, bool archRollingReleaseSaveTriggered = false) { Source = source; Target = target; @@ -183,6 +220,8 @@ namespace KernelPanic.Core ShieldDamage = shieldDamage; UptimeDamage = uptimeDamage; TrueDamage = trueDamage; + ArchBtwBonusAmount = archBtwBonusAmount; + ArchRollingReleaseSaveTriggered = archRollingReleaseSaveTriggered; } public CombatantState Source { get; } @@ -195,6 +234,8 @@ namespace KernelPanic.Core public int ShieldDamage { get; } public int UptimeDamage { get; } public bool TrueDamage { get; } + public int ArchBtwBonusAmount { get; } + public bool ArchRollingReleaseSaveTriggered { get; } public bool WasFullyBlocked => IncomingAmount > 0 && UptimeDamage <= 0; public bool WasMitigated => ShieldDamage > 0 || AbsorbedAmount > 0 || WasFullyBlocked; } @@ -212,6 +253,46 @@ namespace KernelPanic.Core public CombatantState Combatant { get; } } + public readonly struct OverflowDamageTravelEvent + { + public OverflowDamageTravelEvent(CombatantState source, CombatantState from, CombatantState to, int amount, Language language) + { + Source = source; + From = from; + To = to; + Amount = amount; + Language = language; + } + + public CombatantState Source { get; } + public CombatantState From { get; } + public CombatantState To { get; } + public int Amount { get; } + public Language Language { get; } + } + + public readonly struct CombatantDeathVisualCompletedEvent + { + public CombatantDeathVisualCompletedEvent(CombatantState combatant) + { + Combatant = combatant; + } + + public CombatantState Combatant { get; } + } + + public readonly struct DeathSpawnedEnemyEvent + { + public DeathSpawnedEnemyEvent(CombatantState source, EnemyInstance enemy) + { + Source = source; + Enemy = enemy; + } + + public CombatantState Source { get; } + public EnemyInstance Enemy { get; } + } + /// /// Describes an enemy placeholder reaching its structural action point. /// @@ -348,4 +429,62 @@ namespace KernelPanic.Core public bool PlayerDied { get; } public int EntropyEarned { get; } } + + /// + /// Describes an Ubuntu "apt update" peek-and-pick: the cards inspected at the top of the draw + /// pile and which one was added to hand. + /// + public readonly struct UbuntuAptUpdatePeekedEvent + { + public UbuntuAptUpdatePeekedEvent(IReadOnlyList peeked, CardInstance chosen, bool wasTie, int lookCount) + { + Peeked = peeked; + Chosen = chosen; + WasTie = wasTie; + LookCount = lookCount; + } + + public IReadOnlyList Peeked { get; } + public CardInstance Chosen { get; } + public bool WasTie { get; } + public int LookCount { get; } + } + + /// + /// Describes the outcome of Fedora's "Bleeding Edge" crash roll for a bonus-eligible card. + /// + public readonly struct FedoraBleedingEdgeEvent + { + public FedoraBleedingEdgeEvent(CardInstance card, bool crashed, bool firstBonusThisTurn, int damageMultiplierPercent, float crashChanceRolled, float crashChanceAfter) + { + Card = card; + Crashed = crashed; + FirstBonusThisTurn = firstBonusThisTurn; + DamageMultiplierPercent = damageMultiplierPercent; + CrashChanceRolled = crashChanceRolled; + CrashChanceAfter = crashChanceAfter; + } + + public CardInstance Card { get; } + public bool Crashed { get; } + public bool FirstBonusThisTurn { get; } + public int DamageMultiplierPercent { get; } + public float CrashChanceRolled { get; } + public float CrashChanceAfter { get; } + } + + /// + /// Describes the end-of-turn resolution of Arch's btw stacks: whether they reset or persisted. + /// + public readonly struct ArchBtwTurnEndedEvent + { + public ArchBtwTurnEndedEvent(int stacksBefore, bool persisted) + { + StacksBefore = stacksBefore; + Persisted = persisted; + } + + public int StacksBefore { get; } + public bool Persisted { get; } + } } diff --git a/Assets/Scripts/UI/CombatSceneController.cs b/Assets/Scripts/UI/CombatSceneController.cs index 8a9c34a..e2cdb64 100644 --- a/Assets/Scripts/UI/CombatSceneController.cs +++ b/Assets/Scripts/UI/CombatSceneController.cs @@ -52,6 +52,8 @@ namespace KernelPanic.UI private VisualElement _handRow; private VisualElement _turnResourceGrid; private Label _logLabel; + private Button _surrenderButton; + private Label _surrenderConfirmLabel; private VisualElement _feedbackLayer; private VisualElement _damageVignette; private VisualElement _overlay; @@ -63,13 +65,28 @@ namespace KernelPanic.UI private readonly Dictionary _previousUptime = new(); private readonly Dictionary _previousShield = new(); private readonly Dictionary _feedbackBeatVersions = new(); + private readonly Dictionary _lastCombatantFeedbackRects = new(); + private readonly Dictionary _heldDeathUptime = new(); + private readonly HashSet _deathBeatStartedCombatants = new(); + private readonly HashSet _visuallyRemovedCombatants = new(); private readonly HashSet _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() { @@ -105,12 +122,18 @@ namespace KernelPanic.UI 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(HandleKeyDown); } private void Start() @@ -156,12 +179,39 @@ namespace KernelPanic.UI 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(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) @@ -260,6 +310,11 @@ namespace KernelPanic.UI private void BuildLayout() { _root.Clear(); + _lastCombatantFeedbackRects.Clear(); + _heldDeathUptime.Clear(); + _deathBeatStartedCombatants.Clear(); + _visuallyRemovedCombatants.Clear(); + _playerFeedbackAnchorTracked = false; _root.AddToClassList("combat-root"); _root.style.flexGrow = 1; @@ -311,6 +366,16 @@ namespace KernelPanic.UI 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); @@ -428,6 +493,7 @@ namespace KernelPanic.UI 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)); @@ -446,6 +512,21 @@ namespace KernelPanic.UI } _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() @@ -462,6 +543,7 @@ namespace KernelPanic.UI { _enemyRow.Clear(); _combatantElements.Clear(); + PruneVisuallyRemovedCombatants(); int enemyCount = _combatManager.Enemies.Count; bool compactEnemies = enemyCount >= 5; _enemyRow.EnableInClassList("enemy-row-compact", compactEnemies); @@ -474,11 +556,24 @@ namespace KernelPanic.UI { 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) { @@ -488,6 +583,7 @@ namespace KernelPanic.UI Label name = new(enemy.Name); name.AddToClassList("enemy-name"); card.Add(name); + if (enemy.HasPendingMarker) { Label marker = new(enemy.PendingRevive ? "~ reviving" : "~ revived once"); @@ -501,9 +597,13 @@ namespace KernelPanic.UI } _combatantElements[enemy.State] = card; - DetectBeneficialResourceFeedback(enemy.State, card); + if (!deathPending) + { + DetectBeneficialResourceFeedback(enemy.State, card); + } + ApplyStatusStateClasses(card, enemy.State); - card.Add(MeterBlock("uptime", enemy.CurrentUptime, enemy.MaxUptime, MeterTone.Uptime)); + card.Add(MeterBlock("uptime", displayedUptime, enemy.MaxUptime, MeterTone.Uptime)); if (enemy.State.Shield > 0) { Label shield = new($"# shield {enemy.State.Shield}"); @@ -512,8 +612,63 @@ namespace KernelPanic.UI } card.Add(StatusPipRow(enemy.State)); - card.Add(IntentPanel(enemy.DisplayIntent)); + 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 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 staleHeldStates = null; + foreach (CombatantState state in _heldDeathUptime.Keys) + { + if (!IsRemoved(state)) + { + continue; + } + + staleHeldStates ??= new List(); + staleHeldStates.Add(state); + } + + if (staleHeldStates == null) + { + return; + } + + for (int i = 0; i < staleHeldStates.Count; i++) + { + _heldDeathUptime.Remove(staleHeldStates[i]); } } @@ -554,6 +709,73 @@ namespace KernelPanic.UI _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() @@ -568,18 +790,12 @@ namespace KernelPanic.UI { SettleRunRewardsIfNeeded(); _overlay.style.display = DisplayStyle.Flex; - _overlay.Add(OverlayTitle("kernel panic")); - _overlay.Add(OverlayLine("fatal: player uptime reached 0")); - _overlay.Add(OverlayLine($"wave {_runManager.CurrentWaveNumber} reached seed {_runManager.CurrentConfig?.RunSeed ?? 0}")); - _overlay.Add(OverlayLine($"waves cleared: {_runManager.WavesCleared}")); - _overlay.Add(OverlayLine($"+{_runManager.AccruedBandwidth} bandwidth")); - _overlay.Add(OverlayLine($"+{_runManager.AccruedEntropy} entropy")); - Button returnButton = new(() => SceneLoader.LoadMainMenu(_root)) { text = "> return to menu" }; - returnButton.AddToClassList("primary-action"); - _overlay.Add(returnButton); + _overlay.Add(BuildRunEndOverlay()); return; } + _runEndOverlayPresented = false; + _runEndedBySurrender = false; if (_combatManager.AwaitingWaveContinue) { if (!_runManager.RepositoryVisitActive) @@ -596,6 +812,58 @@ namespace KernelPanic.UI _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) @@ -619,10 +887,12 @@ namespace KernelPanic.UI { 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"); @@ -644,12 +914,14 @@ namespace KernelPanic.UI VisualElement footer = new(); footer.AddToClassList("repository-footer"); + footer.AddToClassList("screen-frame-footer"); Button reroll = new(() => _runManager.RerollRepositoryOffers(cardDatabase, languageDeckDatabase)) { - text = $"apt update - reroll ({_runManager.RerollCost} bits)" + 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); @@ -660,8 +932,9 @@ namespace KernelPanic.UI VisualElement leaveBlock = new(); leaveBlock.AddToClassList("repository-leave-block"); leaveBlock.Add(leave); - Label banked = new($"{_runManager.Bits} bits banked"); + 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); @@ -672,19 +945,21 @@ namespace KernelPanic.UI { VisualElement header = new(); header.AddToClassList("repository-header"); + header.AddToClassList("screen-frame-header"); VisualElement copy = new(); copy.AddToClassList("repository-header-copy"); - Label title = new($"repository :: wave {_runManager.CurrentWaveNumber - 1} cleared"); - title.AddToClassList("overlay-title"); - Label subtitle = new($"$ apt browse {_runManager.RepositoryOffers.Count}/{CombatTuning.ShopSize} offers"); - subtitle.AddToClassList("overlay-line"); + 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("bits wallet"); + Label walletLabel = new("available bits"); walletLabel.AddToClassList("status-label"); Label walletValue = new($"◆ {_runManager.Bits}"); walletValue.AddToClassList("repository-wallet-value"); @@ -724,8 +999,9 @@ namespace KernelPanic.UI Button buy = new(() => _runManager.BuyOffer(offer, _combatManager.PlayerState)) { - text = offer.Sold ? "installed" : OfferCommandText(offer) + 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); @@ -812,7 +1088,7 @@ namespace KernelPanic.UI { VisualElement block = new(); block.AddToClassList("repository-remove"); - Label title = new($"apt remove ({CombatTuning.RemoveCardCost} bits)"); + Label title = new($"installed deck maintenance / remove for {CombatTuning.RemoveCardCost} bit"); title.AddToClassList("repository-name"); block.Add(title); @@ -1087,28 +1363,95 @@ namespace KernelPanic.UI 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); - block.Add(StatusPipRow(state)); - - if (includeTooltips) + if (!includeTooltips || state.Statuses.Count == 0) { - for (int i = 0; i < state.Statuses.Count; i++) - { - StatusDescriptor descriptor = StatusEffectController.GetDescriptor(state.Statuses[i].Type); - Label tooltip = new(descriptor.Tooltip); - tooltip.AddToClassList("status-tooltip"); - block.Add(tooltip); - } + 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(); @@ -1232,6 +1575,12 @@ namespace KernelPanic.UI }; 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) @@ -1270,17 +1619,31 @@ namespace KernelPanic.UI } _feedbackCascadeIndex = 0; + _combatBeatCursorMs = 0; + _combatBeatCursorVersion++; ScheduleFeedback(() => PlayElementBeat(_phaseLabel, "phase-pulse", 260), 0); } private void HandleDamageDealt(DamageDealtEvent payload) { bool isPlayer = payload.Target == _combatManager.PlayerState; - VisualElement target = CombatantElement(payload.Target); 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; @@ -1290,39 +1653,156 @@ namespace KernelPanic.UI ? $"{bypass}blocked" : $"{bypass}-{Mathf.RoundToInt(payload.UptimeDamage)}"; string tone = BuildDamageFloatClasses(tier, payload, shieldOnly); - string beatClass = BuildDamageBeatClass(tier, payload, shieldOnly); - int beatDuration = HitBeatDuration(tier, payload.WasMitigated); + 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(target, text, tone, beatIndex); - SpawnImpactMarker(target, payload.WasCritical, payload.WasMitigated, beatIndex, tier, shieldOnly); - PlayElementBeat(target, beatClass, beatDuration); + 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) { - VisualElement target = CombatantElement(payload.Combatant); - Rect targetBounds = target?.worldBound ?? Rect.zero; - ScheduleCombatBeat(() => + HoldDeathUptime(payload.Combatant); + int completionMs = ScheduleCombatBeat(() => { - SpawnFloatingText(target, "killed", "float-kill float-large", 0); - SpawnDeathGhost(targetBounds); - if (target != _enemyRow) + 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 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(() => PlayElementBeat(CombatantElement(payload.Enemy?.State), "enemy-anticipating", 340)); + ScheduleCombatBeat(() => + { + if (TryGetCombatantElement(payload.Enemy?.State, out VisualElement enemy)) + { + PlayElementBeat(enemy, "enemy-anticipating", 340); + } + }); } private void HandlePlayerDamaged(PlayerDamagedEvent payload) @@ -1335,10 +1815,13 @@ namespace KernelPanic.UI ScheduleCombatBeat(() => { StatusDescriptor descriptor = StatusEffectController.GetDescriptor(payload.StatusType); - VisualElement target = CombatantElement(payload.Target); - ApplyStatusStateClass(target, payload.StatusType, true); - SpawnFloatingText(target, $"{descriptor.IconKey} x{payload.Stacks}", descriptor.IsBeneficial ? "float-heal" : "float-status"); - PlayElementBeat(target, descriptor.IsBeneficial ? "feedback-boost" : "feedback-status", 220); + 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"); }); } @@ -1352,6 +1835,221 @@ namespace KernelPanic.UI 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 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) @@ -1449,6 +2147,107 @@ namespace KernelPanic.UI }; } + 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(_ => 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)) @@ -1463,19 +2262,18 @@ namespace KernelPanic.UI { if (card?.TargetSnapshot == null) { - return _enemyRow; + return null; } for (int i = 0; i < card.TargetSnapshot.Count; i++) { - VisualElement element = CombatantElement(card.TargetSnapshot[i]); - if (element != null) + if (TryGetCombatantElement(card.TargetSnapshot[i], out VisualElement element)) { return element; } } - return _enemyRow; + return null; } private void PlayElementBeat(VisualElement element, string className, int durationMs) @@ -1511,14 +2309,32 @@ namespace KernelPanic.UI private void SpawnFloatingText(VisualElement anchor, string text, string className, int cascadeOffset = 0) { - if (_feedbackLayer == null || anchor == null) + 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(); - Rect anchorRect = anchor.worldBound; - Rect rootRect = _root.worldBound; Label label = new(text); label.AddToClassList("float-number"); if (!string.IsNullOrWhiteSpace(className)) @@ -1535,8 +2351,8 @@ namespace KernelPanic.UI float offsetX = (((cascadeOffset % 5) - 2) * 14f); float offsetY = ((cascadeOffset % 3) * 5f); - label.style.left = anchorRect.center.x - rootRect.x - 30f + offsetX; - label.style.top = anchorRect.center.y - rootRect.y - 16f + offsetY; + label.style.left = localRect.center.x - 30f + offsetX; + label.style.top = localRect.center.y - 16f + offsetY; _feedbackLayer.Add(label); if (UIPreferences.ReducedMotion) @@ -1548,7 +2364,7 @@ namespace KernelPanic.UI ScheduleFeedback(() => { - label.style.top = anchorRect.center.y - rootRect.y - 42f + offsetY; + label.style.top = localRect.center.y - 42f + offsetY; label.style.opacity = 0f; }, 20); ScheduleFeedback(() => label.RemoveFromHierarchy(), 520); @@ -1556,22 +2372,40 @@ namespace KernelPanic.UI private void SpawnImpactMarker(VisualElement anchor, bool critical, bool mitigated, int cascadeOffset, HitMagnitudeTier tier = HitMagnitudeTier.Moderate, bool shieldOnly = false) { - if (_feedbackLayer == null || anchor == null) + 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(); - Rect anchorRect = anchor.worldBound; - Rect rootRect = _root.worldBound; 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 = anchorRect.xMax - rootRect.x - 54f; - marker.style.top = anchorRect.y - rootRect.y + 8f + lane; + marker.style.left = localRect.xMax - 54f; + marker.style.top = localRect.y + 8f + lane; _feedbackLayer.Add(marker); if (UIPreferences.ReducedMotion) @@ -1582,7 +2416,7 @@ namespace KernelPanic.UI ScheduleFeedback(() => { - marker.style.left = anchorRect.xMax - rootRect.x - 34f; + marker.style.left = localRect.xMax - 34f; marker.style.opacity = 0f; }, 20); ScheduleFeedback(() => marker.RemoveFromHierarchy(), 420); @@ -1649,21 +2483,22 @@ namespace KernelPanic.UI private void FlyCardGhost(CardInstance card, VisualElement source, VisualElement destination, string className) { - if (_feedbackLayer == null || card == null) + 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; } - Rect rootRect = _root.worldBound; - Rect sourceRect = source == null ? new Rect(rootRect.center.x - 70f, rootRect.center.y - 50f, 140f, 110f) : source.worldBound; - Rect destinationRect = destination == null ? sourceRect : destination.worldBound; - VisualElement ghost = CardFaceView(card); ghost.AddToClassList("feedback-card-ghost"); ghost.AddToClassList(className); ghost.style.position = Position.Absolute; - ghost.style.left = sourceRect.x - rootRect.x; - ghost.style.top = sourceRect.y - rootRect.y; + ghost.style.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); @@ -1677,8 +2512,8 @@ namespace KernelPanic.UI ScheduleFeedback(() => { - ghost.style.left = destinationRect.center.x - rootRect.x - (sourceRect.width * 0.5f); - ghost.style.top = destinationRect.center.y - rootRect.y - (sourceRect.height * 0.5f); + ghost.style.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); @@ -1686,19 +2521,18 @@ namespace KernelPanic.UI private void SpawnDeathGhost(Rect sourceRect) { - if (_feedbackLayer == null || sourceRect.width <= 0f || sourceRect.height <= 0f) + if (_feedbackLayer == null || !TryGetFeedbackLocalRect(sourceRect, out Rect localRect)) { return; } - Rect rootRect = _root.worldBound; 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 = sourceRect.center.x - rootRect.x - (ghostWidth * 0.5f); - ghost.style.top = sourceRect.center.y - rootRect.y - (ghostHeight * 0.5f); + 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); @@ -1717,6 +2551,40 @@ namespace KernelPanic.UI 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) @@ -1755,23 +2623,52 @@ namespace KernelPanic.UI 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 void ScheduleCombatBeat(Action action, int holdMs = 0) + private int ScheduleCombatBeat(Action action, int holdMs = 0) { - ScheduleCombatBeat(_ => action?.Invoke(), holdMs); + return ScheduleCombatBeat(_ => action?.Invoke(), holdMs); } - private void ScheduleCombatBeat(Action action, int holdMs = 0) + private int ScheduleCombatBeat(Action action, int holdMs = 0) { if (action == null) { - return; + return 0; } int beatIndex = _feedbackCascadeIndex++; - int delay = UIPreferences.ReducedMotion ? 0 : Mathf.Min(beatIndex * 120, 840); - ScheduleFeedback(() => action(beatIndex), delay + (UIPreferences.ReducedMotion ? 0 : holdMs)); + 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) @@ -1918,6 +2815,39 @@ namespace KernelPanic.UI 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); @@ -2100,6 +3030,16 @@ namespace KernelPanic.UI }; } + 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)