Fix various issues related to styling and animations

This commit is contained in:
2026-07-10 21:28:10 +02:00
parent c973c2d51a
commit 2ba43fb2d3
10 changed files with 2126 additions and 205 deletions
+272 -24
View File
@@ -28,6 +28,10 @@ namespace KernelPanic.Combat
private readonly HashSet<string> _loggedEffectTodos = new();
private readonly List<CardDefinition> _generatedCardPool = new();
private readonly List<DelayedCombatEffect> _delayedEffects = new();
private readonly HashSet<EnemyInstance> _enemiesPendingExit = new();
private readonly Queue<EnemyInstance> _enemyExitQueue = new();
private readonly Dictionary<EnemyInstance, IReadOnlyList<DeathSpawnRequest>> _pendingDeathSpawns = new();
private readonly HashSet<EnemyInstance> _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<EnemyInstance> 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<string> 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<PeekedCardInfo> 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<DeathSpawnRequest> deathSpawns = HandleEnemyDeath(enemy);
enemy.MarkDefeatedPendingRemoval();
_enemiesPendingExit.Add(enemy);
_pendingDeathSpawns[enemy] = deathSpawns;
_enemyExitQueue.Enqueue(enemy);
if (!_enemyExitQueueDraining)
{
StartCoroutine(DrainEnemyExitQueue());
}
}
private List<DeathSpawnRequest> HandleEnemyDeath(EnemyInstance enemy)
{
List<DeathSpawnRequest> 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<DeathSpawnRequest> 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; }
}
}
}