diff --git a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml
index 4fefd46..89adf20 100644
--- a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml
+++ b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml
@@ -11,25 +11,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
+
+
@@ -68,7 +55,10 @@
+
+
+
@@ -76,6 +66,8 @@
+
+
@@ -91,23 +83,23 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -117,35 +109,35 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
@@ -175,7 +167,7 @@
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
- "settings.editor.selected.configurable": "ml.llm.LLMThirdPartyAiProvidersConfigurable",
+ "settings.editor.selected.configurable": "ml.llm.AcpAgentsConfigurable",
"vue.rearranger.settings.migration": "true"
}
}
@@ -234,7 +226,9 @@
-
+
+
+
@@ -412,7 +406,15 @@
1783500547647
-
+
+
+ 1783503035031
+
+
+
+ 1783503035031
+
+
@@ -449,7 +451,8 @@
-
+
+
diff --git a/Assets/Resources/CombatScene.uss b/Assets/Resources/CombatScene.uss
index 87d4d56..1f25108 100644
--- a/Assets/Resources/CombatScene.uss
+++ b/Assets/Resources/CombatScene.uss
@@ -1034,13 +1034,19 @@
border-right-width: 10px;
border-top-width: 10px;
border-bottom-width: 10px;
- border-color: var(--kp-danger);
+ border-color: transparent;
transition-property: opacity;
transition-duration: 120ms;
}
.damage-vignette-on {
opacity: 0.62;
+ border-color: var(--kp-danger);
+}
+
+.damage-vignette-crit {
+ opacity: 0.82;
+ border-color: var(--kp-kill);
}
.float-number {
@@ -1084,6 +1090,40 @@
transition-duration: 0ms;
}
+.impact-marker {
+ position: absolute;
+ min-width: 48px;
+ height: 18px;
+ padding-left: 4px;
+ padding-right: 4px;
+ -unity-text-align: middle-center;
+ -unity-font-style: bold;
+ font-size: 10px;
+ opacity: 1;
+ background-color: var(--kp-bg-deep);
+ border-left-width: 1px;
+ border-right-width: 1px;
+ border-top-width: 1px;
+ border-bottom-width: 1px;
+ transition-property: left, opacity;
+ transition-duration: 320ms;
+}
+
+.impact-marker-hit {
+ color: var(--kp-damage);
+ border-color: var(--kp-damage);
+}
+
+.impact-marker-crit {
+ color: var(--kp-kill);
+ border-color: var(--kp-kill);
+}
+
+.impact-marker-clang {
+ color: var(--kp-beneficial);
+ border-color: var(--kp-beneficial);
+}
+
.feedback-card-ghost {
opacity: 0.9;
transition-property: left, top, opacity;
@@ -1110,15 +1150,28 @@
.feedback-hit {
background-color: var(--kp-action-hover);
border-color: var(--kp-damage);
+ translate: 3px 0;
scale: 1.025 0.98;
- transition-property: scale, background-color, border-color;
+ transition-property: scale, translate, background-color, border-color;
transition-duration: 90ms;
}
+.feedback-crit {
+ background-color: #2d0707;
+ border-color: var(--kp-kill);
+ translate: 8px 0;
+ scale: 1.08 0.9;
+ transition-property: scale, translate, background-color, border-color;
+ transition-duration: 170ms;
+}
+
+.feedback-clang,
.feedback-block {
+ background-color: var(--kp-bg-deep);
border-color: var(--kp-beneficial);
- transition-property: border-color;
- transition-duration: 90ms;
+ scale: 1.02 1.02;
+ transition-property: scale, background-color, border-color;
+ transition-duration: 80ms;
}
.feedback-status {
@@ -1130,23 +1183,33 @@
}
.feedback-killed {
- opacity: 0.5;
- scale: 0.95 0.95;
+ opacity: 0.28;
+ scale: 0.76 0.28;
+ background-color: #240606;
border-color: var(--kp-kill);
- transition-property: opacity, scale, border-color;
- transition-duration: 180ms;
+ transition-property: opacity, scale, background-color, border-color;
+ transition-duration: 220ms;
}
.death-ghost {
- background-color: var(--kp-panel-raised);
+ background-color: #240606;
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-kill);
- opacity: 0.72;
+ opacity: 0.74;
transition-property: opacity, scale;
- transition-duration: 300ms;
+ transition-duration: 220ms;
+}
+
+.enemy-anticipating {
+ border-color: var(--kp-accent);
+ translate: -8px 0;
+ scale: 0.94 1.06;
+ background-color: var(--kp-action-hover);
+ transition-property: translate, scale, border-color, background-color;
+ transition-duration: 170ms;
}
.enemy-acting {
@@ -1156,6 +1219,33 @@
transition-duration: 120ms;
}
+.status-state-segfault,
+.status-state-deadlock {
+ opacity: 0.68;
+ border-color: var(--kp-lang-haskell);
+ transition-property: opacity, border-color;
+ transition-duration: 300ms;
+}
+
+.status-state-race {
+ border-color: var(--kp-warning);
+ translate: 1px 0;
+ transition-property: translate, border-color;
+ transition-duration: 120ms;
+}
+
+.status-state-deprecated,
+.status-state-dependency-error {
+ border-color: var(--kp-text-dim);
+ opacity: 0.76;
+}
+
+.status-state-upgrading {
+ border-color: var(--kp-beneficial);
+ border-right-color: var(--kp-beneficial);
+ border-right-width: 3px;
+}
+
.phase-pulse {
background-color: var(--kp-warning);
transition-property: background-color;
@@ -1197,10 +1287,14 @@
}
.reduced-motion .float-number,
+.reduced-motion .impact-marker,
.reduced-motion .feedback-card-ghost,
.reduced-motion .damage-vignette,
.reduced-motion .feedback-hit,
+.reduced-motion .feedback-crit,
+.reduced-motion .feedback-clang,
.reduced-motion .feedback-killed,
+.reduced-motion .enemy-anticipating,
.reduced-motion .enemy-acting,
.reduced-motion .phase-pulse,
.reduced-motion .cycles-spent-beat,
diff --git a/Assets/Scripts/Combat/CardEffects.cs b/Assets/Scripts/Combat/CardEffects.cs
index 7259462..8d4f4a4 100644
--- a/Assets/Scripts/Combat/CardEffects.cs
+++ b/Assets/Scripts/Combat/CardEffects.cs
@@ -14,7 +14,7 @@ namespace KernelPanic.Combat
private readonly bool _canCrit;
private readonly bool _allEnemies;
- public DealDamageEffect(int minAmount, int maxAmount, Language language, bool trueDamage = false, bool canCrit = false, bool allEnemies = false)
+ public DealDamageEffect(int minAmount, int maxAmount, Language language, bool trueDamage = false, bool canCrit = true, bool allEnemies = false)
{
_minAmount = minAmount;
_maxAmount = maxAmount;
@@ -78,6 +78,12 @@ namespace KernelPanic.Combat
public void Execute(CombatContext context)
{
int amount = UpgradeMath.ScaleShield(UpgradeMath.ApplySourceFlatBonus(_amount, context), context.Card);
+ if (!context.CombatManager.CanPlayerReceiveCardShield(context.Card))
+ {
+ context.CombatManager.ReportEffectResult($"shield blocked: {amount}");
+ return;
+ }
+
context.Source.Shield += amount;
context.CombatManager.ReportEffectResult($"gained {amount} shield");
}
@@ -98,6 +104,12 @@ namespace KernelPanic.Combat
{
int amount = _baseAmount + (context.Card != null && context.Card.WasFirstCardThisTurn ? _firstCardBonus : 0);
amount = UpgradeMath.ScaleShield(UpgradeMath.ApplySourceFlatBonus(amount, context), context.Card);
+ if (!context.CombatManager.CanPlayerReceiveCardShield(context.Card))
+ {
+ context.CombatManager.ReportEffectResult($"shield blocked: {amount}");
+ return;
+ }
+
context.Source.Shield += amount;
context.CombatManager.ReportEffectResult($"gained {amount} shield");
}
@@ -129,7 +141,7 @@ namespace KernelPanic.Combat
int roll = RandomRoll.RollRange(1, 100, new RollContext(context.Source));
int damageAmount = UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_damageAmount, context), context.Card);
int amount = roll <= _successPercent ? damageAmount : 0;
- context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, false));
+ context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, true));
context.CombatManager.ReportEffectResult(amount == 0 ? "typeof -> NaN (0)" : $"typeof -> {amount}");
}
}
@@ -176,6 +188,13 @@ namespace KernelPanic.Combat
{
if (_targetSelf)
{
+ if (StatusEffectController.GetDescriptor(_statusType).IsBeneficial
+ && !context.CombatManager.CanPlayerReceiveCardBuff(context.Card))
+ {
+ context.CombatManager.ReportEffectResult($"{_statusType} blocked");
+ return;
+ }
+
context.StatusEffects.Apply(context.Source, _statusType, UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_stacks, context), context.Card), _duration, context.Source, _skipNextTick);
return;
}
@@ -473,10 +492,16 @@ namespace KernelPanic.Combat
int amount = UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_amount, context), context.Card);
int survivable = target.CurrentUptime + target.Shield;
- context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, false));
- int overkill = UnityEngine.Mathf.Max(0, amount - survivable);
+ DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, true));
+ int overkill = UnityEngine.Mathf.Max(0, result.IncomingAmount - survivable);
if (overkill > 0)
{
+ if (!context.CombatManager.CanPlayerReceiveCardShield(context.Card))
+ {
+ context.CombatManager.ReportEffectResult($"overkill shield blocked: {overkill}");
+ continue;
+ }
+
context.Source.Shield += overkill;
context.CombatManager.ReportEffectResult($"overkill -> {overkill} shield");
}
diff --git a/Assets/Scripts/Combat/CombatManager.cs b/Assets/Scripts/Combat/CombatManager.cs
index 2dd4ffe..db0d3cb 100644
--- a/Assets/Scripts/Combat/CombatManager.cs
+++ b/Assets/Scripts/Combat/CombatManager.cs
@@ -895,6 +895,48 @@ namespace KernelPanic.Combat
return string.Equals(runConfig?.Distro?.Id, id, StringComparison.OrdinalIgnoreCase);
}
+ public bool CanPlayerReceiveCardShield(CardInstance card)
+ {
+ return CanPlayerReceiveCardBuff(card);
+ }
+
+ public bool CanPlayerReceiveCardHeal(CardInstance card)
+ {
+ return CanPlayerReceiveCardBuff(card);
+ }
+
+ public bool CanPlayerReceiveCardBuff(CardInstance card)
+ {
+ return !IsDistro("mint") || IsCurrentDistroExclusiveCard(card);
+ }
+
+ private bool IsCurrentDistroExclusiveCard(CardInstance card)
+ {
+ CardDefinition definition = card?.Definition;
+ IReadOnlyList exclusiveCards = runConfig?.Distro?.ExclusiveCards;
+ if (definition == null || exclusiveCards == null)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < exclusiveCards.Count; i++)
+ {
+ CardDefinition exclusive = exclusiveCards[i];
+ if (exclusive == null)
+ {
+ continue;
+ }
+
+ if (ReferenceEquals(exclusive, definition)
+ || string.Equals(exclusive.Id, definition.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private CombatContext BuildContext(CardInstance card, IReadOnlyList targets)
{
return new CombatContext(card, playerState, targets, this, damagePipeline, statusEffects, deckController, handController, enemies);
@@ -1151,7 +1193,7 @@ namespace KernelPanic.Combat
List targets = LivingEnemyStates();
for (int i = 0; i < targets.Count; i++)
{
- damagePipeline.DealDamage(new DamageRequest(playerState, targets[i], amount, language, false, false));
+ damagePipeline.DealDamage(new DamageRequest(playerState, targets[i], amount, language, false, true));
}
Log($"{label}: dealt {amount} to all enemies");
diff --git a/Assets/Scripts/Combat/DamagePipeline.cs b/Assets/Scripts/Combat/DamagePipeline.cs
index d1684de..ab7048e 100644
--- a/Assets/Scripts/Combat/DamagePipeline.cs
+++ b/Assets/Scripts/Combat/DamagePipeline.cs
@@ -31,14 +31,24 @@ namespace KernelPanic.Combat
public readonly struct DamageResult
{
- public DamageResult(int finalAmount, bool targetDefeated)
+ public DamageResult(int finalAmount, bool targetDefeated, bool wasCritical = false, int absorbedAmount = 0, int shieldDamage = 0, int uptimeDamage = 0, int incomingAmount = 0)
{
FinalAmount = finalAmount;
TargetDefeated = targetDefeated;
+ WasCritical = wasCritical;
+ AbsorbedAmount = absorbedAmount;
+ ShieldDamage = shieldDamage;
+ UptimeDamage = uptimeDamage;
+ IncomingAmount = incomingAmount;
}
public int FinalAmount { get; }
public bool TargetDefeated { get; }
+ public bool WasCritical { get; }
+ public int AbsorbedAmount { get; }
+ public int ShieldDamage { get; }
+ public int UptimeDamage { get; }
+ public int IncomingAmount { get; }
}
///
@@ -55,11 +65,11 @@ namespace KernelPanic.Combat
int amount = Mathf.Max(0, request.Amount);
amount = ApplyMultipliers(amount, request);
- amount = ApplyCrit(amount, request);
+ amount = ApplyCrit(amount, request, out bool wasCritical);
amount = ApplyResistancesAndWeaknesses(amount, request);
- int finalAmount = ApplyShieldAndUptime(amount, request);
- GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language));
+ int finalAmount = ApplyShieldAndUptime(amount, request, out int absorbedAmount, out int shieldDamage, out int uptimeDamage);
+ GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language, amount, absorbedAmount, wasCritical, shieldDamage, uptimeDamage));
bool defeated = false;
if (request.Target.CurrentUptime <= 0 && !request.Target.IsDefeated)
@@ -69,7 +79,7 @@ namespace KernelPanic.Combat
GameEvents.RaiseCombatantDefeated(new CombatantDefeatedEvent(request.Target));
}
- return new DamageResult(finalAmount, defeated);
+ return new DamageResult(finalAmount, defeated, wasCritical, absorbedAmount, shieldDamage, uptimeDamage, amount);
}
private static int ApplyMultipliers(int amount, DamageRequest request)
@@ -85,15 +95,16 @@ namespace KernelPanic.Combat
return Mathf.RoundToInt(amount * (multiplier / 100f));
}
- private static int ApplyCrit(int amount, DamageRequest request)
+ private static int ApplyCrit(int amount, DamageRequest request, out bool wasCritical)
{
- if (request.Source != null && request.Source.forceMaxRolls)
+ wasCritical = false;
+ if (!request.CanCrit || amount <= 0 || request.Source != null && request.Source.forceMaxRolls)
{
return amount;
}
- // TODO: Add crit calculation and crit modifiers.
- return amount;
+ wasCritical = RandomRoll.RollRange(1, 100, new RollContext(request.Source)) <= 25;
+ return wasCritical ? Mathf.CeilToInt(amount * 1.5f) : amount;
}
private static int ApplyResistancesAndWeaknesses(int amount, DamageRequest request)
@@ -104,11 +115,11 @@ namespace KernelPanic.Combat
return amount;
}
- private static int ApplyShieldAndUptime(int amount, DamageRequest request)
+ private static int ApplyShieldAndUptime(int amount, DamageRequest request, out int absorbedAmount, out int shieldDamage, out int uptimeDamage)
{
CombatantState target = request.Target;
int remaining = amount;
- int shieldDamage = 0;
+ shieldDamage = 0;
if (!request.TrueDamage && target.Shield > 0)
{
@@ -117,8 +128,9 @@ namespace KernelPanic.Combat
remaining -= shieldDamage;
}
- int uptimeDamage = Mathf.Min(target.CurrentUptime, remaining);
+ uptimeDamage = Mathf.Min(target.CurrentUptime, remaining);
target.CurrentUptime = Mathf.Max(0, target.CurrentUptime - uptimeDamage);
+ absorbedAmount = Mathf.Max(0, amount - shieldDamage - uptimeDamage);
return shieldDamage + uptimeDamage;
}
}
diff --git a/Assets/Scripts/Core/GameEvents.cs b/Assets/Scripts/Core/GameEvents.cs
index 3b02006..4e576df 100644
--- a/Assets/Scripts/Core/GameEvents.cs
+++ b/Assets/Scripts/Core/GameEvents.cs
@@ -157,17 +157,39 @@ namespace KernelPanic.Core
public readonly struct DamageDealtEvent
{
public DamageDealtEvent(CombatantState source, CombatantState target, int amount, Language language)
+ : this(source, target, amount, language, amount, 0, false)
+ {
+ }
+
+ public DamageDealtEvent(CombatantState source, CombatantState target, int amount, Language language, int incomingAmount, int absorbedAmount, bool wasCritical)
+ : this(source, target, amount, language, incomingAmount, absorbedAmount, wasCritical, 0, amount)
+ {
+ }
+
+ public DamageDealtEvent(CombatantState source, CombatantState target, int amount, Language language, int incomingAmount, int absorbedAmount, bool wasCritical, int shieldDamage, int uptimeDamage)
{
Source = source;
Target = target;
Amount = amount;
Language = language;
+ IncomingAmount = incomingAmount;
+ AbsorbedAmount = absorbedAmount;
+ WasCritical = wasCritical;
+ ShieldDamage = shieldDamage;
+ UptimeDamage = uptimeDamage;
}
public CombatantState Source { get; }
public CombatantState Target { get; }
public int Amount { get; }
public Language Language { get; }
+ public int IncomingAmount { get; }
+ public int AbsorbedAmount { get; }
+ public bool WasCritical { get; }
+ public int ShieldDamage { get; }
+ public int UptimeDamage { get; }
+ public bool WasFullyBlocked => IncomingAmount > 0 && UptimeDamage <= 0;
+ public bool WasMitigated => ShieldDamage > 0 || AbsorbedAmount > 0 || WasFullyBlocked;
}
///
diff --git a/Assets/Scripts/UI/CombatSceneController.cs b/Assets/Scripts/UI/CombatSceneController.cs
index ddff8eb..8d566b2 100644
--- a/Assets/Scripts/UI/CombatSceneController.cs
+++ b/Assets/Scripts/UI/CombatSceneController.cs
@@ -60,7 +60,11 @@ namespace KernelPanic.UI
private readonly Dictionary stackChipElements = new();
private readonly Dictionary previousUptime = new();
private readonly Dictionary previousShield = new();
+ private readonly Dictionary feedbackBeatVersions = new();
private readonly HashSet knownHandCards = new();
+ private int queueCascadeIndex;
+ private int feedbackCascadeIndex;
+ private int feedbackBeatVersion;
private void Awake()
{
@@ -100,6 +104,8 @@ namespace KernelPanic.UI
GameEvents.EnemyWouldAct += HandleEnemyWouldAct;
GameEvents.PlayerDamaged += HandlePlayerDamaged;
GameEvents.StatusApplied += HandleStatusApplied;
+ GameEvents.StatusExpired += HandleStatusEnded;
+ GameEvents.StatusCleansed += HandleStatusEnded;
}
private void Start()
@@ -148,6 +154,8 @@ namespace KernelPanic.UI
GameEvents.EnemyWouldAct -= HandleEnemyWouldAct;
GameEvents.PlayerDamaged -= HandlePlayerDamaged;
GameEvents.StatusApplied -= HandleStatusApplied;
+ GameEvents.StatusExpired -= HandleStatusEnded;
+ GameEvents.StatusCleansed -= HandleStatusEnded;
}
private bool TryBuildRunConfig(out RunConfig config)
@@ -412,6 +420,7 @@ namespace KernelPanic.UI
CombatantState state = combatManager.PlayerState;
DetectBeneficialResourceFeedback(state, playerPanel);
+ ApplyStatusStateClasses(playerPanel, state);
playerPanel.Add(MeterBlock("uptime", state.CurrentUptime, state.MaxUptime, MeterTone.Uptime));
playerPanel.Add(MeterBlock("shield", state.Shield, Mathf.Max(1, state.Shield), state.Shield > 0 ? MeterTone.Beneficial : MeterTone.Muted));
playerPanel.Add(CycleBlock(state.Cycles, state.MaxCycles));
@@ -459,6 +468,7 @@ namespace KernelPanic.UI
card.Add(name);
combatantElements[enemy.State] = card;
DetectBeneficialResourceFeedback(enemy.State, card);
+ ApplyStatusStateClasses(card, enemy.State);
card.Add(MeterBlock("uptime", enemy.CurrentUptime, enemy.MaxUptime, MeterTone.Uptime));
if (enemy.State.Shield > 0)
{
@@ -1047,9 +1057,14 @@ namespace KernelPanic.UI
if (payload.Track == ResolutionTrack.InterpreterQueue)
{
- VisualElement chip = queueChipElements.TryGetValue(payload.Card, out VisualElement queuedChip) ? queuedChip : interpreterStrip;
- PlayElementBeat(chip, "queue-chip-resolve", 260);
- FlyCardGhost(payload.Card, chip, turnResourceGrid, "feedback-card-discard");
+ int delay = UIPreferences.ReducedMotion ? 0 : Mathf.Min(queueCascadeIndex * 120, 600);
+ queueCascadeIndex++;
+ ScheduleFeedback(() =>
+ {
+ VisualElement chip = queueChipElements.TryGetValue(payload.Card, out VisualElement queuedChip) ? queuedChip : interpreterStrip;
+ PlayElementBeat(chip, "queue-chip-resolve", 260);
+ FlyCardGhost(payload.Card, chip, turnResourceGrid, "feedback-card-discard");
+ }, delay);
return;
}
@@ -1063,63 +1078,86 @@ namespace KernelPanic.UI
private void HandlePhaseChanged(PhaseChangedEvent payload)
{
+ if (payload.NextPhase == TurnPhase.Interpret)
+ {
+ queueCascadeIndex = 0;
+ }
+
+ feedbackCascadeIndex = 0;
ScheduleFeedback(() => PlayElementBeat(phaseLabel, "phase-pulse", 260), 0);
}
private void HandleDamageDealt(DamageDealtEvent payload)
{
- ScheduleFeedback(() =>
+ bool isPlayer = payload.Target == combatManager.PlayerState;
+ VisualElement target = CombatantElement(payload.Target);
+ ScheduleCombatBeat(beatIndex =>
{
- bool isPlayer = payload.Target == combatManager.PlayerState;
- VisualElement target = CombatantElement(payload.Target);
- string text = payload.Amount <= 0 ? "blocked" : $"-{Mathf.RoundToInt(payload.Amount)}";
- string tone = payload.Amount <= 0 ? "float-muted" : "float-damage";
- if (payload.Amount >= 8)
+ string text = payload.WasFullyBlocked ? "clang" : payload.UptimeDamage <= 0 ? "blocked" : $"-{Mathf.RoundToInt(payload.UptimeDamage)}";
+ string tone = payload.WasFullyBlocked || payload.WasMitigated ? "float-muted" : "float-damage";
+ string beatClass = payload.WasFullyBlocked || payload.WasMitigated ? "feedback-clang" : payload.WasCritical ? "feedback-crit" : "feedback-hit";
+ int beatDuration = payload.WasCritical ? 360 : payload.WasMitigated ? 260 : 240;
+
+ if (payload.WasCritical || payload.UptimeDamage >= 8)
{
tone += " float-large";
}
- SpawnFloatingText(target, text, tone);
- PlayElementBeat(target, payload.Amount <= 0 ? "feedback-block" : "feedback-hit", 240);
- if (isPlayer && payload.Amount > 0)
+ SpawnFloatingText(target, text, tone, beatIndex);
+ SpawnImpactMarker(target, payload.WasCritical, payload.WasMitigated, beatIndex);
+ PlayElementBeat(target, beatClass, beatDuration);
+ if (isPlayer && payload.UptimeDamage > 0)
{
- FlashDamageVignette();
+ FlashDamageVignette(payload.WasCritical);
}
- }, 0);
+ });
}
private void HandleCombatantDefeated(CombatantDefeatedEvent payload)
{
- ScheduleFeedback(() =>
+ VisualElement target = CombatantElement(payload.Combatant);
+ Rect targetBounds = target?.worldBound ?? Rect.zero;
+ ScheduleCombatBeat(() =>
{
- VisualElement target = CombatantElement(payload.Combatant);
- SpawnFloatingText(target, "killed", "float-kill float-large");
- SpawnDeathGhost(target);
- PlayElementBeat(target, "feedback-killed", 260);
- }, 0);
+ SpawnFloatingText(target, "killed", "float-kill float-large", 0);
+ SpawnDeathGhost(targetBounds);
+ if (target != enemyRow)
+ {
+ PlayElementBeat(target, "feedback-killed", 420);
+ }
+ });
}
private void HandleEnemyWouldAct(EnemyWouldActEvent payload)
{
- ScheduleFeedback(() => PlayElementBeat(CombatantElement(payload.Enemy?.State), "enemy-acting", 260), 0);
+ ScheduleCombatBeat(() => PlayElementBeat(CombatantElement(payload.Enemy?.State), "enemy-anticipating", 340));
}
private void HandlePlayerDamaged(PlayerDamagedEvent payload)
{
- if (payload.Amount > 0)
- {
- ScheduleFeedback(FlashDamageVignette, 0);
- }
+ // DamageDealt carries richer mitigation data and owns the visual hit beat.
}
private void HandleStatusApplied(StatusAppliedEvent payload)
{
- ScheduleFeedback(() =>
+ ScheduleCombatBeat(() =>
{
StatusDescriptor descriptor = StatusEffectController.GetDescriptor(payload.StatusType);
- SpawnFloatingText(CombatantElement(payload.Target), $"{descriptor.IconKey} x{payload.Stacks}", descriptor.IsBeneficial ? "float-heal" : "float-status");
- PlayElementBeat(CombatantElement(payload.Target), descriptor.IsBeneficial ? "feedback-boost" : "feedback-status", 220);
- }, 0);
+ 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);
+ });
+ }
+
+ private void HandleStatusEnded(StatusExpiredEvent payload)
+ {
+ ApplyStatusStateClass(CombatantElement(payload.Target), payload.StatusType, false);
+ }
+
+ private void HandleStatusEnded(StatusCleansedEvent payload)
+ {
+ ApplyStatusStateClass(CombatantElement(payload.Target), payload.StatusType, false);
}
private void DetectBeneficialResourceFeedback(CombatantState state, VisualElement element)
@@ -1181,12 +1219,31 @@ namespace KernelPanic.UI
return;
}
- element.RemoveFromClassList(className);
- element.AddToClassList(className);
- ScheduleFeedback(() => element.RemoveFromClassList(className), UIPreferences.ReducedMotion ? 80 : durationMs);
+ int version = ++feedbackBeatVersion;
+ feedbackBeatVersions[element] = version;
+ RemoveFeedbackBeatClasses(element);
+ ScheduleFeedback(() =>
+ {
+ if (!feedbackBeatVersions.TryGetValue(element, out int currentVersion) || currentVersion != version)
+ {
+ return;
+ }
+
+ element.AddToClassList(className);
+ ScheduleFeedback(() =>
+ {
+ if (!feedbackBeatVersions.TryGetValue(element, out int removeVersion) || removeVersion != version)
+ {
+ return;
+ }
+
+ element.RemoveFromClassList(className);
+ feedbackBeatVersions.Remove(element);
+ }, UIPreferences.ReducedMotion ? 80 : durationMs);
+ }, 0);
}
- private void SpawnFloatingText(VisualElement anchor, string text, string className)
+ private void SpawnFloatingText(VisualElement anchor, string text, string className, int cascadeOffset = 0)
{
if (feedbackLayer == null || anchor == null)
{
@@ -1209,8 +1266,10 @@ namespace KernelPanic.UI
}
}
- label.style.left = anchorRect.center.x - rootRect.x - 30f;
- label.style.top = anchorRect.center.y - rootRect.y - 16f;
+ 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;
feedbackLayer.Add(label);
if (UIPreferences.ReducedMotion)
@@ -1222,12 +1281,44 @@ namespace KernelPanic.UI
ScheduleFeedback(() =>
{
- label.style.top = anchorRect.center.y - rootRect.y - 42f;
+ label.style.top = anchorRect.center.y - rootRect.y - 42f + offsetY;
label.style.opacity = 0f;
}, 20);
ScheduleFeedback(() => label.RemoveFromHierarchy(), 520);
}
+ private void SpawnImpactMarker(VisualElement anchor, bool critical, bool mitigated, int cascadeOffset)
+ {
+ if (feedbackLayer == null || anchor == null)
+ {
+ return;
+ }
+
+ Rect anchorRect = anchor.worldBound;
+ Rect rootRect = root.worldBound;
+ Label marker = new(critical ? "crit" : mitigated ? "clang" : "hit");
+ marker.AddToClassList("impact-marker");
+ marker.AddToClassList(critical ? "impact-marker-crit" : mitigated ? "impact-marker-clang" : "impact-marker-hit");
+
+ float lane = (cascadeOffset % 4) * 18f;
+ marker.style.left = anchorRect.xMax - rootRect.x - 54f;
+ marker.style.top = anchorRect.y - rootRect.y + 8f + lane;
+ feedbackLayer.Add(marker);
+
+ if (UIPreferences.ReducedMotion)
+ {
+ ScheduleFeedback(() => marker.RemoveFromHierarchy(), 260);
+ return;
+ }
+
+ ScheduleFeedback(() =>
+ {
+ marker.style.left = anchorRect.xMax - rootRect.x - 34f;
+ marker.style.opacity = 0f;
+ }, 20);
+ ScheduleFeedback(() => marker.RemoveFromHierarchy(), 420);
+ }
+
private void FlyCardGhost(CardInstance card, VisualElement source, VisualElement destination, string className)
{
if (feedbackLayer == null || card == null)
@@ -1265,22 +1356,23 @@ namespace KernelPanic.UI
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 360);
}
- private void SpawnDeathGhost(VisualElement source)
+ private void SpawnDeathGhost(Rect sourceRect)
{
- if (feedbackLayer == null || source == null)
+ if (feedbackLayer == null || sourceRect.width <= 0f || sourceRect.height <= 0f)
{
return;
}
Rect rootRect = root.worldBound;
- Rect sourceRect = source.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.x - rootRect.x;
- ghost.style.top = sourceRect.y - rootRect.y;
- ghost.style.width = sourceRect.width;
- ghost.style.height = sourceRect.height;
+ ghost.style.left = sourceRect.center.x - rootRect.x - (ghostWidth * 0.5f);
+ ghost.style.top = sourceRect.center.y - rootRect.y - (ghostHeight * 0.5f);
+ ghost.style.width = ghostWidth;
+ ghost.style.height = ghostHeight;
feedbackLayer.Add(ghost);
if (UIPreferences.ReducedMotion)
@@ -1297,14 +1389,52 @@ namespace KernelPanic.UI
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 360);
}
- private void FlashDamageVignette()
+ private void FlashDamageVignette(bool critical)
{
if (damageVignette == null)
{
return;
}
- PlayElementBeat(damageVignette, "damage-vignette-on", UIPreferences.ReducedMotion ? 120 : 240);
+ PlayElementBeat(damageVignette, critical ? "damage-vignette-crit" : "damage-vignette-on", UIPreferences.ReducedMotion ? 120 : critical ? 320 : 240);
+ }
+
+ private static void RemoveFeedbackBeatClasses(VisualElement element)
+ {
+ element.RemoveFromClassList("feedback-hit");
+ element.RemoveFromClassList("feedback-crit");
+ element.RemoveFromClassList("feedback-clang");
+ element.RemoveFromClassList("feedback-block");
+ element.RemoveFromClassList("feedback-status");
+ element.RemoveFromClassList("feedback-boost");
+ element.RemoveFromClassList("feedback-killed");
+ element.RemoveFromClassList("enemy-anticipating");
+ element.RemoveFromClassList("enemy-acting");
+ element.RemoveFromClassList("phase-pulse");
+ element.RemoveFromClassList("cycles-spent-beat");
+ element.RemoveFromClassList("feedback-denied");
+ element.RemoveFromClassList("queue-chip-resolve");
+ element.RemoveFromClassList("stack-chip-resolve");
+ element.RemoveFromClassList("hand-card-drawn");
+ element.RemoveFromClassList("damage-vignette-on");
+ element.RemoveFromClassList("damage-vignette-crit");
+ }
+
+ private void ScheduleCombatBeat(Action action, int holdMs = 0)
+ {
+ ScheduleCombatBeat(_ => action?.Invoke(), holdMs);
+ }
+
+ private void ScheduleCombatBeat(Action action, int holdMs = 0)
+ {
+ if (action == null)
+ {
+ return;
+ }
+
+ int beatIndex = feedbackCascadeIndex++;
+ int delay = UIPreferences.ReducedMotion ? 0 : Mathf.Min(beatIndex * 120, 840);
+ ScheduleFeedback(() => action(beatIndex), delay + (UIPreferences.ReducedMotion ? 0 : holdMs));
}
private void ScheduleFeedback(Action action, int delayMs)
@@ -1317,6 +1447,47 @@ namespace KernelPanic.UI
root.schedule.Execute(action).StartingIn(Mathf.Max(0, delayMs));
}
+ private static void ApplyStatusStateClasses(VisualElement element, CombatantState state)
+ {
+ if (element == null)
+ {
+ return;
+ }
+
+ RemoveStatusStateClasses(element);
+ if (state == null)
+ {
+ return;
+ }
+
+ for (int i = 0; i < state.Statuses.Count; i++)
+ {
+ ApplyStatusStateClass(element, state.Statuses[i].Type, true);
+ }
+ }
+
+ private static void ApplyStatusStateClass(VisualElement element, StatusType statusType, bool enabled)
+ {
+ string className = StatusStateClass(statusType);
+ if (element == null || string.IsNullOrEmpty(className))
+ {
+ return;
+ }
+
+ element.EnableInClassList(className, enabled);
+ }
+
+ private static void RemoveStatusStateClasses(VisualElement element)
+ {
+ ApplyStatusStateClass(element, StatusType.MemoryLeak, false);
+ ApplyStatusStateClass(element, StatusType.Segfault, false);
+ ApplyStatusStateClass(element, StatusType.RaceCondition, false);
+ ApplyStatusStateClass(element, StatusType.Deprecated, false);
+ ApplyStatusStateClass(element, StatusType.DependencyError, false);
+ ApplyStatusStateClass(element, StatusType.Deadlock, false);
+ ApplyStatusStateClass(element, StatusType.UnattendedUpgrades, false);
+ }
+
private void HandleCombatLog(string message)
{
if (logLabel != null)
@@ -1517,6 +1688,21 @@ namespace KernelPanic.UI
};
}
+ private static string StatusStateClass(StatusType statusType)
+ {
+ return statusType switch
+ {
+ StatusType.MemoryLeak => "status-state-leaking",
+ StatusType.Segfault => "status-state-segfault",
+ StatusType.RaceCondition => "status-state-race",
+ StatusType.Deprecated => "status-state-deprecated",
+ StatusType.DependencyError => "status-state-dependency-error",
+ StatusType.Deadlock => "status-state-deadlock",
+ StatusType.UnattendedUpgrades => "status-state-upgrading",
+ _ => string.Empty
+ };
+ }
+
private static string OfferKindText(RepositoryOfferKind kind)
{
return kind switch