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 @@
-
+
+
+
+
+
@@ -494,7 +501,23 @@
1783671710808
-
+
+
+ 1783675062251
+
+
+
+ 1783675062252
+
+
+
+ 1783677014502
+
+
+
+ 1783677014502
+
+
@@ -510,8 +533,6 @@
-
-
@@ -535,7 +556,9 @@
-
+
+
+
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)